diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58c98c20171b90ed20c7e1baa6faa922b5d269ed..5dcab497c77dbf12f9135e59754de9e446b4fc9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,46 @@ jobs: - name: Run integration tests run: pytest tests/integration/ -v + frontend: + name: Frontend checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Lint frontend + run: npm run lint + working-directory: frontend + + - name: Typecheck frontend + run: npm run typecheck + working-directory: frontend + + - name: Test frontend + run: npm run test + working-directory: frontend + + - name: Build frontend + run: npm run build + working-directory: frontend + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: frontend + + - name: Run Playwright smoke tests + run: npm run test:e2e + working-directory: frontend + evals-smoke: name: Eval smoke test (mock pipeline) runs-on: ubuntu-latest @@ -137,10 +177,30 @@ jobs: --dataset evals/datasets/golden_ci.jsonl \ --judge-provider anthropic \ --judge-model claude-haiku-4-5 \ - --output evals/reports/ \ + --output evals/reports/pr-current.json \ --faithfulness-threshold 0.7 \ --correctness-threshold 0.2 + - name: Compare against baseline (regression gate) + if: ${{ env.ANTHROPIC_API_KEY != '' && hashFiles('evals/reports/baseline.json') != '' }} + run: | + python scripts/compare_evals.py \ + --baseline evals/reports/baseline.json \ + --current evals/reports/pr-current.json \ + --threshold 5.0 + + - name: Comment on regression failure + if: failure() && env.ANTHROPIC_API_KEY != '' + uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "⚠️ **Regression Detected**\n\nEval metrics degraded vs baseline. See `evals/reports/pr-current.json` artifact for details.\n\nTo update the baseline (intentional improvement), run:\n```bash\ngit checkout main\nPYTHONPATH=. python -m evals.run_evals \\\n --dataset evals/datasets/golden_ci.jsonl \\\n --judge-provider anthropic \\\n --judge-model claude-haiku-4-5 \\\n --output evals/reports/baseline.json\ngit add evals/reports/baseline.json\ngit commit -m 'chore: update Phase 5 eval baseline'\n```" + }) + - name: Upload golden eval report if: ${{ always() && env.ANTHROPIC_API_KEY != '' }} uses: actions/upload-artifact@v4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..42d220f5d389f47686301b656c39e531fe712bb3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,59 @@ +# Single-process image: FastAPI serves the React SPA from /app/static and API routes under the same port. +# Hugging Face Docker Spaces expects a Dockerfile at the repository root; local builds use the same file: +# docker build -t doc-ingest . +# Compose: docker/docker-compose.yml (build context is repo root). + +FROM node:20-bookworm-slim AS frontend-builder +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM python:3.11-slim + +WORKDIR /app + +# Install system deps needed by python-magic and runtime health checks. +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements/base.txt requirements/base.txt +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements/base.txt + +COPY --from=frontend-builder /frontend/dist /app/static + +COPY src/ src/ +COPY scripts/ scripts/ +COPY tests/ tests/ +COPY config.yaml config.yaml +COPY README.md README.md +COPY Docs/ Docs/ + +ENV ENV=prod +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app +ENV PORT=8000 +ENV OLLAMA_BASE_URL=http://host.docker.internal:11434 +ENV HF_HOME=/app/.cache/huggingface +ENV TRANSFORMERS_CACHE=/app/.cache/huggingface/transformers +ENV SENTENCE_TRANSFORMERS_HOME=/app/.cache/huggingface/sentence_transformers + +# Preload reranker model at build time to avoid runtime downloads. +RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" + +EXPOSE 8000 + +# HF Spaces runs the container as UID 1000; match that to avoid permission issues. +RUN useradd -m -u 1000 appuser && mkdir -p /app/.cache/huggingface && chown -R appuser:appuser /app +USER appuser + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD sh -c 'curl -fsS "http://127.0.0.1:${PORT:-8000}/health" || exit 1' + +# PORT is honored for Hugging Face (app_port / runtime) and other platforms. +CMD ["sh", "-c", "exec uvicorn src.api.main:app --host 0.0.0.0 --port \"${PORT:-8000}\" --workers 1"] diff --git a/Docs/Phase5-Monitoring-Observability.md b/Docs/Phase5-Monitoring-Observability.md new file mode 100644 index 0000000000000000000000000000000000000000..f3ae911eb6ba60e0ee5f8a8b60077e3e2ab3130f --- /dev/null +++ b/Docs/Phase5-Monitoring-Observability.md @@ -0,0 +1,1764 @@ +# Phase 5: Production Monitoring & Observability + +**Project:** doc-ingestion (RAG System) +**Status:** Planning +**Timeline:** 3 weeks +**Owner:** Vamshi Pokala +**Goal:** Instrument RAG pipeline with production-grade observability, regression gating, and operational dashboards + +--- + +## Executive Summary + +Transform doc-ingestion from a feature-complete RAG system into a **production-hardened platform** by adding: + +1. **Distributed tracing** (LangFuse) across every step: ingestion → retrieval → reranking → generation → citations +2. **Latency profiling** (P50/P95 per component) to identify bottlenecks +3. **Cost tracking** (USD per request) for capacity planning +4. **Quality regression gating** (GitHub Actions CI/CD) to prevent accuracy degradation +5. **Observable metrics dashboard** for real-time operational visibility +6. **Citation accuracy & citation coverage** monitoring + +**Why this matters for your job search:** +- Differentiates you as "production architect" not "demo builder" +- Directly maps to Principal/Director interview questions: "How do you know your AI system is healthy?" +- Concrete story for Vertex (latency budgeting), Elevation Capital (risk reduction), Marriott-like enterprise roles + +--- + +## Current State Analysis + +### Existing Strengths +``` +✅ Multi-format ingestion (PDF, DOCX, TXT, MD, HTML) +✅ Hybrid retrieval (BM25 + vector search with RRF) +✅ Cross-encoder reranking +✅ Citation tracking & verification +✅ Truthfulness scoring (NLI faithfulness) +✅ Multi-provider LLM routing (Ollama, OpenAI, Anthropic, Gemini) +✅ FastAPI + Streamlit UI +✅ Offline evaluation harness (golden datasets, RAGAS) +✅ Docker Compose stack +✅ Rate limiting (Redis-backed) +✅ MetricsCollector in src/utils/log.py (in-memory count/mean/min/max per operation) +✅ Structured JSON audit logging in main.py (_audit_log with latency_ms, provider, model) +✅ processing_time_ms and cached flag already returned in QueryResponse +✅ evals-golden CI job already runs golden_ci.jsonl on every PR +``` + +### Gaps for Production Observability +``` +❌ No distributed tracing (can't see latency breakdown by step) +❌ No real-time metrics dashboard +❌ No cost tracking (USD per request) +❌ No regression gating comparing baseline vs PR metrics in CI/CD +❌ No P50/P95 latency tracking (existing MetricsCollector only tracks mean/min/max) +❌ No citation accuracy trends over time +❌ /metrics endpoint returns config metadata, not operational metrics +❌ No replay/debug mode for failed queries +``` + +### Critical Design Constraints (address before coding) + +These issues will cause bugs or structural debt if not addressed upfront: + +1. **LangFuse span hierarchy**: `self.client.trace()` creates a top-level trace. Calling it once per pipeline step produces 5 disconnected traces per request. The correct pattern is one `trace = client.trace()` per request, then `span = trace.span()` for each step. Instrument at `RAGOrchestrator.run()`, not in `main.py`. + +2. **`observer.flush()` must not block the HTTP response**: LangFuse flush makes a network call. Calling it synchronously before returning adds latency to every request. Use `asyncio.create_task(loop.run_in_executor(..., observer.flush))` or a background thread. + +3. **Instrument at `RAGOrchestrator`, not `main.py`**: The pipeline runs inside `RAGOrchestrator.run()`. Instrumenting in `main.py` via inline `observer.trace_retrieval(fn)(args)` patterns: (a) misses the cache-hit early return, (b) misses CLI and Streamlit code paths, (c) creates a new wrapper closure on every HTTP request. The observer should be injected into or used directly within `RAGOrchestrator.run()`. + +4. **MRR and NDCG are offline-only metrics**: They require ground-truth relevance labels per query. You cannot compute them in production. Remove `mrr` and `ndcg` from `RequestMetrics`; they belong only in the eval harness. + +5. **Don't create a separate regression gate workflow**: `ci.yml` already has `evals-golden` running `golden_ci.jsonl`. Add a comparison step to that existing job rather than duplicating it. Also: the dataset is `golden_ci.jsonl`, not `golden.jsonl`. + +6. **`src/monitoring/metrics.py` should extend, not duplicate `src/utils/log.py`**: The existing `MetricsCollector` in `log.py` tracks mean/min/max. The new one adds percentiles and per-request records. Consolidate: either replace the log.py one or have the new one call through to it. Don't ship two `MetricsCollector` classes. + +7. **`requirements/api.txt` does not exist**: The project has `requirements/base.txt` and `requirements/eval.txt`. Add `langfuse>=2.0.0` to `requirements/base.txt`. + +--- + +## Architecture: Before → After + +### Before (Current) +``` +┌─────────────────────────────────────────────────────────┐ +│ Streamlit UI / FastAPI │ +└──────────────────────┬──────────────────────────────────┘ + │ + ┌──────────────┼──────────────┐ + │ │ │ + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ + │Retrieval│ │Reranking│ │Generation + │ (BM25+ │ │(Cross- │ │(Ollama/ │ + │ Vector) │ │ Encoder)│ │ OpenAI) │ + └─────────┘ └─────────┘ └────┬────┘ + │ + ┌────────▼────────┐ + │Citations & │ + │Truthfulness │ + └─────────────────┘ + +❌ No observability layer +❌ Latency is a black box +❌ Can't track cost +❌ No regression detection +``` + +### After (Phase 5) +``` +┌──────────────────────────────────────────────────────────────────┐ +│ LangFuse Tracing Layer │ +│ (Distributed tracing, step-by-step instrumentation) │ +└────────────────────────────┬─────────────────────────────────────┘ + │ +┌────────────────────────────▼─────────────────────────────────────┐ +│ Streamlit UI / FastAPI │ +└──────────────────────┬──────────────────────────────────────────┘ + │ + ┌──────────────┼──────────────────────┐ + │ │ │ + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ + │Retrieval│ │Reranking│ │Generation + │ (BM25+ │ │(Cross- │ │(Ollama/ │ + │ Vector) │ │ Encoder)│ │ OpenAI) │ + └────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + [TRACE] [TRACE] [TRACE] + - Latency - Latency - Latency + - Chunks - Ranked - Tokens + - Scores - Duration - Cost + │ │ │ + └─────────────┼─────────────┘ + │ + ┌─────────▼──────────┐ + │ Citations & │ + │ Truthfulness │ + │ [TRACE] Cost │ + └─────────┬──────────┘ + │ + ┌─────────────┴──────────────────┐ + │ │ + ┌────▼─────┐ ┌───────────▼────────┐ + │Observ. │ │ GitHub Actions │ + │Dashboard │ │ Regression Gating │ + │(Metrics) │ │ (CI/CD) │ + └──────────┘ └────────────────────┘ + +✅ End-to-end tracing +✅ Real-time latency visibility +✅ Cost per request tracked +✅ Automated regression detection +✅ Observable metrics at /observability/dashboard +``` + +--- + +## Detailed Phase Breakdown + +### Phase 5.1: LangFuse Instrumentation (Week 1) + +**Goal:** Add distributed tracing to every RAG pipeline step + +#### Step 1.1: Create Observability Module +**File:** `src/core/observability.py` (NEW) + +```python +""" +Observability layer for RAG pipeline instrumentation. +Provides decorators and context managers for LangFuse tracing. +""" + +import os +import time +import json +from functools import wraps +from typing import Any, Callable, Dict, Optional, List +from contextlib import contextmanager + +from langfuse import Langfuse +from langfuse.decorators import observe +import logging + +logger = logging.getLogger(__name__) + + +class RAGObserver: + """ + Centralized observer for RAG pipeline. + Manages LangFuse client and provides tracing context managers. + + Usage pattern — instrument inside RAGOrchestrator.run(), not in main.py: + with observer.trace_request("rag_query", query=query_text) as trace: + with trace.span_step("retrieval") as span: + result = hybrid_retriever.retrieve(...) + span["output"] = {"chunks": len(result)} + """ + + def __init__(self, enabled: bool = True, public_key: str = None, secret_key: str = None): + """ + Args: + enabled: If False, all tracing is no-op (demo mode, tests) + public_key: LangFuse public key (defaults to LANGFUSE_PUBLIC_KEY env var) + secret_key: LangFuse secret key (defaults to LANGFUSE_SECRET_KEY env var) + """ + self.enabled = enabled + self.client = None + + if self.enabled: + try: + public_key = public_key or os.getenv("LANGFUSE_PUBLIC_KEY") + secret_key = secret_key or os.getenv("LANGFUSE_SECRET_KEY") + + if public_key and secret_key: + self.client = Langfuse( + public_key=public_key, + secret_key=secret_key, + host=os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com"), + ) + logger.info("LangFuse observability enabled") + else: + self.enabled = False + logger.warning("LangFuse keys not found; observability disabled") + except Exception as e: + logger.error(f"Failed to initialize LangFuse: {e}; observability disabled") + self.enabled = False + + @contextmanager + def trace_request( + self, + name: str, + query: str = "", + metadata: Optional[Dict[str, Any]] = None, + ): + """ + Context manager for a top-level request trace. + One trace per query request — child spans live inside this. + + IMPORTANT: This is the top-level trace object. Use trace.span() for + individual pipeline steps. Never call client.trace() per step — that + creates disconnected traces in the LangFuse UI. + + Usage: + with observer.trace_request("rag_query", query=query_text) as trace: + with observer.trace_step(trace, "retrieval") as span: + chunks = retriever.retrieve(query) + span["chunks_retrieved"] = len(chunks) + """ + if not self.enabled or not self.client: + yield None + return + + trace = self.client.trace( + name=name, + input={"query": query}, + metadata=metadata or {}, + ) + start = time.time() + try: + yield trace + except Exception as e: + trace.update( + output={"error": str(e)}, + metadata={**(metadata or {}), "total_ms": (time.time() - start) * 1000}, + ) + raise + finally: + trace.update( + metadata={**(metadata or {}), "total_ms": round((time.time() - start) * 1000, 2)}, + ) + + @contextmanager + def trace_step( + self, + trace, + step_name: str, + input_data: Optional[Dict[str, Any]] = None, + ): + """ + Context manager for a child span within a request trace. + Attach to the trace returned by trace_request(). + + Args: + trace: The top-level trace object from trace_request() + step_name: Name of the pipeline step (e.g. "retrieval", "generation") + input_data: Optional input metadata for this step + """ + if not self.enabled or trace is None: + yield {} + return + + span = trace.span(name=step_name, input=input_data or {}) + output: Dict[str, Any] = {} + start = time.time() + try: + yield output + except Exception as e: + span.end( + output={"error": str(e)}, + metadata={"latency_ms": round((time.time() - start) * 1000, 2)}, + ) + raise + finally: + output["latency_ms"] = round((time.time() - start) * 1000, 2) + span.end(output=output) + + def flush_async(self) -> None: + """ + Flush pending traces to LangFuse in a background thread. + Call this after the HTTP response is sent — never block the hot path. + + In FastAPI, use a BackgroundTask: + from fastapi import BackgroundTasks + background_tasks.add_task(observer.flush_async) + """ + if not self.client: + return + import threading + threading.Thread(target=self.client.flush, daemon=True).start() + + def flush(self) -> None: + """Synchronous flush — only use in shutdown/test contexts, not request handlers.""" + if self.client: + self.client.flush() + + +# Global observer instance +_observer_instance: Optional[RAGObserver] = None + + +def get_observer() -> RAGObserver: + """Singleton getter for RAGObserver.""" + global _observer_instance + if _observer_instance is None: + enabled = os.getenv("DOC_PROFILE") != "demo" + _observer_instance = RAGObserver(enabled=enabled) + return _observer_instance + + +def init_observer(enabled: bool = True) -> RAGObserver: + """Initialize the observer (useful for testing).""" + global _observer_instance + _observer_instance = RAGObserver(enabled=enabled) + return _observer_instance +``` + +**Testing:** `tests/unit/test_observability.py` (NEW) + +```python +"""Unit tests for observability module.""" + +import pytest +from src.core.observability import RAGObserver, init_observer, get_observer + + +def test_observer_disabled_noop_on_trace_request(): + """Verify trace_request is a no-op when disabled — yields None.""" + observer = RAGObserver(enabled=False) + + with observer.trace_request("rag_query", query="test") as trace: + assert trace is None # no-op when disabled + + +def test_observer_disabled_noop_on_trace_step(): + """Verify trace_step yields empty dict when trace is None (disabled path).""" + observer = RAGObserver(enabled=False) + + with observer.trace_step(None, "retrieval", {"query": "x"}) as output: + output["chunks"] = 3 # should not raise + assert output["chunks"] == 3 # returned value preserved even when disabled + + +def test_trace_step_records_latency(): + """Verify trace_step always populates latency_ms in the output dict.""" + observer = RAGObserver(enabled=False) + + with observer.trace_step(None, "generation") as output: + output["provider"] = "anthropic" + + assert "latency_ms" in output + assert output["latency_ms"] >= 0 + assert output["provider"] == "anthropic" + + +def test_nested_trace_and_step_no_exception(): + """Verify trace_request + trace_step nesting works without LangFuse keys.""" + observer = RAGObserver(enabled=False) + + with observer.trace_request("rag_query", query="hello") as trace: + with observer.trace_step(trace, "retrieval") as s: + s["chunks_retrieved"] = 5 + with observer.trace_step(trace, "generation") as s: + s["provider"] = "ollama" + # No exception = pass +``` + +--- + +#### Step 1.2: Instrument RAGOrchestrator (correct instrumentation point) +**File:** `src/core/rag_orchestrator.py` (MODIFY existing) + +> **Why here, not `main.py`?** `RAGOrchestrator.run()` is called by the API, CLI, and Streamlit — instrumenting here captures all paths. It also correctly observes the cache-hit early return (which main.py wrapping skips entirely). Never create wrapper closures per-call inside the request handler — that's a new function object on every request and misses the orchestrator's internal structure. + +**Changes:** +```python +# In RAGOrchestrator.__init__, add observer: +from src.core.observability import get_observer + +class RAGOrchestrator: + def __init__(self, cfg: Config) -> None: + # ... existing init ... + self.observer = get_observer() + + def run(self, req: QueryRequest) -> QueryResponse: + t0 = time.perf_counter() + # ... existing cache key resolution ... + + # Cache hit: trace as a cache hit and return + cached = self.cache.get(key) if req.use_llm else None + if cached is not None: + with self.observer.trace_request("rag_query_cached", query=req.query_text): + pass # Trace the cache hit for visibility + return QueryResponse(cached=True, ...) + + # Cache miss: trace all pipeline steps under one request trace + with self.observer.trace_request("rag_query", query=req.query_text) as trace: + with self.observer.trace_step(trace, "retrieval", {"top_k": retrieve_k}) as s: + fused = self._retrieve(req.query_text, index, db, qp, top_k=retrieve_k) + s["chunks_retrieved"] = len(fused) + + if req.use_rerank: + with self.observer.trace_step(trace, "reranking", {"input_chunks": len(fused)}) as s: + ranked = reranker.rerank(req.query_text, fused, top_k=req.top_k) + s["output_chunks"] = len(ranked) + + with self.observer.trace_step(trace, "generation", {"provider": selection.provider, "model": selection.model}) as s: + gen_result = generator.generate(req.query_text, docs_for_gen, ...) + s["latency_ms"] = gen_result.latency_ms + + with self.observer.trace_step(trace, "citation_verification") as s: + citations = self.citation_verifier.verify(full, raw_citations, opt.documents) + s["citations_count"] = len(citations) + + with self.observer.trace_step(trace, "truthfulness_scoring") as s: + truthfulness = scorer.score(full, opt.documents) + if truthfulness: + s["nli_faithfulness"] = truthfulness.nli_faithfulness + s["citation_groundedness"] = truthfulness.citation_groundedness + + # Flush in background — do NOT block the response + self.observer.flush_async() + return QueryResponse(...) +``` + +**main.py changes** — only the `/query` endpoint needs to pass `BackgroundTasks` to ensure flush completes even if the orchestrator doesn't hold a reference: + +```python +# main.py — minimal change, no inline tracing wrappers +from fastapi import BackgroundTasks +from src.core.observability import get_observer + +observer = get_observer() + +@app.post("/query") +async def query(request: QueryRequest, background_tasks: BackgroundTasks): + # Tracing happens inside orchestrator.run() — main.py doesn't wrap steps + response = orchestrator.run(build_query_request(request)) + background_tasks.add_task(observer.flush_async) # belt-and-suspenders flush + return build_query_response(response) +``` + +**Deliverable for Week 1:** +- ✅ `src/core/observability.py` (complete) +- ✅ `tests/unit/test_observability.py` (complete) +- ✅ `src/core/rag_orchestrator.py` instrumented with step-level tracing +- ✅ `.env.example` includes `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` +- ✅ `langfuse>=2.0.0` added to `requirements/base.txt` (not api.txt — that file does not exist) + +**Testing Week 1:** +```bash +# Run unit tests +pytest tests/unit/test_observability.py -v + +# Start API with observability enabled +export LANGFUSE_PUBLIC_KEY=pk_... LANGFUSE_SECRET_KEY=sk_... +PYTHONPATH=. uvicorn src.api.main:app --reload --port 8000 + +# Query and check LangFuse dashboard +curl -X POST http://127.0.0.1:8000/query \ + -H "Content-Type: application/json" \ + -d '{"query": "What is RAG?"}' + +# Verify trace appears in LangFuse dashboard +``` + +--- + +### Phase 5.2: Latency Profiling & Metrics Dashboard (Week 2) + +**Goal:** Track and expose real-time operational metrics + +#### Step 2.1: Create Metrics Collector Module +**File:** `src/monitoring/metrics.py` (NEW) + +```python +""" +Metrics collection and aggregation for RAG pipeline. +Tracks latency percentiles, cost, retrieval precision, citation accuracy. +""" + +import json +import time +from typing import Dict, List, Optional, Tuple +from dataclasses import dataclass, asdict +from collections import deque +import threading +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class StepMetrics: + """Metrics for a single RAG pipeline step.""" + step_name: str # "retrieval", "reranking", "generation", "citations", "truthfulness" + latency_ms: float + timestamp: str + metadata: Dict = None # Provider, model, token counts, etc. + + +@dataclass +class RequestMetrics: + """Aggregated metrics for a single query request.""" + request_id: str + total_latency_ms: float + retrieval_latency_ms: float + reranking_latency_ms: float + generation_latency_ms: float + citation_latency_ms: float + truthfulness_latency_ms: float + + # Cost + cost_usd: float + + # Quality (online signals — computable without ground truth) + citation_groundedness: float + nli_faithfulness: float + uncited_claims: int + # NOTE: MRR and NDCG require per-query ground-truth relevance labels. + # They cannot be computed in production. Use them only in the offline + # eval harness (evals/run_evals.py). Removed from RequestMetrics. + + timestamp: str + + +class MetricsCollector: + """ + In-memory metrics collector with time-windowed aggregation. + + Stores metrics in a rolling window (default 1000 last requests). + Computes P50, P95, P99 latencies and cost trends. + + NOTE: src/utils/log.py already has a MetricsCollector (count/mean/min/max + per operation name). This class replaces it — don't run both in parallel. + When implementing, delete or archive the one in log.py to avoid two sources + of truth. The track_duration() context manager in log.py can delegate to + this class instead. + """ + + def __init__(self, window_size: int = 1000): + self.window_size = window_size + self.metrics: deque = deque(maxlen=window_size) + self.lock = threading.Lock() + + def record_request(self, metrics: RequestMetrics): + """Record a completed request's metrics.""" + with self.lock: + self.metrics.append(metrics) + + def get_percentile( + self, metric_field: str, percentile: float + ) -> Optional[float]: + """ + Get percentile value for a metric field. + + Args: + metric_field: e.g., "total_latency_ms", "cost_usd" + percentile: 0-100, e.g., 50 for P50, 95 for P95 + + Returns: + Percentile value or None if insufficient data + """ + with self.lock: + if not self.metrics: + return None + + values = sorted([getattr(m, metric_field) for m in self.metrics]) + idx = int(len(values) * percentile / 100) + return values[min(idx, len(values) - 1)] + + def get_dashboard_metrics(self) -> Dict: + """ + Return aggregated metrics suitable for dashboarding. + """ + with self.lock: + if not self.metrics: + return { + "status": "no_data", + "message": "No requests recorded yet", + } + + metrics_list = list(self.metrics) + n = len(metrics_list) + + # Latency percentiles (ms) + latency_p50 = self.get_percentile("total_latency_ms", 50) + latency_p95 = self.get_percentile("total_latency_ms", 95) + latency_p99 = self.get_percentile("total_latency_ms", 99) + + # Step-wise latencies (average) + retrieval_avg = sum(m.retrieval_latency_ms for m in metrics_list) / n + reranking_avg = sum(m.reranking_latency_ms for m in metrics_list) / n + generation_avg = sum(m.generation_latency_ms for m in metrics_list) / n + citation_avg = sum(m.citation_latency_ms for m in metrics_list) / n + + # Cost + cost_total = sum(m.cost_usd for m in metrics_list) + cost_avg = cost_total / n + cost_p95 = self.get_percentile("cost_usd", 95) + + # Quality + groundedness_avg = sum( + m.citation_groundedness for m in metrics_list if m.citation_groundedness + ) / max(sum(1 for m in metrics_list if m.citation_groundedness), 1) + + faithfulness_avg = sum( + m.nli_faithfulness for m in metrics_list if m.nli_faithfulness + ) / max(sum(1 for m in metrics_list if m.nli_faithfulness), 1) + + # Retrieval quality + mrr_avg = sum(m.mrr for m in metrics_list if m.mrr) / max( + sum(1 for m in metrics_list if m.mrr), 1 + ) + ndcg_avg = sum(m.ndcg for m in metrics_list if m.ndcg) / max( + sum(1 for m in metrics_list if m.ndcg), 1 + ) + + return { + "summary": { + "total_requests": n, + "window_size": self.window_size, + "last_updated": datetime.utcnow().isoformat(), + }, + "latency": { + "total_p50_ms": round(latency_p50, 2), + "total_p95_ms": round(latency_p95, 2), + "total_p99_ms": round(latency_p99, 2), + "retrieval_avg_ms": round(retrieval_avg, 2), + "reranking_avg_ms": round(reranking_avg, 2), + "generation_avg_ms": round(generation_avg, 2), + "citation_avg_ms": round(citation_avg, 2), + "breakdown_pct": { + "retrieval": round(retrieval_avg / (retrieval_avg + reranking_avg + generation_avg + citation_avg) * 100, 1), + "reranking": round(reranking_avg / (retrieval_avg + reranking_avg + generation_avg + citation_avg) * 100, 1), + "generation": round(generation_avg / (retrieval_avg + reranking_avg + generation_avg + citation_avg) * 100, 1), + "citation": round(citation_avg / (retrieval_avg + reranking_avg + generation_avg + citation_avg) * 100, 1), + }, + }, + "cost": { + "total_usd": round(cost_total, 4), + "avg_per_request_usd": round(cost_avg, 6), + "p95_per_request_usd": round(cost_p95, 6), + }, + "quality": { + "citation_groundedness_avg": round(groundedness_avg, 3), + "nli_faithfulness_avg": round(faithfulness_avg, 3), + "mrr_avg": round(mrr_avg, 3), + "ndcg_avg": round(ndcg_avg, 3), + }, + } + + +# Global metrics collector instance +_collector_instance: Optional[MetricsCollector] = None + + +def get_metrics_collector() -> MetricsCollector: + """Singleton getter.""" + global _collector_instance + if _collector_instance is None: + _collector_instance = MetricsCollector() + return _collector_instance +``` + +**Testing:** `tests/unit/test_metrics.py` (NEW) + +```python +"""Unit tests for metrics collector.""" + +import pytest +from src.monitoring.metrics import MetricsCollector, RequestMetrics +from datetime import datetime + + +def test_metrics_collector_records_request(): + """Verify collector records request metrics.""" + collector = MetricsCollector(window_size=100) + + metrics = RequestMetrics( + request_id="req-1", + total_latency_ms=1000.0, + retrieval_latency_ms=200.0, + reranking_latency_ms=150.0, + generation_latency_ms=600.0, + citation_latency_ms=50.0, + truthfulness_latency_ms=0.0, + cost_usd=0.005, + citation_groundedness=0.92, + nli_faithfulness=0.88, + uncited_claims=0, + timestamp=datetime.utcnow().isoformat(), + ) + + collector.record_request(metrics) + assert len(collector.metrics) == 1 + + +def test_metrics_percentile_calculation(): + """Verify P50, P95, P99 calculations.""" + collector = MetricsCollector(window_size=100) + + # Record 100 requests with latencies 100-1000ms + for i in range(1, 101): + metrics = RequestMetrics( + request_id=f"req-{i}", + total_latency_ms=float(i * 10), + retrieval_latency_ms=100.0, + reranking_latency_ms=50.0, + generation_latency_ms=i * 5, + citation_latency_ms=10.0, + truthfulness_latency_ms=0.0, + cost_usd=0.01, + citation_groundedness=0.90, + nli_faithfulness=0.90, + uncited_claims=0, + timestamp=datetime.utcnow().isoformat(), + ) + collector.record_request(metrics) + + # Check percentiles + p50 = collector.get_percentile("total_latency_ms", 50) + p95 = collector.get_percentile("total_latency_ms", 95) + p99 = collector.get_percentile("total_latency_ms", 99) + + assert p50 is not None + assert p95 is not None and p95 >= p50 + assert p99 is not None and p99 >= p95 + + +def test_dashboard_metrics_aggregation(): + """Verify dashboard metrics aggregation.""" + collector = MetricsCollector(window_size=10) + + for i in range(5): + metrics = RequestMetrics( + request_id=f"req-{i}", + total_latency_ms=1000.0, + retrieval_latency_ms=200.0, + reranking_latency_ms=150.0, + generation_latency_ms=600.0, + citation_latency_ms=50.0, + truthfulness_latency_ms=0.0, + cost_usd=0.005, + citation_groundedness=0.92, + nli_faithfulness=0.88, + uncited_claims=0, + timestamp=datetime.utcnow().isoformat(), + ) + collector.record_request(metrics) + + dashboard = collector.get_dashboard_metrics() + + assert dashboard["summary"]["total_requests"] == 5 + assert "latency" in dashboard + assert "cost" in dashboard + assert "quality" in dashboard + assert dashboard["latency"]["total_p50_ms"] > 0 +``` + +--- + +#### Step 2.2: Update FastAPI Routes to Record Metrics +**File:** `src/api/main.py` (MODIFY existing) + +```python +# At top +from src.monitoring.metrics import get_metrics_collector, RequestMetrics +import uuid +from datetime import datetime + +metrics_collector = get_metrics_collector() + +# NOTE: Step-level timing and tracing now live in RAGOrchestrator.run() — see Step 1.2. +# main.py only needs to extract the per-step latencies from the QueryResponse and +# record them into MetricsCollector. RAGOrchestrator.run() returns processing_time_ms +# and per-step breakdowns; extend QueryResponse to carry those fields. + +@app.post("/query") +async def query(request: QueryRequest, background_tasks: BackgroundTasks): + request_id = str(uuid.uuid4()) + + try: + orch_response = orchestrator.run(build_query_request(request)) + + metrics = RequestMetrics( + request_id=request_id, + total_latency_ms=orch_response.processing_time_ms, + retrieval_latency_ms=orch_response.step_latencies.get("retrieval", 0), + reranking_latency_ms=orch_response.step_latencies.get("reranking", 0), + generation_latency_ms=orch_response.step_latencies.get("generation", 0), + citation_latency_ms=orch_response.step_latencies.get("citation_verification", 0), + truthfulness_latency_ms=orch_response.step_latencies.get("truthfulness_scoring", 0), + cost_usd=calculate_cost(orch_response, request.provider, request.model), + citation_groundedness=orch_response.truthfulness.citation_groundedness if orch_response.truthfulness else 0, + nli_faithfulness=orch_response.truthfulness.nli_faithfulness if orch_response.truthfulness else 0, + uncited_claims=orch_response.truthfulness.uncited_claims if orch_response.truthfulness else 0, + timestamp=datetime.utcnow().isoformat(), + ) + metrics_collector.record_request(metrics) + background_tasks.add_task(observer.flush_async) + + return build_api_response(request_id, orch_response) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# NEW endpoint: /observability/dashboard +@app.get("/observability/dashboard") +async def observability_dashboard(): + """Return real-time observability metrics for dashboarding.""" + return metrics_collector.get_dashboard_metrics() + + +def calculate_cost(answer_response, provider: str, model: str) -> float: + """Calculate USD cost of request based on tokens and provider pricing. + + NOTE: This function belongs in src/core/llm_provider.py, not main.py. + LLMProviderRouter already knows the provider/model — move cost calculation + there so it's available to CLI and Streamlit paths as well. + """ + if hasattr(answer_response, "usage"): + # Rough estimates — update as provider pricing changes + if provider == "openai": + return (answer_response.usage.prompt_tokens * 0.000001 + + answer_response.usage.completion_tokens * 0.000002) + elif provider == "anthropic": + return (answer_response.usage.prompt_tokens * 0.0000008 + + answer_response.usage.completion_tokens * 0.0000024) + return 0.0 +``` + +**New endpoint:** `src/api/routes/observability.py` (NEW, optional separation) + +```python +"""Observability and monitoring routes.""" + +from fastapi import APIRouter +from src.monitoring.metrics import get_metrics_collector + +router = APIRouter(prefix="/observability", tags=["observability"]) +metrics_collector = get_metrics_collector() + + +@router.get("/dashboard") +async def get_dashboard(): + """Get real-time dashboard metrics.""" + return metrics_collector.get_dashboard_metrics() + + +@router.get("/health") +async def health_check(): + """Basic health check.""" + return {"status": "healthy"} +``` + +**Deliverable for Week 2:** +- ✅ `src/monitoring/metrics.py` (complete) +- ✅ `tests/unit/test_metrics.py` (complete) +- ✅ `src/api/main.py` updated with step-level timing and metrics recording +- ✅ `src/api/routes/observability.py` (optional separation) +- ✅ `src/api/main.py` includes `/observability/dashboard` endpoint +- ✅ Update `Docs/RUNBOOK.md` with observability dashboard instructions + +**Testing Week 2:** +```bash +# Run unit tests +pytest tests/unit/test_metrics.py -v + +# Query and check metrics +curl -X POST http://127.0.0.1:8000/query \ + -H "Content-Type: application/json" \ + -d '{"query": "What is RAG?"}' + +# View dashboard +curl http://127.0.0.1:8000/observability/dashboard | jq . + +# Should return: +# { +# "summary": { "total_requests": 1, ... }, +# "latency": { "total_p50_ms": ..., "breakdown_pct": ... }, +# "cost": { "avg_per_request_usd": ... }, +# "quality": { "citation_groundedness_avg": ... } +# } +``` + +--- + +### Phase 5.3: Regression Gating in CI/CD (Week 3) + +**Goal:** Automated quality threshold enforcement on PRs + +#### Step 3.1: Create Regression Gate Script +**File:** `scripts/compare_evals.py` (NEW) + +```python +#!/usr/bin/env python3 +""" +Compare evaluation metrics between baseline and current results. +Used in GitHub Actions to gate PRs based on regression thresholds. +""" + +import json +import sys +import argparse +from typing import Dict, Tuple + + +def load_metrics(filepath: str) -> Dict: + """Load metrics from JSON file.""" + try: + with open(filepath, "r") as f: + return json.load(f) + except FileNotFoundError: + print(f"Error: {filepath} not found") + sys.exit(1) + except json.JSONDecodeError: + print(f"Error: {filepath} is not valid JSON") + sys.exit(1) + + +def compare_metrics( + baseline: Dict, current: Dict, threshold_pct: float = 5.0 +) -> Tuple[bool, Dict]: + """ + Compare baseline and current metrics. + + Returns: + (passed: bool, results: Dict with details) + """ + results = { + "passed": True, + "regressions": [], + "threshold_pct": threshold_pct, + } + + # Metrics to track (lower is better for latency/cost, higher is better for quality) + latency_metrics = [ + "total_p50_ms", + "total_p95_ms", + "retrieval_avg_ms", + "generation_avg_ms", + ] + quality_metrics = [ + "citation_groundedness_avg", + "nli_faithfulness_avg", + # mrr_avg and ndcg_avg removed — offline-only, not in RequestMetrics + ] + cost_metrics = ["avg_per_request_usd"] + + # Check latency (should not increase by >threshold%) + baseline_latency = baseline.get("latency", {}) + current_latency = current.get("latency", {}) + + for metric in latency_metrics: + baseline_val = baseline_latency.get(metric) + current_val = current_latency.get(metric) + + if baseline_val is None or current_val is None: + continue + + pct_change = ((current_val - baseline_val) / baseline_val) * 100 + + if pct_change > threshold_pct: + results["regressions"].append({ + "metric": metric, + "baseline": baseline_val, + "current": current_val, + "pct_change": pct_change, + "direction": "worse (latency increased)", + }) + results["passed"] = False + + # Check quality (should not decrease by >threshold%) + baseline_quality = baseline.get("quality", {}) + current_quality = current.get("quality", {}) + + for metric in quality_metrics: + baseline_val = baseline_quality.get(metric) + current_val = current_quality.get(metric) + + if baseline_val is None or current_val is None: + continue + + pct_change = ((baseline_val - current_val) / baseline_val) * 100 + + if pct_change > threshold_pct: + results["regressions"].append({ + "metric": metric, + "baseline": baseline_val, + "current": current_val, + "pct_change": pct_change, + "direction": "worse (quality decreased)", + }) + results["passed"] = False + + # Check cost (should not increase by >threshold%) + baseline_cost = baseline.get("cost", {}) + current_cost = current.get("cost", {}) + + for metric in cost_metrics: + baseline_val = baseline_cost.get(metric) + current_val = current_cost.get(metric) + + if baseline_val is None or current_val is None: + continue + + pct_change = ((current_val - baseline_val) / baseline_val) * 100 + + if pct_change > threshold_pct: + results["regressions"].append({ + "metric": metric, + "baseline": baseline_val, + "current": current_val, + "pct_change": pct_change, + "direction": "worse (cost increased)", + }) + results["passed"] = False + + return results["passed"], results + + +def main(): + parser = argparse.ArgumentParser( + description="Compare evaluation metrics between baseline and current" + ) + parser.add_argument("--baseline", required=True, help="Path to baseline metrics JSON") + parser.add_argument("--current", required=True, help="Path to current metrics JSON") + parser.add_argument( + "--threshold", type=float, default=5.0, help="Regression threshold in percent (default: 5%)" + ) + parser.add_argument("--strict", action="store_true", help="Fail on any regression") + + args = parser.parse_args() + + baseline = load_metrics(args.baseline) + current = load_metrics(args.current) + + threshold = 0 if args.strict else args.threshold + passed, results = compare_metrics(baseline, current, threshold_pct=threshold) + + print(json.dumps(results, indent=2)) + + if not passed: + print(f"\n❌ Regression detected ({len(results['regressions'])} metric(s) failed)") + for reg in results["regressions"]: + print(f" - {reg['metric']}: {reg['pct_change']:.1f}% {reg['direction']}") + sys.exit(1) + else: + print("\n✅ All metrics pass regression gate") + sys.exit(0) + + +if __name__ == "__main__": + main() +``` + +**Testing:** `tests/unit/test_regression_gate.py` (NEW) + +```python +"""Unit tests for regression gate script.""" + +import json +import tempfile +import pytest +from scripts.compare_evals import compare_metrics + + +def test_no_regression_when_metrics_stable(): + """Verify no regression when metrics are unchanged.""" + baseline = { + "latency": {"total_p50_ms": 1000.0, "retrieval_avg_ms": 200.0}, + "quality": {"citation_groundedness_avg": 0.92}, + "cost": {"avg_per_request_usd": 0.005}, + } + current = baseline.copy() + + passed, results = compare_metrics(baseline, current, threshold_pct=5.0) + + assert passed is True + assert len(results["regressions"]) == 0 + + +def test_regression_detected_for_latency_increase(): + """Verify regression detected when latency increases >threshold.""" + baseline = { + "latency": {"total_p50_ms": 1000.0}, + "quality": {}, + "cost": {}, + } + current = { + "latency": {"total_p50_ms": 1100.0}, # 10% increase + "quality": {}, + "cost": {}, + } + + passed, results = compare_metrics(baseline, current, threshold_pct=5.0) + + assert passed is False + assert len(results["regressions"]) == 1 + assert results["regressions"][0]["metric"] == "total_p50_ms" + assert results["regressions"][0]["pct_change"] > 5.0 + + +def test_no_regression_when_quality_improves(): + """Verify no regression when quality improves.""" + baseline = { + "latency": {}, + "quality": {"citation_groundedness_avg": 0.90}, + "cost": {}, + } + current = { + "latency": {}, + "quality": {"citation_groundedness_avg": 0.95}, # Improvement + "cost": {}, + } + + passed, results = compare_metrics(baseline, current, threshold_pct=5.0) + + assert passed is True + assert len(results["regressions"]) == 0 +``` + +--- + +#### Step 3.2: Extend Existing CI Workflow (do NOT create a new file) +**File:** `.github/workflows/ci.yml` (MODIFY existing `evals-golden` job) + +> **Why extend, not create?** `ci.yml` already has an `evals-golden` job that runs `golden_ci.jsonl` on every PR with Anthropic Haiku. Creating `.github/workflows/regression_gate.yml` would duplicate that job, resulting in two separate eval runs per PR at twice the cost and runtime. Extend the existing job with a comparison step instead. +> +> **Dataset filename**: The actual file is `evals/datasets/golden_ci.jsonl`, not `golden.jsonl`. +> +> **Baseline strategy**: Store `evals/reports/baseline.json` in the repo (committed from main). The CI job compares the PR output against this committed baseline. This avoids the fragile "check out main and run evals" approach, which doubles job time and creates a chicken-and-egg bootstrapping problem. + +**Add these steps to the existing `evals-golden` job in `ci.yml`:** + +```yaml + evals-golden: + name: Golden evals (Anthropic Haiku) + runs-on: ubuntu-latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Skip golden evals when secret is missing + if: ${{ env.ANTHROPIC_API_KEY == '' }} + run: echo "ANTHROPIC_API_KEY not set; skipping golden evals." + + - name: Install dependencies + if: ${{ env.ANTHROPIC_API_KEY != '' }} + run: pip install -r requirements/base.txt + + - name: Run golden evals (live pipeline, Anthropic Haiku) + if: ${{ env.ANTHROPIC_API_KEY != '' }} + run: | + PYTHONPATH=. python -m evals.run_evals \ + --dataset evals/datasets/golden_ci.jsonl \ + --judge-provider anthropic \ + --judge-model claude-haiku-4-5 \ + --output evals/reports/pr-current.json \ + --faithfulness-threshold 0.7 \ + --correctness-threshold 0.2 + + # === NEW: regression gate comparison === + - name: Compare against baseline (regression gate) + if: ${{ env.ANTHROPIC_API_KEY != '' && hashFiles('evals/reports/baseline.json') != '' }} + run: | + python scripts/compare_evals.py \ + --baseline evals/reports/baseline.json \ + --current evals/reports/pr-current.json \ + --threshold 5.0 + + - name: Comment on regression failure + if: failure() + uses: actions/github-script@v6 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "⚠️ **Regression Detected**\n\nEval metrics degraded vs baseline. See `evals/reports/pr-current.json` artifact for details.\n\nTo update the baseline (intentional improvement), run `make update-baseline` on main." + }) + # === END: regression gate === + + - name: Upload golden eval report + if: ${{ always() && env.ANTHROPIC_API_KEY != '' }} + uses: actions/upload-artifact@v4 + with: + name: eval-report-golden + path: evals/reports/ +``` + +**One-time baseline setup** (run on main, commit the result): +```bash +git checkout main +PYTHONPATH=. python -m evals.run_evals \ + --dataset evals/datasets/golden_ci.jsonl \ + --judge-provider anthropic \ + --judge-model claude-haiku-4-5 \ + --output evals/reports/baseline.json +git add evals/reports/baseline.json +git commit -m "chore: establish Phase 5 eval baseline" +``` + +--- + +#### Step 3.3: Create Phase 5 Documentation +**File:** `Docs/phase5_observability.md` (NEW) + +```markdown +# Phase 5: Production Monitoring & Observability + +**Timeline:** 3 weeks +**Status:** Implementation in progress + +## Overview + +Phase 5 hardens the doc-ingestion RAG system for production through: + +1. **Distributed tracing** (LangFuse) for end-to-end pipeline visibility +2. **Latency profiling** (P50, P95, P99) per step +3. **Cost tracking** (USD per request) +4. **Real-time metrics dashboard** at `/observability/dashboard` +5. **Regression gating** (GitHub Actions) to prevent accuracy degradation on PRs +6. **Citation accuracy monitoring** (groundedness, coverage trends) + +## Architecture + +### Tracing Flow +``` +User Query + ↓ +[LangFuse Trace Start] + ↓ +Retrieval (BM25 + Vector) +[TRACE: latency, chunks retrieved, scores] + ↓ +Reranking (Cross-Encoder) +[TRACE: latency, input/output chunks] + ↓ +Generation (LLM) +[TRACE: latency, tokens, cost, provider] + ↓ +Citation Verification +[TRACE: latency, citations verified] + ↓ +Truthfulness Scoring +[TRACE: latency, faithfulness, groundedness] + ↓ +[Flush to LangFuse] + ↓ +Response + Metrics Recorded +``` + +### Metrics Aggregation +``` +Per-Request Metrics (RequestMetrics) + ↓ +In-Memory Collector (1000 rolling window) + ↓ +Dashboard Endpoint (/observability/dashboard) + ↓ +JSON: P50/P95/P99 latencies, cost trends, quality scores +``` + +### Regression Gating +``` +PR Submitted + ↓ +GitHub Actions: Run evals on golden dataset + ↓ +Compare against baseline (main branch) + ↓ +Check: Latency increase <5%? Quality decrease <5%? + ↓ +If FAIL: Block PR + comment with regression details +If PASS: Allow merge +``` + +## Key Components + +### 1. Observability Module (`src/core/observability.py`) + +**Provides:** +- `RAGObserver` class with step-level tracing decorators +- Context managers for span-based tracing +- LangFuse client integration +- No-op when disabled (useful for demo mode) + +**Usage:** +```python +observer = get_observer() + +# One trace per request, spans as children — instrument in RAGOrchestrator.run() +with observer.trace_request("rag_query", query=query_text) as trace: + with observer.trace_step(trace, "retrieval") as s: + result = retriever.retrieve(query) + s["chunks_retrieved"] = len(result) + with observer.trace_step(trace, "generation", {"provider": provider}) as s: + answer = generator.generate(query, result) + +observer.flush_async() # non-blocking — run in background thread +``` + +### 2. Metrics Collector (`src/monitoring/metrics.py`) + +**Provides:** +- `MetricsCollector` for in-memory aggregation +- Percentile calculations (P50, P95, P99) +- Dashboard-friendly JSON aggregations +- Thread-safe recording + +**Metrics tracked:** +``` +Latency: +- total_latency_ms (P50, P95, P99) +- retrieval_avg_ms +- reranking_avg_ms +- generation_avg_ms +- citation_avg_ms +- Breakdown percentages + +Cost: +- total_usd (across all requests) +- avg_per_request_usd +- p95_per_request_usd + +Quality (online — no ground truth required): +- citation_groundedness_avg +- nli_faithfulness_avg +(mrr/ndcg are offline-only; they live in evals/run_evals.py, not RequestMetrics) +``` + +### 3. Regression Gate Script (`scripts/compare_evals.py`) + +**Compares:** +- Baseline metrics (main branch) +- Current metrics (PR branch) +- Threshold: 5% by default (configurable) + +**Fails if:** +- Latency increases >5% +- Quality decreases >5% +- Cost increases >5% + +### 4. Regression Gate in `.github/workflows/ci.yml` (extends existing `evals-golden` job) + +**On every PR:** +1. Runs offline evaluations against `evals/datasets/golden_ci.jsonl` +2. Compares against committed `evals/reports/baseline.json` +3. Blocks PR if regressions detected +4. Comments with regression details + +## Setup Instructions + +### Step 1: Set Environment Variables + +```bash +# For development +export LANGFUSE_PUBLIC_KEY=pk_... +export LANGFUSE_SECRET_KEY=sk_... + +# For testing (disabled) +export DOC_PROFILE=demo # Disables LangFuse +``` + +### Step 2: Install Dependencies + +```bash +# langfuse goes into requirements/base.txt (requirements/api.txt does not exist) +pip install -r requirements/base.txt # Includes langfuse>=2.0.0 +``` + +### Step 3: Configure Baseline (One-Time, commit to repo) + +```bash +# Run evaluations on main branch to establish baseline +git checkout main +PYTHONPATH=. python -m evals.run_evals \ + --dataset evals/datasets/golden_ci.jsonl \ + --judge-provider anthropic \ + --judge-model claude-haiku-4-5 \ + --output evals/reports/baseline.json +git add evals/reports/baseline.json +git commit -m "chore: establish Phase 5 eval baseline" +``` + +### Step 4: Query and Monitor + +```bash +# Start API +PYTHONPATH=. uvicorn src.api.main:app --reload + +# Query +curl -X POST http://localhost:8000/query \ + -H "Content-Type: application/json" \ + -d '{"query": "What is RAG?"}' + +# View dashboard +curl http://localhost:8000/observability/dashboard | jq . + +# Output: +# { +# "summary": { "total_requests": 1, ... }, +# "latency": { +# "total_p50_ms": 1247.3, +# "total_p95_ms": 1247.3, +# "breakdown_pct": { +# "retrieval": 18.2, +# "reranking": 12.1, +# "generation": 68.4, +# "citation": 1.3 +# } +# }, +# "cost": { "avg_per_request_usd": 0.00245 }, +# "quality": { +# "citation_groundedness_avg": 0.92, +# "nli_faithfulness_avg": 0.88 +# } +# } +``` + +## Testing + +### Unit Tests + +```bash +# Observability tests +pytest tests/unit/test_observability.py -v + +# Metrics tests +pytest tests/unit/test_metrics.py -v + +# Regression gate tests +pytest tests/unit/test_regression_gate.py -v +``` + +### Integration Test + +```bash +# Full E2E with tracing enabled +LANGFUSE_PUBLIC_KEY=pk_... LANGFUSE_SECRET_KEY=sk_... \ +PYTHONPATH=. python -c " +from src.api.main import app +from fastapi.testclient import TestClient + +client = TestClient(app) +response = client.post('/query', json={'query': 'What is RAG?'}) +print(response.json()) +# Should include request_id and all metrics +" +``` + +## Metrics Interpretation + +### Latency Breakdown Example +``` +Total P50: 1247.3 ms + +Breakdown: +- Retrieval: 227 ms (18.2%) ← BM25 + Vector Search +- Reranking: 151 ms (12.1%) ← Cross-Encoder Rerank +- Generation: 855 ms (68.4%) ← LLM inference +- Citation: 14 ms ( 1.3%) ← Citation Verification + +Interpretation: +Generation is the bottleneck (68.4% of total). +Could optimize by: +1. Using a faster model +2. Using streaming +3. Reducing context size +``` + +### Quality Metrics Example +``` +Citation Groundedness: 0.92 (92% of citations verified) +NLI Faithfulness: 0.88 (88% of answer supported by chunks) +MRR (Retrieval): 0.85 (Mean Reciprocal Rank) +NDCG (Retrieval): 0.80 (NDCG@10) + +Interpretation: +- Citation coverage is strong (92%) +- Faithfulness could improve (88%) +- Retrieval quality is good (MRR 0.85) +- Consider reranking strategy improvements +``` + +### Cost Estimation Example +``` +Cost per Request: $0.00245 (avg) +Cost at P95: $0.00312 + +Annual projection (10K requests/day): +365 * 10K * $0.00245 = $8,927.50 + +Cost Optimization: +- Switch to cheaper model? +- Use batch inference? +- Cache common queries? +``` + +## Deployment Notes + +### Docker + +```dockerfile +# In docker/Dockerfile, ensure observability deps are included +# langfuse is in requirements/base.txt — no separate api.txt exists +RUN pip install -r requirements/base.txt + +# docker-compose sets env vars +environment: + - LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY} + - LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY} +``` + +### Streamlit (Demo Mode) + +```python +# In demo mode, observability is disabled +if os.getenv("DOC_PROFILE") == "demo": + observer = RAGObserver(enabled=False) # No-op +``` + +## Troubleshooting + +### LangFuse traces not appearing + +``` +1. Check credentials: LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY set? +2. Check network: Can you reach https://cloud.langfuse.com? +3. Check logs: Do you see "LangFuse observability enabled"? +4. Verify flush: observer.flush() called after each request? +``` + +### Dashboard metrics all zeros + +``` +1. Check MetricsCollector is receiving data: + print(metrics_collector.metrics) +2. Have you sent enough requests? (P95 needs at least 100) +3. Is metrics_collector.record_request() being called? +``` + +### Regression gate always failing + +``` +1. Baseline exists? evals/reports/baseline.json present? (committed to repo) + If not: run "make update-baseline" on main to generate it. +2. Threshold too strict? Default is 5%, try --threshold 10 +3. Eval dataset: correct file is evals/datasets/golden_ci.jsonl (not golden.jsonl) +4. Check eval logs for errors: evals/reports/pr-current.json artifact +``` + +## Next Steps (Post-Phase 5) + +- [ ] Grafana dashboard integration for long-term trends +- [ ] Alert thresholds (PagerDuty for latency spikes) +- [ ] Cost attribution per LLM provider +- [ ] A/B testing framework (compare models, prompts) +- [ ] User feedback loop (thumbs up/down on answers) +- [ ] Fine-tuning based on eval failures + +## Interview Stories + +### "How do you ensure production RAG reliability?" + +> At Marriott, we deployed an agent handling 10K+ guest queries daily. Without observability, we'd have no idea if accuracy was degrading. I instrumented the pipeline with LangFuse tracing to see every step: retrieval latency, reranking precision, generation tokens, citation accuracy. Now I have a dashboard showing P50/P95 latency breakdown, cost per request, and quality metrics. And I wired up regression gating so no code change ships unless it passes a golden dataset evaluation. This is how you build trust in production AI systems. + +### "How would you scale an AI platform?" + +> Observability is first-class, not an afterthought. The moment you deploy, you need distributed tracing to answer: Where's the bottleneck? Is generation or retrieval slowing us down? What's the cost per request? How are quality metrics trending? I built this with LangFuse + a metrics collector, so we can see the full stack at P50/P95. Then I added regression gating in CI/CD to prevent accuracy regressions from ever shipping. + +--- + +**Deliverables Summary:** + +| Week | Component | Files | +|------|-----------|-------| +| 1 | Instrumentation | `src/core/observability.py`, `tests/unit/test_observability.py`, `src/core/rag_orchestrator.py` (modified) | +| 2 | Metrics Dashboard | `src/monitoring/metrics.py` (replaces log.py MetricsCollector), `/observability/dashboard` endpoint | +| 3 | Regression Gating | `scripts/compare_evals.py`, `.github/workflows/ci.yml` (modified — add comparison step to evals-golden job), `evals/reports/baseline.json` (committed) | + +--- + +## Approval Checklist + +- [ ] Week 1: LangFuse integration with correct span hierarchy (one trace/request, spans as children) +- [ ] Week 1: Instrumentation in `RAGOrchestrator.run()`, not `main.py` +- [ ] Week 1: `flush_async()` used everywhere (no synchronous flush in request path) +- [ ] Week 2: `MetricsCollector` in `src/monitoring/metrics.py` replaces the one in `src/utils/log.py` +- [ ] Week 2: `RequestMetrics` has no `mrr`/`ndcg` fields +- [ ] Week 3: Regression comparison added to existing `evals-golden` job in `ci.yml` +- [ ] Week 3: `evals/reports/baseline.json` committed to repo from main branch +- [ ] Tests: All unit tests passing +- [ ] Integration: E2E query with tracing + metrics recording +- [ ] Interview ready: Stories prepared (see "Interview Stories") +``` + +**Deliverable for Week 3:** +- ✅ `scripts/compare_evals.py` (complete) +- ✅ `tests/unit/test_regression_gate.py` (complete) +- ✅ `.github/workflows/ci.yml` updated — regression comparison step added to `evals-golden` job (no new workflow file) +- ✅ `evals/reports/baseline.json` committed to repo (generated from main branch) +- ✅ `Docs/phase5_observability.md` (comprehensive, 300+ lines) +- ✅ Update `README.md` with observability badge and link to Phase 5 docs +- ✅ Update `Docs/ROADMAP.md` to mark Phase 5 as "Complete" + +--- + +## Testing All Phases (Integration Tests) + +**File:** `tests/integration/test_phase5_e2e.py` (NEW) + +```python +"""End-to-end integration test for Phase 5.""" + +import pytest +from fastapi.testclient import TestClient +from src.api.main import app +from src.core.observability import init_observer +from src.monitoring.metrics import get_metrics_collector + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def setup_observability(): + """Initialize observability for tests.""" + init_observer(enabled=False) # Disabled for unit tests + yield + metrics_collector = get_metrics_collector() + metrics_collector.metrics.clear() + + +def test_full_query_pipeline_with_observability(): + """Test full query pipeline with observability enabled. + + NOTE: This requires the API to be running with documents indexed. + Use the existing tests/fixtures/ for pre-loaded test documents — see + tests/integration/test_pipeline.py for the fixture pattern. + """ + response = client.post( + "/query", + json={"query": "What is RAG?", "provider": "ollama", "model": "qwen2.5:7b"} + ) + + assert response.status_code == 200 + data = response.json() + + # Verify response structure + assert "request_id" in data + assert "answer" in data + assert "citations" in data + assert "truthfulness" in data + + # Verify request_id format + assert len(data["request_id"]) == 36 # UUID length + + +def test_observability_dashboard_endpoint(): + """Test /observability/dashboard endpoint.""" + # Send a few requests + for i in range(5): + client.post( + "/query", + json={"query": f"Query {i}", "provider": "ollama"} + ) + + # Check dashboard + response = client.get("/observability/dashboard") + assert response.status_code == 200 + data = response.json() + + # Verify dashboard structure + assert "summary" in data + assert "latency" in data + assert "cost" in data + assert "quality" in data + + # Verify latency metrics + assert "total_p50_ms" in data["latency"] + assert "breakdown_pct" in data["latency"] + assert data["summary"]["total_requests"] >= 5 +``` + +--- + +## Success Metrics (How to Know Phase 5 Is Complete) + +| Metric | Target | Status | +|--------|--------|--------| +| **Tracing** | Every RAG step traced in LangFuse | ✅ | +| **Latency visibility** | P50/P95/P99 per step on dashboard | ✅ | +| **Cost tracking** | USD per request calculated & exposed | ✅ | +| **Regression gating** | GitHub Actions blocks PRs on degradation | ✅ | +| **Tests passing** | Unit + integration + E2E all passing | ✅ | +| **Documentation** | Phase 5 docs + interview stories | ✅ | +| **Demo-ready** | Can show dashboard in 3 minutes | ✅ | + +--- + +## Interview Talking Points + +### For Vertex (Director, AI Coding Platforms) + +> "Latency budgeting is critical at director level. I instrumented my RAG system to show P50/P95 latency per step. Generation is 68% of the latency. I'd optimize by choosing a faster model or using streaming. This is the mental model: measure first, then optimize. And I wired up regression gating so accuracy never regresses on PRs." + +### For Elevation Capital (Head of AI Strategy) + +> "Risk reduction is how you scale AI platforms. I added observability to my RAG system so we can track: Is accuracy degrading? Are costs trending up? Is latency acceptable? And I automated regression detection in CI/CD. This removes the human risk of accidentally shipping a prompt change that tanks quality." + +### For Marriott-like Enterprise Roles + +> "At enterprise scale, you can't guess. I built a metrics dashboard showing cost per request, citation accuracy, retrieval quality. I monitor P50/P95 latencies to understand where bottlenecks are. And I have a regression gate that prevents code changes from degrading the model without detection. This is how you run a platform." + +--- + +## Timeline Summary + +| Week | Deliverable | Effort | Demo | +|------|-------------|--------|------| +| 1 | LangFuse tracing | 15-20 hrs | Query + LangFuse dashboard | +| 2 | Metrics + dashboard | 10-15 hrs | /observability/dashboard endpoint | +| 3 | Regression gating + docs | 10-15 hrs | GitHub Actions blocking PR demo | + +**Total effort:** ~40-50 hours over 3 weeks + +--- diff --git a/Docs/Phase6-Iterative-Execution-Index.md b/Docs/Phase6-Iterative-Execution-Index.md new file mode 100644 index 0000000000000000000000000000000000000000..3b40a247b3c5de76d3a380790e711d81ae04f876 --- /dev/null +++ b/Docs/Phase6-Iterative-Execution-Index.md @@ -0,0 +1,29 @@ +# Phase 6 Iterative Execution Index + +Use this index to execute Phase 6 one plan at a time while keeping the master plan unchanged. + +Master plan: +- `Docs/Phase6-RefactorDemo_React.md` + +Execution order: +1. `Docs/Phase6.1-Backend-Session-Isolation-Plan.md` +2. `Docs/Phase6.2-React-MVP-Plan.md` +3. `Docs/Phase6.3-Container-Cutover-Plan.md` +4. `Docs/Phase6.4-Streamlit-Decommission-Plan.md` (optional) + +## Phase gate rule + +Do not start the next phase until current phase: +- meets all exit criteria, +- passes phase verification commands, +- and has handoff artifacts ready for the next phase. + +## Shared constraints (apply to all phase files) + +- Knowledge scope stays `global|session|both`. +- Guardrails stay at defaults unless explicitly tuned via `DOC_DEMO_*` env: + - 3 files/session + - 3 MB/file + - 8 MB/session + - 30 min idle TTL +- Keep rollback notes current during 6.3 and 6.4. diff --git a/Docs/Phase6-RefactorDemo_React.md b/Docs/Phase6-RefactorDemo_React.md new file mode 100644 index 0000000000000000000000000000000000000000..178358a5981de2c750f0cf600959b09b20df1c29 --- /dev/null +++ b/Docs/Phase6-RefactorDemo_React.md @@ -0,0 +1,445 @@ +# Plan: Per-session document upload for the demo, on a React + FastAPI front-end + +## Context + +The Hugging Face Spaces demo at [src/web/streamlit_app.py](src/web/streamlit_app.py) currently disables document uploads in demo mode (early-return at [src/web/streamlit_app.py:344-350](src/web/streamlit_app.py#L344-L350)) because the ingestion pipeline writes to a single shared Chroma collection (`"documents"` at [src/core/rag_orchestrator.py:32](src/core/rag_orchestrator.py#L32)) and a single shared BM25 index file ([src/core/rag_orchestrator.py:30](src/core/rag_orchestrator.py#L30)). Visitors can only run pre-canned prompts against pre-loaded sample docs, which leaves them unable to verify whether the RAG pipeline is genuinely grounded — eroding trust on first contact. + +Goal: let a visitor (a) try the existing sample prompts, (b) upload a few of their own documents, (c) ask questions scoped to global / their uploads / both, and (d) see citations they can verify against the file they just uploaded — all without polluting the shared corpus or other visitors' sessions. + +You opted to go straight to a React + FastAPI front-end (rather than extending Streamlit) and to ship the work in phases. Backend isolation must land first regardless of front-end choice, so the plan starts there. + +Decisions captured: **3-way knowledge-scope toggle (Global / Mine / Both)**; **conservative caps: 3 files, 3 MB each, 8 MB total, 30 min idle TTL**. + +## High-level approach + +The clean architectural seam already exists in the code — three hard-coded constants (`BM25_INDEX_PATH`, `COLLECTION_NAME`, `CHROMA_PATH`) at module scope in [src/ingest.py:21-22](src/ingest.py#L21-L22) and [src/core/rag_orchestrator.py:30-32](src/core/rag_orchestrator.py#L30-L32). The plan parameterizes those, threads a session-scoped triple `(bm25_index_path, collection_name, chroma_path)` through the request, and unions retrieval results when scope is "Both". Existing components (`HybridRetriever`, `BM25Search`, `VectorSearch`, `CrossEncoderReranker`, `RAGGenerator`, `CitationVerifier`) require no changes. + +The cached singleton orchestrator at [src/web/streamlit_app.py:39](src/web/streamlit_app.py#L39) stays — it reads its session inputs per-`QueryRequest`, not at construction. + +## Phase 6.1 — Backend session isolation foundation (~2-3 days) + +Ships independently. Streamlit UI continues to work as today. The new HTTP surface unblocks the React build. + +### Objective + +Land session-isolated ingestion/retrieval in the backend while keeping existing Streamlit behavior intact. + +### Scope + +### Files to modify + +**[src/ingest.py](src/ingest.py)** — make `ingest()` accept overrides +- Change signature at [L37](src/ingest.py#L37) to: + `def ingest(docs_path, *, bm25_index_path=BM25_INDEX_PATH, collection_name=COLLECTION_NAME, chroma_path="data/embeddings/chroma", processor=None) -> tuple[BM25Index, VectorDatabase]` +- Replace hard-coded uses at [L54](src/ingest.py#L54), [L55](src/ingest.py#L55), [L91](src/ingest.py#L91), [L97-98](src/ingest.py#L97-L98) with the kwargs. +- When `processor is None`, build one as today; the parameter exists so the caller passes a fresh `DocumentProcessor` per session (its `_seen_hashes` is per-instance and would otherwise leak dedup state across sessions). +- Module constants stay as defaults — CLI usage unchanged. + +**[src/web/ingestion_service.py](src/web/ingestion_service.py)** — caps + session-target passthrough +- Add module constants (env-overridable): + - `MAX_FILES_PER_SESSION = int(os.getenv("DOC_DEMO_MAX_FILES", "3"))` + - `MAX_FILE_BYTES = int(os.getenv("DOC_DEMO_MAX_FILE_MB", "3")) * 1024 * 1024` + - `MAX_SESSION_BYTES = int(os.getenv("DOC_DEMO_MAX_SESSION_MB", "8")) * 1024 * 1024` +- Extend `save_uploaded_files()` at [L29](src/web/ingestion_service.py#L29) to accept `existing_bytes: int = 0, max_files: int | None = None, max_file_bytes: int | None = None, max_session_bytes: int | None = None`. Reject with `IngestFileResult(status="rejected", message=...)` for: oversize file, file count cap, session disk cap. +- Add a magic-bytes sanity check (e.g., `.pdf` must start with `%PDF`, `.docx` must start with `PK\x03\x04`); reject `type_mismatch` otherwise. +- Extend `run_ingest()` at [L50](src/web/ingestion_service.py#L50) to accept `bm25_index_path: str | None = None, collection_name: str | None = None, chroma_path: str | None = None` and forward to `ingest(...)`. + +**`src/web/session_corpus.py`** (new — only new module) +``` +SESSION_ROOT = Path(os.getenv("DOC_DEMO_SESSION_ROOT", "/tmp/doc-ingest-sessions")) +SESSION_TTL_SECONDS = int(os.getenv("DOC_DEMO_SESSION_TTL", "1800")) + +@dataclass +class SessionCorpus: + session_id: str + upload_dir: Path + chroma_path: Path + bm25_index_path: Path + collection_name: str # f"sess_{session_id}" — Chroma-safe + created_at: float + +def new_session_id() -> str # uuid4().hex[:12] +def get_or_create(sid: str) -> SessionCorpus +def touch(sid: str) -> None # bump .touched mtime, refresh TTL +def total_bytes(s: SessionCorpus) -> int +def list_active_sessions() -> list[SessionCorpus] +def delete_session(sid: str) -> None +def janitor_sweep(now: float | None = None) -> int +``` +Layout per session: `${SESSION_ROOT}//{uploads/, chroma/, bm25_index.json, .touched}`. Idempotent and safe under concurrent reruns. + +**[src/core/rag_orchestrator.py](src/core/rag_orchestrator.py)** — session-aware retrieval +- Extend `QueryRequest` at [L36](src/core/rag_orchestrator.py#L36) with: + - `session_bm25_index_path: Optional[str] = None` + - `session_collection_name: Optional[str] = None` + - `session_chroma_path: Optional[str] = None` + - `knowledge_scope: str = "global"` # `"global" | "session" | "both"` +- `_load_components()` at [L87](src/core/rag_orchestrator.py#L87): when scope is `session` or `both`, also load a second `(BM25Index, VectorDatabase)` from session paths. If session BM25 file is missing/empty (user hasn't uploaded yet), fall back gracefully — log warning and demote scope to `global`. +- `_retrieve()` at [L92](src/core/rag_orchestrator.py#L92): when `scope == "session"` run hybrid against the session pair only; when `scope == "both"` run two `HybridRetriever.retrieve()` calls and concatenate, deduping by `id`. The reranker at [L165](src/core/rag_orchestrator.py#L165) is the final arbiter — no change to fusion/rerank logic. +- Cache-key fingerprint at [L126](src/core/rag_orchestrator.py#L126) must include scope and session triple so global cache hits don't leak across users: + `corpus_fingerprint=f"{COLLECTION_NAME}:{BM25_INDEX_PATH}|{req.knowledge_scope}|{req.session_collection_name or '-'}:{req.session_bm25_index_path or '-'}"` + +**[src/api/main.py](src/api/main.py)** — new endpoints + CORS + janitor +- Add CORS middleware (allow the React origin: localhost dev port + the deployed origin from env `DOC_FRONTEND_ORIGINS`). +- New endpoints: + - `POST /sessions` → `{session_id, expires_at}`. Mints id, calls `session_corpus.get_or_create()`. Sets `X-Demo-Session-Id` response header so the React app can also use it without cookies. + - `GET /sessions/{sid}` → `{session_id, files: [...], total_bytes, max_session_bytes, max_files, expires_at}`. Useful for the "My documents" panel. + - `POST /sessions/{sid}/documents` → multipart upload. Calls `save_uploaded_files(session.upload_dir, files, existing_bytes=total_bytes(session), ...)`, then `run_ingest(session.upload_dir, bm25_index_path=session.bm25_index_path, collection_name=session.collection_name, chroma_path=str(session.chroma_path))`. Touches the session. + - `DELETE /sessions/{sid}` → `session_corpus.delete_session(sid)` then mints a new id. +- Extend `POST /query` at [L155](src/api/main.py#L155): accept optional `session_id`, `knowledge_scope`. If provided, look up the session, touch it, and pass `session_*` paths into `QueryRequest`. Reject `session`/`both` scopes when session has no uploads (return 409 with a hint to upload first). +- Demo-mode guard at [L112](src/api/main.py#L112): the new session endpoints are **only** mounted when `DOC_PROFILE=demo` and `DOC_DEMO_UPLOADS=1`. Outside demo mode, ingestion stays through the existing batch path. +- Per-IP upload rate limit: reuse the existing limiter at [L77-99](src/api/main.py#L77-L99) on `POST /sessions/{sid}/documents`. +- FastAPI `lifespan`: start a background `asyncio` task that runs `session_corpus.janitor_sweep()` every 60 s; stop it on shutdown. Replaces the on-rerun best-effort sweep entirely. + +**[spaces/app.py](spaces/app.py)** — opt the deployed demo into Phase 6.1 +- After [L34](spaces/app.py#L34) add the env defaults: + - `DOC_DEMO_UPLOADS=1` + - `DOC_DEMO_SESSION_ROOT=/tmp/doc-ingest-sessions` + - `DOC_DEMO_MAX_FILES=3`, `DOC_DEMO_MAX_FILE_MB=3`, `DOC_DEMO_MAX_SESSION_MB=8`, `DOC_DEMO_SESSION_TTL=1800` +- HF Spaces ephemeral disk is wiped on container restart — `/tmp` keeps the persisted `data/` clean. + +### Functions/classes to reuse unchanged + +- `save_uploaded_files()` at [src/web/ingestion_service.py:29](src/web/ingestion_service.py#L29) — body preserved, signature additions only +- `RAGOrchestrator` class itself at [src/core/rag_orchestrator.py:64](src/core/rag_orchestrator.py#L64) — only `QueryRequest` grows +- `HybridRetriever`, `BM25Search`, `VectorSearch` ([src/core/](src/core/)) — second instance per request when scope demands; otherwise unchanged +- `VectorDatabase` at [src/utils/database.py:29](src/utils/database.py#L29) — already accepts `chroma_path`; just construct a second one for sessions +- `BM25Index.save` / `BM25Index.load` at [src/core/bm25_index.py](src/core/bm25_index.py) — already path-parameterized +- `CrossEncoderReranker`, `RAGGenerator`, `CitationVerifier`, `ResponseCache` — unchanged + +### Tests (Phase 6.1) + +Add under [tests/unit/](tests/unit/) and [tests/integration/](tests/integration/): + +- `tests/unit/test_session_corpus.py` — id format, idempotent `get_or_create`, janitor TTL eviction, `delete_session` on missing dir is a no-op, concurrent `get_or_create` is safe. +- Extend `tests/unit/test_ingestion_service.py` (or create) — caps enforced (oversize, count, session disk), magic-byte mismatch rejected, override kwargs forwarded. +- `tests/unit/test_ingest_overrides.py` — `ingest(tmp, bm25_index_path=..., collection_name="sess_x", chroma_path=...)` writes to overrides and not defaults; default-arg call still hits the global paths. +- Extend `tests/unit/test_streamlit_demo_routing.py` — `knowledge_scope="session"` carries session paths only; `"both"` carries both; cache key changes when session paths change. +- `tests/integration/test_session_isolation.py` — bootstrap a tiny global corpus; mint sessions A and B; ingest different fixtures into each; query A scope=`session` returns only A's chunks; query A scope=`both` returns A+global, never B's; janitor with mocked clock past TTL deletes the session dirs. +- `tests/integration/test_global_corpus_pristine.py` — sha256 the global BM25 + Chroma store before/after multiple session ingests; assert unchanged. +- `tests/integration/test_session_api.py` — exercise `POST /sessions`, `POST /sessions/{id}/documents`, `GET /sessions/{id}`, `DELETE /sessions/{id}` and `POST /query` with session_id end-to-end via FastAPI `TestClient`. + +### Verification (Phase 6.1, local) + +``` +# Unit + integration +pytest tests/unit/test_session_corpus.py tests/unit/test_ingestion_service.py \ + tests/unit/test_ingest_overrides.py tests/unit/test_streamlit_demo_routing.py \ + tests/integration/test_session_isolation.py \ + tests/integration/test_global_corpus_pristine.py \ + tests/integration/test_session_api.py -v + +# Boot demo-mode API + Streamlit (Streamlit still works) +DOC_PROFILE=demo DOC_EMBEDDING_PROVIDER=sentence_transformers \ +DOC_DEMO_UPLOADS=1 DOC_DEMO_SESSION_ROOT=/tmp/doc-ingest-sessions \ +uvicorn src.api.main:app --host 127.0.0.1 --port 8000 & +DOC_PROFILE=demo streamlit run src/web/streamlit_app.py + +# Curl smoke the new API +curl -X POST http://127.0.0.1:8000/sessions +# → {"session_id":"...","expires_at":...} +curl -X POST -F "files=@./README.md" http://127.0.0.1:8000/sessions//documents +curl -X POST http://127.0.0.1:8000/query \ + -H "Content-Type: application/json" \ + -d '{"query":"summarize my doc","session_id":"","knowledge_scope":"session"}' + +# Confirm shared corpus untouched +sha256sum data/embeddings/bm25_index.json # before/after — identical +``` + +### Phase 6.1 handoff (exit criteria) + +- Backend supports isolated session corpus lifecycle (`create/get/upload/query/delete`) without cross-session leakage. +- `knowledge_scope` (`global|session|both`) works end-to-end and cache keys are session-safe. +- Guardrails are enforced server-side (file caps, MIME sanity checks, rate limiting, TTL janitor). +- Existing Streamlit demo still runs in demo profile (no regression to current user flow). +- All Phase 6.1 tests pass locally and in CI. + +### Transition to Phase 6.2 (entry criteria) + +- API contracts are stable for frontend consumption (`/sessions`, `/sessions/{id}`, `/sessions/{id}/documents`, `/query` with session fields). +- OpenAPI spec reflects new request/response shapes. +- Demo env defaults for session uploads are available. + +## Phase 6.2 — React MVP front-end over stable API (~5-7 days) + +Built in a new top-level `frontend/` directory; FastAPI keeps running unchanged. No HF cutover yet — develop locally against `http://127.0.0.1:8000`. + +### Objective + +Ship a usable React demo UI that consumes Phase 6.1 APIs and validates isolated user-upload experience. + +### Scope + +### Stack + +- **Vite + React 18 + TypeScript** (lean SPA, no SSR needed for a demo). +- **Tailwind CSS + shadcn/ui** (Radix-based primitives — drop-in card, tabs, radio-group, file-uploader, progress, toast). +- **TanStack Query** for server state (session, file list, query results) — gives caching, retries, and dedup for free. +- **Zustand** (or React Context) for the session-id slice that needs to outlive a route change. +- **Typed API client** generated from FastAPI's OpenAPI schema via `openapi-typescript` so the FE stays type-safe against the BE contract. +- **Streaming**: consume `POST /query/stream` via the `EventSource`-style `fetch` + `ReadableStream` pattern (since SSE doesn't natively support POST). + +### Component layout + +``` +frontend/ +├─ index.html +├─ vite.config.ts +├─ tailwind.config.ts +├─ src/ +│ ├─ main.tsx +│ ├─ App.tsx # Tabs: Query | My documents +│ ├─ api/ +│ │ ├─ client.ts # fetch wrapper, attaches X-Demo-Session-Id +│ │ └─ generated.ts # openapi-typescript output +│ ├─ session/ +│ │ ├─ SessionProvider.tsx # mints session via POST /sessions on first load +│ │ └─ useSession.ts +│ ├─ tabs/ +│ │ ├─ QueryTab.tsx # sample prompts, scope toggle, run +│ │ └─ DocumentsTab.tsx # drop-zone, file list, caps meter, reset +│ ├─ components/ +│ │ ├─ SamplePromptChips.tsx # mirrors _DEMO_QUESTIONS +│ │ ├─ ScopeToggle.tsx # 3-way radio, disables Mine/Both until upload +│ │ ├─ AnswerPanel.tsx # answer + truthfulness badge +│ │ ├─ CitationsList.tsx # tagged [global]/[yours] +│ │ ├─ RetrievedChunks.tsx +│ │ └─ Uploader.tsx # drag-drop, per-file status +│ └─ lib/streamQuery.ts # SSE-over-POST helper +``` + +### UX wireframe + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Doc Ingestion Assistant session …a91c · 28:14 left │ +│ ⓘ Hosted demo. Your uploads stay in this session for 30 min, │ +│ are not added to the shared corpus, and aren't visible to │ +│ anyone else. │ +├─ [ Query ] [ My documents ] ─────────────────────────────────── │ +│ │ +│ Query tab: │ +│ Try a sample: [What is RAG?] [What is RRF?] [BM25 vs vec…] │ +│ │ +│ Knowledge scope: │ +│ ◉ Global sample corpus │ +│ ○ My uploads only (disabled until upload) │ +│ ○ Both │ +│ │ +│ Provider [ ▼ ] Model [ ▼ ] │ +│ ┌───────────────────────────────────────────────┐ │ +│ │ Ask a question… │ │ +│ └───────────────────────────────────────────────┘ │ +│ [ Run ] │ +│ │ +│ ── Answer ── 🟢 Truthfulness 0.89 │ +│ …answer text streaming in… │ +│ │ +│ Citations: │ +│ [yours] my-resume.pdf · chunk 2 │ +│ [global] phase2_hybrid_retrieval.md · chunk 5 │ +│ │ +│ My documents tab: │ +│ Disk used: 1.2 / 8.0 MB Files: 2 / 3 │ +│ ⓘ ≤ 3 files · ≤ 3 MB each · ≤ 8 MB total │ +│ ┌───────── drop files here ─────────┐ │ +│ └──────────────────────────────────┘ │ +│ • my-resume.pdf indexed │ +│ • report.txt indexed │ +│ [ Clear my session ] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +Behavior detail: +- On first mount, `SessionProvider` calls `POST /sessions` and stashes the id in localStorage (so a refresh keeps the same session until TTL). +- The scope toggle disables Mine/Both until `GET /sessions/{id}` reports ≥ 1 indexed file. +- Sample prompts always target Global scope by default (clicking a chip sets scope=Global and fills the textarea). +- The streaming answer uses `lib/streamQuery.ts` to read tokens off `/query/stream`; falls back to non-streaming if SSE fails. +- "Clear my session" calls `DELETE /sessions/{id}` then mints a new one. + +### Tests (Phase 6.2) + +- `frontend/src/**/*.test.tsx` with **Vitest + React Testing Library**: + - SessionProvider mints a session on first mount and stores it. + - ScopeToggle disables Mine/Both when no uploads, enables after upload. + - Uploader respects 3-file cap client-side and shows server rejection toasts. + - QueryTab renders streamed tokens incrementally. +- **Playwright** smoke (`frontend/e2e/`): full happy-path — load → upload one file → switch to Mine → ask a question → see the file's citation. +- **Playwright** negative path: no uploads keeps Mine/Both disabled; rejected uploads surface clear cap/type errors. + +### Verification (Phase 6.2, local) + +``` +# Backend +DOC_PROFILE=demo DOC_DEMO_UPLOADS=1 \ + uvicorn src.api.main:app --host 127.0.0.1 --port 8000 + +# Frontend +cd frontend && npm install && npm run dev # http://localhost:5173 + +# E2E +cd frontend && npm run test # vitest +npm run test:e2e # playwright +``` + +### Phase 6.2 handoff (exit criteria) + +- React app provides Query + My Documents tabs, scope toggle, streaming/fallback answer flow, and session reset. +- UI clearly communicates upload limits and session TTL. +- Frontend unit tests and e2e tests pass locally and in CI. +- UX supports clear citation provenance (`[global]` vs `[yours]`) for trust validation. + +### Transition to Phase 6.3 (entry criteria) + +- Frontend builds reproducibly (`npm ci && npm run build`) and can be served as static assets. +- API CORS config includes intended frontend origins. +- No unresolved API/frontend contract mismatches remain. + +## Phase 6.3 — Single-container deploy & HF Spaces cutover (~2 days) + +The current HF Space uses the Streamlit SDK (`spaces/README.md`). Switch to the Docker SDK so we ship one container with FastAPI + the built React SPA. + +### Objective + +Deploy one container (FastAPI + built React) to simplify ops and align HF delivery with the new UI. + +### Scope + +### Files to modify + +- **[docker/Dockerfile](docker/Dockerfile)** — multi-stage: + - Stage 1 (`node:20-alpine`): `npm ci && npm run build` → `frontend/dist`. + - Stage 2 (existing Python image): `COPY --from=stage1 /app/frontend/dist /app/static`. + - Final `CMD` runs uvicorn only — Streamlit is no longer in the deployed image. +- **[src/api/main.py](src/api/main.py)** — when the static dir exists, mount it: `app.mount("/", StaticFiles(directory="static", html=True), name="ui")`. Move existing API routes under `/api` prefix (or use `app.mount` ordering so SPA fallback kicks in only on unknown paths). Keep `/health`, `/metrics`, `/query`, `/query/stream` reachable. +- **[spaces/README.md](spaces/README.md)** — change frontmatter: + ```yaml + sdk: docker + app_port: 8000 + ``` + Drop `app_file: spaces/app.py`. +- **[spaces/app.py](spaces/app.py)** — repurpose as a tiny launcher that just sets the demo env vars and execs uvicorn (or remove entirely if env defaults move into the Dockerfile). +- **[.github/workflows/sync-to-spaces.yml](.github/workflows/sync-to-spaces.yml)** — extend to run `npm ci && npm run build` before pushing, OR rely on HF's Docker build (preferred — keeps CI fast). +- **[.github/workflows/ci.yml](.github/workflows/ci.yml)** — add a `frontend` job: `npm ci`, `npm run lint`, `npm run test`, `npm run build`. Add a `e2e` job that boots the API and runs Playwright. + +Streamlit code stays in `src/web/streamlit_app.py` behind an env flag during the cutover so we can roll back to the previous Space SDK by reverting `spaces/README.md`. + +### Verification (Phase 6.3) + +``` +# Build and run the unified container locally +docker build -f docker/Dockerfile -t doc-ingest:demo . +docker run --rm -p 8000:8000 \ + -e DOC_PROFILE=demo -e DOC_DEMO_UPLOADS=1 \ + -e DOC_EMBEDDING_PROVIDER=sentence_transformers \ + doc-ingest:demo +open http://127.0.0.1:8000 + +# Push branch → HF Space rebuilds via Docker SDK; smoke-test the live URL. +``` + +### Phase 6.3 handoff (exit criteria) + +- Unified container runs locally and in HF Spaces with expected routes and SPA fallback behavior. +- Core API routes (`/health`, `/metrics`, `/query`, `/query/stream`) remain reachable and validated. +- Demo smoke tests pass against deployed environment. +- Rollback procedure to prior Space setup is documented and tested. + +### Transition to Phase 6.4 (entry criteria) + +- React demo has soaked in production-like traffic for at least one week. +- No unresolved severity-1/2 issues tied to the new deployment path. +- Team confirms Streamlit rollback is no longer required. + +## Phase 6.4 — Decommission Streamlit (optional, after 6.3 soaks) + +Once the React demo has been live for a week without regressions: + +- Delete [src/web/streamlit_app.py](src/web/streamlit_app.py). +- Remove `streamlit` from [requirements/base.txt](requirements/base.txt). +- Drop the Streamlit container from [docker/docker-compose.yml](docker/docker-compose.yml). +- Update [README.md](README.md) screenshots and quickstart. +- Delete `tests/unit/test_streamlit_demo_routing.py`. + +Keep `_DEMO_QUESTIONS` (move into a small JSON the API serves at `GET /api/sample-prompts` so the React FE stays in sync). + +### Phase 6.4 handoff (exit criteria) + +- Streamlit runtime, dependencies, and tests are removed cleanly. +- Documentation and quickstart reflect the React + FastAPI deployment only. +- Sample prompts are served from API/shared source of truth. + +### Transition to next program increment + +- Phase 6 is complete when 6.1-6.4 exit criteria are satisfied (with 6.4 optional per release decision). +- Any deferred improvements become backlog items for Phase 7 (e.g., hard TTL cap, query concurrency limiter, enhanced abuse controls). + +## Caps & abuse guardrails (locked-in defaults) + +| Guard | Default | Enforced where | Failure mode | +|---|---|---|---| +| Per-file size cap | 3 MB | `save_uploaded_files()` | `rejected: oversize` | +| File count cap | 3 / session | `save_uploaded_files()` | `rejected: file_count_cap` | +| Total session disk cap | 8 MB | `save_uploaded_files()` | `rejected: session_disk_cap` | +| Extension allowlist | `.pdf .docx .txt .md .html` | already at `_SUPPORTED_EXTS` ([L15](src/web/ingestion_service.py#L15)) | `failed: unsupported` | +| MIME magic | header sniff | new helper in `save_uploaded_files()` | `rejected: type_mismatch` | +| Per-IP upload rate-limit | reuse [src/api/main.py:77-99](src/api/main.py#L77-L99) limiter | `POST /sessions/{sid}/documents` | 429 | +| Janitor disk ceiling | total `SESSION_ROOT > 1 GB` evicts oldest | `janitor_sweep()` | oldest sessions dropped | +| Idle TTL | 30 min, refreshed on every query/upload | `.touched` mtime + janitor | session purged | + +All caps overridable via env (`DOC_DEMO_*`) so we can tune on HF without code changes. + +## Phase execution re-review (end-to-end) + +Execution order is intentionally strict: **6.1 -> 6.2 -> 6.3 -> 6.4 (optional)**. + +- **6.1 is the architectural base**: session isolation, scoped retrieval, and backend guardrails must be correct before any UI investment. +- **6.2 depends on 6.1 contracts**: React work starts only after session APIs and `knowledge_scope` behavior are stable and test-covered. +- **6.3 depends on 6.2 build maturity**: container cutover happens only after frontend build/test reliability and CORS/origin alignment are in place. +- **6.4 is a stabilization cleanup**: Streamlit removal is deferred until post-soak confidence to protect rollback safety. + +Readiness checklist before starting each phase: + +- Previous phase exit criteria are met and documented. +- Phase-specific test suite passes locally and in CI. +- No open blocker in cross-phase risks that invalidates next-phase assumptions. +- Handoff artifacts are available (API contract, env defaults, deployment notes, rollback notes as applicable). + +## Cross-phase risks & open questions + +1. **HF Space SDK switch (Streamlit → Docker)** is a one-way door for the running Space. Do the cutover on a fresh Space first (e.g., `…-demo-v2`), validate, then point the public URL at it. +2. **Reranker memory under concurrency** — cross-encoder is the dominant cost (~400 MB) and serializes on CPU. More visitors uploading doesn't worsen retrieval contention, but Phase 6.2 should add a concurrency limiter on `/query` if HF traffic grows. +3. **Cache-key fingerprint correctness** — the change at [rag_orchestrator.py:126](src/core/rag_orchestrator.py#L126) is load-bearing. Test must assert two sessions with identical query text get distinct cache keys. +4. **`DocumentProcessor._seen_hashes` per-instance** ([src/core/document_processor.py:49](src/core/document_processor.py#L49)) — passing a fresh processor per session ingest is required, otherwise a session can silently skip files matching another session's hashes. +5. **TTL refresh on read vs write** — refreshing on every query keeps active users' uploads alive indefinitely; consider an absolute hard cap (4 h) in Phase 6.2 if abuse appears. +6. **SSE-over-POST quirks** — some proxies break long-lived POST streams. The React client falls back to non-streaming on first failure. +7. **CORS scope** — set `DOC_FRONTEND_ORIGINS` tightly (no `"*"`) once the Space URL is final. +8. **Browser refresh** — localStorage retains `session_id`; if backend has expired it, the FE catches a 404 from `GET /sessions/{id}` and re-mints transparently. +9. **Citation labeling** — to display `[yours]` vs `[global]`, the merged `RetrievedResult.metadata` must carry the source collection. Cheapest: prefix chunk `id`s with `sess___` for session uploads (already implicit since the collection name differs); the FE checks the prefix. +10. **Streamlit coexistence during transition** — keep the Streamlit page reachable via a hidden `/legacy` route until Phase 6.4 to ease rollback. + +## Critical files by phase + +- **Phase 6.1** + - [src/ingest.py](src/ingest.py) + - [src/web/ingestion_service.py](src/web/ingestion_service.py) + - `src/web/session_corpus.py` (new) + - [src/core/rag_orchestrator.py](src/core/rag_orchestrator.py) + - [src/api/main.py](src/api/main.py) + - [spaces/app.py](spaces/app.py) +- **Phase 6.2** + - `frontend/` (new tree) +- **Phase 6.3** + - [docker/Dockerfile](docker/Dockerfile) + - [src/api/main.py](src/api/main.py) + - [spaces/README.md](spaces/README.md) + - [spaces/app.py](spaces/app.py) + - [.github/workflows/ci.yml](.github/workflows/ci.yml) + - [.github/workflows/sync-to-spaces.yml](.github/workflows/sync-to-spaces.yml) +- **Phase 6.4** + - [src/web/streamlit_app.py](src/web/streamlit_app.py) + - [requirements/base.txt](requirements/base.txt) + - [docker/docker-compose.yml](docker/docker-compose.yml) + - [README.md](README.md) + - `tests/unit/test_streamlit_demo_routing.py` diff --git a/Docs/Phase6.1-Backend-Session-Isolation-Plan.md b/Docs/Phase6.1-Backend-Session-Isolation-Plan.md new file mode 100644 index 0000000000000000000000000000000000000000..41cb8e1da1eabfd3fb39f44c0e770639be6214b3 --- /dev/null +++ b/Docs/Phase6.1-Backend-Session-Isolation-Plan.md @@ -0,0 +1,125 @@ +# Phase 6.1 Plan: Backend Session Isolation Foundation + +Source of truth: `Docs/Phase6-RefactorDemo_React.md` (this file is an execution slice for iterative delivery). + +## Objective + +Land session-isolated ingestion/retrieval in the backend while keeping existing Streamlit behavior intact. + +## Scope + +Ships independently. Streamlit UI continues to work as today. The new HTTP surface unblocks the React build. + +## Files to modify + +**`src/ingest.py`** — make `ingest()` accept overrides +- Change signature to: + `def ingest(docs_path, *, bm25_index_path=BM25_INDEX_PATH, collection_name=COLLECTION_NAME, chroma_path="data/embeddings/chroma", processor=None) -> tuple[BM25Index, VectorDatabase]` +- Replace hard-coded uses with kwargs. +- Keep module constants as defaults so CLI remains unchanged. +- Ensure fresh `DocumentProcessor` per session when caller passes one. + +**`src/web/ingestion_service.py`** — caps + session-target passthrough +- Add env-overridable caps: + - `DOC_DEMO_MAX_FILES` (default `3`) + - `DOC_DEMO_MAX_FILE_MB` (default `3`) + - `DOC_DEMO_MAX_SESSION_MB` (default `8`) +- Extend `save_uploaded_files()` to enforce: + - per-file cap + - file count cap + - total session cap +- Add magic-bytes check (`.pdf`, `.docx`) and reject type mismatch. +- Extend `run_ingest()` to pass `bm25_index_path`, `collection_name`, `chroma_path` overrides. + +**`src/web/session_corpus.py`** (new) +- Add `SessionCorpus` dataclass and helpers: + - `new_session_id`, `get_or_create`, `touch`, `total_bytes`, `list_active_sessions`, `delete_session`, `janitor_sweep` +- Session layout: + - `${SESSION_ROOT}//{uploads/, chroma/, bm25_index.json, .touched}` +- Defaults: + - `DOC_DEMO_SESSION_ROOT=/tmp/doc-ingest-sessions` + - `DOC_DEMO_SESSION_TTL=1800` + +**`src/core/rag_orchestrator.py`** — session-aware retrieval +- Extend `QueryRequest`: + - `session_bm25_index_path` + - `session_collection_name` + - `session_chroma_path` + - `knowledge_scope` (`global|session|both`) +- `session` scope uses only session corpus. +- `both` scope merges global + session results and dedups by id. +- Cache fingerprint must include scope + session corpus identifiers. + +**`src/api/main.py`** — session endpoints + CORS + janitor +- Add CORS using `DOC_FRONTEND_ORIGINS`. +- Add endpoints: + - `POST /sessions` + - `GET /sessions/{sid}` + - `POST /sessions/{sid}/documents` + - `DELETE /sessions/{sid}` +- Extend `POST /query` with optional `session_id` and `knowledge_scope`. +- Reject `session/both` if session has no uploads (409 with hint). +- Mount only in demo mode: + - `DOC_PROFILE=demo` + - `DOC_DEMO_UPLOADS=1` +- Reuse upload rate limiter for `POST /sessions/{sid}/documents`. +- Add lifespan janitor task (`session_corpus.janitor_sweep()` every 60s). + +**`spaces/app.py`** — enable demo defaults for this phase +- Set: + - `DOC_DEMO_UPLOADS=1` + - `DOC_DEMO_SESSION_ROOT=/tmp/doc-ingest-sessions` + - `DOC_DEMO_MAX_FILES=3` + - `DOC_DEMO_MAX_FILE_MB=3` + - `DOC_DEMO_MAX_SESSION_MB=8` + - `DOC_DEMO_SESSION_TTL=1800` + +## Tests + +- `tests/unit/test_session_corpus.py` +- `tests/unit/test_ingestion_service.py` (extend or create) +- `tests/unit/test_ingest_overrides.py` +- `tests/unit/test_streamlit_demo_routing.py` (extend) +- `tests/integration/test_session_isolation.py` +- `tests/integration/test_global_corpus_pristine.py` +- `tests/integration/test_session_api.py` + +## Verification + +```bash +pytest tests/unit/test_session_corpus.py tests/unit/test_ingestion_service.py \ + tests/unit/test_ingest_overrides.py tests/unit/test_streamlit_demo_routing.py \ + tests/integration/test_session_isolation.py \ + tests/integration/test_global_corpus_pristine.py \ + tests/integration/test_session_api.py -v + +DOC_PROFILE=demo DOC_EMBEDDING_PROVIDER=sentence_transformers \ +DOC_DEMO_UPLOADS=1 DOC_DEMO_SESSION_ROOT=/tmp/doc-ingest-sessions \ +uvicorn src.api.main:app --host 127.0.0.1 --port 8000 & +DOC_PROFILE=demo streamlit run src/web/streamlit_app.py +``` + +API smoke: + +```bash +curl -X POST http://127.0.0.1:8000/sessions +curl -X POST -F "files=@./README.md" http://127.0.0.1:8000/sessions//documents +curl -X POST http://127.0.0.1:8000/query \ + -H "Content-Type: application/json" \ + -d '{"query":"summarize my doc","session_id":"","knowledge_scope":"session"}' +sha256sum data/embeddings/bm25_index.json +``` + +## Handoff (Exit Criteria) + +- Backend supports isolated session lifecycle (`create/get/upload/query/delete`) with no cross-session leakage. +- `knowledge_scope` works end-to-end and cache keys are session-safe. +- Guardrails enforced server-side (caps, MIME checks, rate limiting, TTL janitor). +- Streamlit demo still works in demo profile. +- Phase 6.1 tests pass locally and in CI. + +## Transition to Phase 6.2 + +- API contracts are stable for frontend usage. +- OpenAPI includes new request/response shapes. +- Demo env defaults for session uploads are confirmed. diff --git a/Docs/Phase6.2-React-MVP-Plan.md b/Docs/Phase6.2-React-MVP-Plan.md new file mode 100644 index 0000000000000000000000000000000000000000..7b8b67e22184eec31a06c515d2abed638770f502 --- /dev/null +++ b/Docs/Phase6.2-React-MVP-Plan.md @@ -0,0 +1,83 @@ +# Phase 6.2 Plan: React MVP Front-end Over Stable API + +Source of truth: `Docs/Phase6-RefactorDemo_React.md` (this file is an execution slice for iterative delivery). +Depends on: `Docs/Phase6.1-Backend-Session-Isolation-Plan.md` + +## Objective + +Ship a usable React demo UI that consumes Phase 6.1 APIs and validates isolated user-upload experience. + +## Scope + +Build in top-level `frontend/`; FastAPI backend remains unchanged. No HF cutover yet; develop locally against `http://127.0.0.1:8000`. + +## Stack + +- Vite + React 18 + TypeScript +- Tailwind CSS + shadcn/ui +- TanStack Query +- Zustand (or Context) for session id state +- `openapi-typescript` generated API typings +- Streaming via `POST /query/stream` using `fetch` + `ReadableStream` + +## Planned frontend layout + +```text +frontend/ +├─ src/App.tsx # Query | My documents +├─ src/api/client.ts # fetch wrapper + session header +├─ src/api/generated.ts # OpenAPI types +├─ src/session/SessionProvider.tsx +├─ src/tabs/QueryTab.tsx +├─ src/tabs/DocumentsTab.tsx +├─ src/components/ScopeToggle.tsx +├─ src/components/Uploader.tsx +└─ src/lib/streamQuery.ts +``` + +## Required behavior + +- On first mount, mint session via `POST /sessions`; store id in localStorage. +- Disable Mine/Both scope until session has at least one indexed file. +- Sample prompts default to Global scope. +- Stream response tokens from `/query/stream`; fallback to non-streaming on failure. +- "Clear my session" triggers `DELETE /sessions/{id}` and remints a session id. +- Surface citation provenance as `[global]` and `[yours]`. + +## Tests + +- Vitest + RTL (`frontend/src/**/*.test.tsx`): + - Session mint/persist behavior + - Scope toggle enable/disable states + - Uploader cap and server-rejection UI + - Incremental stream rendering +- Playwright smoke: + - load -> upload -> scope Mine -> query -> citation from uploaded file +- Playwright negative: + - no upload keeps Mine/Both disabled + - rejected upload errors are clearly shown + +## Verification + +```bash +DOC_PROFILE=demo DOC_DEMO_UPLOADS=1 \ + uvicorn src.api.main:app --host 127.0.0.1 --port 8000 + +cd frontend && npm install && npm run dev +cd frontend && npm run test +cd frontend && npm run test:e2e +``` + +## Handoff (Exit Criteria) + +- Query + My Documents tabs are complete with session reset flow. +- Scope toggle, upload caps messaging, and TTL messaging are visible and correct. +- Streaming and fallback response paths are reliable. +- Unit + e2e tests pass locally and in CI. +- Citation source labeling enables user trust verification. + +## Transition to Phase 6.3 + +- Frontend builds reproducibly (`npm ci && npm run build`). +- API CORS includes intended frontend origins. +- No unresolved frontend/backend contract mismatches. diff --git a/Docs/Phase6.3-Container-Cutover-Implementation-Spec.md b/Docs/Phase6.3-Container-Cutover-Implementation-Spec.md new file mode 100644 index 0000000000000000000000000000000000000000..3abbf58d55572de617570a7aa5a6a7c0763db4af --- /dev/null +++ b/Docs/Phase6.3-Container-Cutover-Implementation-Spec.md @@ -0,0 +1,302 @@ +# Phase 6.3 Implementation Spec: Single-Container Deploy and HF Spaces Cutover + +Source plan: `Docs/Phase6.3-Container-Cutover-Plan.md` +Depends on: `Docs/Phase6.2-React-MVP-Plan.md` +Next phase: `Docs/Phase6.4-Streamlit-Decommission-Plan.md` + +## Objective + +Ship the React MVP and FastAPI API from one Docker container, then cut Hugging Face Spaces from the Streamlit SDK runtime to the Docker SDK runtime. + +The deployed container must: + +- Serve the built React SPA at `/`. +- Keep API endpoints reachable with their current contracts. +- Preserve the Streamlit rollback path until Phase 6.4. +- Continue to support demo session uploads, scoped retrieval, citations, and health checks. + +## Current State + +- `frontend/` already has npm scripts for `lint`, `typecheck`, `test`, `test:e2e`, and `build`. +- `.github/workflows/ci.yml` already contains a frontend job, but e2e execution should be reviewed because the Playwright config currently starts only the Vite dev server. +- `docker/Dockerfile` runs FastAPI on port `8000` and still exposes `8501`. +- `spaces/README.md` still declares `sdk: streamlit`, `sdk_version`, and `app_file: spaces/app.py`. +- `spaces/app.py` still starts FastAPI in a background thread and delegates to `src.web.streamlit_app`. +- `src/api/main.py` does not yet mount the React build output as static UI. + +## Non-Goals + +- Do not delete `src/web/streamlit_app.py`. +- Do not remove `streamlit` from `requirements/base.txt`. +- Do not remove the Streamlit service from `docker/docker-compose.yml`. +- Do not change the `/health`, `/metrics`, `/query`, or `/query/stream` API payload contracts. +- Do not introduce a second production web server such as nginx unless a concrete deployment issue requires it. + +## Implementation Sequence + +### 1. Confirm Phase 6.2 Readiness + +Before editing deployment files, verify the React app is buildable and API-compatible: + +```bash +cd frontend +npm ci +npm run lint +npm run typecheck +npm run test +npm run build +``` + +Expected result: + +- `frontend/dist/` is produced reproducibly. +- The frontend does not require a hard-coded `VITE_API_BASE_URL` when served from the same origin. +- Playwright tests can run against a backend URL that represents the deployment shape. + +If Playwright currently depends on a separate dev API, update the e2e setup in this phase so CI boots FastAPI in demo mode before running the browser tests. + +### 2. Update `docker/Dockerfile` + +Convert the Dockerfile to a multi-stage build. + +Recommended structure: + +1. `frontend-builder` stage based on `node:20-alpine`. +2. Python runtime stage based on the existing `python:3.11-slim`. +3. Copy `frontend/package.json` and `frontend/package-lock.json` before copying all frontend files so npm dependencies cache properly. +4. Run `npm ci` and `npm run build`. +5. Copy `frontend/dist` into the runtime image at `/app/static`. +6. Keep `PYTHONPATH=/app`, Hugging Face cache env vars, non-root `appuser`, and the existing FastAPI `CMD`. +7. Remove `EXPOSE 8501` from the final runtime image. + +Best practices: + +- Use `npm ci`, not `npm install`, in image builds. +- Keep dependency installation before source copies where practical for Docker cache reuse. +- Keep the final container single-process: uvicorn only. +- Keep Streamlit installed for rollback during Phase 6.3, but do not run it in the final container command. +- Preserve the existing `/health` Docker healthcheck. + +Acceptance checks: + +- `docker build -f docker/Dockerfile -t doc-ingest:demo .` succeeds from repo root. +- `docker run` starts uvicorn on port `8000`. +- `/app/static/index.html` exists in the image. +- No runtime process listens on port `8501` in the unified image. + +### 3. Mount React Static Assets in `src/api/main.py` + +Serve the SPA only after API routes have been registered. + +Implementation requirements: + +- Import `Path` and `StaticFiles`. +- Resolve the static directory relative to the deployed app, for example `/app/static` in Docker and `static/` from the repo root locally. +- Mount static assets only if the directory exists and contains `index.html`. +- Register all API routes before mounting the catch-all UI route. +- Ensure SPA fallback does not shadow `/health`, `/metrics`, `/query`, `/query/stream`, `/sessions`, `/observability/dashboard`, or OpenAPI docs. + +Recommended route strategy: + +- Keep existing routes at their current paths for backward compatibility. +- Add optional `/api` aliases only if the frontend needs them, but do not remove current top-level API paths. +- Mount `StaticFiles(directory=..., html=True)` at `/` after all current route decorators. + +Testing focus: + +- `GET /` returns the React app when `static/index.html` exists. +- `GET /assets/...` serves bundled frontend assets. +- Unknown browser routes fall back to the SPA. +- API routes continue to return JSON and do not return `index.html`. +- OpenAPI remains available at `/openapi.json`. + +### 4. Rework `spaces/app.py` for Docker Runtime + +In Docker SDK mode, HF Spaces will run the container command, so `spaces/app.py` no longer needs to launch Streamlit. + +Preferred implementation: + +- Keep `spaces/app.py` as a thin bootstrap utility only if it is still useful for local or HF startup. +- Move demo env defaults into the Docker runtime or a small bootstrap function used by the Docker entrypoint. +- Continue to set: + - `DOC_PROFILE=demo` + - `DOC_API_KEYS=demo-key` + - `DOC_EMBEDDING_PROVIDER=sentence_transformers` + - `DOC_DEMO_UPLOADS=1` + - `DOC_DEMO_SESSION_ROOT=/tmp/doc-ingest-sessions` + - `DOC_DEMO_MAX_FILES=3` + - `DOC_DEMO_MAX_FILE_MB=3` + - `DOC_DEMO_MAX_SESSION_MB=8` + - `DOC_DEMO_SESSION_TTL=1800` +- Ensure `spaces.bootstrap_demo.bootstrap_if_needed()` still runs before traffic depends on the sample corpus. + +Acceptable options: + +- Add an entrypoint script that runs bootstrap, then `exec uvicorn src.api.main:app --host 0.0.0.0 --port 8000 --workers 1`. +- Or keep Docker `CMD` as uvicorn and move bootstrap into FastAPI lifespan startup, guarded so it only runs in demo profile. + +Best practice: + +- Prefer `exec` in shell entrypoints so uvicorn receives container signals directly. +- Keep bootstrap idempotent. +- Do not start background API threads in Docker SDK mode. + +### 5. Update `spaces/README.md` + +Change Hugging Face Spaces metadata: + +```yaml +sdk: docker +app_port: 8000 +``` + +Remove: + +```yaml +sdk_version: "1.37.0" +app_file: spaces/app.py +``` + +Refresh user-facing text: + +- Describe the React + FastAPI demo. +- Mention session uploads are enabled in demo mode with the configured limits. +- Point users to the root URL on port `8000` for the UI. +- Keep provider/API-key limitations accurate for HF. + +### 6. Review `.github/workflows/ci.yml` + +The frontend job already exists. Review and adjust it so it reflects the deployment contract: + +- Keep `npm ci`, `npm run lint`, `npm run typecheck`, `npm run test`, and `npm run build`. +- Add a dedicated e2e job or e2e steps that start FastAPI in demo mode before Playwright runs. +- Use `DOC_PROFILE=demo`, `DOC_DEMO_UPLOADS=1`, and `DOC_EMBEDDING_PROVIDER=sentence_transformers` for e2e. +- Wait for `http://127.0.0.1:8000/health` before launching browser tests. +- Keep Python and Node caches scoped to the correct lockfiles. + +Recommended e2e smoke: + +```bash +PYTHONPATH=. DOC_PROFILE=demo DOC_DEMO_UPLOADS=1 \ + DOC_EMBEDDING_PROVIDER=sentence_transformers \ + uvicorn src.api.main:app --host 127.0.0.1 --port 8000 + +cd frontend +npm run test:e2e +``` + +### 7. Review `.github/workflows/sync-to-spaces.yml` + +Keep this workflow lean. Hugging Face should build the Docker image from the pushed repo. + +Implementation notes: + +- Update comments that still say HF uses `spaces/app.py` as the entry point. +- Do not add a prebuild unless HF Docker builds are too slow or unreliable. +- Keep the repo push behavior aligned with the current release process. +- Ensure `spaces/README.md` is included in the pushed content so HF detects Docker SDK metadata. + +## Local Verification + +Run these checks before opening a PR: + +```bash +PYTHONPATH=. python -m pytest tests/unit -q +PYTHONPATH=. python -m pytest tests/integration -q + +cd frontend +npm ci +npm run lint +npm run typecheck +npm run test +npm run build +cd .. + +docker build -f docker/Dockerfile -t doc-ingest:demo . +docker run --rm -p 8000:8000 \ + -e DOC_PROFILE=demo \ + -e DOC_DEMO_UPLOADS=1 \ + -e DOC_EMBEDDING_PROVIDER=sentence_transformers \ + doc-ingest:demo +``` + +Smoke checks while the container is running: + +```bash +curl -fsS http://127.0.0.1:8000/health +curl -fsS http://127.0.0.1:8000/metrics +curl -fsS http://127.0.0.1:8000/openapi.json +curl -fsS http://127.0.0.1:8000/ | head +``` + +Browser checks: + +- Open `http://127.0.0.1:8000`. +- Confirm the React UI loads without Vite. +- Create or reuse a demo session. +- Upload one small supported file. +- Query with `Mine` scope and confirm citation provenance. +- Query with `Global` scope and confirm existing sample corpus still works. +- Refresh the browser and confirm the session resumes or remints cleanly. + +## Hugging Face Spaces Verification + +Recommended cutover flow: + +1. Deploy to a fresh validation Space first, for example `doc-ingestion-demo-v2`. +2. Confirm the Space is using Docker SDK metadata. +3. Wait for Docker build completion. +4. Smoke-test: + - `/` + - `/health` + - `/metrics` + - `/openapi.json` + - `POST /sessions` + - document upload + - scoped query + - streaming query fallback behavior +5. Validate logs for bootstrap, model download, and session janitor errors. +6. Only then switch the public demo target. + +## Rollback Plan + +Rollback must remain available until Phase 6.4 is intentionally executed. + +Fast rollback: + +- Revert `spaces/README.md` to Streamlit SDK metadata: + - `sdk: streamlit` + - `sdk_version: "1.37.0"` + - `app_file: spaces/app.py` +- Restore the pre-cutover `spaces/app.py` behavior that starts FastAPI in a thread and delegates to Streamlit. +- Keep `src/web/streamlit_app.py` and `streamlit` dependency untouched during Phase 6.3. + +Container rollback: + +- Revert the Dockerfile to the previous Python-only image if the multi-stage build breaks HF. +- Keep the React app and backend changes in the branch if they are not the cause. + +Rollback validation: + +- HF Space boots in Streamlit SDK mode. +- Streamlit UI loads. +- `/health` is reachable from the background FastAPI server. +- Sample prompts still work. + +## Acceptance Criteria + +- One Docker image serves FastAPI and the built React SPA. +- `/`, static assets, and client-side browser routes work from the container. +- `/health`, `/metrics`, `/query`, `/query/stream`, `/sessions`, and `/openapi.json` keep expected behavior. +- HF Spaces runs the Docker SDK Space on `app_port: 8000`. +- CI validates backend tests, frontend checks, frontend build, and e2e smoke against a running FastAPI backend. +- Streamlit rollback is documented and tested. + +## Handoff to Phase 6.4 + +Do not start Phase 6.4 until: + +- React demo has soaked for at least one week in the Docker deployment. +- No unresolved severity 1 or severity 2 deployment/runtime defects remain. +- The team confirms Streamlit rollback is no longer needed. +- The rollback steps above were tested at least once during cutover. diff --git a/Docs/Phase6.3-Container-Cutover-Plan.md b/Docs/Phase6.3-Container-Cutover-Plan.md new file mode 100644 index 0000000000000000000000000000000000000000..f2306370f98136617c30a3259e5521b70d63040c --- /dev/null +++ b/Docs/Phase6.3-Container-Cutover-Plan.md @@ -0,0 +1,62 @@ +# Phase 6.3 Plan: Single-Container Deploy and HF Spaces Cutover + +Source of truth: `Docs/Phase6-RefactorDemo_React.md` (this file is an execution slice for iterative delivery). +Depends on: `Docs/Phase6.2-React-MVP-Plan.md` + +## Objective + +Deploy one container (FastAPI + built React SPA) to simplify delivery and align Hugging Face Spaces runtime with the new UI. + +## Scope + +Migrate from Streamlit SDK Space to Docker SDK Space with rollback path preserved. + +## Files to modify + +- `docker/Dockerfile` + - Multi-stage build: + - Node stage builds `frontend/dist` + - Python stage copies static assets to `/app/static` + - Final command runs uvicorn only. +- `src/api/main.py` + - Mount static UI when available. + - Keep API route behavior intact (`/health`, `/metrics`, `/query`, `/query/stream`). + - Ensure SPA fallback does not shadow API routes. +- `spaces/README.md` + - Switch to: + - `sdk: docker` + - `app_port: 8000` + - Remove `app_file` streamlit setting. +- `spaces/app.py` + - Repurpose as thin env bootstrap + uvicorn launcher, or remove if no longer needed. +- `.github/workflows/sync-to-spaces.yml` + - Keep CI lean; prefer relying on HF Docker build unless prebuild is required. +- `.github/workflows/ci.yml` + - Add frontend job (`lint`, `test`, `build`). + - Add e2e job booting API + running Playwright. + +## Verification + +```bash +docker build -f docker/Dockerfile -t doc-ingest:demo . +docker run --rm -p 8000:8000 \ + -e DOC_PROFILE=demo -e DOC_DEMO_UPLOADS=1 \ + -e DOC_EMBEDDING_PROVIDER=sentence_transformers \ + doc-ingest:demo +open http://127.0.0.1:8000 +``` + +Then push branch and validate HF Space after Docker rebuild. + +## Handoff (Exit Criteria) + +- Unified container runs locally and in HF with expected route behavior. +- Core API endpoints stay reachable and validated. +- Deployed smoke tests pass. +- Rollback path to pre-cutover setup is documented and tested. + +## Transition to Phase 6.4 + +- React demo has soaked for at least one week. +- No unresolved high-severity deployment/runtime defects. +- Team confirms Streamlit rollback is no longer needed. diff --git a/Docs/Phase6.4-Streamlit-Decommission-Implementation-Spec.md b/Docs/Phase6.4-Streamlit-Decommission-Implementation-Spec.md new file mode 100644 index 0000000000000000000000000000000000000000..b4cca888897ef5cdb5bf6f02322a26dc31ad7797 --- /dev/null +++ b/Docs/Phase6.4-Streamlit-Decommission-Implementation-Spec.md @@ -0,0 +1,384 @@ +# Phase 6.4 Implementation Spec: Streamlit Decommission + +Source plan: `Docs/Phase6.4-Streamlit-Decommission-Plan.md` +Depends on: `Docs/Phase6.3-Container-Cutover-Plan.md` +Optional phase: execute only after the Docker React deployment has stabilized. + +## Objective + +Remove the Streamlit runtime, legacy UI path, and Streamlit-only tests after the React + FastAPI Docker deployment is stable and rollback to Streamlit is no longer required. + +The final system should have one supported user interface: + +- React SPA served by FastAPI. +- FastAPI APIs for querying, session uploads, metrics, health, and sample prompts. +- No Streamlit dependency, process, compose service, or documentation path. + +## Entry Criteria + +Start this phase only when all are true: + +- Phase 6.3 has been deployed for at least one week. +- No unresolved severity 1 or severity 2 issues exist for the React + FastAPI Docker runtime. +- The team explicitly confirms Streamlit rollback is no longer needed. +- The Phase 6.3 rollback procedure has been tested and documented. +- A current branch or tag exists that can restore the Streamlit implementation if needed later. + +## Non-Goals + +- Do not change retrieval, reranking, citation, or provider behavior. +- Do not change session isolation semantics. +- Do not redesign the React UI. +- Do not remove shared ingestion helpers that are still used by API upload endpoints. +- Do not delete demo sample prompts; move them to an API-served shared source. + +## Implementation Sequence + +### 1. Inventory Streamlit References + +Find all active references before deleting anything: + +```bash +rg "streamlit|8501|src/web/streamlit_app|DOC_INGEST_API_URL|_DEMO_QUESTIONS" . +``` + +Classify each match: + +- Delete: Streamlit runtime, Streamlit command, Streamlit-only tests. +- Replace: documentation and quickstart references. +- Keep: generic `src/web` helper modules used by the API, such as `ingestion_service.py` and `session_corpus.py`. + +Expected Streamlit-specific items to remove or update: + +- `src/web/streamlit_app.py` +- `streamlit>=...` in `requirements/base.txt` +- `streamlit` service in `docker/docker-compose.yml` +- `tests/unit/test_streamlit_demo_routing.py` +- Streamlit SDK references in `README.md` and `spaces/README.md` +- Port `8501` references in docs and Docker metadata + +### 2. Move Sample Prompts to a Shared API Source + +The Streamlit app currently owns `_DEMO_QUESTIONS`. The React app currently has a shorter hard-coded prompt list in `frontend/src/components/SamplePromptChips.tsx`. + +Create a backend-owned shared source before deleting Streamlit. + +Recommended file: + +```text +src/api/sample_prompts.py +``` + +Recommended content shape: + +```python +SAMPLE_PROMPTS: tuple[str, ...] = ( + "What is Retrieval-Augmented Generation?", + "What are the two main phases of a RAG system?", + "How does hybrid retrieval work?", + "What is BM25 and how does it differ from vector search?", + "What are the weaknesses of BM25?", + "What is Reciprocal Rank Fusion (RRF)?", + "What is a vector database?", + "What is HNSW?", + "What is the difference between Chroma and Qdrant?", + "Why use hybrid retrieval instead of just dense vector search?", + "What failure mode does citation tracking help detect?", + "How are embeddings used in a RAG pipeline?", +) +``` + +Add an API endpoint in `src/api/main.py`: + +```text +GET /api/sample-prompts +``` + +Response contract: + +```json +{ + "prompts": [ + "What is Retrieval-Augmented Generation?" + ] +} +``` + +Best practices: + +- Keep the endpoint unauthenticated. It is static demo content. +- Register it before the SPA static mount. +- Keep response shape stable and explicit. +- If API models are used for typed responses, add a small Pydantic response model. +- Add the endpoint to frontend OpenAPI generation if the frontend consumes generated types. + +### 3. Update React Sample Prompt Consumption + +Replace the hard-coded prompt array in `frontend/src/components/SamplePromptChips.tsx` with API-backed data. + +Recommended approach: + +- Add `getSamplePrompts()` to `frontend/src/api/client.ts`. +- Use TanStack Query in either `SamplePromptChips` or the parent `QueryTab`. +- Render a small loading state or skeleton while prompts load. +- Provide a local fallback only for network failure, using the same canonical prompt text as the backend. Keep the fallback clearly secondary so backend remains the source of truth. + +Testing requirements: + +- Unit test that prompts returned by the API render as chips. +- Unit test that selecting a prompt still fills the query text and resets scope to Global if that behavior already exists. +- Unit test the failure fallback or empty-state UI. + +### 4. Delete Streamlit Runtime Code + +Delete: + +```text +src/web/streamlit_app.py +``` + +Keep: + +```text +src/web/ingestion_service.py +src/web/session_corpus.py +``` + +Reason: + +- `ingestion_service.py` and `session_corpus.py` are no longer UI code only; FastAPI session upload endpoints depend on them. +- The package name `src.web` can remain for now to avoid a broad refactor. A later cleanup may move these helpers into `src/api` or `src/services`. + +After deletion, run: + +```bash +rg "src.web.streamlit_app|streamlit_app|_DEMO_QUESTIONS" src tests frontend Docs README.md spaces +``` + +Expected result: + +- No runtime references remain. +- `_DEMO_QUESTIONS` has been replaced by `SAMPLE_PROMPTS`. + +### 5. Remove Streamlit Dependency + +Edit `requirements/base.txt`: + +- Remove `streamlit>=1.37.0`. +- Keep `requests`, `fastapi`, `python-multipart`, and `uvicorn` because the API still needs them. + +Validation: + +```bash +python -m pip install -r requirements/base.txt +PYTHONPATH=. python -m pytest tests/unit -q +``` + +Best practice: + +- If a lockfile is introduced later, regenerate it in the same change. +- Do not remove dependencies solely because they were imported by Streamlit unless no remaining module imports them. + +### 6. Simplify Docker Compose + +Edit `docker/docker-compose.yml`: + +- Remove the `streamlit` service. +- Remove port `8501`. +- Keep `api`, `redis`, `qdrant`, and shared volumes. +- Ensure the API service exposes the React UI through `8000`. +- Add demo env vars to the API service only if local compose should support demo uploads by default. + +Recommended local URL after this phase: + +```text +http://localhost:8000 +``` + +Compose validation: + +```bash +docker compose -f docker/docker-compose.yml up --build +curl -fsS http://127.0.0.1:8000/health +open http://127.0.0.1:8000 +``` + +### 7. Update Dockerfile and HF Files + +Review files touched in Phase 6.3: + +- `docker/Dockerfile` +- `spaces/README.md` +- `spaces/app.py` + +Required outcomes: + +- No `EXPOSE 8501`. +- No Streamlit command. +- No Streamlit SDK metadata. +- No docs claiming `spaces/app.py` is the Streamlit entrypoint. + +If `spaces/app.py` is no longer used: + +- Delete it only if HF Docker runtime and local workflows do not import it. +- Keep `spaces/bootstrap_demo.py` if the Docker startup path still uses it. + +If `spaces/app.py` is kept as a bootstrap helper: + +- Remove all Streamlit imports and comments. +- Keep only demo env defaults/bootstrap logic that is still called. + +### 8. Update Documentation + +Update `README.md`: + +- Replace Streamlit quickstart with React + FastAPI quickstart. +- Change Docker instructions to open `http://localhost:8000`. +- Update architecture bullets: + - `src/api/` serves FastAPI routes and the React SPA. + - `frontend/` contains the React app. + - `src/web/` should not be described as the UI layer if it remains only for helper modules. +- Remove screenshots or text that show the Streamlit sidebar. +- Add sample prompt endpoint reference if useful for frontend/API developers. + +Update `spaces/README.md`: + +- Confirm it describes Docker SDK and app port `8000`. +- Remove upload-disabled Streamlit limitations if Phase 6.1 uploads are enabled. +- Describe the supported upload caps and TTL. + +Update any runbooks or phase docs that still instruct users to run: + +```bash +streamlit run src/web/streamlit_app.py +``` + +Replace with: + +```bash +uvicorn src.api.main:app --host 127.0.0.1 --port 8000 +cd frontend && npm run dev +``` + +or, for unified container: + +```bash +docker build -f docker/Dockerfile -t doc-ingest:demo . +docker run --rm -p 8000:8000 doc-ingest:demo +``` + +### 9. Remove or Replace Streamlit Tests + +Delete: + +```text +tests/unit/test_streamlit_demo_routing.py +``` + +Add or extend tests so the removed behavior remains covered through API and React tests: + +- API test for `GET /api/sample-prompts`. +- API test that demo upload/session routes still work when `DOC_PROFILE=demo` and `DOC_DEMO_UPLOADS=1`. +- Frontend test that sample prompts render from API data. +- Frontend test that sample prompt selection populates the query. +- Playwright smoke that loads the unified UI and runs a global sample prompt. + +Important: + +- Do not reduce coverage for provider/model request passing, session scope, or citation provenance if those were previously asserted through Streamlit tests. +- Move assertions to API or frontend tests rather than deleting them outright. + +## Validation Checklist + +Run after implementation: + +```bash +rg "streamlit|8501|src/web/streamlit_app|_DEMO_QUESTIONS" . +``` + +Expected allowed matches: + +- Historical phase docs may mention Streamlit as completed/decommissioned context. +- No active runtime, dependency, compose, CI, or README quickstart references should remain. + +Backend: + +```bash +PYTHONPATH=. python -m pytest tests/unit -q +PYTHONPATH=. python -m pytest tests/integration -q +PYTHONPATH=. uvicorn src.api.main:app --host 127.0.0.1 --port 8000 +``` + +API smoke: + +```bash +curl -fsS http://127.0.0.1:8000/health +curl -fsS http://127.0.0.1:8000/api/sample-prompts +curl -fsS http://127.0.0.1:8000/ +``` + +Frontend: + +```bash +cd frontend +npm ci +npm run lint +npm run typecheck +npm run test +npm run build +npm run test:e2e +``` + +Docker: + +```bash +docker build -f docker/Dockerfile -t doc-ingest:demo . +docker run --rm -p 8000:8000 \ + -e DOC_PROFILE=demo \ + -e DOC_DEMO_UPLOADS=1 \ + -e DOC_EMBEDDING_PROVIDER=sentence_transformers \ + doc-ingest:demo +``` + +Manual smoke: + +- Open `http://127.0.0.1:8000`. +- Confirm the React UI loads. +- Confirm sample prompt chips load from the API. +- Run a global sample prompt. +- Upload one small supported file. +- Query with `Mine` scope and verify citation provenance. +- Clear the session and confirm a new session is minted. + +## Rollback Plan + +Rollback after this phase is no longer the normal operating path. If rollback is required, use the saved Phase 6.3 branch/tag. + +Emergency rollback steps: + +1. Restore `src/web/streamlit_app.py`. +2. Restore `streamlit` in `requirements/base.txt`. +3. Restore the `streamlit` service in `docker/docker-compose.yml`. +4. Restore Streamlit SDK metadata in `spaces/README.md` if rolling HF back to the old runtime. +5. Restore `spaces/app.py` Streamlit launcher behavior. +6. Re-run backend tests and a Streamlit smoke test. + +Because Phase 6.4 intentionally removes the rollback path, require team approval before merging it. + +## Acceptance Criteria + +- Streamlit runtime code is removed. +- `streamlit` dependency is removed. +- Docker Compose has no Streamlit service or `8501` port. +- HF and Docker docs describe only React + FastAPI on port `8000`. +- Sample prompts are served by `GET /api/sample-prompts` and consumed by the React UI. +- API, frontend, e2e, and Docker smoke checks pass. +- No active runtime or onboarding docs instruct users to run Streamlit. + +## Handoff + +After merge: + +- Mark Phase 6.4 complete in the phase index. +- Record the final React + FastAPI deployment URL and smoke-test date. +- Move any deferred cleanup, such as relocating `src/web/ingestion_service.py`, to the Phase 7 backlog. diff --git a/Docs/Phase6.4-Streamlit-Decommission-Plan.md b/Docs/Phase6.4-Streamlit-Decommission-Plan.md new file mode 100644 index 0000000000000000000000000000000000000000..e8bd533124f855f376370955b711b8ea1c0ed197 --- /dev/null +++ b/Docs/Phase6.4-Streamlit-Decommission-Plan.md @@ -0,0 +1,38 @@ +# Phase 6.4 Plan: Streamlit Decommission (Optional) + +Source of truth: `Docs/Phase6-RefactorDemo_React.md` (this file is an execution slice for iterative delivery). +Depends on: `Docs/Phase6.3-Container-Cutover-Plan.md` + +## Objective + +Remove Streamlit runtime and legacy paths after React + FastAPI deployment has stabilized. + +## Scope + +Run only after at least one week of stable production-like behavior from Phase 6.3. + +## Tasks + +- Delete `src/web/streamlit_app.py`. +- Remove `streamlit` from `requirements/base.txt`. +- Remove Streamlit container from `docker/docker-compose.yml`. +- Update `README.md` screenshots and quickstart docs. +- Remove `tests/unit/test_streamlit_demo_routing.py`. +- Keep sample prompts by serving them via API (`GET /api/sample-prompts`) as shared source of truth. + +## Verification + +- Confirm no imports/runtime references to Streamlit remain. +- Run backend/frontend test suites and smoke checks after cleanup. +- Confirm docs and onboarding instructions match new architecture. + +## Handoff (Exit Criteria) + +- Streamlit code/dependencies/tests are removed cleanly. +- Docs fully reflect React + FastAPI flow. +- Sample prompts are centrally served and consumed. + +## Transition to Next Program Increment + +- Phase 6 closes with 6.1-6.3 complete and 6.4 executed (or intentionally deferred). +- Deferred improvements move to Phase 7 backlog. diff --git a/Docs/phase5_observability.md b/Docs/phase5_observability.md new file mode 100644 index 0000000000000000000000000000000000000000..89f661422dce535eeffa4411c13dbd20d57300df --- /dev/null +++ b/Docs/phase5_observability.md @@ -0,0 +1,412 @@ +# Phase 5: Production Monitoring & Observability + +**Timeline:** 3 weeks +**Status:** Complete +**Owner:** Vamshi Pokala + +## Overview + +Phase 5 hardens the doc-ingestion RAG system for production through: + +1. **Distributed tracing** (LangFuse) for end-to-end pipeline visibility +2. **Latency profiling** (P50, P95, P99) per step +3. **Cost tracking** (USD per request) +4. **Real-time metrics dashboard** at `/observability/dashboard` +5. **Regression gating** (GitHub Actions) to prevent accuracy degradation on PRs +6. **Citation accuracy monitoring** (groundedness, coverage trends) + +## Architecture + +### Tracing Flow +``` +User Query + ↓ +[LangFuse Trace Start] + ↓ +Retrieval (BM25 + Vector) +[TRACE: latency, chunks retrieved, scores] + ↓ +Reranking (Cross-Encoder) +[TRACE: latency, input/output chunks] + ↓ +Generation (LLM) +[TRACE: latency, tokens, cost, provider] + ↓ +Citation Verification +[TRACE: latency, citations verified] + ↓ +[Flush to LangFuse] + ↓ +Response + Metrics Recorded +``` + +### Metrics Aggregation +``` +Per-Request Metrics (RequestMetrics) + ↓ +In-Memory Collector (1000 rolling window) + ↓ +Dashboard Endpoint (/observability/dashboard) + ↓ +JSON: P50/P95/P99 latencies, cost trends, quality scores +``` + +### Regression Gating +``` +PR Submitted + ↓ +GitHub Actions: Run evals on golden dataset + ↓ +Compare against baseline (main branch) + ↓ +Check: Latency increase <5%? Quality decrease <5%? + ↓ +If FAIL: Block PR + comment with regression details +If PASS: Allow merge +``` + +## Key Components + +### 1. Observability Module (`src/core/observability.py`) + +**Provides:** +- `RAGObserver` class with step-level tracing context managers +- LangFuse client integration +- No-op when disabled (useful for demo mode) +- Background-safe async flush + +**Usage:** +```python +observer = get_observer() + +# One trace per request, spans as children +with observer.trace_request("rag_query", query=query_text) as trace: + with observer.trace_step(trace, "retrieval") as s: + result = retriever.retrieve(query) + s["chunks_retrieved"] = len(result) + with observer.trace_step(trace, "generation", {"provider": provider}) as s: + answer = generator.generate(query, result) + +observer.flush_async() # non-blocking +``` + +### 2. Metrics Collector (`src/monitoring/metrics.py`) + +**Provides:** +- `MetricsCollector` for in-memory aggregation +- Percentile calculations (P50, P95, P99) +- Dashboard-friendly JSON aggregations +- Thread-safe recording + +**Metrics tracked:** +``` +Latency: +- total_latency_ms (P50, P95, P99) +- retrieval_avg_ms +- reranking_avg_ms +- generation_avg_ms +- citation_avg_ms +- Breakdown percentages + +Cost: +- total_usd (across all requests) +- avg_per_request_usd +- p95_per_request_usd + +Quality (online — no ground truth required): +- citation_groundedness_avg +- nli_faithfulness_avg +``` + +### 3. Regression Gate Script (`scripts/compare_evals.py`) + +**Compares:** +- Baseline metrics (main branch) +- Current metrics (PR branch) +- Threshold: 5% by default (configurable) + +**Fails if:** +- Latency increases >5% +- Quality decreases >5% +- Cost increases >5% + +### 4. Regression Gate in `.github/workflows/ci.yml` (extended `evals-golden` job) + +**On every PR:** +1. Runs offline evaluations against `evals/datasets/golden_ci.jsonl` +2. Compares against committed `evals/reports/baseline.json` +3. Blocks PR if regressions detected +4. Comments with regression details + +## Setup Instructions + +### Step 1: Set Environment Variables + +```bash +# For development with LangFuse +export LANGFUSE_PUBLIC_KEY=pk_... +export LANGFUSE_SECRET_KEY=sk_... + +# For testing (disabled) +export DOC_PROFILE=demo # Disables LangFuse +``` + +### Step 2: Install Dependencies + +```bash +# langfuse is in requirements/base.txt +pip install -r requirements/base.txt # Includes langfuse>=2.0.0 +``` + +### Step 3: Configure Baseline (One-Time, commit to repo) + +Already done! `evals/reports/baseline.json` is committed. + +To regenerate from main branch: +```bash +git checkout main +PYTHONPATH=. python -m evals.run_evals \ + --dataset evals/datasets/golden_ci.jsonl \ + --judge-provider anthropic \ + --judge-model claude-haiku-4-5 \ + --output evals/reports/baseline.json +git add evals/reports/baseline.json +git commit -m "chore: update Phase 5 eval baseline" +``` + +### Step 4: Query and Monitor + +```bash +# Start API with LangFuse enabled +export LANGFUSE_PUBLIC_KEY=pk_... LANGFUSE_SECRET_KEY=sk_... +PYTHONPATH=. uvicorn src.api.main:app --reload + +# In another terminal, query +curl -X POST http://localhost:8000/query \ + -H "Content-Type: application/json" \ + -d '{"query": "What is RAG?"}' + +# View dashboard +curl http://localhost:8000/observability/dashboard | jq . + +# Output: +# { +# "summary": { "total_requests": 1, ... }, +# "latency": { +# "total_p50_ms": 1247.3, +# "total_p95_ms": 1247.3, +# "breakdown_pct": { +# "retrieval": 18.2, +# "reranking": 12.1, +# "generation": 68.4, +# "citation": 1.3 +# } +# }, +# "cost": { "avg_per_request_usd": 0.00245 }, +# "quality": { +# "citation_groundedness_avg": 0.92, +# "nli_faithfulness_avg": 0.88 +# } +# } +``` + +## Testing + +### Unit Tests + +```bash +# Observability tests +pytest tests/unit/test_observability.py -v + +# Metrics tests +pytest tests/unit/test_metrics.py -v + +# Regression gate tests +pytest tests/unit/test_regression_gate.py -v +``` + +### Integration Test + +```bash +# Full E2E with tracing enabled +LANGFUSE_PUBLIC_KEY=pk_... LANGFUSE_SECRET_KEY=sk_... \ +PYTHONPATH=. python -c " +from src.api.main import app +from fastapi.testclient import TestClient + +client = TestClient(app) +response = client.post('/query', json={'query': 'What is RAG?'}) +print(response.json()) +# Should include request_id and all metrics +" +``` + +## Metrics Interpretation + +### Latency Breakdown Example +``` +Total P50: 1247.3 ms + +Breakdown: +- Retrieval: 227 ms (18.2%) ← BM25 + Vector Search +- Reranking: 151 ms (12.1%) ← Cross-Encoder Rerank +- Generation: 855 ms (68.4%) ← LLM inference +- Citation: 14 ms ( 1.3%) ← Citation Verification + +Interpretation: +Generation is the bottleneck (68.4% of total). +Could optimize by: +1. Using a faster model +2. Using streaming +3. Reducing context size +``` + +### Quality Metrics Example +``` +Citation Groundedness: 0.92 (92% of citations verified) +NLI Faithfulness: 0.88 (88% of answer supported by chunks) + +Interpretation: +- Citation coverage is strong (92%) +- Faithfulness could improve (88%) +- Consider reranking strategy improvements +``` + +### Cost Estimation Example +``` +Cost per Request: $0.00245 (avg) +Cost at P95: $0.00312 + +Annual projection (10K requests/day): +365 * 10K * $0.00245 = $8,927.50 + +Cost Optimization: +- Switch to cheaper model? +- Use batch inference? +- Cache common queries? +``` + +## Deployment Notes + +### Docker + +```dockerfile +# In docker/Dockerfile, ensure observability deps are included +# langfuse is in requirements/base.txt +RUN pip install -r requirements/base.txt + +# docker-compose sets env vars +environment: + - LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY} + - LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY} +``` + +### Streamlit (Demo Mode) + +```python +# In demo mode, observability is disabled +if os.getenv("DOC_PROFILE") == "demo": + observer = RAGObserver(enabled=False) # No-op +``` + +## Troubleshooting + +### LangFuse traces not appearing + +``` +1. Check credentials: LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY set? +2. Check network: Can you reach https://cloud.langfuse.com? +3. Check logs: Do you see "LangFuse observability enabled"? +4. Verify flush: observer.flush_async() called after each request? +``` + +### Dashboard metrics all zeros + +``` +1. Check MetricsCollector is receiving data: + python -c "from src.monitoring.metrics import get_metrics_collector; print(len(get_metrics_collector().metrics))" +2. Have you sent enough requests? (P95 needs at least 20 samples) +3. Is metrics_collector.record_request() being called in /query endpoint? +``` + +### Regression gate always failing + +``` +1. Baseline exists? evals/reports/baseline.json present? (committed to repo) + If not: already committed as part of Phase 5 +2. Threshold too strict? Default is 5%, try --threshold 10 +3. Eval dataset: correct file is evals/datasets/golden_ci.jsonl +4. Check eval logs for errors: see artifact evals/reports/pr-current.json +``` + +## Files Changed/Created + +### Week 1: Instrumentation +- ✅ `src/core/observability.py` (NEW) +- ✅ `tests/unit/test_observability.py` (NEW) +- ✅ `src/core/rag_orchestrator.py` (MODIFIED - added tracing) +- ✅ `src/api/main.py` (MODIFIED - minimal changes) +- ✅ `requirements/base.txt` (MODIFIED - added langfuse) + +### Week 2: Metrics Dashboard +- ✅ `src/monitoring/metrics.py` (NEW) +- ✅ `tests/unit/test_metrics.py` (NEW) +- ✅ `src/api/main.py` (MODIFIED - added metrics recording and dashboard endpoint) +- ✅ `src/utils/log.py` (MODIFIED - replaced MetricsCollector) + +### Week 3: Regression Gating +- ✅ `scripts/compare_evals.py` (NEW) +- ✅ `tests/unit/test_regression_gate.py` (NEW) +- ✅ `.github/workflows/ci.yml` (MODIFIED - extended evals-golden job) +- ✅ `evals/reports/baseline.json` (NEW - committed baseline) + +## Next Steps (Post-Phase 5) + +- [ ] Grafana dashboard integration for long-term trends +- [ ] Alert thresholds (PagerDuty for latency spikes) +- [ ] Cost attribution per LLM provider +- [ ] A/B testing framework (compare models, prompts) +- [ ] User feedback loop (thumbs up/down on answers) +- [ ] Fine-tuning based on eval failures + +## Interview Stories + +### "How do you ensure production RAG reliability?" + +> At Marriott, we deployed an agent handling 10K+ guest queries daily. Without observability, we'd have no idea if accuracy was degrading. I instrumented the pipeline with LangFuse tracing to see every step: retrieval latency, reranking precision, generation tokens, citation accuracy. Now I have a dashboard showing P50/P95 latency breakdown, cost per request, and quality metrics. And I wired up regression gating so no code change ships unless it passes a golden dataset evaluation. This is how you build trust in production AI systems. + +### "How would you scale an AI platform?" + +> Observability is first-class, not an afterthought. The moment you deploy, you need distributed tracing to answer: Where's the bottleneck? Is generation or retrieval slowing us down? What's the cost per request? How are quality metrics trending? I built this with LangFuse + a metrics collector, so we can see the full stack at P50/P95. Then I added regression gating in CI/CD to prevent accuracy regressions from ever shipping. + +### "Describe your observability architecture" + +> Every RAG pipeline step is traced to LangFuse: retrieval, reranking, generation, citation verification. We compute P50/P95/P99 latencies per step and expose them on a dashboard. We also track cost per request and quality metrics (citation groundedness, NLI faithfulness). In CI/CD, we compare PR eval results against a baseline — if latency increases >5% or quality decreases >5%, the PR is blocked with a detailed comment. This gives us real-time visibility and prevents regressions. + +## Approval Checklist + +- [x] Week 1: LangFuse integration with correct span hierarchy (one trace/request, spans as children) +- [x] Week 1: Instrumentation in `RAGOrchestrator.run()`, not `main.py` +- [x] Week 1: `flush_async()` used everywhere (no synchronous flush in request path) +- [x] Week 2: `MetricsCollector` in `src/monitoring/metrics.py` (new one, old one updated for compatibility) +- [x] Week 2: `RequestMetrics` has no `mrr`/`ndcg` fields +- [x] Week 3: Regression comparison added to existing `evals-golden` job in `ci.yml` +- [x] Week 3: `evals/reports/baseline.json` committed to repo +- [x] Tests: All unit tests passing +- [x] Integration: E2E query with tracing + metrics recording +- [x] Interview ready: Stories prepared + +## Timeline Summary + +| Week | Deliverable | Status | +|------|-------------|--------| +| 1 | LangFuse tracing | ✅ Complete | +| 2 | Metrics + dashboard | ✅ Complete | +| 3 | Regression gating + docs | ✅ Complete | + +**Total effort:** ~40-50 hours over 3 weeks + +--- + +**Generated:** 2026-05-01 +**Last Updated:** 2026-05-01 diff --git a/README.md b/README.md index b870a52163c798324fbd525c2d9c03db36cc8ba3..b78fa26ea6a81a815b5ef5e6e560eeb35c032fcc 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,15 @@ title: Doc Ingestion RAG Demo emoji: 📚 colorFrom: blue colorTo: indigo -sdk: streamlit -sdk_version: "1.37.0" -app_file: spaces/app.py +sdk: docker +app_port: 8000 pinned: false license: mit --- # Doc-Ingestion -Doc-Ingestion is a citation-aware RAG system that turns private document collections into grounded question-answering experiences. It demonstrates how to ingest documents, retrieve the right evidence, generate answers from that evidence, and return citations plus truthfulness signals through a Streamlit app, FastAPI service, and CLI. +Doc-Ingestion is a citation-aware RAG system that turns private document collections into grounded question-answering experiences. It demonstrates how to ingest documents, retrieve the right evidence, generate answers from that evidence, and return citations plus truthfulness signals through a React UI (served by FastAPI), standalone FastAPI, optional Streamlit legacy UI, and CLI. > **[Try the live demo on Hugging Face Spaces](https://huggingface.co/spaces/vampokala/doc-ingestion)** - no install required. @@ -154,7 +153,7 @@ In hosted demo mode (`DOC_PROFILE=demo`), Streamlit executes queries in-process ### Try Online -Open the [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion). Sample documents about RAG, vector databases, and BM25 are preloaded. Paste your OpenAI, Anthropic, or Gemini key in the sidebar if you want to use a cloud provider. +Open the [Hugging Face Spaces demo](https://huggingface.co/spaces/vampokala/doc-ingestion). Sample documents about RAG, vector databases, and BM25 are preloaded. Paste your OpenAI, Anthropic, or Gemini key in the app if you want to use a cloud provider. ### Run Locally With Docker @@ -166,7 +165,7 @@ cp docker/.env.example docker/.env docker compose -f docker/docker-compose.yml up ``` -Open `http://localhost:8501` for Streamlit or `http://localhost:8000` for the API. +Open `http://localhost:8000` for the React UI and API (single container image). ### Run From Source @@ -193,6 +192,28 @@ PYTHONPATH=. python -m src.query "What is RAG?" For a full local and Docker runbook, see [`Docs/RUNBOOK.md`](Docs/RUNBOOK.md). +## Ollama and Hugging Face Spaces + +**`SPACE_ID` is not a file in this repository.** It is a **runtime environment variable** that [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces-overview) sets inside the Space container (for example `your-username/your-space-name`). Doc-Ingestion reads it from the process environment in [`src/utils/config.py`](src/utils/config.py) when `load_config("config.yaml")` runs. Static LLM provider and model lists still live in [`config.yaml`](config.yaml); Ollama is only removed from the **effective** config when Space detection says it should be. + +If you **clone this repo and run it locally** (source or Docker on your machine), **Hugging Face does not set `SPACE_ID`**. The Ollama provider therefore stays in the default LLM list from `config.yaml`, and you can use it after starting the [Ollama](https://ollama.com) daemon and pulling the chat and embedding models described in [`Docs/RUNBOOK.md`](Docs/RUNBOOK.md). + +On **Hugging Face Spaces**, the platform **injects `SPACE_ID`** (for example `your-username/your-space-name`). Doc-Ingestion reads that at startup and **removes Ollama** from allowed providers and from `GET /config/llm`, because there is no local Ollama service in the hosted container. Hosted demos use OpenAI, Anthropic, or Gemini with keys you supply in the UI or environment. + +| Where you run | `SPACE_ID` | Ollama in the app | +|---------------|------------|-------------------| +| Your laptop or your own server / Docker | Not set by default | Yes (per `config.yaml`) | +| Hugging Face Space | Set automatically by HF | No (automatic) | + +**Do not define `SPACE_ID` yourself** for local deployment. It exists so the app can tell it is running inside a Space. If you copied Space-style environment variables into a local `.env` and Ollama disappeared from the UI, remove `SPACE_ID` or set **`DOC_OLLAMA_ENABLED=1`** to force Ollama back on. + +**Explicit override (optional):** + +- `DOC_OLLAMA_ENABLED=0` — hide Ollama even when `SPACE_ID` is unset (useful if you want cloud-only in your own container). +- `DOC_OLLAMA_ENABLED=1` — show Ollama even when `SPACE_ID` is set (rare; only if you had a sidecar Ollama and extended the image yourself). + +Implementation: [`src/utils/config.py`](src/utils/config.py) (`doc_ollama_runtime_enabled`, applied inside `load_config`). + ## API Usage ```bash @@ -293,6 +314,11 @@ export GEMINI_API_KEY=... export DOC_API_KEYS=dev-key-1 ``` +Deployment-related environment variables (not stored in `config.yaml`; see [Ollama and Hugging Face Spaces](#ollama-and-hugging-face-spaces) above): + +- **`SPACE_ID`** — injected on Hugging Face Spaces only. You do not add this to a local config file for normal development. +- **`DOC_OLLAMA_ENABLED`** — optional explicit override: `0` / `false` to hide Ollama, `1` / `true` to show it even when `SPACE_ID` is set. + ## Troubleshooting - **Empty results after ingest:** Run `python -m src.ingest --docs data/documents` and verify `data/embeddings/` exists. @@ -300,3 +326,4 @@ export DOC_API_KEYS=dev-key-1 - **Dimension mismatch after model change:** Re-ingest all documents to rebuild the vector index. - **Cloud provider fails:** Check the relevant `*_API_KEY` env var is set. - **Truthfulness score always 0:** The NLI model (`cross-encoder/nli-deberta-v3-small`) downloads on first use. Check internet access or set `evaluation.inline_enabled: false` in `config.yaml` to disable. +- **Ollama missing from the UI or `/config/llm` locally:** You may have `SPACE_ID` or `DOC_OLLAMA_ENABLED=0` in your shell or `docker/.env`. Unset `SPACE_ID` for local runs, or set `DOC_OLLAMA_ENABLED=1`. There is no separate `SPACE_ID` configuration file in the repo—only environment variables and [`config.yaml`](config.yaml). diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 9972e3dfa6e5f8dd34aba0449e2db1924b5928e0..0000000000000000000000000000000000000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,44 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system deps needed by python-magic and runtime health checks. -RUN apt-get update && apt-get install -y --no-install-recommends \ - libmagic1 \ - curl \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -COPY requirements/base.txt requirements/base.txt -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements/base.txt - -COPY src/ src/ -COPY scripts/ scripts/ -COPY tests/ tests/ -COPY config.yaml config.yaml -COPY README.md README.md -COPY Docs/ Docs/ - -ENV ENV=prod -ENV PYTHONUNBUFFERED=1 -ENV PYTHONPATH=/app -ENV OLLAMA_BASE_URL=http://host.docker.internal:11434 -ENV HF_HOME=/app/.cache/huggingface -ENV TRANSFORMERS_CACHE=/app/.cache/huggingface/transformers -ENV SENTENCE_TRANSFORMERS_HOME=/app/.cache/huggingface/sentence_transformers - -# Preload reranker model at build time to avoid runtime downloads. -RUN python -c "from sentence_transformers import CrossEncoder; CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')" - -EXPOSE 8000 -EXPOSE 8501 - -# Use non-root runtime user. -RUN useradd -m appuser && mkdir -p /app/.cache/huggingface && chown -R appuser:appuser /app -USER appuser - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD curl -fsS http://127.0.0.1:8000/health || exit 1 - -CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 120000 index 0000000000000000000000000000000000000000..395595cf0f3ca5844986c1380d7693eb6e31462b --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1 @@ +../Dockerfile \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 14bb196574f39f1e79392d317da95594e872f795..cd17944a7815de1da1ec2d10b2492c1c8f8fd253 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,11 +1,16 @@ +# Full stack (React + FastAPI) in one container on :8000. Streamlit is not part of this stack; +# run it locally only if needed: PYTHONPATH=. streamlit run src/web/streamlit_app.py services: api: build: context: .. - dockerfile: docker/Dockerfile + dockerfile: Dockerfile container_name: doc_ingestion_api environment: - ENV=dev + # React demo session + uploads (override for hardened deploys). + - DOC_PROFILE=${DOC_PROFILE:-demo} + - DOC_DEMO_UPLOADS=${DOC_DEMO_UPLOADS:-1} - DOC_API_KEYS=${DOC_API_KEYS:-change-me} - OPENAI_API_KEY=${OPENAI_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} @@ -17,6 +22,7 @@ services: - SENTENCE_TRANSFORMERS_HOME=/app/.cache/huggingface/sentence_transformers - HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-0} - TRANSFORMERS_OFFLINE=${TRANSFORMERS_OFFLINE:-0} + - PORT=8000 volumes: - ../data:/app/data - ../config.yaml:/app/config.yaml @@ -27,33 +33,6 @@ services: - qdrant - redis - streamlit: - build: - context: .. - dockerfile: docker/Dockerfile - container_name: doc_ingestion_streamlit - command: ["streamlit", "run", "src/web/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"] - environment: - - DOC_INGEST_API_URL=http://api:8000 - - DOC_API_KEY=${DOC_API_KEY:-change-me} - - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://host.docker.internal:11434} - - OPENAI_API_KEY=${OPENAI_API_KEY:-} - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - GEMINI_API_KEY=${GEMINI_API_KEY:-} - - HF_HOME=/app/.cache/huggingface - - TRANSFORMERS_CACHE=/app/.cache/huggingface/transformers - - SENTENCE_TRANSFORMERS_HOME=/app/.cache/huggingface/sentence_transformers - - HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-0} - - TRANSFORMERS_OFFLINE=${TRANSFORMERS_OFFLINE:-0} - volumes: - - ../data:/app/data - - ../config.yaml:/app/config.yaml - - hf_cache:/app/.cache/huggingface - ports: - - "8501:8501" - depends_on: - - api - redis: image: redis:7-alpine container_name: doc_ingestion_redis diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..ade41832307d0abe4bfe72330224b002c1a32a44 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +test-results +playwright-report +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..7dbf7ebf3b2a3d84ad526bc47810d1d211331b8b --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000000000000000000000000000000000000..eed27f3952267d04a63cffa7e84bda3626349a53 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/frontend/e2e/fixtures/uploaded-doc.md b/frontend/e2e/fixtures/uploaded-doc.md new file mode 100644 index 0000000000000000000000000000000000000000..b12f113e9a8ddaf0e72aa9fe1cbc715f016ec6f7 --- /dev/null +++ b/frontend/e2e/fixtures/uploaded-doc.md @@ -0,0 +1,3 @@ +# Uploaded Test Document + +This uploaded document says hello from a private session. diff --git a/frontend/e2e/react-mvp.spec.ts b/frontend/e2e/react-mvp.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a9a2c4be21aff5386753c34e9d0bd6cc33b8e48 --- /dev/null +++ b/frontend/e2e/react-mvp.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, type Page } from '@playwright/test' + +async function mockLlmConfig(page: Page) { + await page.route('http://127.0.0.1:8000/config/llm', async (route) => { + await route.fulfill({ + json: { + default_provider: 'ollama', + default_model_by_provider: { + ollama: 'qwen2.5:7b', + openai: 'gpt-4o-mini', + }, + allowed_models_by_provider: { + ollama: ['qwen2.5:7b'], + openai: ['gpt-4o-mini'], + }, + }, + }) + }) +} + +test('no uploads keeps Mine and Both disabled', async ({ page }) => { + await mockLlmConfig(page) + await page.route('http://127.0.0.1:8000/sessions', async (route) => { + await route.fulfill({ + json: { + session_id: 'abc123demo', + expires_at: Math.floor(Date.now() / 1000) + 1800, + files: [], + total_bytes: 0, + max_session_bytes: 8388608, + max_files: 3, + }, + }) + }) + await page.route('http://127.0.0.1:8000/sessions/abc123demo', async (route) => { + await route.fulfill({ + json: { + session_id: 'abc123demo', + expires_at: Math.floor(Date.now() / 1000) + 1800, + files: [], + total_bytes: 0, + max_session_bytes: 8388608, + max_files: 3, + }, + }) + }) + + await page.goto('/') + await page.getByRole('tab', { name: 'Query' }).click() + await expect(page.getByRole('radio', { name: /my uploads only/i })).toBeDisabled() + await expect(page.getByRole('radio', { name: /both/i })).toBeDisabled() +}) + +test('query streams an answer', async ({ page }) => { + await mockLlmConfig(page) + await page.route('http://127.0.0.1:8000/sessions', async (route) => { + await route.fulfill({ + json: { + session_id: 'abc123demo', + expires_at: Math.floor(Date.now() / 1000) + 1800, + files: [], + total_bytes: 0, + max_session_bytes: 8388608, + max_files: 3, + }, + }) + }) + await page.route('http://127.0.0.1:8000/query/stream', async (route) => { + await route.fulfill({ + contentType: 'text/event-stream', + body: 'data: {"type":"token","text":"Hello from stream"}\n\ndata: {"type":"final","citations":[],"provider":"ollama","model":"llama3"}\n\ndata: [DONE]\n\n', + }) + }) + + await page.goto('/') + await page.getByRole('tab', { name: 'Query' }).click() + await page.getByRole('textbox', { name: /question/i }).fill('What is RAG?') + await page.getByRole('button', { name: 'Run' }).click() + await expect(page.getByText('Hello from stream')).toBeVisible() +}) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ef614d25c11dd1e89a8df0b2eaf934170b44daa8 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..9d2524222dff03c23848412618bca119a9399175 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + frontend + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..d0aec1344ce02999214cd9beeccfc58c71294c68 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6143 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@tanstack/react-query": "^5.100.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.12.2", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "jsdom": "^29.1.1", + "msw": "^2.14.2", + "openapi-typescript": "^7.13.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10", + "vitest": "^4.1.5" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.8", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.8.tgz", + "integrity": "sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.14", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.14.tgz", + "integrity": "sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.8.tgz", + "integrity": "sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.8.tgz", + "integrity": "sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", + "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/type-utils": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz", + "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz", + "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.1", + "@typescript-eslint/types": "^8.59.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz", + "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz", + "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz", + "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz", + "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz", + "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.1", + "@typescript-eslint/tsconfig-utils": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/visitor-keys": "8.59.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz", + "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.1", + "@typescript-eslint/types": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", + "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.2.tgz", + "integrity": "sha512-D2bTe0tpuf9nw4DA39wFaqUD/hRPKj0DKpo2lAqu+A47Ifg4+h0hbfn6QxVOsiUY2uhgEN6TTpGSHDsc+ysYNg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.7", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rettime": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz", + "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz", + "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.1", + "@typescript-eslint/parser": "8.59.1", + "@typescript-eslint/typescript-estree": "8.59.1", + "@typescript-eslint/utils": "8.59.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..10ce3663355382044180c92463ef11dbb18ea6a5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,55 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5173", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview --host 127.0.0.1 --port 4173", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "typecheck": "tsc --noEmit", + "gen:api": "openapi-typescript http://127.0.0.1:8000/openapi.json -o src/api/generated.ts" + }, + "dependencies": { + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@tanstack/react-query": "^5.100.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.14.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^3.5.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.2.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.12.2", + "@types/react": "^18.3.28", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", + "jsdom": "^29.1.1", + "msw": "^2.14.2", + "openapi-typescript": "^7.13.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.59.1", + "vite": "^8.0.10", + "vitest": "^4.1.5" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..13ed8dac153b03f9f355611ea1e4f672de1fe25a --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: 'http://127.0.0.1:5173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..6893eb13237060adc0c968a690149a49faa2d7d3 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..e9522193d9f796a9748e9ad8c952a5df73c87db9 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b9d48a54ffcdff8665b7276b2a80326995cb72e --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,128 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AlertCircle, BookOpen, Database, FileText, Fingerprint } from 'lucide-react' +import { useMemo } from 'react' +import { QueryTab } from './tabs/QueryTab' +import { OverviewTab } from './tabs/OverviewTab' +import { DocumentsTab } from './tabs/DocumentsTab' +import { SessionProvider } from './session/SessionProvider' +import { useSession } from './session/SessionContext' +import { formatTtl, shortSessionId } from './lib/format' + +function Shell() { + const { sessionId, expiresAt, error, retrySession, isMintingSession, isLoading, clearSession } = + useSession() + + return ( +
+
+
+
+
+

Doc Ingestion

+

Document Q&A Assistant

+

+ Ask citation-aware questions against the global demo corpus, your private uploads, or both. +

+
+
+
+
Session {isMintingSession ? 'creating…' : shortSessionId(sessionId)}
+
TTL {formatTtl(expiresAt)}
+
+ +

+ Fresh ID for uploads in this browser. Replaces any current demo session (including uploads on + the server). +

+
+
+
+ Your uploads stay in this browser session, expire after inactivity, and are not added to the + shared corpus. +
+ {error ? ( +
+ + + +
+ ) : null} +
+ + + + + + + + + + + + + + + + + + + + +
+
+ ) +} + +function App() { + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, + }), + [], + ) + + return ( + + + + + + ) +} + +export default App diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ce6b1d2ae930bba62af4c9c6d285e0750168415 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,182 @@ +import type { LlmConfigModel, QueryRequestModel, QueryResponseModel } from './generated' + +function resolveApiBaseUrl(): string { + const raw = import.meta.env.VITE_API_BASE_URL + if (typeof raw === 'string' && raw.trim() !== '') { + return raw.trim().replace(/\/$/, '') + } + if (!import.meta.env.PROD) { + // Vitest + MSW use absolute handlers on http://127.0.0.1:8000. + if (import.meta.env.VITEST) { + return 'http://127.0.0.1:8000' + } + // npm run dev: empty base → same-origin; vite.config.ts proxies to FastAPI. + return '' + } + // Production bundle: same-origin when UI is served by FastAPI (typical Docker) on :8000. + if (typeof window !== 'undefined') { + const port = window.location.port + const sameOriginAsApi = + port === '8000' || port === '' || port === '80' || port === '443' + if (sameOriginAsApi) { + return '' + } + const { protocol, hostname } = window.location + const host = hostname || '127.0.0.1' + return `${protocol}//${host}:8000`.replace(/\/$/, '') + } + return '' +} + +const API_BASE_URL = resolveApiBaseUrl() + +export interface SessionFile { + name: string + size_bytes: number +} + +export interface CreateSessionResponse { + session_id: string + expires_at: number +} + +export interface DeleteSessionResponse { + deleted_session_id: string + session_id: string +} + +export interface SessionSummary extends CreateSessionResponse { + files: SessionFile[] + total_bytes: number + max_session_bytes: number + max_files: number +} + +export interface UploadResult { + filename: string + status: 'queued' | 'skipped' | 'rejected' | 'failed' | string + message: string +} + +export interface UploadDocumentsResponse extends SessionSummary { + results: UploadResult[] +} + +export class ApiError extends Error { + readonly status: number + readonly detail: unknown + + constructor( + message: string, + status: number, + detail: unknown, + ) { + super(message) + this.name = 'ApiError' + this.status = status + this.detail = detail + } +} + +function apiUrl(path: string) { + const suffix = path.startsWith('/') ? path : `/${path}` + return API_BASE_URL ? `${API_BASE_URL}${suffix}` : suffix +} + +function readApiKey() { + return localStorage.getItem('doc-ingestion.api-key') ?? '' +} + +async function parseError(response: Response) { + let detail: unknown + try { + detail = await response.json() + } catch { + detail = await response.text() + } + const message = + typeof detail === 'object' && detail !== null && 'detail' in detail + ? String((detail as { detail: unknown }).detail) + : `Request failed with status ${response.status}` + return new ApiError(message, response.status, detail) +} + +function networkErrorHint(): string { + const target = + API_BASE_URL || + (typeof window !== 'undefined' ? `${window.location.origin} (vite → API)` : 'the API') + const connectivity = + API_BASE_URL === '' && typeof import.meta.env !== 'undefined' && import.meta.env.DEV + ? 'Start uvicorn on the proxy target (default http://127.0.0.1:8000) while npm run dev is running, ' + + 'set VITE_DEV_API_PROXY_TARGET if the API is elsewhere, ' + + 'or set VITE_API_BASE_URL to bypass the proxy. ' + : 'Start the API (e.g. uvicorn on port 8000), or set VITE_API_BASE_URL at build time. ' + return ( + `Cannot reach ${target}. ${connectivity}` + + `Session features need DOC_PROFILE=demo and DOC_DEMO_UPLOADS=1 on the server.` + ) +} + +/** Thrown when `fetch` fails before a response (offline, wrong host/port, CORS, etc.). */ +export function networkFailureError(cause?: unknown): ApiError { + return new ApiError(networkErrorHint(), 0, cause) +} + +async function requestJson(path: string, init: RequestInit = {}): Promise { + const apiKey = readApiKey() + const headers = new Headers(init.headers) + if (!(init.body instanceof FormData)) { + headers.set('Content-Type', 'application/json') + } + if (apiKey) { + headers.set('X-API-Key', apiKey) + } + let response: Response + try { + response = await fetch(apiUrl(path), { ...init, headers }) + } catch (cause) { + throw networkFailureError(cause) + } + if (!response.ok) { + const err = await parseError(response) + if (response.status === 404 && path.startsWith('/sessions')) { + err.message = `${err.message} If the API is up, enable demo sessions: DOC_PROFILE=demo and DOC_DEMO_UPLOADS=1.` + } + throw err + } + return response.json() as Promise +} + +export function createSession() { + return requestJson('/sessions', { method: 'POST' }) +} + +export function getSession(sessionId: string) { + return requestJson(`/sessions/${sessionId}`) +} + +export function deleteSession(sessionId: string) { + return requestJson(`/sessions/${sessionId}`, { method: 'DELETE' }) +} + +export function uploadDocuments(sessionId: string, files: File[]) { + const formData = new FormData() + files.forEach((file) => formData.append('files', file)) + return requestJson(`/sessions/${sessionId}/documents`, { + method: 'POST', + body: formData, + }) +} + +export function queryDocuments(request: QueryRequestModel) { + return requestJson('/query', { + method: 'POST', + body: JSON.stringify(request), + }) +} + +export function fetchLlmConfig() { + return requestJson('/config/llm') +} + +export { API_BASE_URL } diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts new file mode 100644 index 0000000000000000000000000000000000000000..df5c4094573eb4eac1c37b6db6f0ddad86957925 --- /dev/null +++ b/frontend/src/api/generated.ts @@ -0,0 +1,71 @@ +export type KnowledgeScope = 'global' | 'session' | 'both' + +export interface QueryRequestModel { + query: string + top_k?: number + use_llm?: boolean + use_rerank?: boolean + stream?: boolean + include_citations?: boolean + provider?: string | null + model?: string | null + reranker_model?: string | null + provider_api_key?: string | null + session_id?: string | null + knowledge_scope?: KnowledgeScope +} + +export interface CitationModel { + raw_id: string + chunk_id: string + resolved: boolean + title?: string | null + source?: string | null + verification_score: number + verification: string +} + +export interface RetrievedChunkModel { + id: string + score: number + source: string + confidence: number + metadata: Record + preview: string +} + +export interface TruthfulnessModel { + nli_faithfulness: number + citation_groundedness: number + uncited_claims: number + score: number +} + +export interface QueryResponseModel { + query: string + provider: string + model: string + answer: string + processing_time_ms: number + cached: boolean + validation_issues: string[] + citations: CitationModel[] + retrieved: RetrievedChunkModel[] + truthfulness?: TruthfulnessModel | null +} + +export interface HealthModel { + status: string + collection: string +} + +export interface MetricsModel { + cache_ttl_seconds: number + available_providers: string[] +} + +export interface LlmConfigModel { + default_provider: string + default_model_by_provider: Record + allowed_models_by_provider: Record +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..5101b674df391399da71c767aa5c976426c9dc7a --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AnswerPanel.tsx b/frontend/src/components/AnswerPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d3e579cdb911578177b38d715e04cd37a56ab407 --- /dev/null +++ b/frontend/src/components/AnswerPanel.tsx @@ -0,0 +1,35 @@ +import type { QueryResponseModel } from '../api/generated' + +export function AnswerPanel({ + answer, + response, + isLoading, +}: { + answer: string + response: QueryResponseModel | null + isLoading: boolean +}) { + const truthfulness = response?.truthfulness + return ( +
+
+

Answer

+ {truthfulness ? ( + + Truthfulness {truthfulness.score.toFixed(2)} + + ) : null} +
+
+ {answer || (isLoading ? 'Waiting for tokens...' : 'Ask a question to see a grounded answer.')} +
+ {response ? ( +
+ {response.provider} / {response.model} + {Math.round(response.processing_time_ms)} ms + {response.cached ? Cached : null} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/CitationsList.tsx b/frontend/src/components/CitationsList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a3300f6a9ff1242ccabfb3937a010fd3dacc75cb --- /dev/null +++ b/frontend/src/components/CitationsList.tsx @@ -0,0 +1,41 @@ +import type { SessionFile } from '../api/client' +import type { CitationModel } from '../api/generated' +import { citationLabel } from '../lib/citationProvenance' + +export function CitationsList({ + citations, + sessionFiles, +}: { + citations: CitationModel[] + sessionFiles: SessionFile[] +}) { + return ( +
+

Citations

+ {citations.length === 0 ? ( +

No citations returned yet.

+ ) : ( +
    + {citations.map((citation) => { + const label = citationLabel(citation, sessionFiles) + return ( +
  • +
    + + [{label === 'yours' ? 'yours' : 'global'}] + + + {citation.title || citation.source || citation.chunk_id} + +
    +

    + {citation.verification} · score {citation.verification_score.toFixed(2)} +

    +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/RetrievedChunks.tsx b/frontend/src/components/RetrievedChunks.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d0acf23412f218713eae3e6d2b2b3093ce04cf2 --- /dev/null +++ b/frontend/src/components/RetrievedChunks.tsx @@ -0,0 +1,26 @@ +import type { RetrievedChunkModel } from '../api/generated' + +export function RetrievedChunks({ chunks }: { chunks: RetrievedChunkModel[] }) { + return ( +
+ + Retrieved chunks ({chunks.length}) + + {chunks.length === 0 ? ( +

No retrieved chunks returned yet.

+ ) : ( +
    + {chunks.map((chunk) => ( +
  • +
    + {chunk.id} + score {chunk.score.toFixed(3)} +
    +

    {chunk.preview}

    +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/SamplePromptChips.tsx b/frontend/src/components/SamplePromptChips.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6759249f44ee380702e83b51764c6e179a920980 --- /dev/null +++ b/frontend/src/components/SamplePromptChips.tsx @@ -0,0 +1,26 @@ +const prompts = [ + 'What is retrieval augmented generation?', + 'How does hybrid retrieval improve document search?', + 'Explain BM25 vs vector search.', + 'What makes citations useful in a RAG system?', +] + +export function SamplePromptChips({ onSelect }: { onSelect: (prompt: string) => void }) { + return ( +
+

Try a sample

+
+ {prompts.map((prompt) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/ScopeToggle.test.tsx b/frontend/src/components/ScopeToggle.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d313d1f9c56775510bf15a3af477524ff77474da --- /dev/null +++ b/frontend/src/components/ScopeToggle.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ScopeToggle } from './ScopeToggle' + +describe('ScopeToggle', () => { + it('disables Mine and Both until uploads exist', () => { + render() + expect(screen.getByRole('radio', { name: /my uploads only/i })).toBeDisabled() + expect(screen.getByRole('radio', { name: /both/i })).toBeDisabled() + }) + + it('enables session scopes after upload', async () => { + const onChange = vi.fn() + render() + await userEvent.click(screen.getByRole('radio', { name: /my uploads only/i })) + expect(onChange).toHaveBeenCalledWith('session') + }) +}) diff --git a/frontend/src/components/ScopeToggle.tsx b/frontend/src/components/ScopeToggle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c50370269906a475bd90ebd72bd7b83f2027cfb3 --- /dev/null +++ b/frontend/src/components/ScopeToggle.tsx @@ -0,0 +1,57 @@ +import * as RadioGroup from '@radix-ui/react-radio-group' +import type { KnowledgeScope } from '../api/generated' +import { cn } from '../lib/utils' + +const options: Array<{ value: KnowledgeScope; label: string; helper: string }> = [ + { value: 'global', label: 'Global sample corpus', helper: 'Use the preloaded public demo documents.' }, + { value: 'session', label: 'My uploads only', helper: 'Ask only against documents in this browser session.' }, + { value: 'both', label: 'Both', helper: 'Blend sample documents with your uploaded files.' }, +] + +export function ScopeToggle({ + value, + onChange, + hasUploads, +}: { + value: KnowledgeScope + onChange: (value: KnowledgeScope) => void + hasUploads: boolean +}) { + return ( + onChange(next as KnowledgeScope)} + aria-label="Knowledge scope" + > + {options.map((option) => { + const disabled = option.value !== 'global' && !hasUploads + return ( + +
+ + {option.label} +
+

+ {disabled ? 'Upload a document to enable this scope.' : option.helper} +

+
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/Uploader.test.tsx b/frontend/src/components/Uploader.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..679bac9c7f08b78b3a1d3a40b3d4ca86965688af --- /dev/null +++ b/frontend/src/components/Uploader.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Uploader } from './Uploader' + +describe('Uploader', () => { + it('shows client-side file count cap messaging', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await userEvent.upload(input, new File(['hello'], 'd.md', { type: 'text/markdown' })) + expect(screen.getByText(/upload 0 more file/i)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Uploader.tsx b/frontend/src/components/Uploader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..815204dbbf2d2c8b8cc9de7c7a5b2b590c8210c7 --- /dev/null +++ b/frontend/src/components/Uploader.tsx @@ -0,0 +1,108 @@ +import { useRef, useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { Upload } from 'lucide-react' +import { uploadDocuments, type SessionSummary, type UploadResult } from '../api/client' +import { formatBytes } from '../lib/format' + +const ACCEPTED = '.pdf,.docx,.txt,.md,.html' +const MAX_FILE_BYTES = 3 * 1024 * 1024 + +function resultMessage(result: UploadResult) { + const messages: Record = { + queued: 'Uploaded and indexed.', + skipped: 'Duplicate upload skipped.', + oversize: 'File exceeds the 3 MB limit.', + file_count_cap: 'Session file count cap reached.', + session_disk_cap: 'Session disk cap reached.', + type_mismatch: 'File contents do not match the extension.', + } + return messages[result.status] ?? messages[result.message] ?? result.message +} + +export function Uploader({ + sessionId, + summary, + onUploaded, +}: { + sessionId: string + summary: SessionSummary | undefined + onUploaded: () => Promise +}) { + const inputRef = useRef(null) + const [message, setMessage] = useState('') + const [results, setResults] = useState([]) + + const mutation = useMutation({ + mutationFn: (files: File[]) => uploadDocuments(sessionId, files), + onSuccess: async (response) => { + setResults(response.results) + setMessage('Upload finished.') + await onUploaded() + }, + onError: (error) => { + setMessage(error instanceof Error ? error.message : 'Upload failed.') + }, + }) + + const upload = (fileList: FileList | File[]) => { + if (!summary) { + return + } + const files = Array.from(fileList) + const maxFiles = summary?.max_files ?? 3 + const currentFiles = summary?.files.length ?? 0 + if (currentFiles + files.length > maxFiles) { + setMessage(`You can upload ${Math.max(0, maxFiles - currentFiles)} more file(s).`) + return + } + const oversized = files.find((file) => file.size > MAX_FILE_BYTES) + if (oversized) { + setMessage(`${oversized.name} is larger than ${formatBytes(MAX_FILE_BYTES)}.`) + return + } + mutation.mutate(files) + } + + return ( +
+
event.preventDefault()} + onDrop={(event) => { + event.preventDefault() + upload(event.dataTransfer.files) + }} + > +
+ {message ?

{message}

: null} + {results.length > 0 ? ( +
    + {results.map((result) => ( +
  • + {result.filename}: {resultMessage(result)} +
  • + ))} +
+ ) : null} +
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..baca5c3e06251296b0102b72e94276f73b00026d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,42 @@ +@import "tailwindcss"; + +:root { + color: #172033; + background: #f5f7fb; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button, +input, +textarea { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +textarea:focus-visible, +[role="tab"]:focus-visible, +[role="radio"]:focus-visible { + outline: 3px solid #93c5fd; + outline-offset: 2px; +} + +.app-card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.muted { + @apply text-sm text-slate-600; +} diff --git a/frontend/src/lib/citationProvenance.test.ts b/frontend/src/lib/citationProvenance.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..66e163c5048fa7fea0367db6bd60145ae12a6cef --- /dev/null +++ b/frontend/src/lib/citationProvenance.test.ts @@ -0,0 +1,16 @@ +import { citationLabel } from './citationProvenance' + +describe('citationLabel', () => { + it('labels citations matching uploaded files as yours', () => { + expect( + citationLabel( + { title: 'uploaded-doc.md', source: '/tmp/doc-ingest-sessions/abc/uploads/uploaded-doc.md' }, + [{ name: 'uploaded-doc.md', size_bytes: 12 }], + ), + ).toBe('yours') + }) + + it('labels unmatched citations as global', () => { + expect(citationLabel({ title: 'README.md', source: 'data/documents/README.md' }, [])).toBe('global') + }) +}) diff --git a/frontend/src/lib/citationProvenance.ts b/frontend/src/lib/citationProvenance.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d9075287b885f920c22f50409b277b84df0d5a5 --- /dev/null +++ b/frontend/src/lib/citationProvenance.ts @@ -0,0 +1,12 @@ +import type { SessionFile } from '../api/client' +import type { CitationModel } from '../api/generated' + +export type CitationProvenance = 'global' | 'yours' + +export function citationLabel( + citation: Pick, + sessionFiles: SessionFile[], +): CitationProvenance { + const searchable = `${citation.source ?? ''} ${citation.title ?? ''}`.toLowerCase() + return sessionFiles.some((file) => searchable.includes(file.name.toLowerCase())) ? 'yours' : 'global' +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cbdc2c9767567649ee02522cdb4c4057147a52f --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,22 @@ +export function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B' + } + const units = ['B', 'KB', 'MB', 'GB'] + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}` +} + +export function formatTtl(expiresAt: number | null) { + if (!expiresAt) { + return 'unknown' + } + const seconds = Math.max(0, expiresAt - Math.floor(Date.now() / 1000)) + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + return `${minutes}:${remainder.toString().padStart(2, '0')}` +} + +export function shortSessionId(sessionId: string | null) { + return sessionId ? `...${sessionId.slice(-5)}` : 'pending' +} diff --git a/frontend/src/lib/streamQuery.test.ts b/frontend/src/lib/streamQuery.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2d315f57a3b0f3676728335628747307007c407 --- /dev/null +++ b/frontend/src/lib/streamQuery.test.ts @@ -0,0 +1,21 @@ +import { testInternals } from './streamQuery' + +describe('streamQuery parsing', () => { + it('parses token and final events', () => { + expect( + testInternals.parseSseFrame( + 'data: {"type":"token","text":"Hi"}\n\ndata: {"type":"final","citations":[],"retrieved":[],"truthfulness":null,"provider":"ollama","model":"llama3"}', + ), + ).toEqual([ + { type: 'token', text: 'Hi' }, + { + type: 'final', + citations: [], + retrieved: [], + truthfulness: null, + provider: 'ollama', + model: 'llama3', + }, + ]) + }) +}) diff --git a/frontend/src/lib/streamQuery.ts b/frontend/src/lib/streamQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba3a78ae200c7ebc211b113a74f013cdc095d77a --- /dev/null +++ b/frontend/src/lib/streamQuery.ts @@ -0,0 +1,96 @@ +import { API_BASE_URL, ApiError, networkFailureError } from '../api/client' +import type { CitationModel, QueryRequestModel, RetrievedChunkModel, TruthfulnessModel } from '../api/generated' + +export type StreamEvent = + | { type: 'token'; text: string } + | { + type: 'final' + citations: CitationModel[] + retrieved?: RetrievedChunkModel[] + truthfulness?: TruthfulnessModel | null + provider: string + model: string + } + | { type: 'error'; message: string } + +export interface StreamQueryCallbacks { + onToken: (text: string) => void + onFinal: (event: Extract) => void + onError?: (message: string) => void +} + +function parseSseFrame(frame: string): StreamEvent[] { + return frame + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trim()) + .filter((data) => data && data !== '[DONE]') + .map((data) => JSON.parse(data) as StreamEvent) +} + +async function parseError(response: Response) { + try { + const body = await response.json() + return body?.detail ? String(body.detail) : `Stream failed with status ${response.status}` + } catch { + return `Stream failed with status ${response.status}` + } +} + +export async function streamQuery(request: QueryRequestModel, callbacks: StreamQueryCallbacks) { + const apiKey = localStorage.getItem('doc-ingestion.api-key') + const headers = new Headers({ 'Content-Type': 'application/json' }) + if (apiKey) { + headers.set('X-API-Key', apiKey) + } + + const streamPath = + API_BASE_URL && API_BASE_URL.length > 0 ? `${API_BASE_URL}/query/stream` : '/query/stream' + let response: Response + try { + response = await fetch(streamPath, { + method: 'POST', + headers, + body: JSON.stringify({ ...request, stream: true }), + }) + } catch (cause) { + throw networkFailureError(cause) + } + + if (!response.ok) { + throw new ApiError(await parseError(response), response.status, null) + } + if (!response.body) { + throw new ApiError('Streaming is not supported by this browser.', response.status, null) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { value, done } = await reader.read() + buffer += decoder.decode(value, { stream: !done }) + const frames = buffer.split('\n\n') + buffer = frames.pop() ?? '' + + for (const frame of frames) { + for (const event of parseSseFrame(frame)) { + if (event.type === 'token') { + callbacks.onToken(event.text) + } else if (event.type === 'final') { + callbacks.onFinal(event) + } else if (event.type === 'error') { + callbacks.onError?.(event.message) + throw new ApiError(event.message, response.status, event) + } + } + } + + if (done) { + break + } + } +} + +export const testInternals = { parseSseFrame } diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..fed2fe91e4d2321f997c0209881db87fe0d07ba9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bef5202a32cbd0632c43de40f6e908532903fd42 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/session/SessionContext.ts b/frontend/src/session/SessionContext.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1f84ec9267d9214e7ec7adb6c9c3c3deb85f157 --- /dev/null +++ b/frontend/src/session/SessionContext.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react' +import type { SessionContextValue } from './useSession' + +export const SessionContext = createContext(null) + +export function useSession() { + const context = useContext(SessionContext) + if (!context) { + throw new Error('useSession must be used inside SessionProvider') + } + return context +} diff --git a/frontend/src/session/SessionProvider.test.tsx b/frontend/src/session/SessionProvider.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4d5a5dde6d7a414949585c0581e9bd1a6621d7a0 --- /dev/null +++ b/frontend/src/session/SessionProvider.test.tsx @@ -0,0 +1,33 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { useSession } from './SessionContext' +import { SessionProvider } from './SessionProvider' + +function TestConsumer() { + const { sessionId, hasUploads } = useSession() + return ( +
+ session:{sessionId} + uploads:{String(hasUploads)} +
+ ) +} + +function renderWithProvider() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('SessionProvider', () => { + it('mints and stores a session on first mount', async () => { + renderWithProvider() + await waitFor(() => expect(screen.getByText('session:abc123demo')).toBeInTheDocument()) + expect(localStorage.getItem('doc-ingestion.demo.session')).toContain('abc123demo') + }) +}) diff --git a/frontend/src/session/SessionProvider.tsx b/frontend/src/session/SessionProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e64f55611fb1c4ea41e409c402ec1aba71afa8cb --- /dev/null +++ b/frontend/src/session/SessionProvider.tsx @@ -0,0 +1,116 @@ +import { useEffect, useMemo } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + ApiError, + createSession, + deleteSession, + getSession, + type SessionSummary, +} from '../api/client' +import { SessionContext } from './SessionContext' +import { type SessionContextValue, useSessionStore } from './useSession' + +function isStaleSessionError(error: unknown) { + return error instanceof ApiError && [400, 404, 422].includes(error.status) +} + +export function SessionProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient() + const { sessionId, expiresAt, setSession, clearLocalSession } = useSessionStore() + + const createMutation = useMutation({ + mutationFn: createSession, + onSuccess: (session) => { + setSession(session.session_id, session.expires_at) + queryClient.setQueryData(['session', session.session_id], { + ...session, + files: [], + total_bytes: 0, + max_files: 3, + max_session_bytes: 8 * 1024 * 1024, + } satisfies SessionSummary) + }, + }) + + const mutateCreateSession = createMutation.mutate + + const sessionQuery = useQuery({ + queryKey: ['session', sessionId], + queryFn: () => getSession(sessionId as string), + enabled: Boolean(sessionId), + retry: false, + staleTime: 60_000, + gcTime: 30 * 60_000, + }) + + useEffect(() => { + if (sessionId || createMutation.isPending || createMutation.isError) { + return + } + mutateCreateSession() + }, [sessionId, createMutation.isPending, createMutation.isError, mutateCreateSession]) + + useEffect(() => { + if (isStaleSessionError(sessionQuery.error)) { + clearLocalSession() + mutateCreateSession() + } + }, [clearLocalSession, mutateCreateSession, sessionQuery.error]) + + useEffect(() => { + if (sessionQuery.data) { + setSession(sessionQuery.data.session_id, sessionQuery.data.expires_at) + } + }, [sessionQuery.data, setSession]) + + const isMintingSession = Boolean(!sessionId && createMutation.isPending) + const awaitsSessionEnvelope = + Boolean(sessionId) && sessionQuery.data == null && (sessionQuery.isPending || sessionQuery.isFetching) + + const value = useMemo( + () => ({ + sessionId, + expiresAt, + summary: sessionQuery.data, + isMintingSession, + awaitsSessionEnvelope, + isLoading: isMintingSession || awaitsSessionEnvelope, + error: (sessionQuery.error as Error | null) ?? (createMutation.error as Error | null), + hasUploads: Boolean(sessionQuery.data?.files.length), + refreshSession: () => sessionQuery.refetch(), + retrySession: async () => { + clearLocalSession() + await createMutation.mutateAsync() + }, + clearSession: async () => { + if (!sessionId) { + clearLocalSession() + await createMutation.mutateAsync() + return + } + const next = await deleteSession(sessionId) + setSession(next.session_id, null) + queryClient.removeQueries({ queryKey: ['query'] }) + queryClient.removeQueries({ queryKey: ['session'] }) + await queryClient.invalidateQueries({ queryKey: ['session', next.session_id] }) + }, + }), + [ + clearLocalSession, + createMutation, + awaitsSessionEnvelope, + expiresAt, + isMintingSession, + queryClient, + sessionId, + sessionQuery, + sessionQuery.data, + sessionQuery.error, + sessionQuery.isFetching, + sessionQuery.isPending, + setSession, + ], + ) + + return {children} +} diff --git a/frontend/src/session/useSession.ts b/frontend/src/session/useSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fc49db55e9de05719af07a493ecf8184b16937d --- /dev/null +++ b/frontend/src/session/useSession.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand' +import type { SessionSummary } from '../api/client' + +export const SESSION_STORAGE_KEY = 'doc-ingestion.demo.session' + +interface SessionState { + sessionId: string | null + expiresAt: number | null + setSession: (sessionId: string, expiresAt: number | null) => void + clearLocalSession: () => void +} + +function readStoredSession(): Pick { + try { + const raw = localStorage.getItem(SESSION_STORAGE_KEY) + if (!raw) { + return { sessionId: null, expiresAt: null } + } + const parsed = JSON.parse(raw) as { sessionId?: string; expiresAt?: number } + return { sessionId: parsed.sessionId ?? null, expiresAt: parsed.expiresAt ?? null } + } catch { + return { sessionId: null, expiresAt: null } + } +} + +export const useSessionStore = create((set) => ({ + ...readStoredSession(), + setSession: (sessionId, expiresAt) => { + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ sessionId, expiresAt })) + set({ sessionId, expiresAt }) + }, + clearLocalSession: () => { + localStorage.removeItem(SESSION_STORAGE_KEY) + set({ sessionId: null, expiresAt: null }) + }, +})) + +export interface SessionContextValue { + sessionId: string | null + expiresAt: number | null + summary: SessionSummary | undefined + /** True only while POST /sessions is in-flight (nothing in localStorage yet). */ + isMintingSession: boolean + /** + * True while we lack session summary yet (new session bootstrap or hydrating from storage). + * Prefer this or isMintingSession over isLoading when you care about UX flicker. + */ + awaitsSessionEnvelope: boolean + isLoading: boolean + error: Error | null + hasUploads: boolean + refreshSession: () => Promise + clearSession: () => Promise + retrySession: () => Promise +} diff --git a/frontend/src/tabs/DocumentsTab.tsx b/frontend/src/tabs/DocumentsTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22647bb4f02623813f8c9e373209697601d415bb --- /dev/null +++ b/frontend/src/tabs/DocumentsTab.tsx @@ -0,0 +1,83 @@ +import * as Progress from '@radix-ui/react-progress' +import { RotateCcw } from 'lucide-react' +import { Uploader } from '../components/Uploader' +import { formatBytes, formatTtl } from '../lib/format' +import { useSession } from '../session/SessionContext' + +export function DocumentsTab() { + const { sessionId, summary, expiresAt, refreshSession, clearSession, isMintingSession } = useSession() + const usedBytes = summary?.total_bytes ?? 0 + const maxBytes = summary?.max_session_bytes ?? 8 * 1024 * 1024 + const files = summary?.files ?? [] + const maxFiles = summary?.max_files ?? 3 + const bytePercent = Math.min(100, (usedBytes / maxBytes) * 100) + const filePercent = Math.min(100, (files.length / maxFiles) * 100) + + return ( +
+
+
+
+

My documents

+

+ Up to 3 files, 3 MB each, 8 MB total. Sessions expire after 30 minutes of inactivity. +

+
+ +
+ +
+
+
+ Disk used + {formatBytes(usedBytes)} / {formatBytes(maxBytes)} +
+ + + +
+
+
+ Files + {files.length} / {maxFiles} +
+ + + +
+
+

Session expires in {formatTtl(expiresAt)}.

+
+ + {sessionId ? ( +
+ +
+ ) : null} + +
+

Indexed files

+ {files.length === 0 ? ( +

No uploaded documents yet.

+ ) : ( +
    + {files.map((file) => ( +
  • + {file.name} + {formatBytes(file.size_bytes)} +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/tabs/OverviewTab.tsx b/frontend/src/tabs/OverviewTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0429ccca005e8f0f816cc690194d637f7007d6da --- /dev/null +++ b/frontend/src/tabs/OverviewTab.tsx @@ -0,0 +1,139 @@ +export function OverviewTab() { + return ( +
+
+

Overview

+

+ Doc Ingestion answers questions using retrieved document chunks, optional citations in the answer, + and quality signals so you can judge how grounded a reply is. Use this page as a quick reference + for scopes, citations, truthfulness, and retrieval scores. +

+
+ +
+

Knowledge scope

+

+ On the Query tab, choose where the system searches before the model answers. Uploads require an + active session with at least one file. +

+
+
+
Global sample corpus
+
+ Search the preloaded public demo documents only. Always available. +
+
+
+
My uploads only
+
+ Search only files you uploaded in this browser session. Enabled after you upload at least one + document. +
+
+
+
Both
+
+ Combine the global demo corpus with your session uploads so answers can draw from either + source. +
+
+
+

+ Session uploads are private to your session, expire after inactivity, and are not merged into the + shared global corpus. +

+
+ +
+

Citations

+
    +
  • + The model is steered to ground answers in retrieved text and to mark supporting passages with + citation markers (for example references to documents or chunks). +
  • +
  • + In the Citations panel, each entry may show a global{' '} + or yours badge so you can see whether evidence + came from the demo corpus or your uploads. +
  • +
  • + Verification summarizes how well the cited + chunk supports the span that cited it. The score{' '} + (0–1) is a verification confidence for that citation; it feeds into the truthfulness score + below. +
  • +
+
+ +
+

Truthfulness

+

+ When present, the Truthfulness value next to the + answer is a single number from 0 to 1. It blends two signals: +

+
    +
  • + NLI faithfulness — for substantive sentences + in the answer, the share that an entailment model judges as supported by at least one retrieved + chunk (high entailment probability). +
  • +
  • + Citation groundedness — the average citation{' '} + verification_score across returned citations. +
  • +
+

+ The headline score is approximately 60% NLI faithfulness plus{' '} + 40% citation groundedness. +

+
+ + + + + + + + + + + + + + + + + + + + + + +
Truthfulness score bands
RangeHow to read it
≥ 0.80Strong grounding: most claims align with sources and citations verify well.
0.50 – 0.79Mixed: treat as helpful but verify important facts in the cited text.
< 0.50 + Weak: the answer may paraphrase loosely, omit citations, or go beyond the retrieved evidence. + Read the retrieved chunks and citations carefully. +
+
+

+ Truthfulness can be unavailable if scoring is disabled or errors occur; that does not imply the + answer is ungrounded. +

+
+ +
+

Retrieved chunk scores

+

+ Under Retrieved chunks, each line shows a{' '} + score from hybrid search (BM25 plus dense + vectors, fused with reciprocal rank fusion). These numbers are not on the same + 0–1 scale as truthfulness or citation verification. +

+

+ Typical top hits often appear in roughly the 0.01–0.03 range depending on settings. + Compare scores relative to other chunks in the same answer (ranking), not to a fixed + threshold like 0.8. +

+
+
+ ) +} diff --git a/frontend/src/tabs/QueryTab.tsx b/frontend/src/tabs/QueryTab.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8754a1f1b839d41ddfae5995d2a486548b1374f2 --- /dev/null +++ b/frontend/src/tabs/QueryTab.tsx @@ -0,0 +1,301 @@ +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import { AnswerPanel } from '../components/AnswerPanel' +import { CitationsList } from '../components/CitationsList' +import { RetrievedChunks } from '../components/RetrievedChunks' +import { SamplePromptChips } from '../components/SamplePromptChips' +import { ScopeToggle } from '../components/ScopeToggle' +import { queryDocuments, fetchLlmConfig } from '../api/client' +import type { KnowledgeScope, QueryRequestModel, QueryResponseModel } from '../api/generated' +import { streamQuery } from '../lib/streamQuery' +import { useSession } from '../session/SessionContext' + +const PROVIDER_KEY_PREFIX = 'doc-ingestion.provider-key.' + +function buildRequest( + query: string, + sessionId: string | null, + scope: KnowledgeScope, + provider: string, + model: string, + providerApiKey: string, +): QueryRequestModel { + const trimmedKey = providerApiKey.trim() + return { + query, + top_k: 5, + use_llm: true, + use_rerank: true, + stream: true, + include_citations: true, + session_id: sessionId, + knowledge_scope: scope, + provider, + model, + ...(trimmedKey ? { provider_api_key: trimmedKey } : {}), + } +} + +function pickDefaultProvider(cfg: { default_provider: string; allowed_models_by_provider: Record }) { + const keys = Object.keys(cfg.allowed_models_by_provider) + if (keys.includes(cfg.default_provider)) { + return cfg.default_provider + } + return keys[0] ?? 'ollama' +} + +function pickDefaultModel( + cfg: { default_model_by_provider: Record; allowed_models_by_provider: Record }, + provider: string, +) { + const models = cfg.allowed_models_by_provider[provider] ?? [] + const def = cfg.default_model_by_provider[provider] + if (def && models.includes(def)) { + return def + } + return models[0] ?? '' +} + +export function QueryTab() { + const { sessionId, hasUploads, summary } = useSession() + const [queryText, setQueryText] = useState('') + const [scope, setScope] = useState('global') + const [streamingText, setStreamingText] = useState('') + const [response, setResponse] = useState(null) + const [message, setMessage] = useState('') + const answerRef = useRef('') + + const { data: llmConfig, isLoading: llmConfigLoading, isError: llmConfigError } = useQuery({ + queryKey: ['llm-config'], + queryFn: fetchLlmConfig, + staleTime: Infinity, + }) + + const [provider, setProvider] = useState('') + const [model, setModel] = useState('') + const [providerApiKey, setProviderApiKey] = useState('') + const [rememberProviderKey, setRememberProviderKey] = useState(true) + + useEffect(() => { + if (!llmConfig || provider) { + return + } + const p = pickDefaultProvider(llmConfig) + setProvider(p) + setModel(pickDefaultModel(llmConfig, p)) + }, [llmConfig, provider]) + + useEffect(() => { + if (!provider || provider === 'ollama') { + setProviderApiKey('') + return + } + setProviderApiKey(localStorage.getItem(`${PROVIDER_KEY_PREFIX}${provider}`) ?? '') + }, [provider]) + + const handleProviderChange = (next: string) => { + setProvider(next) + if (llmConfig) { + setModel(pickDefaultModel(llmConfig, next)) + } + } + + const fallbackMutation = useMutation({ + mutationFn: (request: QueryRequestModel) => queryDocuments({ ...request, stream: false }), + onSuccess: (data) => { + setResponse(data) + setStreamingText(data.answer) + setMessage('Streaming was unavailable, so the non-streaming response was shown.') + }, + onError: (error) => { + setMessage(error instanceof Error ? error.message : 'Query failed.') + }, + }) + + const [isStreaming, setIsStreaming] = useState(false) + + const modelOptions = llmConfig && provider ? llmConfig.allowed_models_by_provider[provider] ?? [] : [] + const needsProviderKey = provider !== '' && provider !== 'ollama' + + const submit = async () => { + const trimmed = queryText.trim() + if (!trimmed) { + setMessage('Enter a question first.') + return + } + if (!provider || !model) { + setMessage('Select a provider and model.') + return + } + if (needsProviderKey && !providerApiKey.trim()) { + setMessage(`Paste your ${provider} API key to use this provider, or switch to Ollama for local inference.`) + return + } + if (scope !== 'global' && !hasUploads) { + setScope('global') + setMessage('Upload at least one document before querying My uploads.') + return + } + + if (rememberProviderKey && needsProviderKey && providerApiKey.trim()) { + localStorage.setItem(`${PROVIDER_KEY_PREFIX}${provider}`, providerApiKey.trim()) + } + + const request = buildRequest(trimmed, sessionId, scope, provider, model, providerApiKey) + answerRef.current = '' + setStreamingText('') + setResponse(null) + setMessage('') + setIsStreaming(true) + try { + await streamQuery(request, { + onToken: (token) => { + answerRef.current += token + setStreamingText(answerRef.current) + }, + onFinal: (final) => { + setResponse({ + query: trimmed, + provider: final.provider, + model: final.model, + answer: answerRef.current, + processing_time_ms: 0, + cached: false, + validation_issues: [], + citations: final.citations ?? [], + retrieved: final.retrieved ?? [], + truthfulness: final.truthfulness ?? null, + }) + }, + }) + } catch { + await fallbackMutation.mutateAsync(request) + } finally { + setIsStreaming(false) + } + } + + const answer = streamingText || response?.answer || '' + const providerOptions = llmConfig ? Object.keys(llmConfig.allowed_models_by_provider).sort() : [] + const runDisabled = + !provider + || !model + || llmConfigLoading + || (needsProviderKey && !providerApiKey.trim()) + || isStreaming + || fallbackMutation.isPending + + return ( +
+
+ { + setQueryText(prompt) + setScope('global') + }} + /> + +
+

Knowledge scope

+ +
+ + {llmConfigError ? ( +

+ Could not load model options from the API. Check that the server is running and try refreshing. +

+ ) : null} + + {llmConfig ? ( +
+ + +
+ ) : ( +

{llmConfigLoading ? 'Loading model options…' : null}

+ )} + + {needsProviderKey ? ( +
+ + +
+ ) : null} + +