diff --git a/.tmp/lightrag_inspect/lightrag_hku-1.4.16-py3-none-any.whl b/.tmp/lightrag_inspect/lightrag_hku-1.4.16-py3-none-any.whl new file mode 100644 index 0000000..6507fe1 Binary files /dev/null and b/.tmp/lightrag_inspect/lightrag_hku-1.4.16-py3-none-any.whl differ diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/__init__.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/__init__.py new file mode 100644 index 0000000..e269f25 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/__init__.py @@ -0,0 +1,22 @@ +from typing import TYPE_CHECKING, Any + +from ._version import __version__ as __version__ + +__all__ = ["LightRAG", "QueryParam", "__version__"] + +if TYPE_CHECKING: + from .lightrag import LightRAG as LightRAG, QueryParam as QueryParam + + +def __getattr__(name: str) -> Any: + if name in {"LightRAG", "QueryParam"}: + from .lightrag import LightRAG, QueryParam + + value = {"LightRAG": LightRAG, "QueryParam": QueryParam}[name] + globals()[name] = value + return value + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__author__ = "Zirui Guo" +__url__ = "https://github.com/HKUDS/LightRAG" diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/_version.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/_version.py new file mode 100644 index 0000000..0ccdc52 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/_version.py @@ -0,0 +1,4 @@ +"""Lightweight version definitions shared by packaging and runtime code.""" + +__version__ = "1.4.16" +__api_version__ = "0291" diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/__init__.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/__init__.py new file mode 100644 index 0000000..86ac14b --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/__init__.py @@ -0,0 +1 @@ +from .._version import __api_version__ as __api_version__ diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/auth.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/auth.py new file mode 100644 index 0000000..04e7f3c --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/auth.py @@ -0,0 +1,163 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from dotenv import load_dotenv +from fastapi import HTTPException, status +from pydantic import BaseModel + +from ..utils import logger +from .config import DEFAULT_TOKEN_SECRET, global_args +from .passwords import verify_password + +# use the .env that is inside the current folder +# allows to use different .env file for each lightrag instance +# the OS environment variables take precedence over the .env file +load_dotenv(dotenv_path=".env", override=False) + + +class TokenPayload(BaseModel): + sub: str # Username + exp: datetime # Expiration time + role: str = "user" # User role, default is regular user + metadata: dict = {} # Additional metadata + + +class AuthHandler: + def __init__(self): + auth_accounts = global_args.auth_accounts + self.secret = global_args.token_secret + if not self.secret: + if auth_accounts: + raise ValueError( + "TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured." + ) + self.secret = DEFAULT_TOKEN_SECRET + logger.warning( + "TOKEN_SECRET not set and AUTH_ACCOUNTS is not configured. " + "Falling back to the default guest-mode JWT secret. " + ) + algorithm = global_args.jwt_algorithm + if not algorithm or algorithm.lower() == "none": + raise ValueError( + "JWT_ALGORITHM must be set to a secure algorithm (e.g. HS256). " + "The 'none' algorithm is not permitted." + ) + self.algorithm = algorithm + self.expire_hours = global_args.token_expire_hours + self.guest_expire_hours = global_args.guest_token_expire_hours + self.accounts = {} + invalid_accounts = [] + if auth_accounts: + for account in auth_accounts.split(","): + try: + username, password = account.split(":", 1) + if not username or not password: + raise ValueError + self.accounts[username] = password + except ValueError: + invalid_accounts.append(account) + if invalid_accounts: + invalid_entries = ", ".join(invalid_accounts) + logger.error(f"Invalid account format in AUTH_ACCOUNTS: {invalid_entries}") + raise ValueError( + "AUTH_ACCOUNTS must use comma-separated user:password pairs." + ) + + def verify_password(self, username: str, plain_password: str) -> bool: + """ + Verify password for a user. Supports explicit bcrypt values and plaintext. + + Args: + username: Username to verify + plain_password: Plaintext password to check + + Returns: + bool: True if password is correct, False otherwise + """ + if username not in self.accounts: + return False + + stored_password = self.accounts[username] + return verify_password(plain_password, stored_password) + + def create_token( + self, + username: str, + role: str = "user", + custom_expire_hours: int = None, + metadata: dict = None, + ) -> str: + """ + Create JWT token + + Args: + username: Username + role: User role, default is "user", guest is "guest" + custom_expire_hours: Custom expiration time (hours), if None use default value + metadata: Additional metadata + + Returns: + str: Encoded JWT token + """ + # Choose default expiration time based on role + if custom_expire_hours is None: + if role == "guest": + expire_hours = self.guest_expire_hours + else: + expire_hours = self.expire_hours + else: + expire_hours = custom_expire_hours + + expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours) + + # Create payload + payload = TokenPayload( + sub=username, exp=expire, role=role, metadata=metadata or {} + ) + + return jwt.encode(payload.model_dump(), self.secret, algorithm=self.algorithm) + + def validate_token(self, token: str) -> dict: + """ + Validate JWT token + + Args: + token: JWT token + + Returns: + dict: Dictionary containing user information + + Raises: + HTTPException: If token is invalid or expired + """ + try: + # Explicitly exclude 'none' to prevent algorithm confusion attacks + allowed_algorithms = [self.algorithm] + if "none" in (a.lower() for a in allowed_algorithms): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Insecure JWT algorithm configuration", + ) + payload = jwt.decode(token, self.secret, algorithms=allowed_algorithms) + expire_timestamp = payload["exp"] + expire_time = datetime.fromtimestamp(expire_timestamp, timezone.utc) + + if datetime.now(timezone.utc) > expire_time: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired" + ) + + # Return complete payload instead of just username + return { + "username": payload["sub"], + "role": payload.get("role", "user"), + "metadata": payload.get("metadata", {}), + "exp": expire_time, + } + except jwt.PyJWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" + ) + + +auth_handler = AuthHandler() diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/config.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/config.py new file mode 100644 index 0000000..981653c --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/config.py @@ -0,0 +1,697 @@ +""" +Configs for the LightRAG API. +""" + +import os +import re +import argparse +import logging +from dotenv import load_dotenv +from lightrag.utils import get_env_value, logger +from lightrag.llm.binding_options import ( + GeminiEmbeddingOptions, + GeminiLLMOptions, + OllamaEmbeddingOptions, + OllamaLLMOptions, + OpenAILLMOptions, +) +from lightrag.base import OllamaServerInfos +import sys + +from lightrag.constants import ( + DEFAULT_WOKERS, + DEFAULT_TIMEOUT, + DEFAULT_TOP_K, + DEFAULT_CHUNK_TOP_K, + DEFAULT_HISTORY_TURNS, + DEFAULT_MAX_ENTITY_TOKENS, + DEFAULT_MAX_RELATION_TOKENS, + DEFAULT_MAX_TOTAL_TOKENS, + DEFAULT_COSINE_THRESHOLD, + DEFAULT_RELATED_CHUNK_NUMBER, + DEFAULT_MIN_RERANK_SCORE, + DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, + DEFAULT_MAX_ASYNC, + DEFAULT_SUMMARY_MAX_TOKENS, + DEFAULT_SUMMARY_LENGTH_RECOMMENDED, + DEFAULT_SUMMARY_CONTEXT_SIZE, + DEFAULT_SUMMARY_LANGUAGE, + DEFAULT_EMBEDDING_FUNC_MAX_ASYNC, + DEFAULT_EMBEDDING_BATCH_NUM, + DEFAULT_OLLAMA_MODEL_NAME, + DEFAULT_OLLAMA_MODEL_TAG, + DEFAULT_RERANK_BINDING, + DEFAULT_ENTITY_TYPES, +) + +# use the .env that is inside the current folder +# allows to use different .env file for each lightrag instance +# the OS environment variables take precedence over the .env file +load_dotenv(dotenv_path=".env", override=False) + + +ollama_server_infos = OllamaServerInfos() +DEFAULT_TOKEN_SECRET = "lightrag-jwt-default-secret-key!" +NO_PREFIX_SENTINEL = "NO_PREFIX" +PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS = {"gemini", "jina", "voyageai"} +PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS = {"azure_openai", "ollama", "openai"} + + +class DefaultRAGStorageConfig: + KV_STORAGE = "JsonKVStorage" + VECTOR_STORAGE = "NanoVectorDBStorage" + GRAPH_STORAGE = "NetworkXStorage" + DOC_STATUS_STORAGE = "JsonDocStatusStorage" + + +def get_default_host(binding_type: str) -> str: + default_hosts = { + "ollama": os.getenv("LLM_BINDING_HOST", "http://localhost:11434"), + "lollms": os.getenv("LLM_BINDING_HOST", "http://localhost:9600"), + "azure_openai": os.getenv("AZURE_OPENAI_ENDPOINT", "https://api.openai.com/v1"), + "openai": os.getenv("LLM_BINDING_HOST", "https://api.openai.com/v1"), + "gemini": os.getenv( + "LLM_BINDING_HOST", "https://generativelanguage.googleapis.com" + ), + } + return default_hosts.get( + binding_type, os.getenv("LLM_BINDING_HOST", "http://localhost:11434") + ) # fallback to ollama if unknown + + +def resolve_asymmetric_embedding_opt_in( + *, + binding: str, + embedding_asymmetric: bool, + embedding_asymmetric_configured: bool, + query_prefix: str | None, + document_prefix: str | None, + query_prefix_configured: bool = False, + document_prefix_configured: bool = False, +) -> bool: + """Resolve whether query/document-aware embedding behavior should be enabled.""" + has_non_empty_prefix = bool(query_prefix or document_prefix) + has_prefix_config = query_prefix_configured or document_prefix_configured + + if not embedding_asymmetric: + if has_prefix_config: + state = "false" if embedding_asymmetric_configured else "unset" + logger.warning( + f"EMBEDDING_ASYMMETRIC is {state}; " + "EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored." + ) + return False + + if binding in PROVIDER_ASYMMETRIC_EMBEDDING_BINDINGS: + if has_prefix_config: + logger.warning( + f"{binding} embeddings use provider task parameters for asymmetric " + "mode; EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX will be ignored." + ) + return True + + if binding in PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS: + if not query_prefix_configured or not document_prefix_configured: + raise ValueError( + f"EMBEDDING_ASYMMETRIC=true for {binding} embeddings requires both " + "EMBEDDING_QUERY_PREFIX and EMBEDDING_DOCUMENT_PREFIX. Use " + f"{NO_PREFIX_SENTINEL} for a side that should intentionally have no prefix." + ) + + if not has_non_empty_prefix: + raise ValueError( + "At least one of EMBEDDING_QUERY_PREFIX or EMBEDDING_DOCUMENT_PREFIX " + f"must be non-empty. Use {NO_PREFIX_SENTINEL} only for the side that " + "should intentionally have no prefix." + ) + return True + + raise ValueError( + f"EMBEDDING_ASYMMETRIC=true is not supported for {binding} embeddings." + ) + + +def get_embedding_prefix_config(env_key: str) -> tuple[str | None, bool]: + """Read an embedding prefix and whether it was explicitly configured.""" + if env_key not in os.environ: + return None, False + + value = os.environ[env_key] + if value == "None": + return None, False + if value == NO_PREFIX_SENTINEL: + return "", True + if value == "": + raise ValueError( + f"{env_key} is empty. Use {NO_PREFIX_SENTINEL} to explicitly request " + "no prefix, or remove the variable to leave it unconfigured." + ) + return value, True + + +def validate_auth_configuration(args: argparse.Namespace) -> None: + """Reject insecure JWT auth settings before the API starts.""" + auth_accounts = (getattr(args, "auth_accounts", "") or "").strip() + token_secret = (getattr(args, "token_secret", "") or "").strip() + + if auth_accounts and (not token_secret or token_secret == DEFAULT_TOKEN_SECRET): + raise ValueError( + "TOKEN_SECRET must be explicitly set to a non-default value when AUTH_ACCOUNTS is configured." + ) + + +def parse_args() -> argparse.Namespace: + """ + Parse command line arguments with environment variable fallback + + Args: + is_uvicorn_mode: Whether running under uvicorn mode + + Returns: + argparse.Namespace: Parsed arguments + """ + + parser = argparse.ArgumentParser(description="LightRAG API Server") + + # Server configuration + parser.add_argument( + "--host", + default=get_env_value("HOST", "0.0.0.0"), + help="Server host (default: from env or 0.0.0.0)", + ) + parser.add_argument( + "--port", + type=int, + default=get_env_value("PORT", 9621, int), + help="Server port (default: from env or 9621)", + ) + + # Directory configuration + parser.add_argument( + "--working-dir", + default=get_env_value("WORKING_DIR", "./rag_storage"), + help="Working directory for RAG storage (default: from env or ./rag_storage)", + ) + parser.add_argument( + "--input-dir", + default=get_env_value("INPUT_DIR", "./inputs"), + help="Directory containing input documents (default: from env or ./inputs)", + ) + + parser.add_argument( + "--timeout", + default=get_env_value("TIMEOUT", DEFAULT_TIMEOUT, int, special_none=True), + type=int, + help="Timeout in seconds (useful when using slow AI). Use None for infinite timeout", + ) + + # RAG configuration + parser.add_argument( + "--max-async", + type=int, + default=get_env_value("MAX_ASYNC", DEFAULT_MAX_ASYNC, int), + help=f"Maximum async operations (default: from env or {DEFAULT_MAX_ASYNC})", + ) + parser.add_argument( + "--summary-max-tokens", + type=int, + default=get_env_value("SUMMARY_MAX_TOKENS", DEFAULT_SUMMARY_MAX_TOKENS, int), + help=f"Maximum token size for entity/relation summary(default: from env or {DEFAULT_SUMMARY_MAX_TOKENS})", + ) + parser.add_argument( + "--summary-context-size", + type=int, + default=get_env_value( + "SUMMARY_CONTEXT_SIZE", DEFAULT_SUMMARY_CONTEXT_SIZE, int + ), + help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_CONTEXT_SIZE})", + ) + parser.add_argument( + "--summary-length-recommended", + type=int, + default=get_env_value( + "SUMMARY_LENGTH_RECOMMENDED", DEFAULT_SUMMARY_LENGTH_RECOMMENDED, int + ), + help=f"LLM Summary Context size (default: from env or {DEFAULT_SUMMARY_LENGTH_RECOMMENDED})", + ) + + # Logging configuration + parser.add_argument( + "--log-level", + default=get_env_value("LOG_LEVEL", "INFO"), + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + help="Logging level (default: from env or INFO)", + ) + parser.add_argument( + "--verbose", + action="store_true", + default=get_env_value("VERBOSE", False, bool), + help="Enable verbose debug output(only valid for DEBUG log-level)", + ) + + parser.add_argument( + "--key", + type=str, + default=get_env_value("LIGHTRAG_API_KEY", None), + help="API key for authentication. This protects lightrag server against unauthorized access", + ) + + # Optional https parameters + parser.add_argument( + "--ssl", + action="store_true", + default=get_env_value("SSL", False, bool), + help="Enable HTTPS (default: from env or False)", + ) + parser.add_argument( + "--ssl-certfile", + default=get_env_value("SSL_CERTFILE", None), + help="Path to SSL certificate file (required if --ssl is enabled)", + ) + parser.add_argument( + "--ssl-keyfile", + default=get_env_value("SSL_KEYFILE", None), + help="Path to SSL private key file (required if --ssl is enabled)", + ) + + # Ollama model configuration + parser.add_argument( + "--simulated-model-name", + type=str, + default=get_env_value("OLLAMA_EMULATING_MODEL_NAME", DEFAULT_OLLAMA_MODEL_NAME), + help="Name for the simulated Ollama model (default: from env or lightrag)", + ) + + parser.add_argument( + "--simulated-model-tag", + type=str, + default=get_env_value("OLLAMA_EMULATING_MODEL_TAG", DEFAULT_OLLAMA_MODEL_TAG), + help="Tag for the simulated Ollama model (default: from env or latest)", + ) + + # Namespace + parser.add_argument( + "--workspace", + type=str, + default=get_env_value("WORKSPACE", ""), + help="Default workspace for all storage", + ) + + # Server workers configuration + parser.add_argument( + "--workers", + type=int, + default=get_env_value("WORKERS", DEFAULT_WOKERS, int), + help="Number of worker processes (default: from env or 1)", + ) + + # LLM and embedding bindings + parser.add_argument( + "--llm-binding", + type=str, + default=get_env_value("LLM_BINDING", "ollama"), + choices=[ + "lollms", + "ollama", + "openai", + "openai-ollama", + "azure_openai", + "aws_bedrock", + "gemini", + ], + help="LLM binding type (default: from env or ollama)", + ) + parser.add_argument( + "--embedding-binding", + type=str, + default=get_env_value("EMBEDDING_BINDING", "ollama"), + choices=[ + "lollms", + "ollama", + "openai", + "azure_openai", + "aws_bedrock", + "jina", + "gemini", + "voyageai", + ], + help="Embedding binding type (default: from env or ollama)", + ) + parser.add_argument( + "--rerank-binding", + type=str, + default=get_env_value("RERANK_BINDING", DEFAULT_RERANK_BINDING), + choices=["null", "cohere", "jina", "aliyun"], + help=f"Rerank binding type (default: from env or {DEFAULT_RERANK_BINDING})", + ) + + # Document loading engine configuration + parser.add_argument( + "--docling", + action="store_true", + default=False, + help="Enable DOCLING document loading engine (default: from env or DEFAULT)", + ) + + # Conditionally add binding-specific options (Ollama, OpenAI, Azure OpenAI, Gemini) + # This registers command line arguments (e.g., --openai-llm-temperature) + # and reads corresponding environment variables (e.g., OPENAI_LLM_TEMPERATURE) + + # Determine LLM binding value consistently from command line or environment + llm_binding_value = None + if "--llm-binding" in sys.argv: + try: + idx = sys.argv.index("--llm-binding") + if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"): + llm_binding_value = sys.argv[idx + 1] + except IndexError: + pass + + # Fall back to environment variable using same function as argparse default + if llm_binding_value is None: + llm_binding_value = get_env_value("LLM_BINDING", "ollama") + + # Add LLM binding options based on determined value + if llm_binding_value == "ollama": + OllamaLLMOptions.add_args(parser) + elif llm_binding_value in ["openai", "azure_openai"]: + OpenAILLMOptions.add_args(parser) + elif llm_binding_value == "gemini": + GeminiLLMOptions.add_args(parser) + + # Determine embedding binding value consistently from command line or environment + embedding_binding_value = None + if "--embedding-binding" in sys.argv: + try: + idx = sys.argv.index("--embedding-binding") + if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("-"): + embedding_binding_value = sys.argv[idx + 1] + except IndexError: + pass + + # Fall back to environment variable using same function as argparse default + if embedding_binding_value is None: + embedding_binding_value = get_env_value("EMBEDDING_BINDING", "ollama") + + # Add embedding binding options based on determined value + if embedding_binding_value == "ollama": + OllamaEmbeddingOptions.add_args(parser) + elif embedding_binding_value == "gemini": + GeminiEmbeddingOptions.add_args(parser) + + args = parser.parse_args() + + # convert relative path to absolute path + args.working_dir = os.path.abspath(args.working_dir) + args.input_dir = os.path.abspath(args.input_dir) + + # Inject storage configuration from environment variables + args.kv_storage = get_env_value( + "LIGHTRAG_KV_STORAGE", DefaultRAGStorageConfig.KV_STORAGE + ) + args.doc_status_storage = get_env_value( + "LIGHTRAG_DOC_STATUS_STORAGE", DefaultRAGStorageConfig.DOC_STATUS_STORAGE + ) + args.graph_storage = get_env_value( + "LIGHTRAG_GRAPH_STORAGE", DefaultRAGStorageConfig.GRAPH_STORAGE + ) + args.vector_storage = get_env_value( + "LIGHTRAG_VECTOR_STORAGE", DefaultRAGStorageConfig.VECTOR_STORAGE + ) + + # Get MAX_PARALLEL_INSERT from environment + args.max_parallel_insert = get_env_value("MAX_PARALLEL_INSERT", 2, int) + + # Get MAX_GRAPH_NODES from environment + args.max_graph_nodes = get_env_value("MAX_GRAPH_NODES", 1000, int) + + # Handle openai-ollama special case + if args.llm_binding == "openai-ollama": + args.llm_binding = "openai" + args.embedding_binding = "ollama" + + args.llm_binding_host = get_env_value( + "LLM_BINDING_HOST", get_default_host(args.llm_binding) + ) + args.embedding_binding_host = get_env_value( + "EMBEDDING_BINDING_HOST", get_default_host(args.embedding_binding) + ) + args.llm_binding_api_key = get_env_value("LLM_BINDING_API_KEY", None) + args.embedding_binding_api_key = get_env_value("EMBEDDING_BINDING_API_KEY", "") + + # Inject model configuration + args.llm_model = get_env_value("LLM_MODEL", "mistral-nemo:latest") + # EMBEDDING_MODEL defaults to None - each binding will use its own default model + # e.g., OpenAI uses "text-embedding-3-small", Jina uses "jina-embeddings-v4" + args.embedding_model = get_env_value("EMBEDDING_MODEL", None, special_none=True) + # EMBEDDING_DIM defaults to None - each binding will use its own default dimension + # Value is inherited from provider defaults via wrap_embedding_func_with_attrs decorator + args.embedding_dim = get_env_value("EMBEDDING_DIM", None, int, special_none=True) + args.embedding_send_dim = get_env_value("EMBEDDING_SEND_DIM", False, bool) + + # Inject chunk configuration + args.chunk_size = get_env_value("CHUNK_SIZE", 1200, int) + args.chunk_overlap_size = get_env_value("CHUNK_OVERLAP_SIZE", 100, int) + + # Inject LLM cache configuration + args.enable_llm_cache_for_extract = get_env_value( + "ENABLE_LLM_CACHE_FOR_EXTRACT", True, bool + ) + args.enable_llm_cache = get_env_value("ENABLE_LLM_CACHE", True, bool) + + # Set document_loading_engine from --docling flag + if args.docling: + args.document_loading_engine = "DOCLING" + else: + args.document_loading_engine = get_env_value( + "DOCUMENT_LOADING_ENGINE", "DEFAULT" + ) + + # PDF decryption password + args.pdf_decrypt_password = get_env_value("PDF_DECRYPT_PASSWORD", None) + + # Add environment variables that were previously read directly + args.cors_origins = get_env_value("CORS_ORIGINS", "*") + args.summary_language = get_env_value("SUMMARY_LANGUAGE", DEFAULT_SUMMARY_LANGUAGE) + args.entity_types = get_env_value("ENTITY_TYPES", DEFAULT_ENTITY_TYPES, list) + args.whitelist_paths = get_env_value("WHITELIST_PATHS", "/health,/api/*") + + # For JWT Auth + args.auth_accounts = get_env_value("AUTH_ACCOUNTS", "") + args.token_secret = get_env_value("TOKEN_SECRET", None) + args.token_expire_hours = get_env_value("TOKEN_EXPIRE_HOURS", 48, float) + args.guest_token_expire_hours = get_env_value("GUEST_TOKEN_EXPIRE_HOURS", 24, float) + args.jwt_algorithm = get_env_value("JWT_ALGORITHM", "HS256") + + # Token auto-renewal configuration (sliding window expiration) + args.token_auto_renew = get_env_value("TOKEN_AUTO_RENEW", True, bool) + args.token_renew_threshold = get_env_value("TOKEN_RENEW_THRESHOLD", 0.5, float) + + # Rerank model configuration + args.rerank_model = get_env_value("RERANK_MODEL", None) + args.rerank_binding_host = get_env_value("RERANK_BINDING_HOST", None) + args.rerank_binding_api_key = get_env_value("RERANK_BINDING_API_KEY", None) + # Note: rerank_binding is already set by argparse, no need to override from env + + # Min rerank score configuration + args.min_rerank_score = get_env_value( + "MIN_RERANK_SCORE", DEFAULT_MIN_RERANK_SCORE, float + ) + + # Query configuration + args.history_turns = get_env_value("HISTORY_TURNS", DEFAULT_HISTORY_TURNS, int) + args.top_k = get_env_value("TOP_K", DEFAULT_TOP_K, int) + args.chunk_top_k = get_env_value("CHUNK_TOP_K", DEFAULT_CHUNK_TOP_K, int) + args.max_entity_tokens = get_env_value( + "MAX_ENTITY_TOKENS", DEFAULT_MAX_ENTITY_TOKENS, int + ) + args.max_relation_tokens = get_env_value( + "MAX_RELATION_TOKENS", DEFAULT_MAX_RELATION_TOKENS, int + ) + args.max_total_tokens = get_env_value( + "MAX_TOTAL_TOKENS", DEFAULT_MAX_TOTAL_TOKENS, int + ) + args.cosine_threshold = get_env_value( + "COSINE_THRESHOLD", DEFAULT_COSINE_THRESHOLD, float + ) + args.related_chunk_number = get_env_value( + "RELATED_CHUNK_NUMBER", DEFAULT_RELATED_CHUNK_NUMBER, int + ) + + # Add missing environment variables for health endpoint + args.force_llm_summary_on_merge = get_env_value( + "FORCE_LLM_SUMMARY_ON_MERGE", DEFAULT_FORCE_LLM_SUMMARY_ON_MERGE, int + ) + args.embedding_func_max_async = get_env_value( + "EMBEDDING_FUNC_MAX_ASYNC", DEFAULT_EMBEDDING_FUNC_MAX_ASYNC, int + ) + args.embedding_batch_num = get_env_value( + "EMBEDDING_BATCH_NUM", DEFAULT_EMBEDDING_BATCH_NUM, int + ) + + # Embedding token limit configuration + args.embedding_token_limit = get_env_value( + "EMBEDDING_TOKEN_LIMIT", None, int, special_none=True + ) + + # File upload size limit (in bytes, None for unlimited) + # Default: 100MB (104857600 bytes) + args.max_upload_size = get_env_value( + "MAX_UPLOAD_SIZE", 104857600, int, special_none=True + ) + + # Embedding prefix configuration for context-aware embeddings. Empty prefixes + # must be explicit via NO_PREFIX so missing config is distinguishable. + ( + args.embedding_document_prefix, + args.embedding_document_prefix_configured, + ) = get_embedding_prefix_config("EMBEDDING_DOCUMENT_PREFIX") + ( + args.embedding_query_prefix, + args.embedding_query_prefix_configured, + ) = get_embedding_prefix_config("EMBEDDING_QUERY_PREFIX") + args.embedding_prefix_no_prefix_sentinel = NO_PREFIX_SENTINEL + args.embedding_prefixes_configured = ( + args.embedding_document_prefix_configured + or args.embedding_query_prefix_configured + ) + # Asymmetric embedding behavior toggle + args.embedding_asymmetric_configured = "EMBEDDING_ASYMMETRIC" in os.environ + args.embedding_asymmetric = get_env_value("EMBEDDING_ASYMMETRIC", False, bool) + + ollama_server_infos.LIGHTRAG_NAME = args.simulated_model_name + ollama_server_infos.LIGHTRAG_TAG = args.simulated_model_tag + + # Sanitize workspace: only alphanumeric characters and underscores are allowed + if args.workspace: + sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", args.workspace) + if sanitized != args.workspace: + logging.warning( + f"Workspace name '{args.workspace}' contains invalid characters. " + f"It has been sanitized to '{sanitized}'. " + "Only alphanumeric characters and underscores are allowed." + ) + args.workspace = sanitized + + validate_auth_configuration(args) + return args + + +def update_uvicorn_mode_config(): + # If in uvicorn mode and workers > 1, force it to 1 and log warning + if global_args.workers > 1: + original_workers = global_args.workers + global_args.workers = 1 + # Log warning directly here + logging.debug( + f">> Forcing workers=1 in uvicorn mode(Ignoring workers={original_workers})" + ) + + +# Global configuration with lazy initialization +_global_args = None +_initialized = False + + +def initialize_config(args=None, force=False): + """Initialize global configuration + + This function allows explicit initialization of the configuration, + which is useful for programmatic usage, testing, or embedding LightRAG + in other applications. + + Args: + args: Pre-parsed argparse.Namespace or None to parse from sys.argv + force: Force re-initialization even if already initialized + + Returns: + argparse.Namespace: The configured arguments + + Example: + # Use parsed command line arguments (default) + initialize_config() + + # Use custom configuration programmatically + custom_args = argparse.Namespace( + host='localhost', + port=8080, + working_dir='./custom_rag', + # ... other config + ) + initialize_config(custom_args) + """ + global _global_args, _initialized + + if _initialized and not force: + return _global_args + + resolved_args = args if args is not None else parse_args() + validate_auth_configuration(resolved_args) + _global_args = resolved_args + _initialized = True + return _global_args + + +def get_config(): + """Get global configuration, auto-initializing if needed + + Returns: + argparse.Namespace: The configured arguments + """ + if not _initialized: + initialize_config() + return _global_args + + +class _GlobalArgsProxy: + """Proxy object that auto-initializes configuration on first access + + This maintains backward compatibility with existing code while + allowing programmatic control over initialization timing. + + The proxy fully delegates to the underlying argparse.Namespace, + including support for vars() calls which is used by binding_options + to extract provider-specific configuration options. + """ + + def __getattribute__(self, name): + """Override attribute access to support vars() and regular attribute access. + + This method intercepts __dict__ access (used by vars()) and delegates + to the underlying _global_args namespace, ensuring binding options + can be properly extracted. + """ + global _initialized, _global_args + + # Handle __dict__ access for vars() support + if name == "__dict__": + if not _initialized: + initialize_config() + return vars(_global_args) + + # Handle class-level attributes that should come from the proxy itself + if name in ("__class__", "__repr__", "__getattribute__", "__setattr__"): + return object.__getattribute__(self, name) + + # Delegate all other attribute access to the underlying namespace + if not _initialized: + initialize_config() + return getattr(_global_args, name) + + def __setattr__(self, name, value): + global _initialized, _global_args + if not _initialized: + initialize_config() + setattr(_global_args, name, value) + + def __repr__(self): + global _initialized, _global_args + if not _initialized: + return "" + return repr(_global_args) + + +# Create proxy instance for backward compatibility +# Existing code like `from config import global_args` continues to work +# The proxy will auto-initialize on first attribute access +global_args = _GlobalArgsProxy() diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/gunicorn_config.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/gunicorn_config.py new file mode 100644 index 0000000..4d2292d --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/gunicorn_config.py @@ -0,0 +1,162 @@ +# gunicorn_config.py +import os +import logging +from lightrag.kg.shared_storage import finalize_share_data +from lightrag.utils import setup_logger, get_env_value +from lightrag.constants import ( + DEFAULT_LOG_MAX_BYTES, + DEFAULT_LOG_BACKUP_COUNT, + DEFAULT_LOG_FILENAME, +) + + +# Get log directory path from environment variable +log_dir = os.getenv("LOG_DIR", os.getcwd()) +log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME)) + +# Ensure log directory exists +os.makedirs(os.path.dirname(log_file_path), exist_ok=True) + +# Get log file max size and backup count from environment variables +log_max_bytes = get_env_value("LOG_MAX_BYTES", DEFAULT_LOG_MAX_BYTES, int) +log_backup_count = get_env_value("LOG_BACKUP_COUNT", DEFAULT_LOG_BACKUP_COUNT, int) + +# These variables will be set by run_with_gunicorn.py +workers = None +bind = None +loglevel = None +certfile = None +keyfile = None + +# Enable preload_app option +preload_app = True + +# Use Uvicorn worker +worker_class = "uvicorn.workers.UvicornWorker" + +# Other Gunicorn configurations + +# Logging configuration +errorlog = os.getenv("ERROR_LOG", log_file_path) # Default write to lightrag.log +accesslog = os.getenv("ACCESS_LOG", log_file_path) # Default write to lightrag.log + +logconfig_dict = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": {"format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s"}, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "standard", + "stream": "ext://sys.stdout", + }, + "file": { + "class": "logging.handlers.RotatingFileHandler", + "formatter": "standard", + "filename": log_file_path, + "maxBytes": log_max_bytes, + "backupCount": log_backup_count, + "encoding": "utf8", + }, + }, + "filters": { + "path_filter": { + "()": "lightrag.utils.LightragPathFilter", + }, + }, + "loggers": { + "lightrag": { + "handlers": ["console", "file"], + "level": loglevel.upper() if loglevel else "INFO", + "propagate": False, + }, + "gunicorn": { + "handlers": ["console", "file"], + "level": loglevel.upper() if loglevel else "INFO", + "propagate": False, + }, + "gunicorn.error": { + "handlers": ["console", "file"], + "level": loglevel.upper() if loglevel else "INFO", + "propagate": False, + }, + "gunicorn.access": { + "handlers": ["console", "file"], + "level": loglevel.upper() if loglevel else "INFO", + "propagate": False, + "filters": ["path_filter"], + }, + }, +} + + +def on_starting(server): + """ + Executed when Gunicorn starts, before forking the first worker processes + You can use this function to do more initialization tasks for all processes + """ + print("=" * 80) + print(f"GUNICORN MASTER PROCESS: on_starting jobs for {workers} worker(s)") + print(f"Process ID: {os.getpid()}") + print("=" * 80) + + # Memory usage monitoring + try: + import psutil + + process = psutil.Process(os.getpid()) + memory_info = process.memory_info() + msg = ( + f"Memory usage after initialization: {memory_info.rss / 1024 / 1024:.2f} MB" + ) + print(msg) + except ImportError: + print("psutil not installed, skipping memory usage reporting") + + # Log the location of the LightRAG log file + print(f"LightRAG log file: {log_file_path}\n") + + print("Gunicorn initialization complete, forking workers...\n") + + +def on_exit(server): + """ + Executed when Gunicorn is shutting down. + This is a good place to release shared resources. + """ + print("=" * 80) + print("GUNICORN MASTER PROCESS: Shutting down") + print(f"Process ID: {os.getpid()}") + + print("Finalizing shared storage...") + finalize_share_data() + + print("Gunicorn shutdown complete") + print("=" * 80) + + +def post_fork(server, worker): + """ + Executed after a worker has been forked. + This is a good place to set up worker-specific configurations. + """ + # Set up main loggers + log_level = loglevel.upper() if loglevel else "INFO" + setup_logger("uvicorn", log_level, add_filter=False, log_file_path=log_file_path) + setup_logger( + "uvicorn.access", log_level, add_filter=True, log_file_path=log_file_path + ) + setup_logger("lightrag", log_level, add_filter=True, log_file_path=log_file_path) + + # Set up lightrag submodule loggers + for name in logging.root.manager.loggerDict: + if name.startswith("lightrag."): + setup_logger(name, log_level, add_filter=True, log_file_path=log_file_path) + + # Disable uvicorn.error logger + uvicorn_error_logger = logging.getLogger("uvicorn.error") + uvicorn_error_logger.handlers = [] + uvicorn_error_logger.setLevel(logging.CRITICAL) + uvicorn_error_logger.propagate = False diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/lightrag_server.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/lightrag_server.py new file mode 100644 index 0000000..242fe57 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/lightrag_server.py @@ -0,0 +1,1628 @@ +""" +LightRAG FastAPI Server +""" + +from fastapi import FastAPI, Depends, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.openapi.docs import ( + get_swagger_ui_html, + get_swagger_ui_oauth2_redirect_html, +) +import os +import re +import logging +import logging.config +import sys +import uvicorn +import pipmaster as pm +from fastapi.staticfiles import StaticFiles +from fastapi.responses import RedirectResponse +from pathlib import Path +from ascii_colors import ASCIIColors +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +from dotenv import load_dotenv +from lightrag.api.utils_api import ( + get_combined_auth_dependency, + display_splash_screen, + check_env_file, +) +from .config import ( + global_args, + update_uvicorn_mode_config, + get_default_host, + resolve_asymmetric_embedding_opt_in, + PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS, +) +from lightrag.utils import get_env_value +from lightrag import LightRAG, __version__ as core_version +from lightrag.api import __api_version__ +from lightrag.types import GPTKeywordExtractionFormat +from lightrag.utils import EmbeddingFunc +from lightrag.constants import ( + DEFAULT_LOG_MAX_BYTES, + DEFAULT_LOG_BACKUP_COUNT, + DEFAULT_LOG_FILENAME, + DEFAULT_LLM_TIMEOUT, + DEFAULT_EMBEDDING_TIMEOUT, +) +from lightrag.api.routers.document_routes import ( + DocumentManager, + create_document_routes, +) +from lightrag.api.routers.query_routes import create_query_routes +from lightrag.api.routers.graph_routes import create_graph_routes +from lightrag.api.routers.ollama_api import OllamaAPI + +from lightrag.utils import logger, set_verbose_debug +from lightrag.kg.shared_storage import ( + get_namespace_data, + get_default_workspace, + # set_default_workspace, + cleanup_keyed_lock, + finalize_share_data, +) +from fastapi.security import OAuth2PasswordRequestForm +from lightrag.api.auth import auth_handler + +# use the .env that is inside the current folder +# allows to use different .env file for each lightrag instance +# the OS environment variables take precedence over the .env file +load_dotenv(dotenv_path=".env", override=False) + + +webui_title = os.getenv("WEBUI_TITLE") +webui_description = os.getenv("WEBUI_DESCRIPTION") + +# Global authentication configuration +auth_configured = bool(auth_handler.accounts) + + +class LLMConfigCache: + """Smart LLM and Embedding configuration cache class""" + + def __init__(self, args): + self.args = args + + # Initialize configurations based on binding conditions + self.openai_llm_options = None + self.gemini_llm_options = None + self.gemini_embedding_options = None + self.ollama_llm_options = None + self.ollama_embedding_options = None + + # Only initialize and log OpenAI options when using OpenAI-related bindings + if args.llm_binding in ["openai", "azure_openai"]: + from lightrag.llm.binding_options import OpenAILLMOptions + + self.openai_llm_options = OpenAILLMOptions.options_dict(args) + logger.info(f"OpenAI LLM Options: {self.openai_llm_options}") + + if args.llm_binding == "gemini": + from lightrag.llm.binding_options import GeminiLLMOptions + + self.gemini_llm_options = GeminiLLMOptions.options_dict(args) + logger.info(f"Gemini LLM Options: {self.gemini_llm_options}") + + # Only initialize and log Ollama LLM options when using Ollama LLM binding + if args.llm_binding == "ollama": + try: + from lightrag.llm.binding_options import OllamaLLMOptions + + self.ollama_llm_options = OllamaLLMOptions.options_dict(args) + logger.info(f"Ollama LLM Options: {self.ollama_llm_options}") + except ImportError: + logger.warning( + "OllamaLLMOptions not available, using default configuration" + ) + self.ollama_llm_options = {} + + # Only initialize and log Ollama Embedding options when using Ollama Embedding binding + if args.embedding_binding == "ollama": + try: + from lightrag.llm.binding_options import OllamaEmbeddingOptions + + self.ollama_embedding_options = OllamaEmbeddingOptions.options_dict( + args + ) + logger.info( + f"Ollama Embedding Options: {self.ollama_embedding_options}" + ) + except ImportError: + logger.warning( + "OllamaEmbeddingOptions not available, using default configuration" + ) + self.ollama_embedding_options = {} + + # Only initialize and log Gemini Embedding options when using Gemini Embedding binding + if args.embedding_binding == "gemini": + try: + from lightrag.llm.binding_options import GeminiEmbeddingOptions + + self.gemini_embedding_options = GeminiEmbeddingOptions.options_dict( + args + ) + logger.info( + f"Gemini Embedding Options: {self.gemini_embedding_options}" + ) + except ImportError: + logger.warning( + "GeminiEmbeddingOptions not available, using default configuration" + ) + self.gemini_embedding_options = {} + + +def check_frontend_build(): + """Check if frontend is built and optionally check if source is up-to-date + + Returns: + tuple: (assets_exist: bool, is_outdated: bool) + - assets_exist: True if WebUI build files exist + - is_outdated: True if source is newer than build (only in dev environment) + """ + webui_dir = Path(__file__).parent / "webui" + index_html = webui_dir / "index.html" + + # 1. Check if build files exist + if not index_html.exists(): + ASCIIColors.yellow("\n" + "=" * 80) + ASCIIColors.yellow("WARNING: Frontend Not Built") + ASCIIColors.yellow("=" * 80) + ASCIIColors.yellow("The WebUI frontend has not been built yet.") + ASCIIColors.yellow("The API server will start without the WebUI interface.") + ASCIIColors.yellow( + "\nTo enable WebUI, build the frontend using these commands:\n" + ) + ASCIIColors.cyan(" cd lightrag_webui") + ASCIIColors.cyan(" bun install --frozen-lockfile") + ASCIIColors.cyan(" bun run build") + ASCIIColors.cyan(" cd ..") + ASCIIColors.yellow("\nThen restart the service.\n") + ASCIIColors.cyan( + "Note: Make sure you have Bun installed. Visit https://bun.sh for installation." + ) + ASCIIColors.yellow("=" * 80 + "\n") + return (False, False) # Assets don't exist, not outdated + + # 2. Check if this is a development environment (source directory exists) + try: + source_dir = Path(__file__).parent.parent.parent / "lightrag_webui" + src_dir = source_dir / "src" + + # Determine if this is a development environment: source directory exists and contains src directory + if not source_dir.exists() or not src_dir.exists(): + # Production environment, skip source code check + logger.debug( + "Production environment detected, skipping source freshness check" + ) + return (True, False) # Assets exist, not outdated (prod environment) + + # Development environment, perform source code timestamp check + logger.debug("Development environment detected, checking source freshness") + + # Source code file extensions (files to check) + source_extensions = { + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", # TypeScript/JavaScript + ".css", + ".scss", + ".sass", + ".less", # Style files + ".json", + ".jsonc", # Configuration/data files + ".html", + ".htm", # Template files + ".md", + ".mdx", # Markdown + } + + # Key configuration files (in lightrag_webui root directory) + key_files = [ + source_dir / "package.json", + source_dir / "bun.lock", + source_dir / "vite.config.ts", + source_dir / "tsconfig.json", + source_dir / "tailraid.config.js", + source_dir / "index.html", + ] + + # Get the latest modification time of source code + latest_source_time = 0 + + # Check source code files in src directory + for file_path in src_dir.rglob("*"): + if file_path.is_file(): + # Only check source code files, ignore temporary files and logs + if file_path.suffix.lower() in source_extensions: + mtime = file_path.stat().st_mtime + latest_source_time = max(latest_source_time, mtime) + + # Check key configuration files + for key_file in key_files: + if key_file.exists(): + mtime = key_file.stat().st_mtime + latest_source_time = max(latest_source_time, mtime) + + # Get build time + build_time = index_html.stat().st_mtime + + # Compare timestamps (5 second tolerance to avoid file system time precision issues) + if latest_source_time > build_time + 5: + ASCIIColors.yellow("\n" + "=" * 80) + ASCIIColors.yellow("WARNING: Frontend Source Code Has Been Updated") + ASCIIColors.yellow("=" * 80) + ASCIIColors.yellow( + "The frontend source code is newer than the current build." + ) + ASCIIColors.yellow( + "This might happen after 'git pull' or manual code changes.\n" + ) + ASCIIColors.cyan( + "Recommended: Rebuild the frontend to use the latest changes:" + ) + ASCIIColors.cyan(" cd lightrag_webui") + ASCIIColors.cyan(" bun install --frozen-lockfile") + ASCIIColors.cyan(" bun run build") + ASCIIColors.cyan(" cd ..") + ASCIIColors.yellow("\nThe server will continue with the current build.") + ASCIIColors.yellow("=" * 80 + "\n") + return (True, True) # Assets exist, outdated + else: + logger.info("Frontend build is up-to-date") + return (True, False) # Assets exist, up-to-date + + except Exception as e: + # If check fails, log warning but don't affect startup + logger.warning(f"Failed to check frontend source freshness: {e}") + return (True, False) # Assume assets exist and up-to-date on error + + +def create_app(args): + # Check frontend build first and get status + webui_assets_exist, is_frontend_outdated = check_frontend_build() + + # Create unified API version display with warning symbol if frontend is outdated + api_version_display = ( + f"{__api_version__}⚠️" if is_frontend_outdated else __api_version__ + ) + + # Setup logging + logger.setLevel(args.log_level) + set_verbose_debug(args.verbose) + + # Create configuration cache (this will output configuration logs) + config_cache = LLMConfigCache(args) + + # Verify that bindings are correctly setup + if args.llm_binding not in [ + "lollms", + "ollama", + "openai", + "azure_openai", + "aws_bedrock", + "gemini", + ]: + raise Exception("llm binding not supported") + + if args.embedding_binding not in [ + "lollms", + "ollama", + "openai", + "azure_openai", + "aws_bedrock", + "jina", + "gemini", + "voyageai", + ]: + raise Exception(f"embedding binding '{args.embedding_binding}' not supported") + + # Set default hosts if not provided + if args.llm_binding_host is None: + args.llm_binding_host = get_default_host(args.llm_binding) + + if args.embedding_binding_host is None: + args.embedding_binding_host = get_default_host(args.embedding_binding) + + # Add SSL validation + if args.ssl: + if not args.ssl_certfile or not args.ssl_keyfile: + raise Exception( + "SSL certificate and key files must be provided when SSL is enabled" + ) + if not os.path.exists(args.ssl_certfile): + raise Exception(f"SSL certificate file not found: {args.ssl_certfile}") + if not os.path.exists(args.ssl_keyfile): + raise Exception(f"SSL key file not found: {args.ssl_keyfile}") + + # Check if API key is provided either through env var or args + api_key = os.getenv("LIGHTRAG_API_KEY") or args.key + + # Initialize document manager with workspace support for data isolation + doc_manager = DocumentManager(args.input_dir, workspace=args.workspace) + + @asynccontextmanager + async def lifespan(app: FastAPI): + """Lifespan context manager for startup and shutdown events""" + # Store background tasks + app.state.background_tasks = set() + + try: + # Initialize database connections + # Note: initialize_storages() now auto-initializes pipeline_status for rag.workspace + await rag.initialize_storages() + + # Data migration regardless of storage implementation + await rag.check_and_migrate_data() + + ASCIIColors.green("\nServer is ready to accept connections! 🚀\n") + + yield + + finally: + # Clean up database connections + await rag.finalize_storages() + + if "LIGHTRAG_GUNICORN_MODE" not in os.environ: + # Only perform cleanup in Uvicorn single-process mode + logger.debug("Unvicorn Mode: finalizing shared storage...") + finalize_share_data() + else: + # In Gunicorn mode with preload_app=True, cleanup is handled by on_exit hooks + logger.debug( + "Gunicorn Mode: postpone shared storage finalization to master process" + ) + + # Initialize FastAPI + base_description = ( + "Providing API for LightRAG core, Web UI and Ollama Model Emulation" + ) + swagger_description = ( + base_description + + (" (API-Key Enabled)" if api_key else "") + + "\n\n[View ReDoc documentation](/redoc)" + ) + app_kwargs = { + "title": "LightRAG Server API", + "description": swagger_description, + "version": __api_version__, + "openapi_url": "/openapi.json", # Explicitly set OpenAPI schema URL + "docs_url": None, # Disable default docs, we'll create custom endpoint + "redoc_url": "/redoc", # Explicitly set redoc URL + "lifespan": lifespan, + } + + # Configure Swagger UI parameters + # Enable persistAuthorization and tryItOutEnabled for better user experience + app_kwargs["swagger_ui_parameters"] = { + "persistAuthorization": True, + "tryItOutEnabled": True, + } + + app = FastAPI(**app_kwargs) + + # Add custom validation error handler for /query/data endpoint + @app.exception_handler(RequestValidationError) + async def validation_exception_handler( + request: Request, exc: RequestValidationError + ): + # Check if this is a request to /query/data endpoint + if request.url.path.endswith("/query/data"): + # Extract error details + error_details = [] + for error in exc.errors(): + field_path = " -> ".join(str(loc) for loc in error["loc"]) + error_details.append(f"{field_path}: {error['msg']}") + + error_message = "; ".join(error_details) + + # Return in the expected format for /query/data + return JSONResponse( + status_code=400, + content={ + "status": "failure", + "message": f"Validation error: {error_message}", + "data": {}, + "metadata": {}, + }, + ) + else: + # For other endpoints, return the default FastAPI validation error + return JSONResponse(status_code=422, content={"detail": exc.errors()}) + + def get_cors_origins(): + """Get allowed origins from global_args + Returns a list of allowed origins, defaults to ["*"] if not set + """ + origins_str = global_args.cors_origins + if origins_str == "*": + return ["*"] + return [origin.strip() for origin in origins_str.split(",")] + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=get_cors_origins(), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=[ + "X-New-Token" + ], # Expose token renewal header for cross-origin requests + ) + + # Create combined auth dependency for all endpoints + combined_auth = get_combined_auth_dependency(api_key) + + def get_workspace_from_request(request: Request) -> str | None: + """ + Extract workspace from HTTP request header or use default. + + This enables multi-workspace API support by checking the custom + 'LIGHTRAG-WORKSPACE' header. If not present, falls back to the + server's default workspace configuration. + + Args: + request: FastAPI Request object + + Returns: + Workspace identifier (may be empty string for global namespace) + """ + # Check custom header first + workspace = request.headers.get("LIGHTRAG-WORKSPACE", "").strip() + + if not workspace: + workspace = None + else: + sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", workspace) + if sanitized != workspace: + logger.warning( + f"Workspace header '{workspace}' contains invalid characters. " + f"Sanitized to '{sanitized}'." + ) + workspace = sanitized + + return workspace + + # Create working directory if it doesn't exist + Path(args.working_dir).mkdir(parents=True, exist_ok=True) + + def create_optimized_openai_llm_func( + config_cache: LLMConfigCache, args, llm_timeout: int + ): + """Create optimized OpenAI LLM function with pre-processed configuration""" + + async def optimized_openai_alike_model_complete( + prompt, + system_prompt=None, + history_messages=None, + keyword_extraction=False, + **kwargs, + ) -> str: + from lightrag.llm.openai import openai_complete_if_cache + + keyword_extraction = kwargs.pop("keyword_extraction", None) + if keyword_extraction: + kwargs["response_format"] = GPTKeywordExtractionFormat + if history_messages is None: + history_messages = [] + + # Use pre-processed configuration to avoid repeated parsing + kwargs["timeout"] = llm_timeout + if config_cache.openai_llm_options: + kwargs.update(config_cache.openai_llm_options) + + return await openai_complete_if_cache( + args.llm_model, + prompt, + system_prompt=system_prompt, + history_messages=history_messages, + base_url=args.llm_binding_host, + api_key=args.llm_binding_api_key, + **kwargs, + ) + + return optimized_openai_alike_model_complete + + def create_optimized_azure_openai_llm_func( + config_cache: LLMConfigCache, args, llm_timeout: int + ): + """Create optimized Azure OpenAI LLM function with pre-processed configuration""" + + async def optimized_azure_openai_model_complete( + prompt, + system_prompt=None, + history_messages=None, + keyword_extraction=False, + **kwargs, + ) -> str: + from lightrag.llm.azure_openai import azure_openai_complete_if_cache + + keyword_extraction = kwargs.pop("keyword_extraction", None) + if keyword_extraction: + kwargs["response_format"] = GPTKeywordExtractionFormat + if history_messages is None: + history_messages = [] + + # Use pre-processed configuration to avoid repeated parsing + kwargs["timeout"] = llm_timeout + if config_cache.openai_llm_options: + kwargs.update(config_cache.openai_llm_options) + + return await azure_openai_complete_if_cache( + args.llm_model, + prompt, + system_prompt=system_prompt, + history_messages=history_messages, + base_url=args.llm_binding_host, + api_key=os.getenv("AZURE_OPENAI_API_KEY", args.llm_binding_api_key), + api_version=os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview"), + **kwargs, + ) + + return optimized_azure_openai_model_complete + + def create_optimized_gemini_llm_func( + config_cache: LLMConfigCache, args, llm_timeout: int + ): + """Create optimized Gemini LLM function with cached configuration""" + + async def optimized_gemini_model_complete( + prompt, + system_prompt=None, + history_messages=None, + keyword_extraction=False, + **kwargs, + ) -> str: + from lightrag.llm.gemini import gemini_complete_if_cache + + if history_messages is None: + history_messages = [] + + # Use pre-processed configuration to avoid repeated parsing + kwargs["timeout"] = llm_timeout + if ( + config_cache.gemini_llm_options is not None + and "generation_config" not in kwargs + ): + kwargs["generation_config"] = dict(config_cache.gemini_llm_options) + + return await gemini_complete_if_cache( + args.llm_model, + prompt, + system_prompt=system_prompt, + history_messages=history_messages, + api_key=args.llm_binding_api_key, + base_url=args.llm_binding_host, + keyword_extraction=keyword_extraction, + **kwargs, + ) + + return optimized_gemini_model_complete + + def create_llm_model_func(binding: str): + """ + Create LLM model function based on binding type. + Uses optimized functions for OpenAI bindings and lazy import for others. + """ + try: + if binding == "lollms": + from lightrag.llm.lollms import lollms_model_complete + + return lollms_model_complete + elif binding == "ollama": + from lightrag.llm.ollama import ollama_model_complete + + return ollama_model_complete + elif binding == "aws_bedrock": + return bedrock_model_complete # Already defined locally + elif binding == "azure_openai": + # Use optimized function with pre-processed configuration + return create_optimized_azure_openai_llm_func( + config_cache, args, llm_timeout + ) + elif binding == "gemini": + return create_optimized_gemini_llm_func(config_cache, args, llm_timeout) + else: # openai and compatible + # Use optimized function with pre-processed configuration + return create_optimized_openai_llm_func(config_cache, args, llm_timeout) + except ImportError as e: + raise Exception(f"Failed to import {binding} LLM binding: {e}") + + def create_llm_model_kwargs(binding: str, args, llm_timeout: int) -> dict: + """ + Create LLM model kwargs based on binding type. + Uses lazy import for binding-specific options. + """ + if binding in ["lollms", "ollama"]: + try: + from lightrag.llm.binding_options import OllamaLLMOptions + + return { + "host": args.llm_binding_host, + "timeout": llm_timeout, + "options": OllamaLLMOptions.options_dict(args), + "api_key": args.llm_binding_api_key, + } + except ImportError as e: + raise Exception(f"Failed to import {binding} options: {e}") + return {} + + def create_optimized_embedding_function( + config_cache: LLMConfigCache, + binding, + model, + host, + api_key, + args, + document_prefix=None, + query_prefix=None, + ) -> EmbeddingFunc: + """ + Create optimized embedding function and return an EmbeddingFunc instance + with proper max_token_size inheritance from provider defaults. + + This function: + 1. Imports the provider embedding function + 2. Extracts max_token_size and embedding_dim from provider if it's an EmbeddingFunc + 3. Creates an optimized wrapper that calls the underlying function directly (avoiding double-wrapping) + 4. Returns a properly configured EmbeddingFunc instance + + Configuration Rules: + - When EMBEDDING_MODEL is not set: Uses provider's default model and dimension + (e.g., jina-embeddings-v4 with 2048 dims, text-embedding-3-small with 1536 dims) + - When EMBEDDING_MODEL is set to a custom model: User MUST also set EMBEDDING_DIM + to match the custom model's dimension (e.g., for jina-embeddings-v3, set EMBEDDING_DIM=1024) + + Note: The embedding_dim parameter is automatically injected by EmbeddingFunc wrapper + when send_dimensions=True (enabled for Jina and Gemini bindings). This wrapper calls + the underlying provider function directly (.func) to avoid double-wrapping, so we must + explicitly pass embedding_dim to the provider's underlying function. + """ + + # Step 1: Import provider function and extract default attributes + provider_func = None + provider_max_token_size = None + provider_embedding_dim = None + provider_supports_asymmetric = False + + try: + if binding == "openai": + from lightrag.llm.openai import openai_embed + + provider_func = openai_embed + elif binding == "ollama": + from lightrag.llm.ollama import ollama_embed + + provider_func = ollama_embed + elif binding == "gemini": + from lightrag.llm.gemini import gemini_embed + + provider_func = gemini_embed + elif binding == "jina": + from lightrag.llm.jina import jina_embed + + provider_func = jina_embed + elif binding == "azure_openai": + from lightrag.llm.azure_openai import azure_openai_embed + + provider_func = azure_openai_embed + elif binding == "aws_bedrock": + from lightrag.llm.bedrock import bedrock_embed + + provider_func = bedrock_embed + elif binding == "lollms": + from lightrag.llm.lollms import lollms_embed + + provider_func = lollms_embed + elif binding == "voyageai": + from lightrag.llm.voyageai import voyageai_embed + + provider_func = voyageai_embed + # Extract attributes if provider is an EmbeddingFunc + if provider_func and isinstance(provider_func, EmbeddingFunc): + provider_max_token_size = provider_func.max_token_size + provider_embedding_dim = provider_func.embedding_dim + provider_supports_asymmetric = provider_func.supports_asymmetric + logger.debug( + f"Extracted from {binding} provider: " + f"max_token_size={provider_max_token_size}, " + f"embedding_dim={provider_embedding_dim}, " + f"supports_asymmetric={provider_supports_asymmetric}" + ) + except ImportError as e: + logger.warning(f"Could not import provider function for {binding}: {e}") + + # Step 2: Apply priority (user config > provider default) + # For max_token_size: explicit env var > provider default > None + final_max_token_size = args.embedding_token_limit or provider_max_token_size + # For embedding_dim: user config (always has value) takes priority + # Only use provider default if user config is explicitly None (which shouldn't happen) + final_embedding_dim = ( + args.embedding_dim if args.embedding_dim else provider_embedding_dim + ) + # Asymmetric embedding is explicit opt-in only. Provider-specific + # validation decides whether task parameters or prefixes are required. + asymmetric_opt_in = resolve_asymmetric_embedding_opt_in( + binding=binding, + embedding_asymmetric=args.embedding_asymmetric, + embedding_asymmetric_configured=args.embedding_asymmetric_configured, + query_prefix=query_prefix, + document_prefix=document_prefix, + query_prefix_configured=args.embedding_query_prefix_configured, + document_prefix_configured=args.embedding_document_prefix_configured, + ) + + # Step 3: Create optimized embedding function (calls underlying function directly) + # Note: When model is None, each binding will use its own default model + async def optimized_embedding_function( + texts, embedding_dim=None, context="document" + ): + try: + if binding == "lollms": + from lightrag.llm.lollms import lollms_embed + + # Get real function, skip EmbeddingFunc wrapper if present + actual_func = ( + lollms_embed.func + if isinstance(lollms_embed, EmbeddingFunc) + else lollms_embed + ) + # lollms embed_model is not used (server uses configured vectorizer) + # Only pass base_url and api_key + return await actual_func(texts, base_url=host, api_key=api_key) + elif binding == "ollama": + from lightrag.llm.ollama import ollama_embed + + # Get real function, skip EmbeddingFunc wrapper if present + actual_func = ( + ollama_embed.func + if isinstance(ollama_embed, EmbeddingFunc) + else ollama_embed + ) + + # Use pre-processed configuration if available + if config_cache.ollama_embedding_options is not None: + ollama_options = config_cache.ollama_embedding_options + else: + from lightrag.llm.binding_options import OllamaEmbeddingOptions + + ollama_options = OllamaEmbeddingOptions.options_dict(args) + + # Pass embed_model only if provided, let function use its default (bge-m3:latest) + kwargs = { + "texts": texts, + "host": host, + "api_key": api_key, + "options": ollama_options, + } + if provider_supports_asymmetric and asymmetric_opt_in: + kwargs["context"] = context + if query_prefix: + kwargs["query_prefix"] = query_prefix + if document_prefix: + kwargs["document_prefix"] = document_prefix + if model: + kwargs["embed_model"] = model + return await actual_func(**kwargs) + elif binding == "azure_openai": + from lightrag.llm.azure_openai import azure_openai_embed + + actual_func = ( + azure_openai_embed.func + if isinstance(azure_openai_embed, EmbeddingFunc) + else azure_openai_embed + ) + # Pass model only if provided, let function use its default otherwise + kwargs = { + "texts": texts, + "api_key": api_key, + "embedding_dim": embedding_dim, + } + if model: + kwargs["model"] = model + if provider_supports_asymmetric and asymmetric_opt_in: + kwargs["context"] = context + if query_prefix: + kwargs["query_prefix"] = query_prefix + if document_prefix: + kwargs["document_prefix"] = document_prefix + return await actual_func(**kwargs) + elif binding == "aws_bedrock": + from lightrag.llm.bedrock import bedrock_embed + + actual_func = ( + bedrock_embed.func + if isinstance(bedrock_embed, EmbeddingFunc) + else bedrock_embed + ) + # Pass model only if provided, let function use its default otherwise + kwargs = {"texts": texts} + if model: + kwargs["model"] = model + return await actual_func(**kwargs) + elif binding == "jina": + from lightrag.llm.jina import jina_embed + + actual_func = ( + jina_embed.func + if isinstance(jina_embed, EmbeddingFunc) + else jina_embed + ) + # Pass model only if provided, let function use its default (jina-embeddings-v4) + kwargs = { + "texts": texts, + "embedding_dim": embedding_dim, + "base_url": host, + "api_key": api_key, + } + if model: + kwargs["model"] = model + if provider_supports_asymmetric and asymmetric_opt_in: + kwargs["context"] = context + kwargs["task"] = None + return await actual_func(**kwargs) + elif binding == "gemini": + from lightrag.llm.gemini import gemini_embed + + actual_func = ( + gemini_embed.func + if isinstance(gemini_embed, EmbeddingFunc) + else gemini_embed + ) + + # Use pre-processed configuration if available + if config_cache.gemini_embedding_options is not None: + gemini_options = config_cache.gemini_embedding_options + else: + from lightrag.llm.binding_options import GeminiEmbeddingOptions + + gemini_options = GeminiEmbeddingOptions.options_dict(args) + # Pass model only if provided, let function use its default (gemini-embedding-001) + kwargs = { + "texts": texts, + "base_url": host, + "api_key": api_key, + "embedding_dim": embedding_dim, + } + if model: + kwargs["model"] = model + task_type = gemini_options.get("task_type") + if task_type is not None: + kwargs["task_type"] = task_type + if provider_supports_asymmetric and asymmetric_opt_in: + kwargs["context"] = context + return await actual_func(**kwargs) + elif binding == "voyageai": + from lightrag.llm.voyageai import voyageai_embed + + actual_func = ( + voyageai_embed.func + if isinstance(voyageai_embed, EmbeddingFunc) + else voyageai_embed + ) + kwargs = { + "texts": texts, + "api_key": api_key, + "embedding_dim": embedding_dim, + } + if model: + kwargs["model"] = model + if provider_supports_asymmetric and asymmetric_opt_in: + kwargs["context"] = context + return await actual_func(**kwargs) + else: # openai and compatible + from lightrag.llm.openai import openai_embed + + actual_func = ( + openai_embed.func + if isinstance(openai_embed, EmbeddingFunc) + else openai_embed + ) + # Pass model only if provided, let function use its default (text-embedding-3-small) + kwargs = { + "texts": texts, + "base_url": host, + "api_key": api_key, + "embedding_dim": embedding_dim, + } + if model: + kwargs["model"] = model + if provider_supports_asymmetric and asymmetric_opt_in: + kwargs["context"] = context + if query_prefix: + kwargs["query_prefix"] = query_prefix + if document_prefix: + kwargs["document_prefix"] = document_prefix + return await actual_func(**kwargs) + except ImportError as e: + raise Exception(f"Failed to import {binding} embedding: {e}") + + # Step 4: Wrap in EmbeddingFunc and return + embedding_func_instance = EmbeddingFunc( + embedding_dim=final_embedding_dim, + func=optimized_embedding_function, + max_token_size=final_max_token_size, + send_dimensions=False, # Will be set later based on binding requirements + model_name=model, + supports_asymmetric=provider_supports_asymmetric and asymmetric_opt_in, + ) + + # Log final embedding configuration. Only include prefix info when + # prefixes will actually be applied (prefix-based asymmetric mode). + prefix_info = "" + if ( + asymmetric_opt_in + and binding in PREFIX_ASYMMETRIC_EMBEDDING_BINDINGS + and (document_prefix or query_prefix) + ): + prefix_info = f" document_prefix={repr(document_prefix)} query_prefix={repr(query_prefix)}" + logger.info( + f"Embedding config: binding={binding} model={model} " + f"embedding_dim={final_embedding_dim} max_token_size={final_max_token_size}{prefix_info}" + ) + + return embedding_func_instance + + llm_timeout = get_env_value("LLM_TIMEOUT", DEFAULT_LLM_TIMEOUT, int) + embedding_timeout = get_env_value( + "EMBEDDING_TIMEOUT", DEFAULT_EMBEDDING_TIMEOUT, int + ) + + async def bedrock_model_complete( + prompt, + system_prompt=None, + history_messages=None, + keyword_extraction=False, + **kwargs, + ) -> str: + # Lazy import + from lightrag.llm.bedrock import bedrock_complete_if_cache + + keyword_extraction = kwargs.pop("keyword_extraction", None) + if keyword_extraction: + kwargs["response_format"] = GPTKeywordExtractionFormat + if history_messages is None: + history_messages = [] + + # Use global temperature for Bedrock + kwargs["temperature"] = get_env_value("BEDROCK_LLM_TEMPERATURE", 1.0, float) + + return await bedrock_complete_if_cache( + args.llm_model, + prompt, + system_prompt=system_prompt, + history_messages=history_messages, + **kwargs, + ) + + # Create embedding function with optimized configuration and max_token_size inheritance + import inspect + + # Create the EmbeddingFunc instance (now returns complete EmbeddingFunc with max_token_size) + embedding_func = create_optimized_embedding_function( + config_cache=config_cache, + binding=args.embedding_binding, + model=args.embedding_model, + host=args.embedding_binding_host, + api_key=args.embedding_binding_api_key, + args=args, + document_prefix=args.embedding_document_prefix, + query_prefix=args.embedding_query_prefix, + ) + + # Get embedding_send_dim from centralized configuration + embedding_send_dim = args.embedding_send_dim + + # Check if the underlying function signature has embedding_dim parameter + sig = inspect.signature(embedding_func.func) + has_embedding_dim_param = "embedding_dim" in sig.parameters + + # Determine send_dimensions value based on binding type + # Jina and Gemini REQUIRE dimension parameter (forced to True) + # OpenAI and others: controlled by EMBEDDING_SEND_DIM environment variable + if args.embedding_binding in ["jina", "gemini"]: + # Jina and Gemini APIs require dimension parameter - always send it + send_dimensions = has_embedding_dim_param + dimension_control = f"forced by {args.embedding_binding.title()} API" + else: + # For OpenAI and other bindings, respect EMBEDDING_SEND_DIM setting + send_dimensions = embedding_send_dim and has_embedding_dim_param + if send_dimensions or not embedding_send_dim: + dimension_control = "by env var" + else: + dimension_control = "by not hasparam" + + # Set send_dimensions on the EmbeddingFunc instance + embedding_func.send_dimensions = send_dimensions + + logger.info( + f"Send embedding dimension: {send_dimensions} {dimension_control} " + f"(dimensions={embedding_func.embedding_dim}, has_param={has_embedding_dim_param}, " + f"binding={args.embedding_binding})" + ) + + # Log max_token_size source + if embedding_func.max_token_size: + source = ( + "env variable" + if args.embedding_token_limit + else f"{args.embedding_binding} provider default" + ) + logger.info( + f"Embedding max_token_size: {embedding_func.max_token_size} (from {source})" + ) + else: + logger.info( + "Embedding max_token_size: None (Embedding token limit is disabled)." + ) + + # Configure rerank function based on args.rerank_bindingparameter + rerank_model_func = None + if args.rerank_binding != "null": + from lightrag.rerank import cohere_rerank, jina_rerank, ali_rerank + + # Map rerank binding to corresponding function + rerank_functions = { + "cohere": cohere_rerank, + "jina": jina_rerank, + "aliyun": ali_rerank, + } + + # Select the appropriate rerank function based on binding + selected_rerank_func = rerank_functions.get(args.rerank_binding) + if not selected_rerank_func: + logger.error(f"Unsupported rerank binding: {args.rerank_binding}") + raise ValueError(f"Unsupported rerank binding: {args.rerank_binding}") + + # Get default values from selected_rerank_func if args values are None + if args.rerank_model is None or args.rerank_binding_host is None: + sig = inspect.signature(selected_rerank_func) + + # Set default model if args.rerank_model is None + if args.rerank_model is None and "model" in sig.parameters: + default_model = sig.parameters["model"].default + if default_model != inspect.Parameter.empty: + args.rerank_model = default_model + + # Set default base_url if args.rerank_binding_host is None + if args.rerank_binding_host is None and "base_url" in sig.parameters: + default_base_url = sig.parameters["base_url"].default + if default_base_url != inspect.Parameter.empty: + args.rerank_binding_host = default_base_url + + async def server_rerank_func( + query: str, documents: list, top_n: int = None, extra_body: dict = None + ): + """Server rerank function with configuration from environment variables""" + # Prepare kwargs for rerank function + kwargs = { + "query": query, + "documents": documents, + "top_n": top_n, + "api_key": args.rerank_binding_api_key, + "model": args.rerank_model, + "base_url": args.rerank_binding_host, + } + + # Add Cohere-specific parameters if using cohere binding + if args.rerank_binding == "cohere": + # Enable chunking if configured (useful for models with token limits like ColBERT) + kwargs["enable_chunking"] = ( + os.getenv("RERANK_ENABLE_CHUNKING", "false").lower() == "true" + ) + kwargs["max_tokens_per_doc"] = int( + os.getenv("RERANK_MAX_TOKENS_PER_DOC", "4096") + ) + + return await selected_rerank_func(**kwargs, extra_body=extra_body) + + rerank_model_func = server_rerank_func + logger.info( + f"Reranking is enabled: {args.rerank_model or 'default model'} using {args.rerank_binding} provider" + ) + else: + logger.info("Reranking is disabled") + + # Create ollama_server_infos from command line arguments + from lightrag.api.config import OllamaServerInfos + + ollama_server_infos = OllamaServerInfos( + name=args.simulated_model_name, tag=args.simulated_model_tag + ) + + # Initialize RAG with unified configuration + try: + rag = LightRAG( + working_dir=args.working_dir, + workspace=args.workspace, + llm_model_func=create_llm_model_func(args.llm_binding), + llm_model_name=args.llm_model, + llm_model_max_async=args.max_async, + summary_max_tokens=args.summary_max_tokens, + summary_context_size=args.summary_context_size, + chunk_token_size=int(args.chunk_size), + chunk_overlap_token_size=int(args.chunk_overlap_size), + llm_model_kwargs=create_llm_model_kwargs( + args.llm_binding, args, llm_timeout + ), + embedding_func=embedding_func, + default_llm_timeout=llm_timeout, + default_embedding_timeout=embedding_timeout, + kv_storage=args.kv_storage, + graph_storage=args.graph_storage, + vector_storage=args.vector_storage, + doc_status_storage=args.doc_status_storage, + vector_db_storage_cls_kwargs={ + "cosine_better_than_threshold": args.cosine_threshold + }, + enable_llm_cache_for_entity_extract=args.enable_llm_cache_for_extract, + enable_llm_cache=args.enable_llm_cache, + rerank_model_func=rerank_model_func, + max_parallel_insert=args.max_parallel_insert, + max_graph_nodes=args.max_graph_nodes, + addon_params={ + "language": args.summary_language, + "entity_types": args.entity_types, + }, + ollama_server_infos=ollama_server_infos, + ) + except Exception as e: + logger.error(f"Failed to initialize LightRAG: {e}") + raise + + # Add routes + app.include_router( + create_document_routes( + rag, + doc_manager, + api_key, + ) + ) + app.include_router(create_query_routes(rag, api_key, args.top_k)) + app.include_router(create_graph_routes(rag, api_key)) + + # Add Ollama API routes + ollama_api = OllamaAPI(rag, top_k=args.top_k, api_key=api_key) + app.include_router(ollama_api.router, prefix="/api") + + # Custom Swagger UI endpoint for offline support + @app.get("/docs", include_in_schema=False) + async def custom_swagger_ui_html(): + """Custom Swagger UI HTML with local static files""" + return get_swagger_ui_html( + openapi_url=app.openapi_url, + title=app.title + " - Swagger UI", + oauth2_redirect_url="/docs/oauth2-redirect", + swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js", + swagger_css_url="/static/swagger-ui/swagger-ui.css", + swagger_favicon_url="/static/swagger-ui/favicon-32x32.png", + swagger_ui_parameters=app.swagger_ui_parameters, + ) + + @app.get("/docs/oauth2-redirect", include_in_schema=False) + async def swagger_ui_redirect(): + """OAuth2 redirect for Swagger UI""" + return get_swagger_ui_oauth2_redirect_html() + + @app.get("/") + async def redirect_to_webui(): + """Redirect root path based on WebUI availability""" + if webui_assets_exist: + return RedirectResponse(url="/webui") + else: + return RedirectResponse(url="/docs") + + @app.get("/auth-status") + async def get_auth_status(): + """Get authentication status and guest token if auth is not configured""" + + if not auth_handler.accounts: + # Authentication not configured, return guest token + guest_token = auth_handler.create_token( + username="guest", role="guest", metadata={"auth_mode": "disabled"} + ) + return { + "auth_configured": False, + "access_token": guest_token, + "token_type": "bearer", + "auth_mode": "disabled", + "message": "Authentication is disabled. Using guest access.", + "core_version": core_version, + "api_version": api_version_display, + "webui_title": webui_title, + "webui_description": webui_description, + } + + return { + "auth_configured": True, + "auth_mode": "enabled", + "core_version": core_version, + "api_version": api_version_display, + "webui_title": webui_title, + "webui_description": webui_description, + } + + @app.post("/login") + async def login(form_data: OAuth2PasswordRequestForm = Depends()): + if not auth_handler.accounts: + # Authentication not configured, return guest token + guest_token = auth_handler.create_token( + username="guest", role="guest", metadata={"auth_mode": "disabled"} + ) + return { + "access_token": guest_token, + "token_type": "bearer", + "auth_mode": "disabled", + "message": "Authentication is disabled. Using guest access.", + "core_version": core_version, + "api_version": api_version_display, + "webui_title": webui_title, + "webui_description": webui_description, + } + username = form_data.username + if not auth_handler.verify_password(username, form_data.password): + raise HTTPException(status_code=401, detail="Incorrect credentials") + + # Regular user login + user_token = auth_handler.create_token( + username=username, role="user", metadata={"auth_mode": "enabled"} + ) + return { + "access_token": user_token, + "token_type": "bearer", + "auth_mode": "enabled", + "core_version": core_version, + "api_version": api_version_display, + "webui_title": webui_title, + "webui_description": webui_description, + } + + @app.get( + "/health", + dependencies=[Depends(combined_auth)], + summary="Get system health and configuration status", + description="Returns comprehensive system status including WebUI availability, configuration, and operational metrics", + response_description="System health status with configuration details", + responses={ + 200: { + "description": "Successful response with system status", + "content": { + "application/json": { + "example": { + "status": "healthy", + "webui_available": True, + "working_directory": "/path/to/working/dir", + "input_directory": "/path/to/input/dir", + "configuration": { + "llm_binding": "openai", + "llm_model": "gpt-4", + "embedding_binding": "openai", + "embedding_model": "text-embedding-ada-002", + "workspace": "default", + }, + "auth_mode": "enabled", + "pipeline_busy": False, + "core_version": "0.0.1", + "api_version": "0.0.1", + } + } + }, + } + }, + ) + async def get_status(request: Request): + """Get current system status including WebUI availability""" + try: + workspace = get_workspace_from_request(request) + default_workspace = get_default_workspace() + if workspace is None: + workspace = default_workspace + pipeline_status = await get_namespace_data( + "pipeline_status", workspace=workspace + ) + + if not auth_configured: + auth_mode = "disabled" + else: + auth_mode = "enabled" + + # Cleanup expired keyed locks and get status + keyed_lock_info = cleanup_keyed_lock() + + return { + "status": "healthy", + "webui_available": webui_assets_exist, + "working_directory": str(args.working_dir), + "input_directory": str(args.input_dir), + "configuration": { + # LLM configuration binding/host address (if applicable)/model (if applicable) + "llm_binding": args.llm_binding, + "llm_binding_host": args.llm_binding_host, + "llm_model": args.llm_model, + # embedding model configuration binding/host address (if applicable)/model (if applicable) + "embedding_binding": args.embedding_binding, + "embedding_binding_host": args.embedding_binding_host, + "embedding_model": args.embedding_model, + "summary_max_tokens": args.summary_max_tokens, + "summary_context_size": args.summary_context_size, + "kv_storage": args.kv_storage, + "doc_status_storage": args.doc_status_storage, + "graph_storage": args.graph_storage, + "vector_storage": args.vector_storage, + "enable_llm_cache_for_extract": args.enable_llm_cache_for_extract, + "enable_llm_cache": args.enable_llm_cache, + "workspace": default_workspace, + "max_graph_nodes": args.max_graph_nodes, + # Rerank configuration + "enable_rerank": rerank_model_func is not None, + "rerank_binding": args.rerank_binding, + "rerank_model": args.rerank_model if rerank_model_func else None, + "rerank_binding_host": args.rerank_binding_host + if rerank_model_func + else None, + # Environment variable status (requested configuration) + "summary_language": args.summary_language, + "force_llm_summary_on_merge": args.force_llm_summary_on_merge, + "max_parallel_insert": args.max_parallel_insert, + "cosine_threshold": args.cosine_threshold, + "min_rerank_score": args.min_rerank_score, + "related_chunk_number": args.related_chunk_number, + "max_async": args.max_async, + "embedding_func_max_async": args.embedding_func_max_async, + "embedding_batch_num": args.embedding_batch_num, + }, + "auth_mode": auth_mode, + "pipeline_busy": pipeline_status.get("busy", False), + "keyed_locks": keyed_lock_info, + "core_version": core_version, + "api_version": api_version_display, + "webui_title": webui_title, + "webui_description": webui_description, + } + except Exception as e: + logger.error(f"Error getting health status: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + # Custom StaticFiles class for smart caching + class SmartStaticFiles(StaticFiles): # Renamed from NoCacheStaticFiles + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + + is_html = path.endswith(".html") or response.media_type == "text/html" + + if is_html: + response.headers["Cache-Control"] = ( + "no-cache, no-store, must-revalidate" + ) + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + elif ( + "/assets/" in path + ): # Assets (JS, CSS, images, fonts) generated by Vite with hash in filename + response.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" + ) + # Add other rules here if needed for non-HTML, non-asset files + + # Ensure correct Content-Type + if path.endswith(".js"): + response.headers["Content-Type"] = "application/javascript" + elif path.endswith(".css"): + response.headers["Content-Type"] = "text/css" + + return response + + # Mount Swagger UI static files for offline support + swagger_static_dir = Path(__file__).parent / "static" / "swagger-ui" + if swagger_static_dir.exists(): + app.mount( + "/static/swagger-ui", + StaticFiles(directory=swagger_static_dir), + name="swagger-ui-static", + ) + + # Conditionally mount WebUI only if assets exist + if webui_assets_exist: + static_dir = Path(__file__).parent / "webui" + static_dir.mkdir(exist_ok=True) + app.mount( + "/webui", + SmartStaticFiles( + directory=static_dir, html=True, check_dir=True + ), # Use SmartStaticFiles + name="webui", + ) + logger.info("WebUI assets mounted at /webui") + else: + logger.info("WebUI assets not available, /webui route not mounted") + + # Add redirect for /webui when assets are not available + @app.get("/webui") + @app.get("/webui/") + async def webui_redirect_to_docs(): + """Redirect /webui to /docs when WebUI is not available""" + return RedirectResponse(url="/docs") + + return app + + +def get_application(args=None): + """Factory function for creating the FastAPI application""" + if args is None: + args = global_args + return create_app(args) + + +def configure_logging(): + """Configure logging for uvicorn startup""" + + # Reset any existing handlers to ensure clean configuration + for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "lightrag"]: + logger = logging.getLogger(logger_name) + logger.handlers = [] + logger.filters = [] + + # Get log directory path from environment variable + log_dir = os.getenv("LOG_DIR", os.getcwd()) + log_file_path = os.path.abspath(os.path.join(log_dir, DEFAULT_LOG_FILENAME)) + + print(f"\nLightRAG log file: {log_file_path}\n") + os.makedirs(os.path.dirname(log_dir), exist_ok=True) + + # Get log file max size and backup count from environment variables + log_max_bytes = get_env_value("LOG_MAX_BYTES", DEFAULT_LOG_MAX_BYTES, int) + log_backup_count = get_env_value("LOG_BACKUP_COUNT", DEFAULT_LOG_BACKUP_COUNT, int) + + logging.config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(levelname)s: %(message)s", + }, + "detailed": { + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + }, + }, + "handlers": { + "console": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + }, + "file": { + "formatter": "detailed", + "class": "logging.handlers.RotatingFileHandler", + "filename": log_file_path, + "maxBytes": log_max_bytes, + "backupCount": log_backup_count, + "encoding": "utf-8", + }, + }, + "loggers": { + # Configure all uvicorn related loggers + "uvicorn": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + }, + "uvicorn.access": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + "filters": ["path_filter"], + }, + "uvicorn.error": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + }, + "lightrag": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + "filters": ["path_filter"], + }, + }, + "filters": { + "path_filter": { + "()": "lightrag.utils.LightragPathFilter", + }, + }, + } + ) + + +def check_and_install_dependencies(): + """Check and install required dependencies""" + required_packages = [ + "uvicorn", + "tiktoken", + "fastapi", + # Add other required packages here + ] + + for package in required_packages: + if not pm.is_installed(package): + print(f"Installing {package}...") + pm.install(package) + print(f"{package} installed successfully") + + +def main(): + # On Windows, ProactorEventLoop (default since Python 3.8) has known + # race conditions with uvicorn's socket binding that can cause the server + # to report it's running while the port is never actually bound. + # Using SelectorEventLoop resolves this issue. + # See: https://github.com/HKUDS/LightRAG/issues/2438 + if sys.platform == "win32": + import asyncio + + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + # Explicitly initialize configuration for clarity + # (The proxy will auto-initialize anyway, but this makes intent clear) + from .config import initialize_config + + initialize_config() + + # Check if running under Gunicorn + if "GUNICORN_CMD_ARGS" in os.environ: + # If started with Gunicorn, return directly as Gunicorn will call get_application + print("Running under Gunicorn - worker management handled by Gunicorn") + return + + # Check .env file + if not check_env_file(): + sys.exit(1) + + # Check and install dependencies + check_and_install_dependencies() + + from multiprocessing import freeze_support + + freeze_support() + + # Configure logging before parsing args + configure_logging() + update_uvicorn_mode_config() + display_splash_screen(global_args) + + # Note: Signal handlers are NOT registered here because: + # - Uvicorn has built-in signal handling that properly calls lifespan shutdown + # - Custom signal handlers can interfere with uvicorn's graceful shutdown + # - Cleanup is handled by the lifespan context manager's finally block + + # Create application instance directly instead of using factory function + app = create_app(global_args) + + # Start Uvicorn in single process mode + uvicorn_config = { + "app": app, # Pass application instance directly instead of string path + "host": global_args.host, + "port": global_args.port, + "log_config": None, # Disable default config + } + + if global_args.ssl: + uvicorn_config.update( + { + "ssl_certfile": global_args.ssl_certfile, + "ssl_keyfile": global_args.ssl_keyfile, + } + ) + + print( + f"Starting Uvicorn server in single-process mode on {global_args.host}:{global_args.port}" + ) + uvicorn.run(**uvicorn_config) + + +if __name__ == "__main__": + main() diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/passwords.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/passwords.py new file mode 100644 index 0000000..92eefa2 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/passwords.py @@ -0,0 +1,26 @@ +import bcrypt + +BCRYPT_PASSWORD_PREFIX = "{bcrypt}" + + +def hash_password(password: str) -> str: + """Return an AUTH_ACCOUNTS-ready bcrypt password value.""" + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8") + return f"{BCRYPT_PASSWORD_PREFIX}{hashed}" + + +def verify_password(plain_password: str, stored_password: str) -> bool: + """Verify a plaintext password against a stored password spec.""" + if stored_password.startswith(BCRYPT_PASSWORD_PREFIX): + hashed_password = stored_password[len(BCRYPT_PASSWORD_PREFIX) :] + if not hashed_password: + return False + try: + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + except ValueError: + return False + + return stored_password == plain_password diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/__init__.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/__init__.py new file mode 100644 index 0000000..b71f204 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/__init__.py @@ -0,0 +1,10 @@ +""" +This module contains all the routers for the LightRAG API. +""" + +from .document_routes import router as document_router +from .query_routes import router as query_router +from .graph_routes import router as graph_router +from .ollama_api import OllamaAPI + +__all__ = ["document_router", "query_router", "graph_router", "OllamaAPI"] diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/document_routes.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/document_routes.py new file mode 100644 index 0000000..9e6fab9 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/document_routes.py @@ -0,0 +1,3427 @@ +""" +This module contains all document-related routes for the LightRAG API. +""" + +import asyncio +import time +from uuid import uuid4 +from functools import lru_cache +from lightrag.utils import logger, get_pinyin_sort_key, performance_timing_log +import aiofiles +import traceback +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional, Any, Literal +from io import BytesIO +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + File, + HTTPException, + UploadFile, +) +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from lightrag import LightRAG +from lightrag.base import DeletionResult, DocProcessingStatus, DocStatus +from lightrag.utils import ( + generate_track_id, + compute_mdhash_id, + sanitize_text_for_encoding, +) +from lightrag.api.utils_api import get_combined_auth_dependency +from ..config import global_args + + +@lru_cache(maxsize=1) +def _is_docling_available() -> bool: + """Check if docling is available (cached check). + + This function uses lru_cache to avoid repeated import attempts. + The result is cached after the first call. + + Returns: + bool: True if docling is available, False otherwise + """ + try: + import docling # noqa: F401 # type: ignore[import-not-found] + + return True + except ImportError: + return False + + +# Function to format datetime to ISO format string with timezone information +def format_datetime(dt: Any) -> Optional[str]: + """Format datetime to ISO format string with timezone information + + Args: + dt: Datetime object, string, or None + + Returns: + ISO format string with timezone information, or None if input is None + """ + if dt is None: + return None + if isinstance(dt, str): + return dt + + # Check if datetime object has timezone information + if isinstance(dt, datetime): + # If datetime object has no timezone info (naive datetime), add UTC timezone + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + + # Return ISO format string with timezone information + return dt.isoformat() + + +router = APIRouter( + prefix="/documents", + tags=["documents"], +) + +# Temporary file prefix +temp_prefix = "__tmp__" +UNKNOWN_FILE_SOURCE = "unknown_source" +LEGACY_EMPTY_FILE_PATH_SENTINELS = {"", "no-file-path"} + + +def normalize_file_path(file_path: str | None) -> str: + """Normalize missing document sources to a single non-null sentinel.""" + if file_path is None: + return UNKNOWN_FILE_SOURCE + + normalized = file_path.strip() + if normalized in LEGACY_EMPTY_FILE_PATH_SENTINELS: + return UNKNOWN_FILE_SOURCE + + return normalized + + +def sanitize_filename(filename: str, input_dir: Path) -> str: + """ + Sanitize uploaded filename to prevent Path Traversal attacks. + + Args: + filename: The original filename from the upload + input_dir: The target input directory + + Returns: + str: Sanitized filename that is safe to use + + Raises: + HTTPException: If the filename is unsafe or invalid + """ + # Basic validation + if not filename or not filename.strip(): + raise HTTPException(status_code=400, detail="Filename cannot be empty") + + # Remove path separators and traversal sequences + clean_name = filename.replace("/", "").replace("\\", "") + clean_name = clean_name.replace("..", "") + + # Remove control characters and null bytes + clean_name = "".join(c for c in clean_name if ord(c) >= 32 and c != "\x7f") + + # Remove leading/trailing whitespace and dots + clean_name = clean_name.strip().strip(".") + + # Check if anything is left after sanitization + if not clean_name: + raise HTTPException( + status_code=400, detail="Invalid filename after sanitization" + ) + + # Verify the final path stays within the input directory + try: + final_path = (input_dir / clean_name).resolve() + if not final_path.is_relative_to(input_dir.resolve()): + raise HTTPException(status_code=400, detail="Unsafe filename detected") + except (OSError, ValueError): + raise HTTPException(status_code=400, detail="Invalid filename") + + return clean_name + + +class ScanResponse(BaseModel): + """Response model for document scanning operation + + Attributes: + status: Status of the scanning operation + message: Optional message with additional details + track_id: Tracking ID for monitoring scanning progress + """ + + status: Literal["scanning_started"] = Field( + description="Status of the scanning operation" + ) + message: Optional[str] = Field( + default=None, description="Additional details about the scanning operation" + ) + track_id: str = Field(description="Tracking ID for monitoring scanning progress") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "scanning_started", + "message": "Scanning process has been initiated in the background", + "track_id": "scan_20250729_170612_abc123", + } + } + ) + + +class ReprocessResponse(BaseModel): + """Response model for reprocessing failed documents operation + + Attributes: + status: Status of the reprocessing operation + message: Message describing the operation result + track_id: Always empty string. Reprocessed documents retain their original track_id. + """ + + status: Literal["reprocessing_started"] = Field( + description="Status of the reprocessing operation" + ) + message: str = Field(description="Human-readable message describing the operation") + track_id: str = Field( + default="", + description="Always empty string. Reprocessed documents retain their original track_id from initial upload.", + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "reprocessing_started", + "message": "Reprocessing of failed documents has been initiated in background", + "track_id": "", + } + } + ) + + +class CancelPipelineResponse(BaseModel): + """Response model for pipeline cancellation operation + + Attributes: + status: Status of the cancellation request + message: Message describing the operation result + """ + + status: Literal["cancellation_requested", "not_busy"] = Field( + description="Status of the cancellation request" + ) + message: str = Field(description="Human-readable message describing the operation") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "cancellation_requested", + "message": "Pipeline cancellation has been requested. Documents will be marked as FAILED.", + } + } + ) + + +class InsertTextRequest(BaseModel): + """Request model for inserting a single text document + + Attributes: + text: The text content to be inserted into the RAG system + file_source: Source of the text (optional) + """ + + text: str = Field( + min_length=1, + description="The text to insert", + ) + file_source: Optional[str] = Field( + default=None, min_length=0, description="File Source" + ) + + @field_validator("text", mode="after") + @classmethod + def strip_text_after(cls, text: str) -> str: + return text.strip() + + @field_validator("file_source", mode="before") + @classmethod + def normalize_source_before(cls, file_source: Optional[str]) -> str: + return normalize_file_path(file_source) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "text": "This is a sample text to be inserted into the RAG system.", + "file_source": "Source of the text (optional)", + } + } + ) + + +class InsertTextsRequest(BaseModel): + """Request model for inserting multiple text documents + + Attributes: + texts: List of text contents to be inserted into the RAG system + file_sources: Sources of the texts (optional) + """ + + texts: list[str] = Field( + min_length=1, + description="The texts to insert", + ) + file_sources: Optional[list[str]] = Field( + default=None, min_length=0, description="Sources of the texts" + ) + + @field_validator("texts", mode="after") + @classmethod + def strip_texts_after(cls, texts: list[str]) -> list[str]: + return [text.strip() for text in texts] + + @field_validator("file_sources", mode="before") + @classmethod + def normalize_sources_before( + cls, file_sources: Optional[list[str]] + ) -> Optional[list[str]]: + if file_sources is None: + return None + + return [normalize_file_path(file_source) for file_source in file_sources] + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "texts": [ + "This is the first text to be inserted.", + "This is the second text to be inserted.", + ], + "file_sources": [ + "First file source (optional)", + ], + } + } + ) + + +class InsertResponse(BaseModel): + """Response model for document insertion operations + + Attributes: + status: Status of the operation (success, duplicated, partial_success, failure) + message: Detailed message describing the operation result + track_id: Tracking ID for monitoring processing status + """ + + status: Literal["success", "duplicated", "partial_success", "failure"] = Field( + description="Status of the operation" + ) + message: str = Field(description="Message describing the operation result") + track_id: str = Field(description="Tracking ID for monitoring processing status") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "success", + "message": "File 'document.pdf' uploaded successfully. Processing will continue in background.", + "track_id": "upload_20250729_170612_abc123", + } + } + ) + + +class ClearDocumentsResponse(BaseModel): + """Response model for document clearing operation + + Attributes: + status: Status of the clear operation + message: Detailed message describing the operation result + """ + + status: Literal["success", "partial_success", "busy", "fail"] = Field( + description="Status of the clear operation" + ) + message: str = Field(description="Message describing the operation result") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "success", + "message": "All documents cleared successfully. Deleted 15 files.", + } + } + ) + + +class ClearCacheRequest(BaseModel): + """Request model for clearing cache + + This model is kept for API compatibility but no longer accepts any parameters. + All cache will be cleared regardless of the request content. + """ + + model_config = ConfigDict(json_schema_extra={"example": {}}) + + +class ClearCacheResponse(BaseModel): + """Response model for cache clearing operation + + Attributes: + status: Status of the clear operation + message: Detailed message describing the operation result + """ + + status: Literal["success", "fail"] = Field( + description="Status of the clear operation" + ) + message: str = Field(description="Message describing the operation result") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status": "success", + "message": "Successfully cleared cache for modes: ['default', 'naive']", + } + } + ) + + +"""Response model for document status + +Attributes: + id: Document identifier + content_summary: Summary of document content + content_length: Length of document content + status: Current processing status + created_at: Creation timestamp (ISO format string) + updated_at: Last update timestamp (ISO format string) + chunks_count: Number of chunks (optional) + error: Error message if any (optional) + metadata: Additional metadata (optional) + file_path: Path to the document file +""" + + +class DeleteDocRequest(BaseModel): + doc_ids: List[str] = Field(..., description="The IDs of the documents to delete.") + delete_file: bool = Field( + default=False, + description="Whether to delete the corresponding file in the upload directory.", + ) + delete_llm_cache: bool = Field( + default=False, + description="Whether to delete cached LLM extraction results for the documents.", + ) + + @field_validator("doc_ids", mode="after") + @classmethod + def validate_doc_ids(cls, doc_ids: List[str]) -> List[str]: + if not doc_ids: + raise ValueError("Document IDs list cannot be empty") + + validated_ids = [] + for doc_id in doc_ids: + if not doc_id or not doc_id.strip(): + raise ValueError("Document ID cannot be empty") + validated_ids.append(doc_id.strip()) + + # Check for duplicates + if len(validated_ids) != len(set(validated_ids)): + raise ValueError("Document IDs must be unique") + + return validated_ids + + +class DeleteEntityRequest(BaseModel): + entity_name: str = Field(..., description="The name of the entity to delete.") + + @field_validator("entity_name", mode="after") + @classmethod + def validate_entity_name(cls, entity_name: str) -> str: + if not entity_name or not entity_name.strip(): + raise ValueError("Entity name cannot be empty") + return entity_name.strip() + + +class DeleteRelationRequest(BaseModel): + source_entity: str = Field(..., description="The name of the source entity.") + target_entity: str = Field(..., description="The name of the target entity.") + + @field_validator("source_entity", "target_entity", mode="after") + @classmethod + def validate_entity_names(cls, entity_name: str) -> str: + if not entity_name or not entity_name.strip(): + raise ValueError("Entity name cannot be empty") + return entity_name.strip() + + +class DocStatusResponse(BaseModel): + id: str = Field(description="Document identifier") + content_summary: str = Field(description="Summary of document content") + content_length: int = Field(description="Length of document content in characters") + status: DocStatus = Field(description="Current processing status") + created_at: str = Field(description="Creation timestamp (ISO format string)") + updated_at: str = Field(description="Last update timestamp (ISO format string)") + track_id: Optional[str] = Field( + default=None, description="Tracking ID for monitoring progress" + ) + chunks_count: Optional[int] = Field( + default=None, description="Number of chunks the document was split into" + ) + error_msg: Optional[str] = Field( + default=None, description="Error message if processing failed" + ) + metadata: Optional[dict[str, Any]] = Field( + default=None, description="Additional metadata about the document" + ) + file_path: str = Field(description="Path to the document file") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": "doc_123456", + "content_summary": "Research paper on machine learning", + "content_length": 15240, + "status": "processed", + "created_at": "2025-03-31T12:34:56", + "updated_at": "2025-03-31T12:35:30", + "track_id": "upload_20250729_170612_abc123", + "chunks_count": 12, + "error": None, + "metadata": {"author": "John Doe", "year": 2025}, + "file_path": "research_paper.pdf", + } + } + ) + + +class DocsStatusesResponse(BaseModel): + """Response model for document statuses + + Attributes: + statuses: Dictionary mapping document status to lists of document status responses + """ + + statuses: Dict[DocStatus, List[DocStatusResponse]] = Field( + default_factory=dict, + description="Dictionary mapping document status to lists of document status responses", + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "statuses": { + "PENDING": [ + { + "id": "doc_123", + "content_summary": "Pending document", + "content_length": 5000, + "status": "pending", + "created_at": "2025-03-31T10:00:00", + "updated_at": "2025-03-31T10:00:00", + "track_id": "upload_20250331_100000_abc123", + "chunks_count": None, + "error": None, + "metadata": None, + "file_path": "pending_doc.pdf", + } + ], + "PREPROCESSED": [ + { + "id": "doc_789", + "content_summary": "Document pending final indexing", + "content_length": 7200, + "status": "preprocessed", + "created_at": "2025-03-31T09:30:00", + "updated_at": "2025-03-31T09:35:00", + "track_id": "upload_20250331_093000_xyz789", + "chunks_count": 10, + "error": None, + "metadata": None, + "file_path": "preprocessed_doc.pdf", + } + ], + "PROCESSED": [ + { + "id": "doc_456", + "content_summary": "Processed document", + "content_length": 8000, + "status": "processed", + "created_at": "2025-03-31T09:00:00", + "updated_at": "2025-03-31T09:05:00", + "track_id": "insert_20250331_090000_def456", + "chunks_count": 8, + "error": None, + "metadata": {"author": "John Doe"}, + "file_path": "processed_doc.pdf", + } + ], + } + } + } + ) + + +class TrackStatusResponse(BaseModel): + """Response model for tracking document processing status by track_id + + Attributes: + track_id: The tracking ID + documents: List of documents associated with this track_id + total_count: Total number of documents for this track_id + status_summary: Count of documents by status + """ + + track_id: str = Field(description="The tracking ID") + documents: List[DocStatusResponse] = Field( + description="List of documents associated with this track_id" + ) + total_count: int = Field(description="Total number of documents for this track_id") + status_summary: Dict[str, int] = Field(description="Count of documents by status") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "track_id": "upload_20250729_170612_abc123", + "documents": [ + { + "id": "doc_123456", + "content_summary": "Research paper on machine learning", + "content_length": 15240, + "status": "PROCESSED", + "created_at": "2025-03-31T12:34:56", + "updated_at": "2025-03-31T12:35:30", + "track_id": "upload_20250729_170612_abc123", + "chunks_count": 12, + "error": None, + "metadata": {"author": "John Doe", "year": 2025}, + "file_path": "research_paper.pdf", + } + ], + "total_count": 1, + "status_summary": {"PROCESSED": 1}, + } + } + ) + + +class DocumentsRequest(BaseModel): + """Request model for paginated document queries + + Attributes: + status_filter: Filter by document status, None for all statuses + page: Page number (1-based) + page_size: Number of documents per page (10-200) + sort_field: Field to sort by ('created_at', 'updated_at', 'id', 'file_path') + sort_direction: Sort direction ('asc' or 'desc') + """ + + status_filter: Optional[DocStatus] = Field( + default=None, description="Filter by document status, None for all statuses" + ) + page: int = Field(default=1, ge=1, description="Page number (1-based)") + page_size: int = Field( + default=50, ge=10, le=200, description="Number of documents per page (10-200)" + ) + sort_field: Literal["created_at", "updated_at", "id", "file_path"] = Field( + default="updated_at", description="Field to sort by" + ) + sort_direction: Literal["asc", "desc"] = Field( + default="desc", description="Sort direction" + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status_filter": "PROCESSED", + "page": 1, + "page_size": 50, + "sort_field": "updated_at", + "sort_direction": "desc", + } + } + ) + + +class PaginationInfo(BaseModel): + """Pagination information + + Attributes: + page: Current page number + page_size: Number of items per page + total_count: Total number of items + total_pages: Total number of pages + has_next: Whether there is a next page + has_prev: Whether there is a previous page + """ + + page: int = Field(description="Current page number") + page_size: int = Field(description="Number of items per page") + total_count: int = Field(description="Total number of items") + total_pages: int = Field(description="Total number of pages") + has_next: bool = Field(description="Whether there is a next page") + has_prev: bool = Field(description="Whether there is a previous page") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "page": 1, + "page_size": 50, + "total_count": 150, + "total_pages": 3, + "has_next": True, + "has_prev": False, + } + } + ) + + +class PaginatedDocsResponse(BaseModel): + """Response model for paginated document queries + + Attributes: + documents: List of documents for the current page + pagination: Pagination information + status_counts: Count of documents by status for all documents + """ + + documents: List[DocStatusResponse] = Field( + description="List of documents for the current page" + ) + pagination: PaginationInfo = Field(description="Pagination information") + status_counts: Dict[str, int] = Field( + description="Count of documents by status for all documents" + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "documents": [ + { + "id": "doc_123456", + "content_summary": "Research paper on machine learning", + "content_length": 15240, + "status": "PROCESSED", + "created_at": "2025-03-31T12:34:56", + "updated_at": "2025-03-31T12:35:30", + "track_id": "upload_20250729_170612_abc123", + "chunks_count": 12, + "error_msg": None, + "metadata": {"author": "John Doe", "year": 2025}, + "file_path": "research_paper.pdf", + } + ], + "pagination": { + "page": 1, + "page_size": 50, + "total_count": 150, + "total_pages": 3, + "has_next": True, + "has_prev": False, + }, + "status_counts": { + "PENDING": 10, + "PROCESSING": 5, + "PREPROCESSED": 5, + "PROCESSED": 130, + "FAILED": 5, + }, + } + } + ) + + +class StatusCountsResponse(BaseModel): + """Response model for document status counts + + Attributes: + status_counts: Count of documents by status + """ + + status_counts: Dict[str, int] = Field(description="Count of documents by status") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "status_counts": { + "PENDING": 10, + "PROCESSING": 5, + "PREPROCESSED": 5, + "PROCESSED": 130, + "FAILED": 5, + } + } + } + ) + + +class PipelineStatusResponse(BaseModel): + """Response model for pipeline status + + Attributes: + autoscanned: Whether auto-scan has started + busy: Whether the pipeline is currently busy + job_name: Current job name (e.g., indexing files/indexing texts) + job_start: Job start time as ISO format string with timezone (optional) + docs: Total number of documents to be indexed + batchs: Number of batches for processing documents + cur_batch: Current processing batch + request_pending: Flag for pending request for processing + latest_message: Latest message from pipeline processing + history_messages: List of history messages + update_status: Status of update flags for all namespaces + """ + + autoscanned: bool = False + busy: bool = False + job_name: str = "Default Job" + job_start: Optional[str] = None + docs: int = 0 + batchs: int = 0 + cur_batch: int = 0 + request_pending: bool = False + latest_message: str = "" + history_messages: Optional[List[str]] = None + update_status: Optional[dict] = None + + @field_validator("job_start", mode="before") + @classmethod + def parse_job_start(cls, value): + """Process datetime and return as ISO format string with timezone""" + return format_datetime(value) + + model_config = ConfigDict(extra="allow") + + +class DocumentManager: + def __init__( + self, + input_dir: str, + workspace: str = "", # New parameter for workspace isolation + supported_extensions: tuple = ( + ".txt", + ".md", + ".mdx", # MDX (Markdown + JSX) + ".pdf", + ".docx", + ".pptx", + ".xlsx", + ".rtf", # Rich Text Format + ".odt", # OpenDocument Text + ".tex", # LaTeX + ".epub", # Electronic Publication + ".html", # HyperText Markup Language + ".htm", # HyperText Markup Language + ".csv", # Comma-Separated Values + ".json", # JavaScript Object Notation + ".xml", # eXtensible Markup Language + ".yaml", # YAML Ain't Markup Language + ".yml", # YAML + ".log", # Log files + ".conf", # Configuration files + ".ini", # Initialization files + ".properties", # Java properties files + ".sql", # SQL scripts + ".bat", # Batch files + ".sh", # Shell scripts + ".c", # C source code + ".h", # C header + ".cpp", # C++ source code + ".hpp", # C++ header + ".py", # Python source code + ".java", # Java source code + ".js", # JavaScript source code + ".ts", # TypeScript source code + ".swift", # Swift source code + ".go", # Go source code + ".rb", # Ruby source code + ".php", # PHP source code + ".css", # Cascading Style Sheets + ".scss", # Sassy CSS + ".less", # LESS CSS + ), + ): + # Store the base input directory and workspace + self.base_input_dir = Path(input_dir) + self.workspace = workspace + self.supported_extensions = supported_extensions + self.indexed_files = set() + + # Create workspace-specific input directory + # If workspace is provided, create a subdirectory for data isolation + if workspace: + self.input_dir = self.base_input_dir / workspace + else: + self.input_dir = self.base_input_dir + + # Create input directory if it doesn't exist + self.input_dir.mkdir(parents=True, exist_ok=True) + + def scan_directory_for_new_files(self) -> List[Path]: + """Scan input directory for new files""" + new_files = [] + for ext in self.supported_extensions: + logger.debug(f"Scanning for {ext} files in {self.input_dir}") + for file_path in self.input_dir.glob(f"*{ext}"): + if file_path not in self.indexed_files: + new_files.append(file_path) + return new_files + + def mark_as_indexed(self, file_path: Path): + self.indexed_files.add(file_path) + + def is_supported_file(self, filename: str) -> bool: + return any(filename.lower().endswith(ext) for ext in self.supported_extensions) + + +def validate_file_path_security(file_path_str: str, base_dir: Path) -> Optional[Path]: + """ + Validate file path security to prevent Path Traversal attacks. + + Args: + file_path_str: The file path string to validate + base_dir: The base directory that the file must be within + + Returns: + Path: Safe file path if valid, None if unsafe or invalid + """ + if not file_path_str or not file_path_str.strip(): + return None + + try: + # Clean the file path string + clean_path_str = file_path_str.strip() + + # Check for obvious path traversal patterns before processing + # This catches both Unix (..) and Windows (..\) style traversals + if ".." in clean_path_str: + # Additional check for Windows-style backslash traversal + if ( + "\\..\\" in clean_path_str + or clean_path_str.startswith("..\\") + or clean_path_str.endswith("\\..") + ): + # logger.warning( + # f"Security violation: Windows path traversal attempt detected - {file_path_str}" + # ) + return None + + # Normalize path separators (convert backslashes to forward slashes) + # This helps handle Windows-style paths on Unix systems + normalized_path = clean_path_str.replace("\\", "/") + + # Create path object and resolve it (handles symlinks and relative paths) + candidate_path = (base_dir / normalized_path).resolve() + base_dir_resolved = base_dir.resolve() + + # Check if the resolved path is within the base directory + if not candidate_path.is_relative_to(base_dir_resolved): + # logger.warning( + # f"Security violation: Path traversal attempt detected - {file_path_str}" + # ) + return None + + return candidate_path + + except (OSError, ValueError, Exception) as e: + logger.warning(f"Invalid file path detected: {file_path_str} - {str(e)}") + return None + + +def get_unique_filename_in_enqueued(target_dir: Path, original_name: str) -> str: + """Generate a unique filename in the target directory by adding numeric suffixes if needed + + Args: + target_dir: Target directory path + original_name: Original filename + + Returns: + str: Unique filename (may have numeric suffix added) + """ + import time + + original_path = Path(original_name) + base_name = original_path.stem + extension = original_path.suffix + + # Try original name first + if not (target_dir / original_name).exists(): + return original_name + + # Try with numeric suffixes 001-999 + for i in range(1, 1000): + suffix = f"{i:03d}" + new_name = f"{base_name}_{suffix}{extension}" + if not (target_dir / new_name).exists(): + return new_name + + # Fallback with timestamp if all 999 slots are taken + timestamp = int(time.time()) + return f"{base_name}_{timestamp}{extension}" + + +# Document processing helper functions (synchronous) +# These functions run in thread pool via asyncio.to_thread() to avoid blocking the event loop + + +def _convert_with_docling(file_path: Path) -> str: + """Convert document using docling (synchronous). + + Args: + file_path: Path to the document file + + Returns: + str: Extracted markdown content + """ + from docling.document_converter import DocumentConverter # type: ignore + + converter = DocumentConverter() + result = converter.convert(file_path) + return result.document.export_to_markdown() + + +def _extract_pdf_pypdf(file_bytes: bytes, password: str = None) -> str: + """Extract PDF content using pypdf (synchronous). + + Args: + file_bytes: PDF file content as bytes + password: Optional password for encrypted PDFs + + Returns: + str: Extracted text content + + Raises: + Exception: If PDF is encrypted and password is incorrect or missing + """ + from pypdf import PdfReader # type: ignore + + pdf_file = BytesIO(file_bytes) + reader = PdfReader(pdf_file) + + # Check if PDF is encrypted + if reader.is_encrypted: + # Try empty password first (covers permission-only encrypted PDFs) + decrypt_result = reader.decrypt(password or "") + if decrypt_result == 0: + if password: + raise Exception("Incorrect PDF password") + else: + raise Exception("PDF is encrypted but no password provided") + + # Extract text from all pages + content = "" + for page in reader.pages: + content += page.extract_text() + "\n" + + return content + + +def _extract_docx(file_bytes: bytes) -> str: + """Extract DOCX content including tables in document order (synchronous). + + Args: + file_bytes: DOCX file content as bytes + + Returns: + str: Extracted text content with tables in their original positions. + Tables are separated from paragraphs with blank lines for clarity. + """ + from docx import Document # type: ignore + from docx.table import Table # type: ignore + from docx.text.paragraph import Paragraph # type: ignore + + docx_file = BytesIO(file_bytes) + doc = Document(docx_file) + + def escape_cell(cell_value: str | None) -> str: + """Escape characters that would break tab-delimited layout. + + Escape order is critical: backslashes first, then tabs/newlines. + This prevents double-escaping issues. + + Args: + cell_value: The cell value to escape (can be None or str) + + Returns: + str: Escaped cell value safe for tab-delimited format + """ + if cell_value is None: + return "" + text = str(cell_value) + # CRITICAL: Escape backslash first to avoid double-escaping + return ( + text.replace("\\", "\\\\") # Must be first: \ -> \\ + .replace("\t", "  ") # Tab -> \t (visible) + .replace("\r\n", "
") # Windows newline -> \n + .replace("\r", "
") # Mac newline -> \n + .replace("\n", "
") # Unix newline -> \n + ) + + content_parts = [] + in_table = False # Track if we're currently processing a table + + # Iterate through all body elements in document order + for element in doc.element.body: + # Check if element is a paragraph + if element.tag.endswith("p"): + # If coming out of a table, add blank line after table + if in_table: + content_parts.append("") # Blank line after table + in_table = False + + paragraph = Paragraph(element, doc) + text = paragraph.text + # Always append to preserve document spacing (including blank paragraphs) + content_parts.append(text) + + # Check if element is a table + elif element.tag.endswith("tbl"): + # Add blank line before table (if content exists) + if content_parts and not in_table: + content_parts.append("") # Blank line before table + + in_table = True + table = Table(element, doc) + for row in table.rows: + row_text = [] + for cell in row.cells: + cell_text = cell.text + # Escape special characters to preserve tab-delimited structure + row_text.append(escape_cell(cell_text)) + # Only add row if at least one cell has content + if any(cell for cell in row_text): + content_parts.append("\t".join(row_text)) + + return "\n".join(content_parts) + + +def _extract_pptx(file_bytes: bytes) -> str: + """Extract PPTX content (synchronous). + + Args: + file_bytes: PPTX file content as bytes + + Returns: + str: Extracted text content + """ + from pptx import Presentation # type: ignore + + pptx_file = BytesIO(file_bytes) + prs = Presentation(pptx_file) + content = "" + for slide in prs.slides: + for shape in slide.shapes: + if hasattr(shape, "text"): + content += shape.text + "\n" + return content + + +def _extract_xlsx(file_bytes: bytes) -> str: + """Extract XLSX content in tab-delimited format with clear sheet separation. + + This function processes Excel workbooks and converts them to a structured text format + suitable for LLM prompts and RAG systems. Each sheet is clearly delimited with + separator lines, and special characters are escaped to preserve the tab-delimited structure. + + Features: + - Each sheet is wrapped with '====================' separators for visual distinction + - Special characters (tabs, newlines, backslashes) are escaped to prevent structure corruption + - Column alignment is preserved across all rows to maintain tabular structure + - Empty rows are preserved as blank lines to maintain row structure + - Uses sheet.max_column to determine column width efficiently + + Args: + file_bytes: XLSX file content as bytes + + Returns: + str: Extracted text content with all sheets in tab-delimited format. + Format: Sheet separators, sheet name, then tab-delimited rows. + + Example output: + ==================== Sheet: Data ==================== + Name\tAge\tCity + Alice\t30\tNew York + Bob\t25\tLondon + + ==================== Sheet: Summary ==================== + Total\t2 + ==================== + """ + from openpyxl import load_workbook # type: ignore + + xlsx_file = BytesIO(file_bytes) + wb = load_workbook(xlsx_file) + + def escape_cell(cell_value: str | int | float | None) -> str: + """Escape characters that would break tab-delimited layout. + + Escape order is critical: backslashes first, then tabs/newlines. + This prevents double-escaping issues. + + Args: + cell_value: The cell value to escape (can be None, str, int, or float) + + Returns: + str: Escaped cell value safe for tab-delimited format + """ + if cell_value is None: + return "" + text = str(cell_value) + # CRITICAL: Escape backslash first to avoid double-escaping + return ( + text.replace("\\", "\\\\") # Must be first: \ -> \\ + .replace("\t", "\\t") # Tab -> \t (visible) + .replace("\r\n", "\\n") # Windows newline -> \n + .replace("\r", "\\n") # Mac newline -> \n + .replace("\n", "\\n") # Unix newline -> \n + ) + + def escape_sheet_title(title: str) -> str: + """Escape sheet title to prevent formatting issues in separators. + + Args: + title: Original sheet title + + Returns: + str: Sanitized sheet title with tabs/newlines replaced + """ + return str(title).replace("\n", " ").replace("\t", " ").replace("\r", " ") + + content_parts: list[str] = [] + sheet_separator = "=" * 20 + + for idx, sheet in enumerate(wb): + if idx > 0: + content_parts.append("") # Blank line between sheets for readability + + # Escape sheet title to handle edge cases with special characters + safe_title = escape_sheet_title(sheet.title) + content_parts.append(f"{sheet_separator} Sheet: {safe_title} {sheet_separator}") + + # Use sheet.max_column to get the maximum column width directly + max_columns = sheet.max_column if sheet.max_column else 0 + + # Extract rows with consistent width to preserve column alignment + for row in sheet.iter_rows(values_only=True): + row_parts = [] + + # Build row up to max_columns width + for idx in range(max_columns): + if idx < len(row): + row_parts.append(escape_cell(row[idx])) + else: + row_parts.append("") # Pad short rows + + # Check if row is completely empty + if all(part == "" for part in row_parts): + # Preserve empty rows as blank lines (maintains row structure) + content_parts.append("") + else: + # Join all columns to maintain consistent column count + content_parts.append("\t".join(row_parts)) + + # Final separator for symmetry (makes parsing easier) + content_parts.append(sheet_separator) + return "\n".join(content_parts) + + +async def pipeline_enqueue_file( + rag: LightRAG, file_path: Path, track_id: str = None +) -> tuple[bool, str]: + """Add a file to the queue for processing + + Args: + rag: LightRAG instance + file_path: Path to the saved file + track_id: Optional tracking ID, if not provided will be generated + Returns: + tuple: (success: bool, track_id: str) + """ + + # Generate track_id if not provided + if track_id is None: + track_id = generate_track_id("unknown") + + try: + content = "" + ext = file_path.suffix.lower() + file_size = 0 + + # Get file size for error reporting + try: + stat = await asyncio.to_thread(file_path.stat) + file_size = stat.st_size + except Exception: + file_size = 0 + + file = None + try: + async with aiofiles.open(file_path, "rb") as f: + file = await f.read() + except PermissionError as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]Permission denied - cannot read file", + "original_error": str(e), + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error( + f"[File Extraction]Permission denied reading file: {file_path.name}" + ) + return False, track_id + except FileNotFoundError as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]File not found", + "original_error": str(e), + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error(f"[File Extraction]File not found: {file_path.name}") + return False, track_id + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]File reading error", + "original_error": str(e), + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error( + f"[File Extraction]Error reading file {file_path.name}: {str(e)}" + ) + return False, track_id + + # Process based on file type + try: + match ext: + case ( + ".txt" + | ".md" + | ".mdx" + | ".html" + | ".htm" + | ".tex" + | ".json" + | ".xml" + | ".yaml" + | ".yml" + | ".rtf" + | ".odt" + | ".epub" + | ".csv" + | ".log" + | ".conf" + | ".ini" + | ".properties" + | ".sql" + | ".bat" + | ".sh" + | ".c" + | ".h" + | ".cpp" + | ".hpp" + | ".py" + | ".java" + | ".js" + | ".ts" + | ".swift" + | ".go" + | ".rb" + | ".php" + | ".css" + | ".scss" + | ".less" + ): + try: + # Try to decode as UTF-8 (offloaded to thread to avoid blocking the event loop) + content = await asyncio.to_thread(file.decode, "utf-8") + + # Validate content + if not content or len(content.strip()) == 0: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]Empty file content", + "original_error": "File contains no content or only whitespace", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]Empty content in file: {file_path.name}" + ) + return False, track_id + + # Check if content looks like binary data string representation + if content.startswith("b'") or content.startswith('b"'): + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]Binary data in text file", + "original_error": "File appears to contain binary data representation instead of text", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]File {file_path.name} appears to contain binary data representation instead of text" + ) + return False, track_id + + except UnicodeDecodeError as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]UTF-8 encoding error, please convert it to UTF-8 before processing", + "original_error": f"File is not valid UTF-8 encoded text: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]File {file_path.name} is not valid UTF-8 encoded text. Please convert it to UTF-8 before processing." + ) + return False, track_id + + case ".pdf": + try: + # Try DOCLING first if configured and available + if ( + global_args.document_loading_engine == "DOCLING" + and _is_docling_available() + ): + content = await asyncio.to_thread( + _convert_with_docling, file_path + ) + else: + if ( + global_args.document_loading_engine == "DOCLING" + and not _is_docling_available() + ): + logger.warning( + f"DOCLING engine configured but not available for {file_path.name}. Falling back to pypdf." + ) + # Use pypdf (non-blocking via to_thread) + content = await asyncio.to_thread( + _extract_pdf_pypdf, + file, + global_args.pdf_decrypt_password, + ) + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]PDF processing error", + "original_error": f"Failed to extract text from PDF: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]Error processing PDF {file_path.name}: {str(e)}" + ) + return False, track_id + + case ".docx": + try: + # Try DOCLING first if configured and available + if ( + global_args.document_loading_engine == "DOCLING" + and _is_docling_available() + ): + content = await asyncio.to_thread( + _convert_with_docling, file_path + ) + else: + if ( + global_args.document_loading_engine == "DOCLING" + and not _is_docling_available() + ): + logger.warning( + f"DOCLING engine configured but not available for {file_path.name}. Falling back to python-docx." + ) + # Use python-docx (non-blocking via to_thread) + content = await asyncio.to_thread(_extract_docx, file) + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]DOCX processing error", + "original_error": f"Failed to extract text from DOCX: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]Error processing DOCX {file_path.name}: {str(e)}" + ) + return False, track_id + + case ".pptx": + try: + # Try DOCLING first if configured and available + if ( + global_args.document_loading_engine == "DOCLING" + and _is_docling_available() + ): + content = await asyncio.to_thread( + _convert_with_docling, file_path + ) + else: + if ( + global_args.document_loading_engine == "DOCLING" + and not _is_docling_available() + ): + logger.warning( + f"DOCLING engine configured but not available for {file_path.name}. Falling back to python-pptx." + ) + # Use python-pptx (non-blocking via to_thread) + content = await asyncio.to_thread(_extract_pptx, file) + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]PPTX processing error", + "original_error": f"Failed to extract text from PPTX: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]Error processing PPTX {file_path.name}: {str(e)}" + ) + return False, track_id + + case ".xlsx": + try: + # Try DOCLING first if configured and available + if ( + global_args.document_loading_engine == "DOCLING" + and _is_docling_available() + ): + content = await asyncio.to_thread( + _convert_with_docling, file_path + ) + else: + if ( + global_args.document_loading_engine == "DOCLING" + and not _is_docling_available() + ): + logger.warning( + f"DOCLING engine configured but not available for {file_path.name}. Falling back to openpyxl." + ) + # Use openpyxl (non-blocking via to_thread) + content = await asyncio.to_thread(_extract_xlsx, file) + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]XLSX processing error", + "original_error": f"Failed to extract text from XLSX: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents( + error_files, track_id + ) + logger.error( + f"[File Extraction]Error processing XLSX {file_path.name}: {str(e)}" + ) + return False, track_id + + case _: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": f"[File Extraction]Unsupported file type: {ext}", + "original_error": f"File extension {ext} is not supported", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error( + f"[File Extraction]Unsupported file type: {file_path.name} (extension {ext})" + ) + return False, track_id + + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]File format processing error", + "original_error": f"Unexpected error during file extracting: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error( + f"[File Extraction]Unexpected error during {file_path.name} extracting: {str(e)}" + ) + return False, track_id + + # Insert into the RAG queue + if content: + # Check if content contains only whitespace characters + if not content.strip(): + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "[File Extraction]File contains only whitespace", + "original_error": "File content contains only whitespace characters", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.warning( + f"[File Extraction]File contains only whitespace characters: {file_path.name}" + ) + return False, track_id + + try: + await rag.apipeline_enqueue_documents( + content, file_paths=file_path.name, track_id=track_id + ) + + logger.info( + f"Successfully extracted and enqueued file: {file_path.name}" + ) + + # Move file to __enqueued__ directory after enqueuing + try: + enqueued_dir = file_path.parent / "__enqueued__" + await asyncio.to_thread(enqueued_dir.mkdir, exist_ok=True) + + # Generate unique filename to avoid conflicts + unique_filename = get_unique_filename_in_enqueued( + enqueued_dir, file_path.name + ) + target_path = enqueued_dir / unique_filename + + # Move the file + await asyncio.to_thread(file_path.rename, target_path) + logger.debug( + f"Moved file to enqueued directory: {file_path.name} -> {unique_filename}" + ) + + except Exception as move_error: + logger.error( + f"Failed to move file {file_path.name} to __enqueued__ directory: {move_error}" + ) + # Don't affect the main function's success status + + return True, track_id + + except Exception as e: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "Document enqueue error", + "original_error": f"Failed to enqueue document: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error(f"Error enqueueing document {file_path.name}: {str(e)}") + return False, track_id + else: + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "No content extracted", + "original_error": "No content could be extracted from file", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error(f"No content extracted from file: {file_path.name}") + return False, track_id + + except Exception as e: + # Catch-all for any unexpected errors + try: + file_size = file_path.stat().st_size if file_path.exists() else 0 + except Exception: + file_size = 0 + + error_files = [ + { + "file_path": str(file_path.name), + "error_description": "Unexpected processing error", + "original_error": f"Unexpected error: {str(e)}", + "file_size": file_size, + } + ] + await rag.apipeline_enqueue_error_documents(error_files, track_id) + logger.error(f"Enqueuing file {file_path.name} error: {str(e)}") + logger.error(traceback.format_exc()) + return False, track_id + finally: + if file_path.name.startswith(temp_prefix): + try: + file_path.unlink() + except Exception as e: + logger.error(f"Error deleting file {file_path}: {str(e)}") + + +async def pipeline_index_file(rag: LightRAG, file_path: Path, track_id: str = None): + """Index a file with track_id + + Args: + rag: LightRAG instance + file_path: Path to the saved file + track_id: Optional tracking ID + """ + try: + success, returned_track_id = await pipeline_enqueue_file( + rag, file_path, track_id + ) + if success: + await rag.apipeline_process_enqueue_documents() + + except Exception as e: + logger.error(f"Error indexing file {file_path.name}: {str(e)}") + logger.error(traceback.format_exc()) + + +async def pipeline_index_files( + rag: LightRAG, file_paths: List[Path], track_id: str = None +): + """Index multiple files sequentially to avoid high CPU load + + Args: + rag: LightRAG instance + file_paths: Paths to the files to index + track_id: Optional tracking ID to pass to all files + """ + if not file_paths: + return + try: + enqueued = False + + # Use get_pinyin_sort_key for Chinese pinyin sorting + sorted_file_paths = sorted( + file_paths, key=lambda p: get_pinyin_sort_key(str(p)) + ) + + # Process files sequentially with track_id + for file_path in sorted_file_paths: + success, _ = await pipeline_enqueue_file(rag, file_path, track_id) + if success: + enqueued = True + + # Process the queue only if at least one file was successfully enqueued + if enqueued: + await rag.apipeline_process_enqueue_documents() + except Exception as e: + logger.error(f"Error indexing files: {str(e)}") + logger.error(traceback.format_exc()) + + +async def pipeline_index_texts( + rag: LightRAG, + texts: List[str], + file_sources: List[str] = None, + track_id: str = None, +): + """Index a list of texts with track_id + + Args: + rag: LightRAG instance + texts: The texts to index + file_sources: Sources of the texts + track_id: Optional tracking ID + """ + if not texts: + return + + normalized_file_sources: list[str] | None = None + if file_sources: + normalized_file_sources = [ + normalize_file_path(source) for source in file_sources + ] + if len(normalized_file_sources) > len(texts): + raise ValueError("Number of file sources must not exceed number of texts") + if len(normalized_file_sources) < len(texts): + normalized_file_sources.extend( + [UNKNOWN_FILE_SOURCE] * (len(texts) - len(normalized_file_sources)) + ) + + await rag.apipeline_enqueue_documents( + input=texts, file_paths=normalized_file_sources, track_id=track_id + ) + await rag.apipeline_process_enqueue_documents() + + +async def run_scanning_process( + rag: LightRAG, doc_manager: DocumentManager, track_id: str = None +): + """Background task to scan and index documents + + Args: + rag: LightRAG instance + doc_manager: DocumentManager instance + track_id: Optional tracking ID to pass to all scanned files + """ + try: + new_files = doc_manager.scan_directory_for_new_files() + total_files = len(new_files) + logger.info(f"Found {total_files} files to index.") + + if new_files: + # Check for files with PROCESSED status and filter them out + valid_files = [] + processed_files = [] + + for file_path in new_files: + filename = file_path.name + existing_doc_data = await rag.doc_status.get_doc_by_file_path(filename) + + if existing_doc_data and existing_doc_data.get("status") == "processed": + # File is already PROCESSED, skip it with warning + processed_files.append(filename) + logger.warning(f"Skipping already processed file: {filename}") + else: + # File is new or in non-PROCESSED status, add to processing list + valid_files.append(file_path) + + # Process valid files (new files + non-PROCESSED status files) + if valid_files: + await pipeline_index_files(rag, valid_files, track_id) + if processed_files: + logger.info( + f"Scanning process completed: {len(valid_files)} files Processed {len(processed_files)} skipped." + ) + else: + logger.info( + f"Scanning process completed: {len(valid_files)} files Processed." + ) + else: + logger.info( + "No files to process after filtering already processed files." + ) + else: + # No new files to index, check if there are any documents in the queue + logger.info( + "No upload file found, check if there are any documents in the queue..." + ) + await rag.apipeline_process_enqueue_documents() + + except Exception as e: + logger.error(f"Error during scanning process: {str(e)}") + logger.error(traceback.format_exc()) + + +async def background_delete_documents( + rag: LightRAG, + doc_manager: DocumentManager, + doc_ids: List[str], + delete_file: bool = False, + delete_llm_cache: bool = False, +): + """Background task to delete multiple documents""" + from lightrag.kg.shared_storage import ( + get_namespace_data, + get_namespace_lock, + ) + + pipeline_status = await get_namespace_data( + "pipeline_status", workspace=rag.workspace + ) + pipeline_status_lock = get_namespace_lock( + "pipeline_status", workspace=rag.workspace + ) + + total_docs = len(doc_ids) + successful_deletions = [] + failed_deletions = [] + + # Double-check pipeline status before proceeding + async with pipeline_status_lock: + if pipeline_status.get("busy", False): + logger.warning("Error: Unexpected pipeline busy state, aborting deletion.") + return # Abort deletion operation + + # Set pipeline status to busy for deletion + pipeline_status.update( + { + "busy": True, + # Job name can not be changed, it's verified in adelete_by_doc_id() + "job_name": f"Deleting {total_docs} Documents", + "job_start": datetime.now().isoformat(), + "docs": total_docs, + "batchs": total_docs, + "cur_batch": 0, + "latest_message": "Starting document deletion process", + } + ) + # Use slice assignment to clear the list in place + pipeline_status["history_messages"][:] = ["Starting document deletion process"] + if delete_llm_cache: + pipeline_status["history_messages"].append( + "LLM cache cleanup requested for this deletion job" + ) + + try: + # Loop through each document ID and delete them one by one + for i, doc_id in enumerate(doc_ids, 1): + # Check for cancellation at the start of each document deletion + async with pipeline_status_lock: + if pipeline_status.get("cancellation_requested", False): + cancel_msg = f"Deletion cancelled by user at document {i}/{total_docs}. {len(successful_deletions)} deleted, {total_docs - i + 1} remaining." + logger.info(cancel_msg) + pipeline_status["latest_message"] = cancel_msg + pipeline_status["history_messages"].append(cancel_msg) + # Add remaining documents to failed list with cancellation reason + failed_deletions.extend( + doc_ids[i - 1 :] + ) # i-1 because enumerate starts at 1 + break # Exit the loop, remaining documents unchanged + + start_msg = f"Deleting document {i}/{total_docs}: {doc_id}" + logger.info(start_msg) + pipeline_status["cur_batch"] = i + pipeline_status["latest_message"] = start_msg + pipeline_status["history_messages"].append(start_msg) + + file_path = "#" + try: + result = await rag.adelete_by_doc_id( + doc_id, delete_llm_cache=delete_llm_cache + ) + file_path = ( + getattr(result, "file_path", "-") if "result" in locals() else "-" + ) + if result.status == "success": + successful_deletions.append(doc_id) + success_msg = ( + f"Document deleted {i}/{total_docs}: {doc_id}[{file_path}]" + ) + logger.info(success_msg) + async with pipeline_status_lock: + pipeline_status["history_messages"].append(success_msg) + + # Handle file deletion if requested and file_path is available + if ( + delete_file + and result.file_path + and result.file_path != "unknown_source" + ): + try: + deleted_files = [] + # SECURITY FIX: Use secure path validation to prevent arbitrary file deletion + safe_file_path = validate_file_path_security( + result.file_path, doc_manager.input_dir + ) + + if safe_file_path is None: + # Security violation detected - log and skip file deletion + security_msg = f"Security violation: Unsafe file path detected for deletion - {result.file_path}" + logger.warning(security_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = security_msg + pipeline_status["history_messages"].append( + security_msg + ) + else: + # check and delete files from input_dir directory + if safe_file_path.exists(): + try: + safe_file_path.unlink() + deleted_files.append(safe_file_path.name) + file_delete_msg = f"Successfully deleted input_dir file: {result.file_path}" + logger.info(file_delete_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = ( + file_delete_msg + ) + pipeline_status["history_messages"].append( + file_delete_msg + ) + except Exception as file_error: + file_error_msg = f"Failed to delete input_dir file {result.file_path}: {str(file_error)}" + logger.debug(file_error_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = ( + file_error_msg + ) + pipeline_status["history_messages"].append( + file_error_msg + ) + + # Also check and delete files from __enqueued__ directory + enqueued_dir = doc_manager.input_dir / "__enqueued__" + if enqueued_dir.exists(): + # SECURITY FIX: Validate that the file path is safe before processing + # Only proceed if the original path validation passed + base_name = Path(result.file_path).stem + extension = Path(result.file_path).suffix + + # Search for exact match and files with numeric suffixes + for enqueued_file in enqueued_dir.glob( + f"{base_name}*{extension}" + ): + # Additional security check: ensure enqueued file is within enqueued directory + safe_enqueued_path = ( + validate_file_path_security( + enqueued_file.name, enqueued_dir + ) + ) + if safe_enqueued_path is not None: + try: + enqueued_file.unlink() + deleted_files.append(enqueued_file.name) + logger.info( + f"Successfully deleted enqueued file: {enqueued_file.name}" + ) + except Exception as enqueued_error: + file_error_msg = f"Failed to delete enqueued file {enqueued_file.name}: {str(enqueued_error)}" + logger.debug(file_error_msg) + async with pipeline_status_lock: + pipeline_status[ + "latest_message" + ] = file_error_msg + pipeline_status[ + "history_messages" + ].append(file_error_msg) + else: + security_msg = f"Security violation: Unsafe enqueued file path detected - {enqueued_file.name}" + logger.warning(security_msg) + + if deleted_files == []: + file_error_msg = f"File deletion skipped, missing or unsafe file: {result.file_path}" + logger.warning(file_error_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = file_error_msg + pipeline_status["history_messages"].append( + file_error_msg + ) + + except Exception as file_error: + file_error_msg = f"Failed to delete file {result.file_path}: {str(file_error)}" + logger.error(file_error_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = file_error_msg + pipeline_status["history_messages"].append( + file_error_msg + ) + elif delete_file: + no_file_msg = ( + f"File deletion skipped, missing file path: {doc_id}" + ) + logger.warning(no_file_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = no_file_msg + pipeline_status["history_messages"].append(no_file_msg) + else: + failed_deletions.append(doc_id) + error_msg = f"Failed to delete {i}/{total_docs}: {doc_id}[{file_path}] - {result.message}" + logger.error(error_msg) + async with pipeline_status_lock: + pipeline_status["latest_message"] = error_msg + pipeline_status["history_messages"].append(error_msg) + + except Exception as e: + failed_deletions.append(doc_id) + error_msg = f"Error deleting document {i}/{total_docs}: {doc_id}[{file_path}] - {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + async with pipeline_status_lock: + pipeline_status["latest_message"] = error_msg + pipeline_status["history_messages"].append(error_msg) + + except Exception as e: + error_msg = f"Critical error during batch deletion: {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + async with pipeline_status_lock: + pipeline_status["history_messages"].append(error_msg) + finally: + # Final summary and check for pending requests + async with pipeline_status_lock: + pipeline_status["busy"] = False + pipeline_status["pending_requests"] = False # Reset pending requests flag + pipeline_status["cancellation_requested"] = ( + False # Always reset cancellation flag + ) + completion_msg = f"Deletion completed: {len(successful_deletions)} successful, {len(failed_deletions)} failed" + pipeline_status["latest_message"] = completion_msg + pipeline_status["history_messages"].append(completion_msg) + + # Check if there are pending document indexing requests + has_pending_request = pipeline_status.get("request_pending", False) + + # If there are pending requests, start document processing pipeline + if has_pending_request: + try: + logger.info( + "Processing pending document indexing requests after deletion" + ) + await rag.apipeline_process_enqueue_documents() + except Exception as e: + logger.error(f"Error processing pending documents after deletion: {e}") + + +def create_document_routes( + rag: LightRAG, doc_manager: DocumentManager, api_key: Optional[str] = None +): + # Create combined auth dependency for document routes + combined_auth = get_combined_auth_dependency(api_key) + + @router.post( + "/scan", response_model=ScanResponse, dependencies=[Depends(combined_auth)] + ) + async def scan_for_new_documents(background_tasks: BackgroundTasks): + """ + Trigger the scanning process for new documents. + + This endpoint initiates a background task that scans the input directory for new documents + and processes them. If a scanning process is already running, it returns a status indicating + that fact. + + Returns: + ScanResponse: A response object containing the scanning status and track_id + """ + # Generate track_id with "scan" prefix for scanning operation + track_id = generate_track_id("scan") + + # Start the scanning process in the background with track_id + background_tasks.add_task(run_scanning_process, rag, doc_manager, track_id) + return ScanResponse( + status="scanning_started", + message="Scanning process has been initiated in the background", + track_id=track_id, + ) + + @router.post( + "/upload", response_model=InsertResponse, dependencies=[Depends(combined_auth)] + ) + async def upload_to_input_dir( + background_tasks: BackgroundTasks, file: UploadFile = File(...) + ): + """ + Upload a file to the input directory and index it. + + This API endpoint accepts a file through an HTTP POST request, checks if the + uploaded file is of a supported type, saves it in the specified input directory, + indexes it for retrieval, and returns a success status with relevant details. + + **File Size Limit:** + - Configurable via `MAX_UPLOAD_SIZE` environment variable (default: 100MB) + - Set to `None` or `0` for unlimited upload size + - Returns HTTP 413 (Request Entity Too Large) if file exceeds limit + + **Duplicate Detection Behavior:** + + This endpoint handles two types of duplicate scenarios differently: + + 1. **Filename Duplicate (Synchronous Detection)**: + - Detected immediately before file processing + - Returns `status="duplicated"` with the existing document's track_id + - Two cases: + - If filename exists in document storage: returns existing track_id + - If filename exists in file system only: returns empty track_id ("") + + 2. **Content Duplicate (Asynchronous Detection)**: + - Detected during background processing after content extraction + - Returns `status="success"` with a new track_id immediately + - The duplicate is detected later when processing the file content + - Use `/documents/track_status/{track_id}` to check the final result: + - Document will have `status="FAILED"` + - `error_msg` contains "Content already exists. Original doc_id: xxx" + - `metadata.is_duplicate=true` with reference to original document + - `metadata.original_doc_id` points to the existing document + - `metadata.original_track_id` shows the original upload's track_id + + **Why Different Behavior?** + - Filename check is fast (simple lookup), done synchronously + - Content extraction is expensive (PDF/DOCX parsing), done asynchronously + - This design prevents blocking the client during expensive operations + + Args: + background_tasks: FastAPI BackgroundTasks for async processing + file (UploadFile): The file to be uploaded. It must have an allowed extension. + + Returns: + InsertResponse: A response object containing the upload status and a message. + - status="success": File accepted and queued for processing + - status="duplicated": Filename already exists (see track_id for existing document) + + Raises: + HTTPException: If the file type is not supported (400), file too large (413), or other errors occur (500). + """ + try: + # Sanitize filename to prevent Path Traversal attacks + safe_filename = sanitize_filename(file.filename, doc_manager.input_dir) + + if not doc_manager.is_supported_file(safe_filename): + raise HTTPException( + status_code=400, + detail=f"Unsupported file type. Supported types: {doc_manager.supported_extensions}", + ) + + # Check file size limit (if configured) + if ( + global_args.max_upload_size is not None + and global_args.max_upload_size > 0 + ): + # Safe access to file size (not available in older Starlette versions) + file_size = getattr(file, "size", None) + + # Pre-flight size check (only if size is available) + if file_size is not None: + if file_size > global_args.max_upload_size: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size: {global_args.max_upload_size / 1024 / 1024:.1f}MB, uploaded: {file_size / 1024 / 1024:.1f}MB", + ) + else: + # If size not available, we'll check during streaming + logger.debug( + f"File size not available in UploadFile for {safe_filename}, will check during streaming" + ) + + # Check if filename already exists in doc_status storage + existing_doc_data = await rag.doc_status.get_doc_by_file_path(safe_filename) + if existing_doc_data: + # Get document status and track_id from existing document + status = existing_doc_data.get("status", "unknown") + # Use `or ""` to handle both missing key and None value (e.g., legacy rows without track_id) + existing_track_id = existing_doc_data.get("track_id") or "" + return InsertResponse( + status="duplicated", + message=f"File '{safe_filename}' already exists in document storage (Status: {status}).", + track_id=existing_track_id, + ) + + file_path = doc_manager.input_dir / safe_filename + # Check if file already exists in file system + if file_path.exists(): + return InsertResponse( + status="duplicated", + message=f"File '{safe_filename}' already exists in the input directory.", + track_id="", + ) + + # Async streaming write with size check + bytes_written = 0 + chunk_size = 1024 * 1024 # 1MB chunks + needs_cleanup = False + + async with aiofiles.open(file_path, "wb") as out_file: + while True: + # Read chunk from upload stream + chunk = await file.read(chunk_size) + if not chunk: + break + + # Check size limit during streaming (if not checked before) + if ( + global_args.max_upload_size is not None + and global_args.max_upload_size > 0 + ): + bytes_written += len(chunk) + if bytes_written > global_args.max_upload_size: + needs_cleanup = True + break + + # Write chunk to file + await out_file.write(chunk) + + # Cleanup after file is closed + if needs_cleanup: + try: + file_path.unlink() + except Exception as cleanup_error: + logger.error( + f"Error cleaning up oversized file {safe_filename}: {cleanup_error}" + ) + + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size: {global_args.max_upload_size / 1024 / 1024:.1f}MB, uploaded: {bytes_written / 1024 / 1024:.1f}MB", + ) + + track_id = generate_track_id("upload") + + # Add to background tasks and get track_id + background_tasks.add_task(pipeline_index_file, rag, file_path, track_id) + + return InsertResponse( + status="success", + message=f"File '{safe_filename}' uploaded successfully. Processing will continue in background.", + track_id=track_id, + ) + + except HTTPException: + # Re-raise HTTP exceptions (400, 413, etc.) + raise + except Exception as e: + logger.error(f"Error /documents/upload: {file.filename}: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/text", response_model=InsertResponse, dependencies=[Depends(combined_auth)] + ) + async def insert_text( + request: InsertTextRequest, background_tasks: BackgroundTasks + ): + """ + Insert text into the RAG system. + + This endpoint allows you to insert text data into the RAG system for later retrieval + and use in generating responses. + + Args: + request (InsertTextRequest): The request body containing the text to be inserted. + background_tasks: FastAPI BackgroundTasks for async processing + + Returns: + InsertResponse: A response object containing the status of the operation. + + Raises: + HTTPException: If an error occurs during text processing (500). + """ + try: + # Check if file_source already exists in doc_status storage + if ( + request.file_source + and request.file_source.strip() + and request.file_source != "unknown_source" + ): + existing_doc_data = await rag.doc_status.get_doc_by_file_path( + request.file_source + ) + if existing_doc_data: + # Get document status and track_id from existing document + status = existing_doc_data.get("status", "unknown") + # Use `or ""` to handle both missing key and None value (e.g., legacy rows without track_id) + existing_track_id = existing_doc_data.get("track_id") or "" + return InsertResponse( + status="duplicated", + message=f"File source '{request.file_source}' already exists in document storage (Status: {status}).", + track_id=existing_track_id, + ) + + # Check if content already exists by computing content hash (doc_id) + sanitized_text = sanitize_text_for_encoding(request.text) + content_doc_id = compute_mdhash_id(sanitized_text, prefix="doc-") + existing_doc = await rag.doc_status.get_by_id(content_doc_id) + if existing_doc: + # Content already exists, return duplicated with existing track_id + status = existing_doc.get("status", "unknown") + existing_track_id = existing_doc.get("track_id") or "" + return InsertResponse( + status="duplicated", + message=f"Identical content already exists in document storage (doc_id: {content_doc_id}, Status: {status}).", + track_id=existing_track_id, + ) + + # Generate track_id for text insertion + track_id = generate_track_id("insert") + + background_tasks.add_task( + pipeline_index_texts, + rag, + [request.text], + file_sources=[request.file_source], + track_id=track_id, + ) + + return InsertResponse( + status="success", + message="Text successfully received. Processing will continue in background.", + track_id=track_id, + ) + except Exception as e: + logger.error(f"Error /documents/text: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/texts", + response_model=InsertResponse, + dependencies=[Depends(combined_auth)], + ) + async def insert_texts( + request: InsertTextsRequest, background_tasks: BackgroundTasks + ): + """ + Insert multiple texts into the RAG system. + + This endpoint allows you to insert multiple text entries into the RAG system + in a single request. + + Args: + request (InsertTextsRequest): The request body containing the list of texts. + background_tasks: FastAPI BackgroundTasks for async processing + + Returns: + InsertResponse: A response object containing the status of the operation. + + Raises: + HTTPException: If an error occurs during text processing (500). + """ + try: + # Check if any file_sources already exist in doc_status storage + if request.file_sources: + for file_source in request.file_sources: + if ( + file_source + and file_source.strip() + and file_source != "unknown_source" + ): + existing_doc_data = await rag.doc_status.get_doc_by_file_path( + file_source + ) + if existing_doc_data: + # Get document status and track_id from existing document + status = existing_doc_data.get("status", "unknown") + # Use `or ""` to handle both missing key and None value (e.g., legacy rows without track_id) + existing_track_id = existing_doc_data.get("track_id") or "" + return InsertResponse( + status="duplicated", + message=f"File source '{file_source}' already exists in document storage (Status: {status}).", + track_id=existing_track_id, + ) + + # Check if any content already exists by computing content hash (doc_id) + for text in request.texts: + sanitized_text = sanitize_text_for_encoding(text) + content_doc_id = compute_mdhash_id(sanitized_text, prefix="doc-") + existing_doc = await rag.doc_status.get_by_id(content_doc_id) + if existing_doc: + # Content already exists, return duplicated with existing track_id + status = existing_doc.get("status", "unknown") + existing_track_id = existing_doc.get("track_id") or "" + return InsertResponse( + status="duplicated", + message=f"Identical content already exists in document storage (doc_id: {content_doc_id}, Status: {status}).", + track_id=existing_track_id, + ) + + # Generate track_id for texts insertion + track_id = generate_track_id("insert") + + background_tasks.add_task( + pipeline_index_texts, + rag, + request.texts, + file_sources=request.file_sources, + track_id=track_id, + ) + + return InsertResponse( + status="success", + message="Texts successfully received. Processing will continue in background.", + track_id=track_id, + ) + except Exception as e: + logger.error(f"Error /documents/texts: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.delete( + "", response_model=ClearDocumentsResponse, dependencies=[Depends(combined_auth)] + ) + async def clear_documents(): + """ + Clear all documents from the RAG system. + + This endpoint deletes all documents, entities, relationships, and files from the system. + It uses the storage drop methods to properly clean up all data and removes all files + from the input directory. + + Returns: + ClearDocumentsResponse: A response object containing the status and message. + - status="success": All documents and files were successfully cleared. + - status="partial_success": Document clear job exit with some errors. + - status="busy": Operation could not be completed because the pipeline is busy. + - status="fail": All storage drop operations failed, with message + - message: Detailed information about the operation results, including counts + of deleted files and any errors encountered. + + Raises: + HTTPException: Raised when a serious error occurs during the clearing process, + with status code 500 and error details in the detail field. + """ + from lightrag.kg.shared_storage import ( + get_namespace_data, + get_namespace_lock, + ) + + # Get pipeline status and lock + pipeline_status = await get_namespace_data( + "pipeline_status", workspace=rag.workspace + ) + pipeline_status_lock = get_namespace_lock( + "pipeline_status", workspace=rag.workspace + ) + + # Check and set status with lock + async with pipeline_status_lock: + if pipeline_status.get("busy", False): + return ClearDocumentsResponse( + status="busy", + message="Cannot clear documents while pipeline is busy", + ) + # Set busy to true + pipeline_status.update( + { + "busy": True, + "job_name": "Clearing Documents", + "job_start": datetime.now().isoformat(), + "docs": 0, + "batchs": 0, + "cur_batch": 0, + "request_pending": False, # Clear any previous request + "latest_message": "Starting document clearing process", + } + ) + # Cleaning history_messages without breaking it as a shared list object + del pipeline_status["history_messages"][:] + pipeline_status["history_messages"].append( + "Starting document clearing process" + ) + + try: + # Use drop method to clear all data + drop_tasks = [] + storages = [ + rag.text_chunks, + rag.full_docs, + rag.full_entities, + rag.full_relations, + rag.entity_chunks, + rag.relation_chunks, + rag.entities_vdb, + rag.relationships_vdb, + rag.chunks_vdb, + rag.chunk_entity_relation_graph, + rag.doc_status, + ] + + # Log storage drop start + if "history_messages" in pipeline_status: + pipeline_status["history_messages"].append( + "Starting to drop storage components" + ) + + for storage in storages: + if storage is not None: + drop_tasks.append(storage.drop()) + + # Wait for all drop tasks to complete + drop_results = await asyncio.gather(*drop_tasks, return_exceptions=True) + + # Check for errors and log results + errors = [] + storage_success_count = 0 + storage_error_count = 0 + + for i, result in enumerate(drop_results): + storage_name = storages[i].__class__.__name__ + if isinstance(result, Exception): + error_msg = f"Error dropping {storage_name}: {str(result)}" + errors.append(error_msg) + logger.error(error_msg) + storage_error_count += 1 + else: + namespace = storages[i].namespace + workspace = storages[i].workspace + logger.info( + f"Successfully dropped {storage_name}: {workspace}/{namespace}" + ) + storage_success_count += 1 + + # Log storage drop results + if "history_messages" in pipeline_status: + if storage_error_count > 0: + pipeline_status["history_messages"].append( + f"Dropped {storage_success_count} storage components with {storage_error_count} errors" + ) + else: + pipeline_status["history_messages"].append( + f"Successfully dropped all {storage_success_count} storage components" + ) + + # If all storage operations failed, return error status and don't proceed with file deletion + if storage_success_count == 0 and storage_error_count > 0: + error_message = "All storage drop operations failed. Aborting document clearing process." + logger.error(error_message) + if "history_messages" in pipeline_status: + pipeline_status["history_messages"].append(error_message) + return ClearDocumentsResponse(status="fail", message=error_message) + + # Log file deletion start + if "history_messages" in pipeline_status: + pipeline_status["history_messages"].append( + "Starting to delete files in input directory" + ) + + # Delete only files in the current directory, preserve files in subdirectories + deleted_files_count = 0 + file_errors_count = 0 + + for file_path in doc_manager.input_dir.glob("*"): + if file_path.is_file(): + try: + file_path.unlink() + deleted_files_count += 1 + except Exception as e: + logger.error(f"Error deleting file {file_path}: {str(e)}") + file_errors_count += 1 + + # Log file deletion results + if "history_messages" in pipeline_status: + if file_errors_count > 0: + pipeline_status["history_messages"].append( + f"Deleted {deleted_files_count} files with {file_errors_count} errors" + ) + errors.append(f"Failed to delete {file_errors_count} files") + else: + pipeline_status["history_messages"].append( + f"Successfully deleted {deleted_files_count} files" + ) + + # Prepare final result message + final_message = "" + if errors: + final_message = f"Cleared documents with some errors. Deleted {deleted_files_count} files." + status = "partial_success" + else: + final_message = f"All documents cleared successfully. Deleted {deleted_files_count} files." + status = "success" + + # Log final result + if "history_messages" in pipeline_status: + pipeline_status["history_messages"].append(final_message) + + # Return response based on results + return ClearDocumentsResponse(status=status, message=final_message) + except Exception as e: + error_msg = f"Error clearing documents: {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + if "history_messages" in pipeline_status: + pipeline_status["history_messages"].append(error_msg) + raise HTTPException(status_code=500, detail=str(e)) + finally: + # Reset busy status after completion + async with pipeline_status_lock: + pipeline_status["busy"] = False + completion_msg = "Document clearing process completed" + pipeline_status["latest_message"] = completion_msg + if "history_messages" in pipeline_status: + pipeline_status["history_messages"].append(completion_msg) + + @router.get( + "/pipeline_status", + dependencies=[Depends(combined_auth)], + response_model=PipelineStatusResponse, + ) + async def get_pipeline_status() -> PipelineStatusResponse: + """ + Get the current status of the document indexing pipeline. + + This endpoint returns information about the current state of the document processing pipeline, + including the processing status, progress information, and history messages. + + Returns: + PipelineStatusResponse: A response object containing: + - autoscanned (bool): Whether auto-scan has started + - busy (bool): Whether the pipeline is currently busy + - job_name (str): Current job name (e.g., indexing files/indexing texts) + - job_start (str, optional): Job start time as ISO format string + - docs (int): Total number of documents to be indexed + - batchs (int): Number of batches for processing documents + - cur_batch (int): Current processing batch + - request_pending (bool): Flag for pending request for processing + - latest_message (str): Latest message from pipeline processing + - history_messages (List[str], optional): List of history messages (limited to latest 1000 entries, + with truncation message if more than 1000 messages exist) + + Raises: + HTTPException: If an error occurs while retrieving pipeline status (500) + """ + try: + from lightrag.kg.shared_storage import ( + get_namespace_data, + get_namespace_lock, + get_all_update_flags_status, + ) + + pipeline_status = await get_namespace_data( + "pipeline_status", workspace=rag.workspace + ) + pipeline_status_lock = get_namespace_lock( + "pipeline_status", workspace=rag.workspace + ) + + # Get update flags status for all namespaces + update_status = await get_all_update_flags_status(workspace=rag.workspace) + + # Convert MutableBoolean objects to regular boolean values + processed_update_status = {} + for namespace, flags in update_status.items(): + processed_flags = [] + for flag in flags: + # Handle both multiprocess and single process cases + if hasattr(flag, "value"): + processed_flags.append(bool(flag.value)) + else: + processed_flags.append(bool(flag)) + processed_update_status[namespace] = processed_flags + + async with pipeline_status_lock: + # Convert to regular dict if it's a Manager.dict + status_dict = dict(pipeline_status) + + # Add processed update_status to the status dictionary + status_dict["update_status"] = processed_update_status + + # Convert history_messages to a regular list if it's a Manager.list + # and limit to latest 1000 entries with truncation message if needed + if "history_messages" in status_dict: + history_list = list(status_dict["history_messages"]) + total_count = len(history_list) + + if total_count > 1000: + # Calculate truncated message count + truncated_count = total_count - 1000 + + # Take only the latest 1000 messages + latest_messages = history_list[-1000:] + + # Add truncation message at the beginning + truncation_message = ( + f"[Truncated history messages: {truncated_count}/{total_count}]" + ) + status_dict["history_messages"] = [ + truncation_message + ] + latest_messages + else: + # No truncation needed, return all messages + status_dict["history_messages"] = history_list + + # Ensure job_start is properly formatted as a string with timezone information + if "job_start" in status_dict and status_dict["job_start"]: + # Use format_datetime to ensure consistent formatting + status_dict["job_start"] = format_datetime(status_dict["job_start"]) + + return PipelineStatusResponse(**status_dict) + except Exception as e: + logger.error(f"Error getting pipeline status: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + # TODO: Deprecated, use /documents/paginated instead + @router.get( + "", response_model=DocsStatusesResponse, dependencies=[Depends(combined_auth)] + ) + async def documents() -> DocsStatusesResponse: + """ + Get the status of all documents in the system. This endpoint is deprecated; use /documents/paginated instead. + To prevent excessive resource consumption, a maximum of 1,000 records is returned. + + This endpoint retrieves the current status of all documents, grouped by their + processing status (PENDING, PROCESSING, PREPROCESSED, PROCESSED, FAILED). The results are + limited to 1000 total documents with fair distribution across all statuses. + + Returns: + DocsStatusesResponse: A response object containing a dictionary where keys are + DocStatus values and values are lists of DocStatusResponse + objects representing documents in each status category. + Maximum 1000 documents total will be returned. + + Raises: + HTTPException: If an error occurs while retrieving document statuses (500). + """ + try: + statuses = ( + DocStatus.PENDING, + DocStatus.PROCESSING, + DocStatus.PREPROCESSED, + DocStatus.PROCESSED, + DocStatus.FAILED, + ) + + tasks = [rag.get_docs_by_status(status) for status in statuses] + results: List[Dict[str, DocProcessingStatus]] = await asyncio.gather(*tasks) + + response = DocsStatusesResponse() + total_documents = 0 + max_documents = 1000 + + # Convert results to lists for easier processing + status_documents = [] + for idx, result in enumerate(results): + status = statuses[idx] + docs_list = [] + for doc_id, doc_status in result.items(): + docs_list.append((doc_id, doc_status)) + status_documents.append((status, docs_list)) + + # Fair distribution: round-robin across statuses + status_indices = [0] * len( + status_documents + ) # Track current index for each status + current_status_idx = 0 + + while total_documents < max_documents: + # Check if we have any documents left to process + has_remaining = False + for status_idx, (status, docs_list) in enumerate(status_documents): + if status_indices[status_idx] < len(docs_list): + has_remaining = True + break + + if not has_remaining: + break + + # Try to get a document from the current status + status, docs_list = status_documents[current_status_idx] + current_index = status_indices[current_status_idx] + + if current_index < len(docs_list): + doc_id, doc_status = docs_list[current_index] + + if status not in response.statuses: + response.statuses[status] = [] + + response.statuses[status].append( + DocStatusResponse( + id=doc_id, + content_summary=doc_status.content_summary, + content_length=doc_status.content_length, + status=doc_status.status, + created_at=format_datetime(doc_status.created_at), + updated_at=format_datetime(doc_status.updated_at), + track_id=doc_status.track_id, + chunks_count=doc_status.chunks_count, + error_msg=doc_status.error_msg, + metadata=doc_status.metadata, + file_path=normalize_file_path(doc_status.file_path), + ) + ) + + status_indices[current_status_idx] += 1 + total_documents += 1 + + # Move to next status (round-robin) + current_status_idx = (current_status_idx + 1) % len(status_documents) + + return response + except Exception as e: + logger.error(f"Error GET /documents: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + class DeleteDocByIdResponse(BaseModel): + """Response model for single document deletion operation.""" + + status: Literal["deletion_started", "busy", "not_allowed"] = Field( + description="Status of the deletion operation" + ) + message: str = Field(description="Message describing the operation result") + doc_id: str = Field(description="The ID of the document to delete") + + @router.delete( + "/delete_document", + response_model=DeleteDocByIdResponse, + dependencies=[Depends(combined_auth)], + summary="Delete a document and all its associated data by its ID.", + ) + async def delete_document( + delete_request: DeleteDocRequest, + background_tasks: BackgroundTasks, + ) -> DeleteDocByIdResponse: + """ + Delete documents and all their associated data by their IDs using background processing. + + Deletes specific documents and all their associated data, including their status, + text chunks, vector embeddings, and any related graph data. When requested, + cached LLM extraction responses are removed after graph deletion/rebuild completes. + The deletion process runs in the background to avoid blocking the client connection. + + This operation is irreversible and will interact with the pipeline status. + + Args: + delete_request (DeleteDocRequest): The request containing the document IDs and deletion options. + background_tasks: FastAPI BackgroundTasks for async processing + + Returns: + DeleteDocByIdResponse: The result of the deletion operation. + - status="deletion_started": The document deletion has been initiated in the background. + - status="busy": The pipeline is busy with another operation. + + Raises: + HTTPException: + - 500: If an unexpected internal error occurs during initialization. + """ + doc_ids = delete_request.doc_ids + + try: + from lightrag.kg.shared_storage import ( + get_namespace_data, + get_namespace_lock, + ) + + pipeline_status = await get_namespace_data( + "pipeline_status", workspace=rag.workspace + ) + pipeline_status_lock = get_namespace_lock( + "pipeline_status", workspace=rag.workspace + ) + + # Check if pipeline is busy with proper lock + async with pipeline_status_lock: + if pipeline_status.get("busy", False): + return DeleteDocByIdResponse( + status="busy", + message="Cannot delete documents while pipeline is busy", + doc_id=", ".join(doc_ids), + ) + + # Add deletion task to background tasks + background_tasks.add_task( + background_delete_documents, + rag, + doc_manager, + doc_ids, + delete_request.delete_file, + delete_request.delete_llm_cache, + ) + + return DeleteDocByIdResponse( + status="deletion_started", + message=f"Document deletion for {len(doc_ids)} documents has been initiated. Processing will continue in background.", + doc_id=", ".join(doc_ids), + ) + + except Exception as e: + error_msg = f"Error initiating document deletion for {delete_request.doc_ids}: {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=error_msg) + + @router.post( + "/clear_cache", + response_model=ClearCacheResponse, + dependencies=[Depends(combined_auth)], + ) + async def clear_cache(request: ClearCacheRequest): + """ + Clear all cache data from the LLM response cache storage. + + This endpoint clears all cached LLM responses regardless of mode. + The request body is accepted for API compatibility but is ignored. + + Args: + request (ClearCacheRequest): The request body (ignored for compatibility). + + Returns: + ClearCacheResponse: A response object containing the status and message. + + Raises: + HTTPException: If an error occurs during cache clearing (500). + """ + try: + # Call the aclear_cache method (no modes parameter) + await rag.aclear_cache() + + # Prepare success message + message = "Successfully cleared all cache" + + return ClearCacheResponse(status="success", message=message) + except Exception as e: + logger.error(f"Error clearing cache: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.delete( + "/delete_entity", + response_model=DeletionResult, + dependencies=[Depends(combined_auth)], + ) + async def delete_entity(request: DeleteEntityRequest): + """ + Delete an entity and all its relationships from the knowledge graph. + + Args: + request (DeleteEntityRequest): The request body containing the entity name. + + Returns: + DeletionResult: An object containing the outcome of the deletion process. + + Raises: + HTTPException: If the entity is not found (404) or an error occurs (500). + """ + try: + result = await rag.adelete_by_entity(entity_name=request.entity_name) + if result.status == "not_found": + raise HTTPException(status_code=404, detail=result.message) + if result.status == "fail": + raise HTTPException(status_code=500, detail=result.message) + # Set doc_id to empty string since this is an entity operation, not document + result.doc_id = "" + return result + except HTTPException: + raise + except Exception as e: + error_msg = f"Error deleting entity '{request.entity_name}': {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=error_msg) + + @router.delete( + "/delete_relation", + response_model=DeletionResult, + dependencies=[Depends(combined_auth)], + ) + async def delete_relation(request: DeleteRelationRequest): + """ + Delete a relationship between two entities from the knowledge graph. + + Args: + request (DeleteRelationRequest): The request body containing the source and target entity names. + + Returns: + DeletionResult: An object containing the outcome of the deletion process. + + Raises: + HTTPException: If the relation is not found (404) or an error occurs (500). + """ + try: + result = await rag.adelete_by_relation( + source_entity=request.source_entity, + target_entity=request.target_entity, + ) + if result.status == "not_found": + raise HTTPException(status_code=404, detail=result.message) + if result.status == "fail": + raise HTTPException(status_code=500, detail=result.message) + # Set doc_id to empty string since this is a relation operation, not document + result.doc_id = "" + return result + except HTTPException: + raise + except Exception as e: + error_msg = f"Error deleting relation from '{request.source_entity}' to '{request.target_entity}': {str(e)}" + logger.error(error_msg) + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=error_msg) + + @router.get( + "/track_status/{track_id}", + response_model=TrackStatusResponse, + dependencies=[Depends(combined_auth)], + ) + async def get_track_status(track_id: str) -> TrackStatusResponse: + """ + Get the processing status of documents by tracking ID. + + This endpoint retrieves all documents associated with a specific tracking ID, + allowing users to monitor the processing progress of their uploaded files or inserted texts. + + Args: + track_id (str): The tracking ID returned from upload, text, or texts endpoints + + Returns: + TrackStatusResponse: A response object containing: + - track_id: The tracking ID + - documents: List of documents associated with this track_id + - total_count: Total number of documents for this track_id + + Raises: + HTTPException: If track_id is invalid (400) or an error occurs (500). + """ + try: + # Validate track_id + if not track_id or not track_id.strip(): + raise HTTPException(status_code=400, detail="Track ID cannot be empty") + + track_id = track_id.strip() + + # Get documents by track_id + docs_by_track_id = await rag.aget_docs_by_track_id(track_id) + + # Convert to response format + documents = [] + status_summary = {} + + for doc_id, doc_status in docs_by_track_id.items(): + documents.append( + DocStatusResponse( + id=doc_id, + content_summary=doc_status.content_summary, + content_length=doc_status.content_length, + status=doc_status.status, + created_at=format_datetime(doc_status.created_at), + updated_at=format_datetime(doc_status.updated_at), + track_id=doc_status.track_id, + chunks_count=doc_status.chunks_count, + error_msg=doc_status.error_msg, + metadata=doc_status.metadata, + file_path=normalize_file_path(doc_status.file_path), + ) + ) + + # Build status summary + # Handle both DocStatus enum and string cases for robust deserialization + status_key = str(doc_status.status) + status_summary[status_key] = status_summary.get(status_key, 0) + 1 + + return TrackStatusResponse( + track_id=track_id, + documents=documents, + total_count=len(documents), + status_summary=status_summary, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting track status for {track_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/paginated", + response_model=PaginatedDocsResponse, + dependencies=[Depends(combined_auth)], + ) + async def get_documents_paginated( + request: DocumentsRequest, + ) -> PaginatedDocsResponse: + """ + Get documents with pagination support. + + This endpoint retrieves documents with pagination, filtering, and sorting capabilities. + It provides better performance for large document collections by loading only the + requested page of data. + + Args: + request (DocumentsRequest): The request body containing pagination parameters + + Returns: + PaginatedDocsResponse: A response object containing: + - documents: List of documents for the current page + - pagination: Pagination information (page, total_count, etc.) + - status_counts: Count of documents by status for all documents + + Raises: + HTTPException: If an error occurs while retrieving documents (500). + """ + trace_id = uuid4().hex[:8] + request_start = time.perf_counter() + status_filter_value = ( + request.status_filter.value if request.status_filter is not None else None + ) + + performance_timing_log( + "[documents/paginated][%s] Request start workspace=%s status_filter=%s page=%s page_size=%s sort_field=%s sort_direction=%s", + trace_id, + rag.workspace, + status_filter_value, + request.page, + request.page_size, + request.sort_field, + request.sort_direction, + ) + + try: + + async def _timed_call(operation_name: str, operation): + operation_start = time.perf_counter() + performance_timing_log( + "[documents/paginated][%s] %s started", + trace_id, + operation_name, + ) + try: + result = await operation + except Exception: + elapsed = time.perf_counter() - operation_start + performance_timing_log( + "[documents/paginated][%s] %s failed after %.4fs", + trace_id, + operation_name, + elapsed, + ) + raise + + elapsed = time.perf_counter() - operation_start + performance_timing_log( + "[documents/paginated][%s] %s completed in %.4fs", + trace_id, + operation_name, + elapsed, + ) + return result + + query_task_create_start = time.perf_counter() + docs_task = asyncio.create_task( + _timed_call( + "get_docs_paginated", + rag.doc_status.get_docs_paginated( + status_filter=request.status_filter, + page=request.page, + page_size=request.page_size, + sort_field=request.sort_field, + sort_direction=request.sort_direction, + ), + ) + ) + status_counts_task = asyncio.create_task( + _timed_call( + "get_all_status_counts", + rag.doc_status.get_all_status_counts(), + ) + ) + query_task_create_elapsed = time.perf_counter() - query_task_create_start + performance_timing_log( + "[documents/paginated][%s] Query tasks created in %.4fs", + trace_id, + query_task_create_elapsed, + ) + + query_await_start = time.perf_counter() + (documents_with_ids, total_count), status_counts = await asyncio.gather( + docs_task, status_counts_task + ) + query_await_elapsed = time.perf_counter() - query_await_start + performance_timing_log( + "[documents/paginated][%s] Query tasks awaited in %.4fs", + trace_id, + query_await_elapsed, + ) + + # Convert documents to response format + response_assembly_start = time.perf_counter() + doc_responses = [] + for doc_id, doc in documents_with_ids: + doc_responses.append( + DocStatusResponse( + id=doc_id, + content_summary=doc.content_summary, + content_length=doc.content_length, + status=doc.status, + created_at=format_datetime(doc.created_at), + updated_at=format_datetime(doc.updated_at), + track_id=doc.track_id, + chunks_count=doc.chunks_count, + error_msg=doc.error_msg, + metadata=doc.metadata, + file_path=normalize_file_path(doc.file_path), + ) + ) + + # Calculate pagination info + total_pages = (total_count + request.page_size - 1) // request.page_size + has_next = request.page < total_pages + has_prev = request.page > 1 + + pagination = PaginationInfo( + page=request.page, + page_size=request.page_size, + total_count=total_count, + total_pages=total_pages, + has_next=has_next, + has_prev=has_prev, + ) + response = PaginatedDocsResponse( + documents=doc_responses, + pagination=pagination, + status_counts=status_counts, + ) + response_assembly_elapsed = time.perf_counter() - response_assembly_start + total_elapsed = time.perf_counter() - request_start + + performance_timing_log( + "[documents/paginated][%s] Response assembled in %.4fs", + trace_id, + response_assembly_elapsed, + ) + performance_timing_log( + "[documents/paginated][%s] Request completed in %.4fs returned_rows=%s total_count=%s status_count_keys=%s", + trace_id, + total_elapsed, + len(doc_responses), + total_count, + sorted(status_counts.keys()), + ) + + return response + + except Exception as e: + total_elapsed = time.perf_counter() - request_start + performance_timing_log( + "[documents/paginated][%s] Request failed after %.4fs", + trace_id, + total_elapsed, + ) + logger.error(f"Error getting paginated documents: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.get( + "/status_counts", + response_model=StatusCountsResponse, + dependencies=[Depends(combined_auth)], + ) + async def get_document_status_counts() -> StatusCountsResponse: + """ + Get counts of documents by status. + + This endpoint retrieves the count of documents in each processing status + (PENDING, PROCESSING, PROCESSED, FAILED) for all documents in the system. + + Returns: + StatusCountsResponse: A response object containing status counts + + Raises: + HTTPException: If an error occurs while retrieving status counts (500). + """ + try: + status_counts = await rag.doc_status.get_all_status_counts() + return StatusCountsResponse(status_counts=status_counts) + + except Exception as e: + logger.error(f"Error getting document status counts: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/reprocess_failed", + response_model=ReprocessResponse, + dependencies=[Depends(combined_auth)], + ) + async def reprocess_failed_documents(background_tasks: BackgroundTasks): + """ + Reprocess failed and pending documents. + + This endpoint triggers the document processing pipeline which automatically + picks up and reprocesses documents in the following statuses: + - FAILED: Documents that failed during previous processing attempts + - PENDING: Documents waiting to be processed + - PROCESSING: Documents with abnormally terminated processing (e.g., server crashes) + + This is useful for recovering from server crashes, network errors, LLM service + outages, or other temporary failures that caused document processing to fail. + + The processing happens in the background and can be monitored by checking the + pipeline status. The reprocessed documents retain their original track_id from + initial upload, so use their original track_id to monitor progress. + + Returns: + ReprocessResponse: Response with status and message. + track_id is always empty string because reprocessed documents retain + their original track_id from initial upload. + + Raises: + HTTPException: If an error occurs while initiating reprocessing (500). + """ + try: + # Start the reprocessing in the background + # Note: Reprocessed documents retain their original track_id from initial upload + background_tasks.add_task(rag.apipeline_process_enqueue_documents) + logger.info("Reprocessing of failed documents initiated") + + return ReprocessResponse( + status="reprocessing_started", + message="Reprocessing of failed documents has been initiated in background. Documents retain their original track_id.", + ) + + except Exception as e: + logger.error(f"Error initiating reprocessing of failed documents: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/cancel_pipeline", + response_model=CancelPipelineResponse, + dependencies=[Depends(combined_auth)], + ) + async def cancel_pipeline(): + """ + Request cancellation of the currently running pipeline. + + This endpoint sets a cancellation flag in the pipeline status. The pipeline will: + 1. Check this flag at key processing points + 2. Stop processing new documents + 3. Cancel all running document processing tasks + 4. Mark all PROCESSING documents as FAILED with reason "User cancelled" + + The cancellation is graceful and ensures data consistency. Documents that have + completed processing will remain in PROCESSED status. + + Returns: + CancelPipelineResponse: Response with status and message + - status="cancellation_requested": Cancellation flag has been set + - status="not_busy": Pipeline is not currently running + + Raises: + HTTPException: If an error occurs while setting cancellation flag (500). + """ + try: + from lightrag.kg.shared_storage import ( + get_namespace_data, + get_namespace_lock, + ) + + pipeline_status = await get_namespace_data( + "pipeline_status", workspace=rag.workspace + ) + pipeline_status_lock = get_namespace_lock( + "pipeline_status", workspace=rag.workspace + ) + + async with pipeline_status_lock: + if not pipeline_status.get("busy", False): + return CancelPipelineResponse( + status="not_busy", + message="Pipeline is not currently running. No cancellation needed.", + ) + + # Set cancellation flag + pipeline_status["cancellation_requested"] = True + cancel_msg = "Pipeline cancellation requested by user" + logger.info(cancel_msg) + pipeline_status["latest_message"] = cancel_msg + pipeline_status["history_messages"].append(cancel_msg) + + return CancelPipelineResponse( + status="cancellation_requested", + message="Pipeline cancellation has been requested. Documents will be marked as FAILED.", + ) + + except Exception as e: + logger.error(f"Error requesting pipeline cancellation: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + return router diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/graph_routes.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/graph_routes.py new file mode 100644 index 0000000..e892ff0 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/graph_routes.py @@ -0,0 +1,688 @@ +""" +This module contains all graph-related routes for the LightRAG API. +""" + +from typing import Optional, Dict, Any +import traceback +from fastapi import APIRouter, Depends, Query, HTTPException +from pydantic import BaseModel, Field + +from lightrag.utils import logger +from ..utils_api import get_combined_auth_dependency + +router = APIRouter(tags=["graph"]) + + +class EntityUpdateRequest(BaseModel): + entity_name: str + updated_data: Dict[str, Any] + allow_rename: bool = False + allow_merge: bool = False + + +class RelationUpdateRequest(BaseModel): + source_id: str + target_id: str + updated_data: Dict[str, Any] + + +class EntityMergeRequest(BaseModel): + entities_to_change: list[str] = Field( + ..., + description="List of entity names to be merged and deleted. These are typically duplicate or misspelled entities.", + min_length=1, + examples=[["Elon Msk", "Ellon Musk"]], + ) + entity_to_change_into: str = Field( + ..., + description="Target entity name that will receive all relationships from the source entities. This entity will be preserved.", + min_length=1, + examples=["Elon Musk"], + ) + + +class EntityCreateRequest(BaseModel): + entity_name: str = Field( + ..., + description="Unique name for the new entity", + min_length=1, + examples=["Tesla"], + ) + entity_data: Dict[str, Any] = Field( + ..., + description="Dictionary containing entity properties. Common fields include 'description' and 'entity_type'.", + examples=[ + { + "description": "Electric vehicle manufacturer", + "entity_type": "ORGANIZATION", + } + ], + ) + + +class RelationCreateRequest(BaseModel): + source_entity: str = Field( + ..., + description="Name of the source entity. This entity must already exist in the knowledge graph.", + min_length=1, + examples=["Elon Musk"], + ) + target_entity: str = Field( + ..., + description="Name of the target entity. This entity must already exist in the knowledge graph.", + min_length=1, + examples=["Tesla"], + ) + relation_data: Dict[str, Any] = Field( + ..., + description="Dictionary containing relationship properties. Common fields include 'description', 'keywords', and 'weight'.", + examples=[ + { + "description": "Elon Musk is the CEO of Tesla", + "keywords": "CEO, founder", + "weight": 1.0, + } + ], + ) + + +def create_graph_routes(rag, api_key: Optional[str] = None): + combined_auth = get_combined_auth_dependency(api_key) + + @router.get("/graph/label/list", dependencies=[Depends(combined_auth)]) + async def get_graph_labels(): + """ + Get all graph labels + + Returns: + List[str]: List of graph labels + """ + try: + return await rag.get_graph_labels() + except Exception as e: + logger.error(f"Error getting graph labels: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error getting graph labels: {str(e)}" + ) + + @router.get("/graph/label/popular", dependencies=[Depends(combined_auth)]) + async def get_popular_labels( + limit: int = Query( + 300, description="Maximum number of popular labels to return", ge=1, le=1000 + ), + ): + """ + Get popular labels by node degree (most connected entities) + + Args: + limit (int): Maximum number of labels to return (default: 300, max: 1000) + + Returns: + List[str]: List of popular labels sorted by degree (highest first) + """ + try: + return await rag.chunk_entity_relation_graph.get_popular_labels(limit) + except Exception as e: + logger.error(f"Error getting popular labels: {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error getting popular labels: {str(e)}" + ) + + @router.get("/graph/label/search", dependencies=[Depends(combined_auth)]) + async def search_labels( + q: str = Query(..., description="Search query string"), + limit: int = Query( + 50, description="Maximum number of search results to return", ge=1, le=100 + ), + ): + """ + Search labels with fuzzy matching + + Args: + q (str): Search query string + limit (int): Maximum number of results to return (default: 50, max: 100) + + Returns: + List[str]: List of matching labels sorted by relevance + """ + try: + return await rag.chunk_entity_relation_graph.search_labels(q, limit) + except Exception as e: + logger.error(f"Error searching labels with query '{q}': {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error searching labels: {str(e)}" + ) + + @router.get("/graphs", dependencies=[Depends(combined_auth)]) + async def get_knowledge_graph( + label: str = Query(..., description="Label to get knowledge graph for"), + max_depth: int = Query(3, description="Maximum depth of graph", ge=1), + max_nodes: int = Query(1000, description="Maximum nodes to return", ge=1), + ): + """ + Retrieve a connected subgraph of nodes where the label includes the specified label. + When reducing the number of nodes, the prioritization criteria are as follows: + 1. Hops(path) to the staring node take precedence + 2. Followed by the degree of the nodes + + Args: + label (str): Label of the starting node + max_depth (int, optional): Maximum depth of the subgraph,Defaults to 3 + max_nodes: Maxiumu nodes to return + + Returns: + Dict[str, List[str]]: Knowledge graph for label + """ + try: + # Log the label parameter to check for leading spaces + logger.debug( + f"get_knowledge_graph called with label: '{label}' (length: {len(label)}, repr: {repr(label)})" + ) + + return await rag.get_knowledge_graph( + node_label=label, + max_depth=max_depth, + max_nodes=max_nodes, + ) + except Exception as e: + logger.error(f"Error getting knowledge graph for label '{label}': {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error getting knowledge graph: {str(e)}" + ) + + @router.get("/graph/entity/exists", dependencies=[Depends(combined_auth)]) + async def check_entity_exists( + name: str = Query(..., description="Entity name to check"), + ): + """ + Check if an entity with the given name exists in the knowledge graph + + Args: + name (str): Name of the entity to check + + Returns: + Dict[str, bool]: Dictionary with 'exists' key indicating if entity exists + """ + try: + exists = await rag.chunk_entity_relation_graph.has_node(name) + return {"exists": exists} + except Exception as e: + logger.error(f"Error checking entity existence for '{name}': {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error checking entity existence: {str(e)}" + ) + + @router.post("/graph/entity/edit", dependencies=[Depends(combined_auth)]) + async def update_entity(request: EntityUpdateRequest): + """ + Update an entity's properties in the knowledge graph + + This endpoint allows updating entity properties, including renaming entities. + When renaming to an existing entity name, the behavior depends on allow_merge: + + Args: + request (EntityUpdateRequest): Request containing: + - entity_name (str): Name of the entity to update + - updated_data (Dict[str, Any]): Dictionary of properties to update + - allow_rename (bool): Whether to allow entity renaming (default: False) + - allow_merge (bool): Whether to merge into existing entity when renaming + causes name conflict (default: False) + + Returns: + Dict with the following structure: + { + "status": "success", + "message": "Entity updated successfully" | "Entity merged successfully into 'target_name'", + "data": { + "entity_name": str, # Final entity name + "description": str, # Entity description + "entity_type": str, # Entity type + "source_id": str, # Source chunk IDs + ... # Other entity properties + }, + "operation_summary": { + "merged": bool, # Whether entity was merged into another + "merge_status": str, # "success" | "failed" | "not_attempted" + "merge_error": str | None, # Error message if merge failed + "operation_status": str, # "success" | "partial_success" | "failure" + "target_entity": str | None, # Target entity name if renaming/merging + "final_entity": str, # Final entity name after operation + "renamed": bool # Whether entity was renamed + } + } + + operation_status values explained: + - "success": All operations completed successfully + * For simple updates: entity properties updated + * For renames: entity renamed successfully + * For merges: non-name updates applied AND merge completed + + - "partial_success": Update succeeded but merge failed + * Non-name property updates were applied successfully + * Merge operation failed (entity not merged) + * Original entity still exists with updated properties + * Use merge_error for failure details + + - "failure": Operation failed completely + * If merge_status == "failed": Merge attempted but both update and merge failed + * If merge_status == "not_attempted": Regular update failed + * No changes were applied to the entity + + merge_status values explained: + - "success": Entity successfully merged into target entity + - "failed": Merge operation was attempted but failed + - "not_attempted": No merge was attempted (normal update/rename) + + Behavior when renaming to an existing entity: + - If allow_merge=False: Raises ValueError with 400 status (default behavior) + - If allow_merge=True: Automatically merges the source entity into the existing target entity, + preserving all relationships and applying non-name updates first + + Example Request (simple update): + POST /graph/entity/edit + { + "entity_name": "Tesla", + "updated_data": {"description": "Updated description"}, + "allow_rename": false, + "allow_merge": false + } + + Example Response (simple update success): + { + "status": "success", + "message": "Entity updated successfully", + "data": { ... }, + "operation_summary": { + "merged": false, + "merge_status": "not_attempted", + "merge_error": null, + "operation_status": "success", + "target_entity": null, + "final_entity": "Tesla", + "renamed": false + } + } + + Example Request (rename with auto-merge): + POST /graph/entity/edit + { + "entity_name": "Elon Msk", + "updated_data": { + "entity_name": "Elon Musk", + "description": "Corrected description" + }, + "allow_rename": true, + "allow_merge": true + } + + Example Response (merge success): + { + "status": "success", + "message": "Entity merged successfully into 'Elon Musk'", + "data": { ... }, + "operation_summary": { + "merged": true, + "merge_status": "success", + "merge_error": null, + "operation_status": "success", + "target_entity": "Elon Musk", + "final_entity": "Elon Musk", + "renamed": true + } + } + + Example Response (partial success - update succeeded but merge failed): + { + "status": "success", + "message": "Entity updated successfully", + "data": { ... }, # Data reflects updated "Elon Msk" entity + "operation_summary": { + "merged": false, + "merge_status": "failed", + "merge_error": "Target entity locked by another operation", + "operation_status": "partial_success", + "target_entity": "Elon Musk", + "final_entity": "Elon Msk", # Original entity still exists + "renamed": true + } + } + """ + try: + result = await rag.aedit_entity( + entity_name=request.entity_name, + updated_data=request.updated_data, + allow_rename=request.allow_rename, + allow_merge=request.allow_merge, + ) + + # Extract operation_summary from result, with fallback for backward compatibility + operation_summary = result.get( + "operation_summary", + { + "merged": False, + "merge_status": "not_attempted", + "merge_error": None, + "operation_status": "success", + "target_entity": None, + "final_entity": request.updated_data.get( + "entity_name", request.entity_name + ), + "renamed": request.updated_data.get( + "entity_name", request.entity_name + ) + != request.entity_name, + }, + ) + + # Separate entity data from operation_summary for clean response + entity_data = dict(result) + entity_data.pop("operation_summary", None) + + # Generate appropriate response message based on merge status + response_message = ( + f"Entity merged successfully into '{operation_summary['final_entity']}'" + if operation_summary.get("merged") + else "Entity updated successfully" + ) + return { + "status": "success", + "message": response_message, + "data": entity_data, + "operation_summary": operation_summary, + } + except ValueError as ve: + logger.error( + f"Validation error updating entity '{request.entity_name}': {str(ve)}" + ) + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error(f"Error updating entity '{request.entity_name}': {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error updating entity: {str(e)}" + ) + + @router.post("/graph/relation/edit", dependencies=[Depends(combined_auth)]) + async def update_relation(request: RelationUpdateRequest): + """Update a relation's properties in the knowledge graph + + Args: + request (RelationUpdateRequest): Request containing source ID, target ID and updated data + + Returns: + Dict: Updated relation information + """ + try: + result = await rag.aedit_relation( + source_entity=request.source_id, + target_entity=request.target_id, + updated_data=request.updated_data, + ) + return { + "status": "success", + "message": "Relation updated successfully", + "data": result, + } + except ValueError as ve: + logger.error( + f"Validation error updating relation between '{request.source_id}' and '{request.target_id}': {str(ve)}" + ) + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error( + f"Error updating relation between '{request.source_id}' and '{request.target_id}': {str(e)}" + ) + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error updating relation: {str(e)}" + ) + + @router.post("/graph/entity/create", dependencies=[Depends(combined_auth)]) + async def create_entity(request: EntityCreateRequest): + """ + Create a new entity in the knowledge graph + + This endpoint creates a new entity node in the knowledge graph with the specified + properties. The system automatically generates vector embeddings for the entity + to enable semantic search and retrieval. + + Request Body: + entity_name (str): Unique name identifier for the entity + entity_data (dict): Entity properties including: + - description (str): Textual description of the entity + - entity_type (str): Category/type of the entity (e.g., PERSON, ORGANIZATION, LOCATION) + - source_id (str): Related chunk_id from which the description originates + - Additional custom properties as needed + + Response Schema: + { + "status": "success", + "message": "Entity 'Tesla' created successfully", + "data": { + "entity_name": "Tesla", + "description": "Electric vehicle manufacturer", + "entity_type": "ORGANIZATION", + "source_id": "chunk-123chunk-456" + ... (other entity properties) + } + } + + HTTP Status Codes: + 200: Entity created successfully + 400: Invalid request (e.g., missing required fields, duplicate entity) + 500: Internal server error + + Example Request: + POST /graph/entity/create + { + "entity_name": "Tesla", + "entity_data": { + "description": "Electric vehicle manufacturer", + "entity_type": "ORGANIZATION" + } + } + """ + try: + # Use the proper acreate_entity method which handles: + # - Graph lock for concurrency + # - Vector embedding creation in entities_vdb + # - Metadata population and defaults + # - Index consistency via _edit_entity_done + result = await rag.acreate_entity( + entity_name=request.entity_name, + entity_data=request.entity_data, + ) + + return { + "status": "success", + "message": f"Entity '{request.entity_name}' created successfully", + "data": result, + } + except ValueError as ve: + logger.error( + f"Validation error creating entity '{request.entity_name}': {str(ve)}" + ) + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error(f"Error creating entity '{request.entity_name}': {str(e)}") + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error creating entity: {str(e)}" + ) + + @router.post("/graph/relation/create", dependencies=[Depends(combined_auth)]) + async def create_relation(request: RelationCreateRequest): + """ + Create a new relationship between two entities in the knowledge graph + + This endpoint establishes an undirected relationship between two existing entities. + The provided source/target order is accepted for convenience, but the backend + stored edge is undirected and may be returned with the entities swapped. + Both entities must already exist in the knowledge graph. The system automatically + generates vector embeddings for the relationship to enable semantic search and graph traversal. + + Prerequisites: + - Both source_entity and target_entity must exist in the knowledge graph + - Use /graph/entity/create to create entities first if they don't exist + + Request Body: + source_entity (str): Name of the source entity (relationship origin) + target_entity (str): Name of the target entity (relationship destination) + relation_data (dict): Relationship properties including: + - description (str): Textual description of the relationship + - keywords (str): Comma-separated keywords describing the relationship type + - source_id (str): Related chunk_id from which the description originates + - weight (float): Relationship strength/importance (default: 1.0) + - Additional custom properties as needed + + Response Schema: + { + "status": "success", + "message": "Relation created successfully between 'Elon Musk' and 'Tesla'", + "data": { + "src_id": "Elon Musk", + "tgt_id": "Tesla", + "description": "Elon Musk is the CEO of Tesla", + "keywords": "CEO, founder", + "source_id": "chunk-123chunk-456" + "weight": 1.0, + ... (other relationship properties) + } + } + + HTTP Status Codes: + 200: Relationship created successfully + 400: Invalid request (e.g., missing entities, invalid data, duplicate relationship) + 500: Internal server error + + Example Request: + POST /graph/relation/create + { + "source_entity": "Elon Musk", + "target_entity": "Tesla", + "relation_data": { + "description": "Elon Musk is the CEO of Tesla", + "keywords": "CEO, founder", + "weight": 1.0 + } + } + """ + try: + # Use the proper acreate_relation method which handles: + # - Graph lock for concurrency + # - Entity existence validation + # - Duplicate relation checks + # - Vector embedding creation in relationships_vdb + # - Index consistency via _edit_relation_done + result = await rag.acreate_relation( + source_entity=request.source_entity, + target_entity=request.target_entity, + relation_data=request.relation_data, + ) + + return { + "status": "success", + "message": f"Relation created successfully between '{request.source_entity}' and '{request.target_entity}'", + "data": result, + } + except ValueError as ve: + logger.error( + f"Validation error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(ve)}" + ) + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error( + f"Error creating relation between '{request.source_entity}' and '{request.target_entity}': {str(e)}" + ) + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error creating relation: {str(e)}" + ) + + @router.post("/graph/entities/merge", dependencies=[Depends(combined_auth)]) + async def merge_entities(request: EntityMergeRequest): + """ + Merge multiple entities into a single entity, preserving all relationships + + This endpoint consolidates duplicate or misspelled entities while preserving the entire + graph structure. It's particularly useful for cleaning up knowledge graphs after document + processing or correcting entity name variations. + + What the Merge Operation Does: + 1. Deletes the specified source entities from the knowledge graph + 2. Transfers all relationships from source entities to the target entity + 3. Intelligently merges duplicate relationships (if multiple sources have the same relationship) + 4. Updates vector embeddings for accurate retrieval and search + 5. Preserves the complete graph structure and connectivity + 6. Maintains relationship properties and metadata + + Use Cases: + - Fixing spelling errors in entity names (e.g., "Elon Msk" -> "Elon Musk") + - Consolidating duplicate entities discovered after document processing + - Merging name variations (e.g., "NY", "New York", "New York City") + - Cleaning up the knowledge graph for better query performance + - Standardizing entity names across the knowledge base + + Request Body: + entities_to_change (list[str]): List of entity names to be merged and deleted + entity_to_change_into (str): Target entity that will receive all relationships + + Response Schema: + { + "status": "success", + "message": "Successfully merged 2 entities into 'Elon Musk'", + "data": { + "merged_entity": "Elon Musk", + "deleted_entities": ["Elon Msk", "Ellon Musk"], + "relationships_transferred": 15, + ... (merge operation details) + } + } + + HTTP Status Codes: + 200: Entities merged successfully + 400: Invalid request (e.g., empty entity list, target entity doesn't exist) + 500: Internal server error + + Example Request: + POST /graph/entities/merge + { + "entities_to_change": ["Elon Msk", "Ellon Musk"], + "entity_to_change_into": "Elon Musk" + } + + Note: + - The target entity (entity_to_change_into) must exist in the knowledge graph + - Source entities will be permanently deleted after the merge + - This operation cannot be undone, so verify entity names before merging + """ + try: + result = await rag.amerge_entities( + source_entities=request.entities_to_change, + target_entity=request.entity_to_change_into, + ) + return { + "status": "success", + "message": f"Successfully merged {len(request.entities_to_change)} entities into '{request.entity_to_change_into}'", + "data": result, + } + except ValueError as ve: + logger.error( + f"Validation error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(ve)}" + ) + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error( + f"Error merging entities {request.entities_to_change} into '{request.entity_to_change_into}': {str(e)}" + ) + logger.error(traceback.format_exc()) + raise HTTPException( + status_code=500, detail=f"Error merging entities: {str(e)}" + ) + + return router diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/ollama_api.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/ollama_api.py new file mode 100644 index 0000000..15c695c --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/ollama_api.py @@ -0,0 +1,723 @@ +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel +from typing import List, Dict, Any, Optional, Type +from lightrag.utils import logger +import time +import json +import re +from enum import Enum +from fastapi.responses import StreamingResponse +import asyncio +from lightrag import LightRAG, QueryParam +from lightrag.utils import TiktokenTokenizer +from lightrag.api.utils_api import get_combined_auth_dependency +from fastapi import Depends + + +# query mode according to query prefix (bypass is not LightRAG quer mode) +class SearchMode(str, Enum): + naive = "naive" + local = "local" + global_ = "global" + hybrid = "hybrid" + mix = "mix" + bypass = "bypass" + context = "context" + + +class OllamaMessage(BaseModel): + role: str + content: str + images: Optional[List[str]] = None + + +class OllamaChatRequest(BaseModel): + model: str + messages: List[OllamaMessage] + stream: bool = True + options: Optional[Dict[str, Any]] = None + system: Optional[str] = None + + +class OllamaChatResponse(BaseModel): + model: str + created_at: str + message: OllamaMessage + done: bool + + +class OllamaGenerateRequest(BaseModel): + model: str + prompt: str + system: Optional[str] = None + stream: bool = False + options: Optional[Dict[str, Any]] = None + + +class OllamaGenerateResponse(BaseModel): + model: str + created_at: str + response: str + done: bool + context: Optional[List[int]] + total_duration: Optional[int] + load_duration: Optional[int] + prompt_eval_count: Optional[int] + prompt_eval_duration: Optional[int] + eval_count: Optional[int] + eval_duration: Optional[int] + + +class OllamaVersionResponse(BaseModel): + version: str + + +class OllamaModelDetails(BaseModel): + parent_model: str + format: str + family: str + families: List[str] + parameter_size: str + quantization_level: str + + +class OllamaModel(BaseModel): + name: str + model: str + size: int + digest: str + modified_at: str + details: OllamaModelDetails + + +class OllamaTagResponse(BaseModel): + models: List[OllamaModel] + + +class OllamaRunningModelDetails(BaseModel): + parent_model: str + format: str + family: str + families: List[str] + parameter_size: str + quantization_level: str + + +class OllamaRunningModel(BaseModel): + name: str + model: str + size: int + digest: str + details: OllamaRunningModelDetails + expires_at: str + size_vram: int + + +class OllamaPsResponse(BaseModel): + models: List[OllamaRunningModel] + + +async def parse_request_body( + request: Request, model_class: Type[BaseModel] +) -> BaseModel: + """ + Parse request body based on Content-Type header. + Supports both application/json and application/octet-stream. + + Args: + request: The FastAPI Request object + model_class: The Pydantic model class to parse the request into + + Returns: + An instance of the provided model_class + """ + content_type = request.headers.get("content-type", "").lower() + + try: + if content_type.startswith("application/json"): + # FastAPI already handles JSON parsing for us + body = await request.json() + elif content_type.startswith("application/octet-stream"): + # Manually parse octet-stream as JSON + body_bytes = await request.body() + body = json.loads(body_bytes.decode("utf-8")) + else: + # Try to parse as JSON for any other content type + body_bytes = await request.body() + body = json.loads(body_bytes.decode("utf-8")) + + # Create an instance of the model + return model_class(**body) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON in request body") + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Error parsing request body: {str(e)}" + ) + + +def estimate_tokens(text: str) -> int: + """Estimate the number of tokens in text using tiktoken""" + tokens = TiktokenTokenizer().encode(text) + return len(tokens) + + +def parse_query_mode(query: str) -> tuple[str, SearchMode, bool, Optional[str]]: + """Parse query prefix to determine search mode + Returns tuple of (cleaned_query, search_mode, only_need_context, user_prompt) + + Examples: + - "/local[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.local, False, "use mermaid format for diagrams") + - "/[use mermaid format for diagrams] query string" -> (cleaned_query, SearchMode.hybrid, False, "use mermaid format for diagrams") + - "/local query string" -> (cleaned_query, SearchMode.local, False, None) + """ + # Initialize user_prompt as None + user_prompt = None + + # First check if there's a bracket format for user prompt + bracket_pattern = r"^/([a-z]*)\[(.*?)\](.*)" + bracket_match = re.match(bracket_pattern, query) + + if bracket_match: + mode_prefix = bracket_match.group(1) + user_prompt = bracket_match.group(2) + remaining_query = bracket_match.group(3).lstrip() + + # Reconstruct query, removing the bracket part + query = f"/{mode_prefix} {remaining_query}".strip() + + # Unified handling of mode and only_need_context determination + mode_map = { + "/local ": (SearchMode.local, False), + "/global ": ( + SearchMode.global_, + False, + ), # global_ is used because 'global' is a Python keyword + "/naive ": (SearchMode.naive, False), + "/hybrid ": (SearchMode.hybrid, False), + "/mix ": (SearchMode.mix, False), + "/bypass ": (SearchMode.bypass, False), + "/context": ( + SearchMode.mix, + True, + ), + "/localcontext": (SearchMode.local, True), + "/globalcontext": (SearchMode.global_, True), + "/hybridcontext": (SearchMode.hybrid, True), + "/naivecontext": (SearchMode.naive, True), + "/mixcontext": (SearchMode.mix, True), + } + + for prefix, (mode, only_need_context) in mode_map.items(): + if query.startswith(prefix): + # After removing prefix and leading spaces + cleaned_query = query[len(prefix) :].lstrip() + return cleaned_query, mode, only_need_context, user_prompt + + return query, SearchMode.mix, False, user_prompt + + +class OllamaAPI: + def __init__(self, rag: LightRAG, top_k: int = 60, api_key: Optional[str] = None): + self.rag = rag + self.ollama_server_infos = rag.ollama_server_infos + self.top_k = top_k + self.api_key = api_key + self.router = APIRouter(tags=["ollama"]) + self.setup_routes() + + def setup_routes(self): + # Create combined auth dependency for Ollama API routes + combined_auth = get_combined_auth_dependency(self.api_key) + + @self.router.get("/version", dependencies=[Depends(combined_auth)]) + async def get_version(): + """Get Ollama version information""" + return OllamaVersionResponse(version="0.9.3") + + @self.router.get("/tags", dependencies=[Depends(combined_auth)]) + async def get_tags(): + """Return available models acting as an Ollama server""" + return OllamaTagResponse( + models=[ + { + "name": self.ollama_server_infos.LIGHTRAG_MODEL, + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "modified_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "size": self.ollama_server_infos.LIGHTRAG_SIZE, + "digest": self.ollama_server_infos.LIGHTRAG_DIGEST, + "details": { + "parent_model": "", + "format": "gguf", + "family": self.ollama_server_infos.LIGHTRAG_NAME, + "families": [self.ollama_server_infos.LIGHTRAG_NAME], + "parameter_size": "13B", + "quantization_level": "Q4_0", + }, + } + ] + ) + + @self.router.get("/ps", dependencies=[Depends(combined_auth)]) + async def get_running_models(): + """List Running Models - returns currently running models""" + return OllamaPsResponse( + models=[ + { + "name": self.ollama_server_infos.LIGHTRAG_MODEL, + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "size": self.ollama_server_infos.LIGHTRAG_SIZE, + "digest": self.ollama_server_infos.LIGHTRAG_DIGEST, + "details": { + "parent_model": "", + "format": "gguf", + "family": "llama", + "families": ["llama"], + "parameter_size": "7.2B", + "quantization_level": "Q4_0", + }, + "expires_at": "2050-12-31T14:38:31.83753-07:00", + "size_vram": self.ollama_server_infos.LIGHTRAG_SIZE, + } + ] + ) + + @self.router.post( + "/generate", dependencies=[Depends(combined_auth)], include_in_schema=True + ) + async def generate(raw_request: Request): + """Handle generate completion requests acting as an Ollama model + For compatibility purpose, the request is not processed by LightRAG, + and will be handled by underlying LLM model. + Supports both application/json and application/octet-stream Content-Types. + """ + try: + # Parse the request body manually + request = await parse_request_body(raw_request, OllamaGenerateRequest) + + query = request.prompt + start_time = time.time_ns() + prompt_tokens = estimate_tokens(query) + + if request.system: + self.rag.llm_model_kwargs["system_prompt"] = request.system + + if request.stream: + response = await self.rag.llm_model_func( + query, stream=True, **self.rag.llm_model_kwargs + ) + + async def stream_generator(): + first_chunk_time = None + last_chunk_time = time.time_ns() + total_response = "" + + # Ensure response is an async generator + if isinstance(response, str): + # If it's a string, send in two parts + first_chunk_time = start_time + last_chunk_time = time.time_ns() + total_response = response + + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": response, + "done": False, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + + completion_tokens = estimate_tokens(total_response) + total_time = last_chunk_time - start_time + prompt_eval_time = first_chunk_time - start_time + eval_time = last_chunk_time - first_chunk_time + + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": "", + "done": True, + "done_reason": "stop", + "context": [], + "total_duration": total_time, + "load_duration": 0, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_eval_time, + "eval_count": completion_tokens, + "eval_duration": eval_time, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + else: + try: + async for chunk in response: + if chunk: + if first_chunk_time is None: + first_chunk_time = time.time_ns() + + last_chunk_time = time.time_ns() + + total_response += chunk + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": chunk, + "done": False, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + except (asyncio.CancelledError, Exception) as e: + error_msg = str(e) + if isinstance(e, asyncio.CancelledError): + error_msg = "Stream was cancelled by server" + else: + error_msg = f"Provider error: {error_msg}" + + logger.error(f"Stream error: {error_msg}") + + # Send error message to client + error_data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": f"\n\nError: {error_msg}", + "error": f"\n\nError: {error_msg}", + "done": False, + } + yield f"{json.dumps(error_data, ensure_ascii=False)}\n" + + # Send final message to close the stream + final_data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": "", + "done": True, + } + yield f"{json.dumps(final_data, ensure_ascii=False)}\n" + return + if first_chunk_time is None: + first_chunk_time = start_time + completion_tokens = estimate_tokens(total_response) + total_time = last_chunk_time - start_time + prompt_eval_time = first_chunk_time - start_time + eval_time = last_chunk_time - first_chunk_time + + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": "", + "done": True, + "done_reason": "stop", + "context": [], + "total_duration": total_time, + "load_duration": 0, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_eval_time, + "eval_count": completion_tokens, + "eval_duration": eval_time, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + return + + return StreamingResponse( + stream_generator(), + media_type="application/x-ndjson", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "application/x-ndjson", + "X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy + }, + ) + else: + first_chunk_time = time.time_ns() + response_text = await self.rag.llm_model_func( + query, stream=False, **self.rag.llm_model_kwargs + ) + last_chunk_time = time.time_ns() + + if not response_text: + response_text = "No response generated" + + completion_tokens = estimate_tokens(str(response_text)) + total_time = last_chunk_time - start_time + prompt_eval_time = first_chunk_time - start_time + eval_time = last_chunk_time - first_chunk_time + + return { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "response": str(response_text), + "done": True, + "done_reason": "stop", + "context": [], + "total_duration": total_time, + "load_duration": 0, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_eval_time, + "eval_count": completion_tokens, + "eval_duration": eval_time, + } + except Exception as e: + logger.error(f"Ollama generate error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @self.router.post( + "/chat", dependencies=[Depends(combined_auth)], include_in_schema=True + ) + async def chat(raw_request: Request): + """Process chat completion requests by acting as an Ollama model. + Routes user queries through LightRAG by selecting query mode based on query prefix. + Detects and forwards OpenWebUI session-related requests (for meta data generation task) directly to LLM. + Supports both application/json and application/octet-stream Content-Types. + """ + try: + # Parse the request body manually + request = await parse_request_body(raw_request, OllamaChatRequest) + + # Get all messages + messages = request.messages + if not messages: + raise HTTPException(status_code=400, detail="No messages provided") + + # Validate that the last message is from a user + if messages[-1].role != "user": + raise HTTPException( + status_code=400, detail="Last message must be from user role" + ) + + # Get the last message as query and previous messages as history + query = messages[-1].content + # Convert OllamaMessage objects to dictionaries + conversation_history = [ + {"role": msg.role, "content": msg.content} for msg in messages[:-1] + ] + + # Check for query prefix + cleaned_query, mode, only_need_context, user_prompt = parse_query_mode( + query + ) + + start_time = time.time_ns() + prompt_tokens = estimate_tokens(cleaned_query) + + param_dict = { + "mode": mode.value, + "stream": request.stream, + "only_need_context": only_need_context, + "conversation_history": conversation_history, + "top_k": self.top_k, + } + + # Add user_prompt to param_dict + if user_prompt is not None: + param_dict["user_prompt"] = user_prompt + + query_param = QueryParam(**param_dict) + + if request.stream: + # Determine if the request is prefix with "/bypass" + if mode == SearchMode.bypass: + if request.system: + self.rag.llm_model_kwargs["system_prompt"] = request.system + response = await self.rag.llm_model_func( + cleaned_query, + stream=True, + history_messages=conversation_history, + **self.rag.llm_model_kwargs, + ) + else: + response = await self.rag.aquery( + cleaned_query, param=query_param + ) + + async def stream_generator(): + first_chunk_time = None + last_chunk_time = time.time_ns() + total_response = "" + + # Ensure response is an async generator + if isinstance(response, str): + # If it's a string, send in two parts + first_chunk_time = start_time + last_chunk_time = time.time_ns() + total_response = response + + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": response, + "images": None, + }, + "done": False, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + + completion_tokens = estimate_tokens(total_response) + total_time = last_chunk_time - start_time + prompt_eval_time = first_chunk_time - start_time + eval_time = last_chunk_time - first_chunk_time + + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": "", + "images": None, + }, + "done_reason": "stop", + "done": True, + "total_duration": total_time, + "load_duration": 0, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_eval_time, + "eval_count": completion_tokens, + "eval_duration": eval_time, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + else: + try: + async for chunk in response: + if chunk: + if first_chunk_time is None: + first_chunk_time = time.time_ns() + + last_chunk_time = time.time_ns() + + total_response += chunk + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": chunk, + "images": None, + }, + "done": False, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + except (asyncio.CancelledError, Exception) as e: + error_msg = str(e) + if isinstance(e, asyncio.CancelledError): + error_msg = "Stream was cancelled by server" + else: + error_msg = f"Provider error: {error_msg}" + + logger.error(f"Stream error: {error_msg}") + + # Send error message to client + error_data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": f"\n\nError: {error_msg}", + "images": None, + }, + "error": f"\n\nError: {error_msg}", + "done": False, + } + yield f"{json.dumps(error_data, ensure_ascii=False)}\n" + + # Send final message to close the stream + final_data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": "", + "images": None, + }, + "done": True, + } + yield f"{json.dumps(final_data, ensure_ascii=False)}\n" + return + + if first_chunk_time is None: + first_chunk_time = start_time + completion_tokens = estimate_tokens(total_response) + total_time = last_chunk_time - start_time + prompt_eval_time = first_chunk_time - start_time + eval_time = last_chunk_time - first_chunk_time + + data = { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": "", + "images": None, + }, + "done_reason": "stop", + "done": True, + "total_duration": total_time, + "load_duration": 0, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_eval_time, + "eval_count": completion_tokens, + "eval_duration": eval_time, + } + yield f"{json.dumps(data, ensure_ascii=False)}\n" + + return StreamingResponse( + stream_generator(), + media_type="application/x-ndjson", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "application/x-ndjson", + "X-Accel-Buffering": "no", # Ensure proper handling of streaming responses in Nginx proxy + }, + ) + else: + first_chunk_time = time.time_ns() + + # Determine if the request is prefix with "/bypass" or from Open WebUI's session title and session keyword generation task + match_result = re.search( + r"\n\nUSER:", cleaned_query, re.MULTILINE + ) + if match_result or mode == SearchMode.bypass: + if request.system: + self.rag.llm_model_kwargs["system_prompt"] = request.system + + response_text = await self.rag.llm_model_func( + cleaned_query, + stream=False, + history_messages=conversation_history, + **self.rag.llm_model_kwargs, + ) + else: + response_text = await self.rag.aquery( + cleaned_query, param=query_param + ) + + last_chunk_time = time.time_ns() + + if not response_text: + response_text = "No response generated" + + completion_tokens = estimate_tokens(str(response_text)) + total_time = last_chunk_time - start_time + prompt_eval_time = first_chunk_time - start_time + eval_time = last_chunk_time - first_chunk_time + + return { + "model": self.ollama_server_infos.LIGHTRAG_MODEL, + "created_at": self.ollama_server_infos.LIGHTRAG_CREATED_AT, + "message": { + "role": "assistant", + "content": str(response_text), + "images": None, + }, + "done_reason": "stop", + "done": True, + "total_duration": total_time, + "load_duration": 0, + "prompt_eval_count": prompt_tokens, + "prompt_eval_duration": prompt_eval_time, + "eval_count": completion_tokens, + "eval_duration": eval_time, + } + except Exception as e: + logger.error(f"Ollama chat error: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/query_routes.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/query_routes.py new file mode 100644 index 0000000..2295815 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/routers/query_routes.py @@ -0,0 +1,1160 @@ +""" +This module contains all query-related routes for the LightRAG API. +""" + +import json +from typing import Any, Dict, List, Literal, Optional +from fastapi import APIRouter, Depends, HTTPException +from lightrag.base import QueryParam +from lightrag.api.utils_api import get_combined_auth_dependency +from lightrag.utils import logger +from pydantic import BaseModel, Field, field_validator + +router = APIRouter(tags=["query"]) + + +class QueryRequest(BaseModel): + query: str = Field( + min_length=3, + description="The query text", + ) + + mode: Literal["local", "global", "hybrid", "naive", "mix", "bypass"] = Field( + default="mix", + description="Query mode", + ) + + only_need_context: Optional[bool] = Field( + default=None, + description="If True, only returns the retrieved context without generating a response.", + ) + + only_need_prompt: Optional[bool] = Field( + default=None, + description="If True, only returns the generated prompt without producing a response.", + ) + + response_type: Optional[str] = Field( + min_length=1, + default=None, + description="Defines the response format. Examples: 'Multiple Paragraphs', 'Single Paragraph', 'Bullet Points'.", + ) + + top_k: Optional[int] = Field( + ge=1, + default=None, + description="Number of top items to retrieve. Represents entities in 'local' mode and relationships in 'global' mode.", + ) + + chunk_top_k: Optional[int] = Field( + ge=1, + default=None, + description="Number of text chunks to retrieve initially from vector search and keep after reranking.", + ) + + max_entity_tokens: Optional[int] = Field( + default=None, + description="Maximum number of tokens allocated for entity context in unified token control system.", + ge=1, + ) + + max_relation_tokens: Optional[int] = Field( + default=None, + description="Maximum number of tokens allocated for relationship context in unified token control system.", + ge=1, + ) + + max_total_tokens: Optional[int] = Field( + default=None, + description="Maximum total tokens budget for the entire query context (entities + relations + chunks + system prompt).", + ge=1, + ) + + hl_keywords: list[str] = Field( + default_factory=list, + description="List of high-level keywords to prioritize in retrieval. Leave empty to use the LLM to generate the keywords.", + ) + + ll_keywords: list[str] = Field( + default_factory=list, + description="List of low-level keywords to refine retrieval focus. Leave empty to use the LLM to generate the keywords.", + ) + + conversation_history: Optional[List[Dict[str, Any]]] = Field( + default=None, + description="History messages are only sent to LLM for context, not used for retrieval. Format: [{'role': 'user/assistant', 'content': 'message'}].", + ) + + user_prompt: Optional[str] = Field( + default=None, + description="User-provided prompt for the query. If provided, this will be used instead of the default value from prompt template.", + ) + + enable_rerank: Optional[bool] = Field( + default=None, + description="Enable reranking for retrieved text chunks. If True but no rerank model is configured, a warning will be issued. Default is True.", + ) + + include_references: Optional[bool] = Field( + default=True, + description="If True, includes reference list in responses. Affects /query and /query/stream endpoints. /query/data always includes references.", + ) + + include_chunk_content: Optional[bool] = Field( + default=False, + description="If True, includes actual chunk text content in references. Only applies when include_references=True. Useful for evaluation and debugging.", + ) + + stream: Optional[bool] = Field( + default=True, + description="If True, enables streaming output for real-time responses. Only affects /query/stream endpoint.", + ) + + @field_validator("query", mode="after") + @classmethod + def query_strip_after(cls, query: str) -> str: + return query.strip() + + @field_validator("conversation_history", mode="after") + @classmethod + def conversation_history_role_check( + cls, conversation_history: List[Dict[str, Any]] | None + ) -> List[Dict[str, Any]] | None: + if conversation_history is None: + return None + for msg in conversation_history: + if "role" not in msg: + raise ValueError("Each message must have a 'role' key.") + if not isinstance(msg["role"], str) or not msg["role"].strip(): + raise ValueError("Each message 'role' must be a non-empty string.") + return conversation_history + + def to_query_params(self, is_stream: bool) -> "QueryParam": + """Converts a QueryRequest instance into a QueryParam instance.""" + # Use Pydantic's `.model_dump(exclude_none=True)` to remove None values automatically + # Exclude API-level parameters that don't belong in QueryParam + request_data = self.model_dump( + exclude_none=True, exclude={"query", "include_chunk_content"} + ) + + # Ensure `mode` and `stream` are set explicitly + param = QueryParam(**request_data) + param.stream = is_stream + return param + + +class ReferenceItem(BaseModel): + """A single reference item in query responses.""" + + reference_id: str = Field(description="Unique reference identifier") + file_path: str = Field(description="Path to the source file") + content: Optional[List[str]] = Field( + default=None, + description="List of chunk contents from this file (only present when include_chunk_content=True)", + ) + + +class QueryResponse(BaseModel): + response: str = Field( + description="The generated response", + ) + references: Optional[List[ReferenceItem]] = Field( + default=None, + description="Reference list (Disabled when include_references=False, /query/data always includes references.)", + ) + + +class QueryDataResponse(BaseModel): + status: str = Field(description="Query execution status") + message: str = Field(description="Status message") + data: Dict[str, Any] = Field( + description="Query result data containing entities, relationships, chunks, and references" + ) + metadata: Dict[str, Any] = Field( + description="Query metadata including mode, keywords, and processing information" + ) + + +class StreamChunkResponse(BaseModel): + """Response model for streaming chunks in NDJSON format""" + + references: Optional[List[Dict[str, str]]] = Field( + default=None, + description="Reference list (only in first chunk when include_references=True)", + ) + response: Optional[str] = Field( + default=None, description="Response content chunk or complete response" + ) + error: Optional[str] = Field( + default=None, description="Error message if processing fails" + ) + + +def create_query_routes(rag, api_key: Optional[str] = None, top_k: int = 60): + combined_auth = get_combined_auth_dependency(api_key) + + @router.post( + "/query", + response_model=QueryResponse, + dependencies=[Depends(combined_auth)], + responses={ + 200: { + "description": "Successful RAG query response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "description": "The generated response from the RAG system", + }, + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "reference_id": {"type": "string"}, + "file_path": {"type": "string"}, + "content": { + "type": "array", + "items": {"type": "string"}, + "description": "List of chunk contents from this file (only included when include_chunk_content=True)", + }, + }, + }, + "description": "Reference list (only included when include_references=True)", + }, + }, + "required": ["response"], + }, + "examples": { + "with_references": { + "summary": "Response with references", + "description": "Example response when include_references=True", + "value": { + "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.", + "references": [ + { + "reference_id": "1", + "file_path": "/documents/ai_overview.pdf", + }, + { + "reference_id": "2", + "file_path": "/documents/machine_learning.txt", + }, + ], + }, + }, + "with_chunk_content": { + "summary": "Response with chunk content", + "description": "Example response when include_references=True and include_chunk_content=True. Note: content is an array of chunks from the same file.", + "value": { + "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.", + "references": [ + { + "reference_id": "1", + "file_path": "/documents/ai_overview.pdf", + "content": [ + "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.", + "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.", + ], + }, + { + "reference_id": "2", + "file_path": "/documents/machine_learning.txt", + "content": [ + "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." + ], + }, + ], + }, + }, + "without_references": { + "summary": "Response without references", + "description": "Example response when include_references=False", + "value": { + "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." + }, + }, + "different_modes": { + "summary": "Different query modes", + "description": "Examples of responses from different query modes", + "value": { + "local_mode": "Focuses on specific entities and their relationships", + "global_mode": "Provides broader context from relationship patterns", + "hybrid_mode": "Combines local and global approaches", + "naive_mode": "Simple vector similarity search", + "mix_mode": "Integrates knowledge graph and vector retrieval", + }, + }, + }, + } + }, + }, + 400: { + "description": "Bad Request - Invalid input parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"detail": {"type": "string"}}, + }, + "example": { + "detail": "Query text must be at least 3 characters long" + }, + } + }, + }, + 500: { + "description": "Internal Server Error - Query processing failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"detail": {"type": "string"}}, + }, + "example": { + "detail": "Failed to process query: LLM service unavailable" + }, + } + }, + }, + }, + ) + async def query_text(request: QueryRequest): + """ + Comprehensive RAG query endpoint with non-streaming response. Parameter "stream" is ignored. + + This endpoint performs Retrieval-Augmented Generation (RAG) queries using various modes + to provide intelligent responses based on your knowledge base. + + **Query Modes:** + - **local**: Focuses on specific entities and their direct relationships + - **global**: Analyzes broader patterns and relationships across the knowledge graph + - **hybrid**: Combines local and global approaches for comprehensive results + - **naive**: Simple vector similarity search without knowledge graph + - **mix**: Integrates knowledge graph retrieval with vector search (recommended) + - **bypass**: Direct LLM query without knowledge retrieval + + conversation_history parameteris sent to LLM only, does not affect retrieval results. + + **Usage Examples:** + + Basic query: + ```json + { + "query": "What is machine learning?", + "mode": "mix" + } + ``` + + Bypass initial LLM call by providing high-level and low-level keywords: + ```json + { + "query": "What is Retrieval-Augmented-Generation?", + "hl_keywords": ["machine learning", "information retrieval", "natural language processing"], + "ll_keywords": ["retrieval augmented generation", "RAG", "knowledge base"], + "mode": "mix" + } + ``` + + Advanced query with references: + ```json + { + "query": "Explain neural networks", + "mode": "hybrid", + "include_references": true, + "response_type": "Multiple Paragraphs", + "top_k": 10 + } + ``` + + Conversation with history: + ```json + { + "query": "Can you give me more details?", + "conversation_history": [ + {"role": "user", "content": "What is AI?"}, + {"role": "assistant", "content": "AI is artificial intelligence..."} + ] + } + ``` + + Args: + request (QueryRequest): The request object containing query parameters: + - **query**: The question or prompt to process (min 3 characters) + - **mode**: Query strategy - "mix" recommended for best results + - **include_references**: Whether to include source citations + - **response_type**: Format preference (e.g., "Multiple Paragraphs") + - **top_k**: Number of top entities/relations to retrieve + - **conversation_history**: Previous dialogue context + - **max_total_tokens**: Token budget for the entire response + + Returns: + QueryResponse: JSON response containing: + - **response**: The generated answer to your query + - **references**: Source citations (if include_references=True) + + Raises: + HTTPException: + - 400: Invalid input parameters (e.g., query too short) + - 500: Internal processing error (e.g., LLM service unavailable) + """ + try: + param = request.to_query_params( + False + ) # Ensure stream=False for non-streaming endpoint + # Force stream=False for /query endpoint regardless of include_references setting + param.stream = False + + # Unified approach: always use aquery_llm for both cases + result = await rag.aquery_llm(request.query, param=param) + + # Extract LLM response and references from unified result + llm_response = result.get("llm_response", {}) + data = result.get("data", {}) + references = data.get("references", []) + + # Get the non-streaming response content + response_content = llm_response.get("content", "") + if not response_content: + response_content = "No relevant context found for the query." + + # Enrich references with chunk content if requested + if request.include_references and request.include_chunk_content: + chunks = data.get("chunks", []) + # Create a mapping from reference_id to chunk content + ref_id_to_content = {} + for chunk in chunks: + ref_id = chunk.get("reference_id", "") + content = chunk.get("content", "") + if ref_id and content: + # Collect chunk content; join later to avoid quadratic string concatenation + ref_id_to_content.setdefault(ref_id, []).append(content) + + # Add content to references + enriched_references = [] + for ref in references: + ref_copy = ref.copy() + ref_id = ref.get("reference_id", "") + if ref_id in ref_id_to_content: + # Keep content as a list of chunks (one file may have multiple chunks) + ref_copy["content"] = ref_id_to_content[ref_id] + enriched_references.append(ref_copy) + references = enriched_references + + # Return response with or without references based on request + if request.include_references: + return QueryResponse(response=response_content, references=references) + else: + return QueryResponse(response=response_content, references=None) + except Exception as e: + logger.error(f"Error processing query: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/query/stream", + dependencies=[Depends(combined_auth)], + responses={ + 200: { + "description": "Flexible RAG query response - format depends on stream parameter", + "content": { + "application/x-ndjson": { + "schema": { + "type": "string", + "format": "ndjson", + "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.", + "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."}', + }, + "examples": { + "streaming_with_references": { + "summary": "Streaming mode with references (stream=true)", + "description": "Multiple NDJSON lines when stream=True and include_references=True. First line contains references, subsequent lines contain response chunks.", + "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."}', + }, + "streaming_with_chunk_content": { + "summary": "Streaming mode with chunk content (stream=true, include_chunk_content=true)", + "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.", + "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."}', + }, + "streaming_without_references": { + "summary": "Streaming mode without references (stream=true)", + "description": "Multiple NDJSON lines when stream=True and include_references=False. Only response chunks are sent.", + "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."}', + }, + "non_streaming_with_references": { + "summary": "Non-streaming mode with references (stream=false)", + "description": "Single NDJSON line when stream=False and include_references=True. Complete response with references in one message.", + "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."}', + }, + "non_streaming_without_references": { + "summary": "Non-streaming mode without references (stream=false)", + "description": "Single NDJSON line when stream=False and include_references=False. Complete response only.", + "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."}', + }, + "error_response": { + "summary": "Error during streaming", + "description": "Error handling in NDJSON format when an error occurs during processing.", + "value": '{"references": [{"reference_id": "1", "file_path": "/documents/ai.pdf"}]}\n{"response": "Artificial Intelligence is"}\n{"error": "LLM service temporarily unavailable"}', + }, + }, + } + }, + }, + 400: { + "description": "Bad Request - Invalid input parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"detail": {"type": "string"}}, + }, + "example": { + "detail": "Query text must be at least 3 characters long" + }, + } + }, + }, + 500: { + "description": "Internal Server Error - Query processing failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"detail": {"type": "string"}}, + }, + "example": { + "detail": "Failed to process streaming query: Knowledge graph unavailable" + }, + } + }, + }, + }, + ) + async def query_text_stream(request: QueryRequest): + """ + Advanced RAG query endpoint with flexible streaming response. + + This endpoint provides the most flexible querying experience, supporting both real-time streaming + and complete response delivery based on your integration needs. + + **Response Modes:** + - Real-time response delivery as content is generated + - NDJSON format: each line is a separate JSON object + - First line: `{"references": [...]}` (if include_references=True) + - Subsequent lines: `{"response": "content chunk"}` + - Error handling: `{"error": "error message"}` + + > If stream parameter is False, or the query hit LLM cache, complete response delivered in a single streaming message. + + **Response Format Details** + - **Content-Type**: `application/x-ndjson` (Newline-Delimited JSON) + - **Structure**: Each line is an independent, valid JSON object + - **Parsing**: Process line-by-line, each line is self-contained + - **Headers**: Includes cache control and connection management + + **Query Modes (same as /query endpoint)** + - **local**: Entity-focused retrieval with direct relationships + - **global**: Pattern analysis across the knowledge graph + - **hybrid**: Combined local and global strategies + - **naive**: Vector similarity search only + - **mix**: Integrated knowledge graph + vector retrieval (recommended) + - **bypass**: Direct LLM query without knowledge retrieval + + conversation_history parameteris sent to LLM only, does not affect retrieval results. + + **Usage Examples** + + Real-time streaming query: + ```json + { + "query": "Explain machine learning algorithms", + "mode": "mix", + "stream": true, + "include_references": true + } + ``` + + Bypass initial LLM call by providing high-level and low-level keywords: + ```json + { + "query": "What is Retrieval-Augmented-Generation?", + "hl_keywords": ["machine learning", "information retrieval", "natural language processing"], + "ll_keywords": ["retrieval augmented generation", "RAG", "knowledge base"], + "mode": "mix" + } + ``` + + Complete response query: + ```json + { + "query": "What is deep learning?", + "mode": "hybrid", + "stream": false, + "response_type": "Multiple Paragraphs" + } + ``` + + Conversation with context: + ```json + { + "query": "Can you elaborate on that?", + "stream": true, + "conversation_history": [ + {"role": "user", "content": "What is neural network?"}, + {"role": "assistant", "content": "A neural network is..."} + ] + } + ``` + + **Response Processing:** + + ```python + async for line in response.iter_lines(): + data = json.loads(line) + if "references" in data: + # Handle references (first message) + references = data["references"] + if "response" in data: + # Handle content chunk + content_chunk = data["response"] + if "error" in data: + # Handle error + error_message = data["error"] + ``` + + **Error Handling:** + - Streaming errors are delivered as `{"error": "message"}` lines + - Non-streaming errors raise HTTP exceptions + - Partial responses may be delivered before errors in streaming mode + - Always check for error objects when processing streaming responses + + Args: + request (QueryRequest): The request object containing query parameters: + - **query**: The question or prompt to process (min 3 characters) + - **mode**: Query strategy - "mix" recommended for best results + - **stream**: Enable streaming (True) or complete response (False) + - **include_references**: Whether to include source citations + - **response_type**: Format preference (e.g., "Multiple Paragraphs") + - **top_k**: Number of top entities/relations to retrieve + - **conversation_history**: Previous dialogue context for multi-turn conversations + - **max_total_tokens**: Token budget for the entire response + + Returns: + StreamingResponse: NDJSON streaming response containing: + - **Streaming mode**: Multiple JSON objects, one per line + - References object (if requested): `{"references": [...]}` + - Content chunks: `{"response": "chunk content"}` + - Error objects: `{"error": "error message"}` + - **Non-streaming mode**: Single JSON object + - Complete response: `{"references": [...], "response": "complete content"}` + + Raises: + HTTPException: + - 400: Invalid input parameters (e.g., query too short, invalid mode) + - 500: Internal processing error (e.g., LLM service unavailable) + + Note: + This endpoint is ideal for applications requiring flexible response delivery. + Use streaming mode for real-time interfaces and non-streaming for batch processing. + """ + try: + # Use the stream parameter from the request, defaulting to True if not specified + stream_mode = request.stream if request.stream is not None else True + param = request.to_query_params(stream_mode) + + from fastapi.responses import StreamingResponse + + # Unified approach: always use aquery_llm for all cases + result = await rag.aquery_llm(request.query, param=param) + + async def stream_generator(): + # Extract references and LLM response from unified result + references = result.get("data", {}).get("references", []) + llm_response = result.get("llm_response", {}) + + # Enrich references with chunk content if requested + if request.include_references and request.include_chunk_content: + data = result.get("data", {}) + chunks = data.get("chunks", []) + # Create a mapping from reference_id to chunk content + ref_id_to_content = {} + for chunk in chunks: + ref_id = chunk.get("reference_id", "") + content = chunk.get("content", "") + if ref_id and content: + # Collect chunk content + ref_id_to_content.setdefault(ref_id, []).append(content) + + # Add content to references + enriched_references = [] + for ref in references: + ref_copy = ref.copy() + ref_id = ref.get("reference_id", "") + if ref_id in ref_id_to_content: + # Keep content as a list of chunks (one file may have multiple chunks) + ref_copy["content"] = ref_id_to_content[ref_id] + enriched_references.append(ref_copy) + references = enriched_references + + if llm_response.get("is_streaming"): + # Streaming mode: send references first, then stream response chunks + if request.include_references: + yield f"{json.dumps({'references': references})}\n" + + response_stream = llm_response.get("response_iterator") + if response_stream: + try: + async for chunk in response_stream: + if chunk: # Only send non-empty content + yield f"{json.dumps({'response': chunk})}\n" + except Exception as e: + logger.error(f"Streaming error: {str(e)}") + yield f"{json.dumps({'error': str(e)})}\n" + else: + # Non-streaming mode: send complete response in one message + response_content = llm_response.get("content", "") + if not response_content: + response_content = "No relevant context found for the query." + + # Create complete response object + complete_response = {"response": response_content} + if request.include_references: + complete_response["references"] = references + + yield f"{json.dumps(complete_response)}\n" + + return StreamingResponse( + stream_generator(), + media_type="application/x-ndjson", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Content-Type": "application/x-ndjson", + "X-Accel-Buffering": "no", # Ensure proper handling of streaming response when proxied by Nginx + }, + ) + except Exception as e: + logger.error(f"Error processing streaming query: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + @router.post( + "/query/data", + response_model=QueryDataResponse, + dependencies=[Depends(combined_auth)], + responses={ + 200: { + "description": "Successful data retrieval response with structured RAG data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["success", "failure"], + "description": "Query execution status", + }, + "message": { + "type": "string", + "description": "Status message describing the result", + }, + "data": { + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entity_name": {"type": "string"}, + "entity_type": {"type": "string"}, + "description": {"type": "string"}, + "source_id": {"type": "string"}, + "file_path": {"type": "string"}, + "reference_id": {"type": "string"}, + }, + }, + "description": "Retrieved entities from knowledge graph", + }, + "relationships": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src_id": {"type": "string"}, + "tgt_id": {"type": "string"}, + "description": {"type": "string"}, + "keywords": {"type": "string"}, + "weight": {"type": "number"}, + "source_id": {"type": "string"}, + "file_path": {"type": "string"}, + "reference_id": {"type": "string"}, + }, + }, + "description": "Retrieved relationships from knowledge graph", + }, + "chunks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "file_path": {"type": "string"}, + "chunk_id": {"type": "string"}, + "reference_id": {"type": "string"}, + }, + }, + "description": "Retrieved text chunks from vector database", + }, + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "reference_id": {"type": "string"}, + "file_path": {"type": "string"}, + }, + }, + "description": "Reference list for citation purposes", + }, + }, + "description": "Structured retrieval data containing entities, relationships, chunks, and references", + }, + "metadata": { + "type": "object", + "properties": { + "query_mode": {"type": "string"}, + "keywords": { + "type": "object", + "properties": { + "high_level": { + "type": "array", + "items": {"type": "string"}, + }, + "low_level": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, + "processing_info": { + "type": "object", + "properties": { + "total_entities_found": { + "type": "integer" + }, + "total_relations_found": { + "type": "integer" + }, + "entities_after_truncation": { + "type": "integer" + }, + "relations_after_truncation": { + "type": "integer" + }, + "final_chunks_count": { + "type": "integer" + }, + }, + }, + }, + "description": "Query metadata including mode, keywords, and processing information", + }, + }, + "required": ["status", "message", "data", "metadata"], + }, + "examples": { + "successful_local_mode": { + "summary": "Local mode data retrieval", + "description": "Example of structured data from local mode query focusing on specific entities", + "value": { + "status": "success", + "message": "Query executed successfully", + "data": { + "entities": [ + { + "entity_name": "Neural Networks", + "entity_type": "CONCEPT", + "description": "Computational models inspired by biological neural networks", + "source_id": "chunk-123", + "file_path": "/documents/ai_basics.pdf", + "reference_id": "1", + } + ], + "relationships": [ + { + "src_id": "Neural Networks", + "tgt_id": "Machine Learning", + "description": "Neural networks are a subset of machine learning algorithms", + "keywords": "subset, algorithm, learning", + "weight": 0.85, + "source_id": "chunk-123", + "file_path": "/documents/ai_basics.pdf", + "reference_id": "1", + } + ], + "chunks": [ + { + "content": "Neural networks are computational models that mimic the way biological neural networks work...", + "file_path": "/documents/ai_basics.pdf", + "chunk_id": "chunk-123", + "reference_id": "1", + } + ], + "references": [ + { + "reference_id": "1", + "file_path": "/documents/ai_basics.pdf", + } + ], + }, + "metadata": { + "query_mode": "local", + "keywords": { + "high_level": ["neural", "networks"], + "low_level": [ + "computation", + "model", + "algorithm", + ], + }, + "processing_info": { + "total_entities_found": 5, + "total_relations_found": 3, + "entities_after_truncation": 1, + "relations_after_truncation": 1, + "final_chunks_count": 1, + }, + }, + }, + }, + "global_mode": { + "summary": "Global mode data retrieval", + "description": "Example of structured data from global mode query analyzing broader patterns", + "value": { + "status": "success", + "message": "Query executed successfully", + "data": { + "entities": [], + "relationships": [ + { + "src_id": "Artificial Intelligence", + "tgt_id": "Machine Learning", + "description": "AI encompasses machine learning as a core component", + "keywords": "encompasses, component, field", + "weight": 0.92, + "source_id": "chunk-456", + "file_path": "/documents/ai_overview.pdf", + "reference_id": "2", + } + ], + "chunks": [], + "references": [ + { + "reference_id": "2", + "file_path": "/documents/ai_overview.pdf", + } + ], + }, + "metadata": { + "query_mode": "global", + "keywords": { + "high_level": [ + "artificial", + "intelligence", + "overview", + ], + "low_level": [], + }, + }, + }, + }, + "naive_mode": { + "summary": "Naive mode data retrieval", + "description": "Example of structured data from naive mode using only vector search", + "value": { + "status": "success", + "message": "Query executed successfully", + "data": { + "entities": [], + "relationships": [], + "chunks": [ + { + "content": "Deep learning is a subset of machine learning that uses neural networks with multiple layers...", + "file_path": "/documents/deep_learning.pdf", + "chunk_id": "chunk-789", + "reference_id": "3", + } + ], + "references": [ + { + "reference_id": "3", + "file_path": "/documents/deep_learning.pdf", + } + ], + }, + "metadata": { + "query_mode": "naive", + "keywords": {"high_level": [], "low_level": []}, + }, + }, + }, + }, + } + }, + }, + 400: { + "description": "Bad Request - Invalid input parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"detail": {"type": "string"}}, + }, + "example": { + "detail": "Query text must be at least 3 characters long" + }, + } + }, + }, + 500: { + "description": "Internal Server Error - Data retrieval failed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"detail": {"type": "string"}}, + }, + "example": { + "detail": "Failed to retrieve data: Knowledge graph unavailable" + }, + } + }, + }, + }, + ) + async def query_data(request: QueryRequest): + """ + Advanced data retrieval endpoint for structured RAG analysis. + + This endpoint provides raw retrieval results without LLM generation, perfect for: + - **Data Analysis**: Examine what information would be used for RAG + - **System Integration**: Get structured data for custom processing + - **Debugging**: Understand retrieval behavior and quality + - **Research**: Analyze knowledge graph structure and relationships + + **Key Features:** + - No LLM generation - pure data retrieval + - Complete structured output with entities, relationships, and chunks + - Always includes references for citation + - Detailed metadata about processing and keywords + - Compatible with all query modes and parameters + + **Query Mode Behaviors:** + - **local**: Returns entities and their direct relationships + related chunks + - **global**: Returns relationship patterns across the knowledge graph + - **hybrid**: Combines local and global retrieval strategies + - **naive**: Returns only vector-retrieved text chunks (no knowledge graph) + - **mix**: Integrates knowledge graph data with vector-retrieved chunks + - **bypass**: Returns empty data arrays (used for direct LLM queries) + + **Data Structure:** + - **entities**: Knowledge graph entities with descriptions and metadata + - **relationships**: Connections between entities with weights and descriptions + - **chunks**: Text segments from documents with source information + - **references**: Citation information mapping reference IDs to file paths + - **metadata**: Processing information, keywords, and query statistics + + **Usage Examples:** + + Analyze entity relationships: + ```json + { + "query": "machine learning algorithms", + "mode": "local", + "top_k": 10 + } + ``` + + Explore global patterns: + ```json + { + "query": "artificial intelligence trends", + "mode": "global", + "max_relation_tokens": 2000 + } + ``` + + Vector similarity search: + ```json + { + "query": "neural network architectures", + "mode": "naive", + "chunk_top_k": 5 + } + ``` + + Bypass initial LLM call by providing high-level and low-level keywords: + ```json + { + "query": "What is Retrieval-Augmented-Generation?", + "hl_keywords": ["machine learning", "information retrieval", "natural language processing"], + "ll_keywords": ["retrieval augmented generation", "RAG", "knowledge base"], + "mode": "mix" + } + ``` + + **Response Analysis:** + - **Empty arrays**: Normal for certain modes (e.g., naive mode has no entities/relationships) + - **Processing info**: Shows retrieval statistics and token usage + - **Keywords**: High-level and low-level keywords extracted from query + - **Reference mapping**: Links all data back to source documents + + Args: + request (QueryRequest): The request object containing query parameters: + - **query**: The search query to analyze (min 3 characters) + - **mode**: Retrieval strategy affecting data types returned + - **top_k**: Number of top entities/relationships to retrieve + - **chunk_top_k**: Number of text chunks to retrieve + - **max_entity_tokens**: Token limit for entity context + - **max_relation_tokens**: Token limit for relationship context + - **max_total_tokens**: Overall token budget for retrieval + + Returns: + QueryDataResponse: Structured JSON response containing: + - **status**: "success" or "failure" + - **message**: Human-readable status description + - **data**: Complete retrieval results with entities, relationships, chunks, references + - **metadata**: Query processing information and statistics + + Raises: + HTTPException: + - 400: Invalid input parameters (e.g., query too short, invalid mode) + - 500: Internal processing error (e.g., knowledge graph unavailable) + + Note: + This endpoint always includes references regardless of the include_references parameter, + as structured data analysis typically requires source attribution. + """ + try: + param = request.to_query_params(False) # No streaming for data endpoint + response = await rag.aquery_data(request.query, param=param) + + # aquery_data returns the new format with status, message, data, and metadata + if isinstance(response, dict): + return QueryDataResponse(**response) + else: + # Handle unexpected response format + return QueryDataResponse( + status="failure", + message="Invalid response type", + data={}, + metadata={}, + ) + except Exception as e: + logger.error(f"Error processing data query: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + return router diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/run_with_gunicorn.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/run_with_gunicorn.py new file mode 100644 index 0000000..e3bc0a8 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/run_with_gunicorn.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python +""" +Start LightRAG server with Gunicorn +""" + +import os +import sys +import platform +import pipmaster as pm +from lightrag.api.utils_api import display_splash_screen, check_env_file +from lightrag.api.config import global_args +from lightrag.utils import get_env_value +from lightrag.kg.shared_storage import initialize_share_data + +from lightrag.constants import ( + DEFAULT_WOKERS, + DEFAULT_TIMEOUT, +) + + +def check_and_install_dependencies(): + """Check and install required dependencies""" + required_packages = [ + "gunicorn", + "tiktoken", + "psutil", + # Add other required packages here + ] + + for package in required_packages: + if not pm.is_installed(package): + print(f"Installing {package}...") + pm.install(package) + print(f"{package} installed successfully") + + +def main(): + # Explicitly initialize configuration for Gunicorn mode + from lightrag.api.config import initialize_config + + initialize_config() + + # Set Gunicorn mode flag for lifespan cleanup detection + os.environ["LIGHTRAG_GUNICORN_MODE"] = "1" + + # Check .env file + if not check_env_file(): + sys.exit(1) + + # Check DOCLING compatibility with Gunicorn multi-worker mode on macOS + if ( + platform.system() == "Darwin" + and global_args.document_loading_engine == "DOCLING" + and global_args.workers > 1 + ): + print("\n" + "=" * 80) + print("❌ ERROR: Incompatible configuration detected!") + print("=" * 80) + print( + "\nDOCLING engine with Gunicorn multi-worker mode is not supported on macOS" + ) + print("\nReason:") + print(" PyTorch (required by DOCLING) has known compatibility issues with") + print(" fork-based multiprocessing on macOS, which can cause crashes or") + print(" unexpected behavior when using Gunicorn with multiple workers.") + print("\nCurrent configuration:") + print(" - Operating System: macOS (Darwin)") + print(f" - Document Engine: {global_args.document_loading_engine}") + print(f" - Workers: {global_args.workers}") + print("\nPossible solutions:") + print(" 1. Use single worker mode:") + print(" --workers 1") + print("\n 2. Change document loading engine in .env:") + print(" DOCUMENT_LOADING_ENGINE=DEFAULT") + print("\n 3. Deploy on Linux where multi-worker mode is fully supported") + print("=" * 80 + "\n") + sys.exit(1) + + # Check macOS fork safety environment variable for multi-worker mode + if ( + platform.system() == "Darwin" + and global_args.workers > 1 + and os.environ.get("OBJC_DISABLE_INITIALIZE_FORK_SAFETY") != "YES" + ): + print("\n" + "=" * 80) + print("❌ ERROR: Missing required environment variable on macOS!") + print("=" * 80) + print("\nmacOS with Gunicorn multi-worker mode requires:") + print(" OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES") + print("\nReason:") + print(" NumPy uses macOS's Accelerate framework (Objective-C based) for") + print(" vector computations. The Objective-C runtime has fork safety checks") + print(" that will crash worker processes when embedding functions are called.") + print("\nCurrent configuration:") + print(" - Operating System: macOS (Darwin)") + print(f" - Workers: {global_args.workers}") + print( + f" - Environment Variable: {os.environ.get('OBJC_DISABLE_INITIALIZE_FORK_SAFETY', 'NOT SET')}" + ) + print("\nHow to fix:") + print(" Option 1 - Set environment variable before starting (recommended):") + print(" export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES") + print(" lightrag-gunicorn --workers 2") + print("\n Option 2 - Add to your shell profile (~/.zshrc or ~/.bash_profile):") + print(" echo 'export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES' >> ~/.zshrc") + print(" source ~/.zshrc") + print("\n Option 3 - Use single worker mode (no multiprocessing):") + print(" lightrag-server --workers 1") + print("=" * 80 + "\n") + sys.exit(1) + + # Check and install dependencies + check_and_install_dependencies() + + # Note: Signal handlers are NOT registered here because: + # - Master cleanup already handled by gunicorn_config.on_exit() + + # Display startup information + display_splash_screen(global_args) + + print("🚀 Starting LightRAG with Gunicorn") + print(f"🔄 Worker management: Gunicorn (workers={global_args.workers})") + print("🔍 Preloading app: Enabled") + print("📝 Note: Using Gunicorn's preload feature for shared data initialization") + print("\n\n" + "=" * 80) + print("MAIN PROCESS INITIALIZATION") + print(f"Process ID: {os.getpid()}") + print(f"Workers setting: {global_args.workers}") + print("=" * 80 + "\n") + + # Import Gunicorn's StandaloneApplication + from gunicorn.app.base import BaseApplication + + # Define a custom application class that loads our config + class GunicornApp(BaseApplication): + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + # Define valid Gunicorn configuration options + valid_options = { + "bind", + "workers", + "worker_class", + "timeout", + "keepalive", + "preload_app", + "errorlog", + "accesslog", + "loglevel", + "certfile", + "keyfile", + "limit_request_line", + "limit_request_fields", + "limit_request_field_size", + "graceful_timeout", + "max_requests", + "max_requests_jitter", + } + + # Special hooks that need to be set separately + special_hooks = { + "on_starting", + "on_reload", + "on_exit", + "pre_fork", + "post_fork", + "pre_exec", + "pre_request", + "post_request", + "worker_init", + "worker_exit", + "nworkers_changed", + "child_exit", + } + + # Import and configure the gunicorn_config module + from lightrag.api import gunicorn_config + + # Set configuration variables in gunicorn_config, prioritizing command line arguments + gunicorn_config.workers = ( + global_args.workers + if global_args.workers + else get_env_value("WORKERS", DEFAULT_WOKERS, int) + ) + + # Bind configuration prioritizes command line arguments + host = ( + global_args.host + if global_args.host != "0.0.0.0" + else os.getenv("HOST", "0.0.0.0") + ) + port = ( + global_args.port + if global_args.port != 9621 + else get_env_value("PORT", 9621, int) + ) + gunicorn_config.bind = f"{host}:{port}" + + # Log level configuration prioritizes command line arguments + gunicorn_config.loglevel = ( + global_args.log_level.lower() + if global_args.log_level + else os.getenv("LOG_LEVEL", "info") + ) + + # Timeout configuration prioritizes command line arguments + gunicorn_config.timeout = ( + global_args.timeout + 30 + if global_args.timeout is not None + else get_env_value( + "TIMEOUT", DEFAULT_TIMEOUT + 30, int, special_none=True + ) + ) + + # Keepalive configuration + gunicorn_config.keepalive = get_env_value("KEEPALIVE", 5, int) + + # SSL configuration prioritizes command line arguments + if global_args.ssl or os.getenv("SSL", "").lower() in ( + "true", + "1", + "yes", + "t", + "on", + ): + gunicorn_config.certfile = ( + global_args.ssl_certfile + if global_args.ssl_certfile + else os.getenv("SSL_CERTFILE") + ) + gunicorn_config.keyfile = ( + global_args.ssl_keyfile + if global_args.ssl_keyfile + else os.getenv("SSL_KEYFILE") + ) + + # Set configuration options from the module + for key in dir(gunicorn_config): + if key in valid_options: + value = getattr(gunicorn_config, key) + # Skip functions like on_starting and None values + if not callable(value) and value is not None: + self.cfg.set(key, value) + # Set special hooks + elif key in special_hooks: + value = getattr(gunicorn_config, key) + if callable(value): + self.cfg.set(key, value) + + if hasattr(gunicorn_config, "logconfig_dict"): + self.cfg.set( + "logconfig_dict", getattr(gunicorn_config, "logconfig_dict") + ) + + def load(self): + # Import the application + from lightrag.api.lightrag_server import get_application + + return get_application(global_args) + + # Create the application + app = GunicornApp("") + + # Force workers to be an integer and greater than 1 for multi-process mode + workers_count = global_args.workers + if workers_count > 1: + # Set a flag to indicate we're in the main process + os.environ["LIGHTRAG_MAIN_PROCESS"] = "1" + initialize_share_data(workers_count) + else: + initialize_share_data(1) + + # Run the application + print("\nStarting Gunicorn with direct Python API...") + app.run() + + +if __name__ == "__main__": + main() diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/runtime_validation.py b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/runtime_validation.py new file mode 100644 index 0000000..f3f3a4a --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/runtime_validation.py @@ -0,0 +1,128 @@ +"""Helpers for validating startup runtime expectations from `.env`.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import dotenv_values + +_CONTAINER_RUNTIME_TARGETS = {"compose", "docker"} + + +@dataclass(frozen=True) +class RuntimeEnvironment: + """Describes whether the current process is running in a container runtime.""" + + in_container: bool + in_docker: bool + in_kubernetes: bool + + @property + def label(self) -> str: + if self.in_kubernetes: + return "Kubernetes" + if self.in_docker: + return "Docker" + return "host" + + +def _read_cgroup_content() -> str: + """Best-effort read of cgroup metadata for container detection.""" + + for candidate in ("/proc/1/cgroup", "/proc/self/cgroup"): + try: + return Path(candidate).read_text(encoding="utf-8") + except OSError: + continue + return "" + + +def detect_runtime_environment( + environ: dict[str, str] | None = None, +) -> RuntimeEnvironment: + """Detect whether the current process is running on host, Docker, or Kubernetes.""" + + environ = environ or os.environ + cgroup_content = _read_cgroup_content().lower() + + in_kubernetes = bool( + environ.get("KUBERNETES_SERVICE_HOST") + or Path("/var/run/secrets/kubernetes.io/serviceaccount").exists() + or "kubepods" in cgroup_content + or "kubernetes" in cgroup_content + ) + in_docker = bool( + Path("/.dockerenv").exists() + or Path("/run/.containerenv").exists() + or any( + marker in cgroup_content + for marker in ("docker", "containerd", "libpod", "podman") + ) + ) + + return RuntimeEnvironment( + in_container=in_kubernetes or in_docker, + in_docker=in_docker, + in_kubernetes=in_kubernetes, + ) + + +def load_runtime_target_from_env_file(env_path: str | Path = ".env") -> str | None: + """Return the raw LIGHTRAG_RUNTIME_TARGET value from the `.env` file, if present.""" + + env_values = dotenv_values(str(env_path)) + runtime_target = env_values.get("LIGHTRAG_RUNTIME_TARGET") + if runtime_target is None: + return None + return runtime_target.strip() + + +def validate_runtime_target( + runtime_target: str | None, + runtime_environment: RuntimeEnvironment | None = None, +) -> tuple[bool, str | None]: + """Validate `.env` runtime target against the current runtime environment.""" + + if runtime_target is None: + return True, None + + normalized_target = runtime_target.strip().lower() + runtime_environment = runtime_environment or detect_runtime_environment() + + if normalized_target == "host": + if runtime_environment.in_container: + return ( + False, + "Configuration error in .env: LIGHTRAG_RUNTIME_TARGET=host.\n" + "This value from .env requires the server process to run on the host, " + f"but the current process is running inside {runtime_environment.label}.", + ) + return True, None + + if normalized_target in _CONTAINER_RUNTIME_TARGETS: + if runtime_environment.in_container: + return True, None + return ( + False, + f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target}.\n" + "This value from .env requires the server process to run inside Docker or " + "Kubernetes, but the current process is running on the host.", + ) + + return ( + False, + f"Configuration error in .env: LIGHTRAG_RUNTIME_TARGET={runtime_target!r}.\n" + "This value from .env must be 'host' or 'compose' (alias: 'docker').", + ) + + +def validate_runtime_target_from_env_file( + env_path: str | Path = ".env", + runtime_environment: RuntimeEnvironment | None = None, +) -> tuple[bool, str | None]: + """Load LIGHTRAG_RUNTIME_TARGET from `.env` and validate it if declared.""" + + runtime_target = load_runtime_target_from_env_file(env_path) + return validate_runtime_target(runtime_target, runtime_environment) diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/static/swagger-ui/favicon-32x32.png b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/static/swagger-ui/favicon-32x32.png new file mode 100644 index 0000000..249737f Binary files /dev/null and b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/static/swagger-ui/favicon-32x32.png differ diff --git a/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/static/swagger-ui/swagger-ui-bundle.js b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/static/swagger-ui/swagger-ui-bundle.js new file mode 100644 index 0000000..ed21274 --- /dev/null +++ b/.tmp/lightrag_inspect/lightrag_pkg/lightrag/api/static/swagger-ui/swagger-ui-bundle.js @@ -0,0 +1,2 @@ +/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ +!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<>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<>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<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=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>",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?_--:++_{"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{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{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{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;su;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;uthis.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<>>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),oa?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;_=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=0&&o=0&&ii?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;i0)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)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<>>=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>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=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<=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&&--z0&&a=0&&s>>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>>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<=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&&j0){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<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<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>>L&C;if(ae!==V>>>L&C)break;ae&&($+=(1<u&&(B=B.removeBefore(a,L,x-$)),B&&Vu&&(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>>w<=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&&ox)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||x0}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=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 so?-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{"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{s.exports=function arrayPush(s,o){for(var i=-1,a=o.length,u=s.length;++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{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;$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{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-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:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[a,w,_,u,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[a,u,w,_]}]}]},s.COMMENT(//,{relevance:10}),{begin://,relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[x],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[x],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:concat(//,/>/,/\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(("/g,"]]]]>")+"]]>"),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.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{"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{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{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