From fa0d9fd9fa61e0e9fe56d54ea8e8b0d4e2cc9165 Mon Sep 17 00:00:00 2001 From: Ayush Gaur Date: Thu, 2 Apr 2026 19:38:36 -0400 Subject: [PATCH 1/3] Add backend and reliability improvements --- .dockerignore | 16 + .env.example | 4 +- .github/workflows/frontend-checks.yml | 43 ++ Dockerfile | 2 +- README.md | 28 +- app/agent/analysis.py | 8 +- app/agent/planner.py | 21 +- app/api/routes.py | 51 ++- app/api/workspace.py | 482 +++++++++++++++++++++++ app/config.py | 1 - app/llm/__init__.py | 6 +- app/llm/gemini.py | 24 +- app/llm/json_response.py | 96 ++--- app/llm/openai_client.py | 17 +- app/schemas.py | 123 +++++- docker-compose.yml | 18 +- requirements.txt | 4 +- tests/test_api.py | 109 ++++- tests/test_intent.py | 2 +- tests/test_llm_json.py | 36 +- tests/test_planner_schema.py | 2 +- ui/Dockerfile | 20 + ui/README.md | 16 +- ui/eslint.config.js | 35 ++ ui/package.json | 18 +- ui/requirements.txt | 1 - ui/src/api/chat.ts | 10 +- ui/src/api/client.ts | 25 +- ui/src/api/mappers.ts | 2 +- ui/src/api/types.ts | 1 + ui/src/api/uploads.ts | 3 +- ui/src/components/app/AppHeader.test.tsx | 25 ++ ui/src/components/app/AppHeader.tsx | 14 +- ui/src/hooks/useUpload.test.tsx | 32 ++ ui/src/hooks/useUpload.ts | 8 +- ui/src/lib/classNames.test.ts | 12 + ui/src/pages/AppPage.tsx | 55 ++- ui/src/test/setup.ts | 1 + ui/vite.config.ts | 7 +- 39 files changed, 1200 insertions(+), 178 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/frontend-checks.yml create mode 100644 app/api/workspace.py create mode 100644 ui/Dockerfile create mode 100644 ui/eslint.config.js delete mode 100644 ui/requirements.txt create mode 100644 ui/src/components/app/AppHeader.test.tsx create mode 100644 ui/src/hooks/useUpload.test.tsx create mode 100644 ui/src/lib/classNames.test.ts create mode 100644 ui/src/test/setup.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b49bae6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.DS_Store +.env +.env.* +!.env.example +__pycache__/ +.pytest_cache/ +.venv/ +.vscode/ +node_modules/ +ui/node_modules/ +ui/dist/ +dist/ +build/ +coverage/ diff --git a/.env.example b/.env.example index 80453b8..e55057c 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,10 @@ APP_NAME=GTM Analytics Copilot APP_ENV=development API_HOST=0.0.0.0 API_PORT=8000 -STREAMLIT_PORT=8501 REFERENCE_DATE=2026-03-21 LLM_PROVIDER=openai -GEMINI_API_KEY=''cd +GEMINI_API_KEY='' GEMINI_MODEL=gemini-2.5-flash OPENAI_API_KEY='' OPENAI_MODEL=gpt-4.1-mini LOG_LEVEL=INFO -API_BASE_URL=http://localhost:8000 diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml new file mode 100644 index 0000000..8d39269 --- /dev/null +++ b/.github/workflows/frontend-checks.yml @@ -0,0 +1,43 @@ +name: Frontend Checks + +on: + pull_request: + paths: + - "ui/**" + - ".github/workflows/frontend-checks.yml" + push: + branches: + - main + paths: + - "ui/**" + - ".github/workflows/frontend-checks.yml" + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + + - name: Test + run: npm run test:run + + - name: Build + run: npm run build diff --git a/Dockerfile b/Dockerfile index 6e2e85f..315ef45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -EXPOSE 8000 8501 +EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index d4a9ecd..db9fbba 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,12 @@ Core modules: - `app/agent/analysis.py`: single-pass narrative from query + steps - `app/agent/graph.py`: LangGraph orchestration - `app/api/routes.py`: API surface -- `ui/streamlit_app.py`: demo UI +- `ui/`: React + Vite frontend ## Repo Structure ```text -gtm-copilot/ +planera/ ├── app/ ├── ui/ ├── data/ @@ -93,7 +93,7 @@ gtm-copilot/ ### 1. Create the environment ```bash -cd gtm-copilot +cd planera python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt @@ -110,6 +110,8 @@ API endpoints: - `GET /health` - `GET /sample-questions` +- `POST /uploads` +- `GET /inspections/{inspection_id}` - `POST /analyze` Example request: @@ -120,29 +122,33 @@ curl -X POST http://localhost:8000/analyze \ -d '{"query":"Why did pipeline velocity drop this week?"}' ``` -### 3. Run the Streamlit UI +### 3. Run the React UI In a second terminal: ```bash -streamlit run ui/streamlit_app.py +cd ui +npm install +npm run dev ``` ## Environment Variables -Defined in `.env.example`: +Backend settings are defined in `.env.example`: - `APP_NAME` - `APP_ENV` - `API_HOST` - `API_PORT` -- `STREAMLIT_PORT` - `GEMINI_API_KEY` - `GEMINI_MODEL` +- `OPENAI_API_KEY` +- `OPENAI_MODEL` - `LOG_LEVEL` -- `API_BASE_URL` -Set `LLM_PROVIDER` to `openai` or `gemini` and provide the matching API key (`OPENAI_API_KEY` or `GEMINI_API_KEY`). This version does not include a non-LLM fallback path. +Frontend settings live in `ui/.env.example`. + +Set `LLM_PROVIDER` to `openai` or `gemini` and provide the matching API key (`OPENAI_API_KEY` or `GEMINI_API_KEY`). ## Data Model @@ -195,11 +201,11 @@ docker compose up --build Then open: - API: [http://localhost:8000](http://localhost:8000) -- UI: [http://localhost:8501](http://localhost:8501) +- UI: [http://localhost:5173](http://localhost:5173) ## Demo Script -1. Open the Streamlit UI. +1. Open the Planera UI. 2. Select "Why did pipeline velocity drop this week?" 3. Run the analysis and show the planner-executor loop spinner. 4. Open the executed-steps panel and show the generated SQL or pandas code. diff --git a/app/agent/analysis.py b/app/agent/analysis.py index f8a0e72..8b48c61 100644 --- a/app/agent/analysis.py +++ b/app/agent/analysis.py @@ -6,6 +6,7 @@ from app.agent.state import AnalysisState from app.llm import get_llm_client +from app.schemas import AnalysisNarrativeResponse def run_analysis_narrative(state: AnalysisState) -> AnalysisState: @@ -30,7 +31,7 @@ def run_analysis_narrative(state: AnalysisState) -> AnalysisState: - Base conclusions only on the executed steps and their artifacts below. Do not invent numbers. - If there were no successful steps or data is insufficient, say so clearly. - Use markdown: short headings, bullets where helpful. -- Return JSON only in this shape: +- Follow the response schema exactly. The content should match this shape: {{ "analysis": "" }} User question: @@ -49,8 +50,9 @@ def run_analysis_narrative(state: AnalysisState) -> AnalysisState: {json.dumps(state["executed_steps"], indent=2)} """ try: - result = get_llm_client().generate_json(prompt) - state["analysis"] = result.get("analysis", "").strip() or "No analysis text was returned." + result = get_llm_client().generate_json(prompt, schema=AnalysisNarrativeResponse) + parsed = result if isinstance(result, AnalysisNarrativeResponse) else AnalysisNarrativeResponse.model_validate(result) + state["analysis"] = parsed.analysis.strip() or "No analysis text was returned." except Exception as exc: # pragma: no cover - defensive state["analysis"] = ( f"The analysis step could not complete ({exc!s}). " diff --git a/app/agent/planner.py b/app/agent/planner.py index 0adcad3..c0fc727 100644 --- a/app/agent/planner.py +++ b/app/agent/planner.py @@ -25,7 +25,7 @@ def _build_compiled_planner_prompt(state: AnalysisState, validation_feedback: st Return a single JSON object that describes a full multi-step plan to answer the user's question using only the dataset described below. Rules: -- Return JSON only. +- Follow the response schema exactly. - Produce 1 to 3 items in "plan" (at most three SQL steps). Each step must add incremental explanatory value; avoid redundant segmentation. - CRITICAL — the "max_steps" field: set it to the integer 3 always. It is the platform's fixed ceiling, not the count of steps you return. Do not set max_steps to 1 or 2 even if the plan has only one or two queries. - Every step must use "type": "sql" and put the full SQL statement in "query". @@ -74,7 +74,7 @@ def _build_repair_prompt(state: AnalysisState, failed_step_id: str, error_messag Repair the failed step only: return JSON that replaces that step with corrected SQL. Do not add new steps. Rules: -- Return JSON only. +- Follow the response schema exactly. - repair_action must be "replace_step". - updated_step must use "type": "sql", the same id as the failed step ({failed_step_id}), and a fixed "query". - Use only registered view names from the schema manifest. @@ -112,16 +112,16 @@ def plan_compiled_query(state: AnalysisState) -> AnalysisState: for attempt in range(1, _COMPILED_PLANNER_ATTEMPTS + 1): prompt = _build_compiled_planner_prompt(state, validation_feedback=feedback) - decision = client.generate_json(prompt) try: - parsed = CompiledPlan.model_validate(decision) - except ValidationError as exc: - feedback = exc.json(indent=2) + decision = client.generate_json(prompt, schema=CompiledPlan) + parsed = decision if isinstance(decision, CompiledPlan) else CompiledPlan.model_validate(decision) + except (ValidationError, ValueError) as exc: + feedback = exc.json(indent=2) if isinstance(exc, ValidationError) else str(exc) logger.warning( "Compiled plan validation failed (attempt %s/%s): %s", attempt, _COMPILED_PLANNER_ATTEMPTS, - exc.errors(), + exc.errors() if isinstance(exc, ValidationError) else str(exc), ) if attempt >= _COMPILED_PLANNER_ATTEMPTS: raise @@ -138,8 +138,11 @@ def plan_compiled_query(state: AnalysisState) -> AnalysisState: def repair_failed_step(state: AnalysisState, failed_step_id: str, error_message: str) -> AnalysisState: """Call the LLM once to replace a single failed plan step.""" - raw = get_llm_client().generate_json(_build_repair_prompt(state, failed_step_id, error_message)) - parsed = RepairDecision.model_validate(raw) + raw = get_llm_client().generate_json( + _build_repair_prompt(state, failed_step_id, error_message), + schema=RepairDecision, + ) + parsed = raw if isinstance(raw, RepairDecision) else RepairDecision.model_validate(raw) if str(parsed.updated_step.id) != str(failed_step_id): raise ValueError(f"Repair returned mismatched step id: expected {failed_step_id}, got {parsed.updated_step.id}") diff --git a/app/api/routes.py b/app/api/routes.py index 9062068..91a3ec6 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -2,15 +2,18 @@ from __future__ import annotations -from fastapi import APIRouter +from fastapi import APIRouter, File, HTTPException, UploadFile, status from app.agent.graph import run_analysis +from app.api.workspace import get_inspection, profile_upload, store_inspection from app.config import get_settings -from app.schemas import AnalyzeRequest, AnalyzeResponse, HealthResponse, SampleQuestionsResponse +from app.schemas import AnalyzeRequest, AnalyzeResponse, HealthResponse, InspectionResponse, SampleQuestionsResponse, UploadResponse from app.utils.constants import SAMPLE_QUESTIONS +from app.utils.logging import get_logger router = APIRouter() +logger = get_logger(__name__) @router.get("/health", response_model=HealthResponse) @@ -28,22 +31,54 @@ def sample_questions() -> SampleQuestionsResponse: return SampleQuestionsResponse(questions=SAMPLE_QUESTIONS) +@router.post("/uploads", response_model=UploadResponse) +async def upload_dataset(file: UploadFile = File(...)) -> UploadResponse: + """Accept a workspace upload and return a profiled asset summary.""" + + contents = await file.read() + try: + asset = profile_upload(file.filename or "upload.csv", contents) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail={"message": str(exc)}) from exc + return UploadResponse(asset=asset, fallback=False) + + +@router.get("/inspections/{inspection_id}", response_model=InspectionResponse) +def inspection_details(inspection_id: str) -> InspectionResponse: + """Return a stored inspection payload for the requested analysis.""" + + inspection = get_inspection(inspection_id) + if inspection is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={"message": "Inspection not found."}) + return InspectionResponse(inspection=inspection, fallback=False) + + @router.post("/analyze", response_model=AnalyzeResponse) def analyze(request: AnalyzeRequest) -> AnalyzeResponse: """Execute the analytics workflow for a user query.""" try: state = run_analysis(request.query) - return AnalyzeResponse( + base_response = AnalyzeResponse( analysis=state["analysis"], trace=state.get("trace", []), executed_steps=state.get("executed_steps", []), errors=state.get("errors", []), ) - except Exception as exc: # pragma: no cover - defensive API fallback + inspection_id = store_inspection(request.query, base_response) return AnalyzeResponse( - analysis="The analysis could not complete successfully. Inspect the error payload and retry.", - trace=[{"step": "api_analyze", "status": "failed", "details": {"message": str(exc)}}], - executed_steps=[], - errors=[{"step": "api_analyze", "message": str(exc), "recoverable": False, "details": {}}], + analysis=base_response.analysis, + trace=base_response.trace, + executed_steps=base_response.executed_steps, + errors=base_response.errors, + inspection_id=inspection_id, ) + except Exception as exc: # pragma: no cover - defensive API fallback + logger.exception("Analyze request failed", extra={"query": request.query}) + settings = get_settings() + detail: dict[str, str] = { + "message": "The analysis could not complete successfully. Inspect the server logs and retry.", + } + if settings.app_env.lower() != "production": + detail["error"] = str(exc) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=detail) from exc diff --git a/app/api/workspace.py b/app/api/workspace.py new file mode 100644 index 0000000..2613ac3 --- /dev/null +++ b/app/api/workspace.py @@ -0,0 +1,482 @@ +"""Workspace-facing API helpers for uploads and inspection payloads.""" + +from __future__ import annotations + +import json +import re +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path +from threading import Lock +from typing import Any +from uuid import uuid4 + +import pandas as pd + +from app.schemas import AnalyzeResponse, ArtifactSummary, InspectionData, MetadataItem, ResultTableData, TraceEntry, UploadedAsset, ValidationCheck + + +STEP_LABELS: dict[str, str] = { + "load_schema_context_node": "Schema Context", + "planner_compiled_node": "Query Planning", + "execute_plan_node": "Execution", + "analysis_node": "Narrative Synthesis", + "api_analyze": "API Analyze", + "repair_planner": "Repair Planning", +} + +_STORE_LOCK = Lock() +_INSPECTIONS: dict[str, InspectionData] = {} +_UPLOAD_DIR = Path(tempfile.gettempdir()) / "planera_uploads" + + +@dataclass +class StoredUpload: + """Backend-only metadata for uploaded files.""" + + asset: UploadedAsset + file_path: Path + + +_UPLOADS: dict[str, StoredUpload] = {} + + +def clear_workspace_state() -> None: + """Reset in-memory upload/inspection storage for tests.""" + + with _STORE_LOCK: + for stored in _UPLOADS.values(): + if stored.file_path.exists(): + stored.file_path.unlink(missing_ok=True) + _UPLOADS.clear() + _INSPECTIONS.clear() + + +def profile_upload(filename: str, content: bytes) -> UploadedAsset: + """Profile an uploaded CSV/TSV file and persist it to a temp location.""" + + safe_name = Path(filename or "upload.csv").name + frame = _read_uploaded_frame(safe_name, content) + row_count = int(len(frame)) + column_count = int(len(frame.columns)) + asset = UploadedAsset( + id=_short_id("upload"), + name=safe_name, + type=_derive_file_type(safe_name), + source="Workspace upload", + sizeLabel=_bytes_to_size(len(content)), + uploadedAt=_now_iso(), + status="verified", + rows=row_count, + columns=column_count, + summary=_build_upload_summary(frame, row_count, column_count), + ) + + _UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + file_path = _UPLOAD_DIR / f"{asset.id}_{safe_name}" + file_path.write_bytes(content) + + with _STORE_LOCK: + _UPLOADS[asset.id] = StoredUpload(asset=asset, file_path=file_path) + + return asset + + +def store_inspection(prompt: str, response: AnalyzeResponse) -> str: + """Build and store an inspection payload for later retrieval.""" + + inspection = _build_inspection(_short_id("inspect"), prompt, response) + with _STORE_LOCK: + _INSPECTIONS[inspection.id] = inspection + return inspection.id + + +def get_inspection(inspection_id: str) -> InspectionData | None: + """Return a stored inspection payload by id.""" + + with _STORE_LOCK: + return _INSPECTIONS.get(inspection_id) + + +def _read_uploaded_frame(filename: str, content: bytes) -> pd.DataFrame: + if not content: + raise ValueError("Uploaded file is empty.") + + suffix = Path(filename).suffix.lower() + buffer = BytesIO(content) + + try: + if suffix in {".csv"}: + return pd.read_csv(buffer) + if suffix in {".tsv", ".tab"}: + return pd.read_csv(buffer, sep="\t") + if suffix in {".txt"}: + return pd.read_csv(buffer, sep=None, engine="python") + except Exception as exc: # pragma: no cover - pandas error details vary + raise ValueError(f"Could not parse {filename} as a structured text dataset.") from exc + + raise ValueError("Only CSV, TSV, TAB, and TXT uploads are currently supported.") + + +def _build_upload_summary(frame: pd.DataFrame, row_count: int, column_count: int) -> str: + preview_columns = [str(column) for column in list(frame.columns[:4])] + column_label = ", ".join(preview_columns) + suffix = "..." if column_count > 4 else "" + if column_label: + return f"Profiled {row_count} rows across {column_count} columns. Leading columns: {column_label}{suffix}." + return f"Profiled {row_count} rows across {column_count} columns." + + +def _build_inspection(inspection_id: str, prompt: str, response: AnalyzeResponse) -> InspectionData: + executed_steps = response.executed_steps or [] + primary_artifact = _pick_primary_artifact(response) + results = _artifact_to_table(primary_artifact) + rows_returned = primary_artifact.row_count if primary_artifact else 0 + confidence = _derive_confidence(response, primary_artifact) + inspection_status = _derive_inspection_status(response) + verified = ( + inspection_status == "valid" + and len(response.errors) == 0 + and any(step.status == "success" for step in executed_steps) + ) + runtime_ms = None + + return InspectionData( + id=inspection_id, + title=_conversation_title_from_prompt(prompt), + query=_build_code_bundle(response), + status=inspection_status, + rowsReturned=rows_returned, + runtimeMs=runtime_ms, + filters=_build_execution_chips(response, primary_artifact), + confidence=confidence, + verified=verified, + dataSource=_derive_data_source(response), + lastUpdated=_now_iso(), + engine="DuckDB", + queryType=_derive_query_type(response), + results=results, + trace=_build_trace_entries(response), + validation=_build_validation(response, primary_artifact, confidence), + metadata=_build_metadata(response, primary_artifact, rows_returned, len(results.columns), verified, runtime_ms), + ) + + +def _conversation_title_from_prompt(prompt: str) -> str: + compact = " ".join(prompt.split()).strip() + if len(compact) <= 56: + return compact + return f"{compact[:53]}..." + + +def _build_execution_chips(response: AnalyzeResponse, primary_artifact: ArtifactSummary | None) -> list[str]: + executed_steps = len(response.executed_steps) + retry_count = sum(1 for step in response.executed_steps if step.attempt > 1) + return _dedupe( + [ + f"{executed_steps} workflow step{'' if executed_steps == 1 else 's'}" if executed_steps else "No executed steps", + f"Output: {primary_artifact.alias}" if primary_artifact else "No output alias", + f"{retry_count} retry attempt{'' if retry_count == 1 else 's'}" if retry_count else "No retries", + f"{len(response.errors)} issue{'' if len(response.errors) == 1 else 's'}" if response.errors else "No recorded errors", + ] + )[:4] + + +def _build_validation( + response: AnalyzeResponse, + primary_artifact: ArtifactSummary | None, + confidence: float, +) -> list[ValidationCheck]: + success_count = sum(1 for step in response.executed_steps if step.status == "success") + total_steps = len(response.executed_steps) + recoverable_errors = sum(1 for item in response.errors if item.recoverable) + fatal_errors = sum(1 for item in response.errors if not item.recoverable) + retry_count = sum(1 for step in response.executed_steps if step.attempt > 1) + + return [ + ValidationCheck( + id="query_validity", + label="Query validity", + detail=( + "The backend reported a non-recoverable workflow error during execution." + if fatal_errors + else "At least one execution step completed successfully." + if success_count > 0 + else "No successful execution steps were returned for this prompt." + ), + status="fail" if fatal_errors else "pass" if success_count > 0 else "warn", + ), + ValidationCheck( + id="step_coverage", + label="Step coverage", + detail=( + f"{success_count} of {total_steps} executed step{'' if total_steps == 1 else 's'} completed successfully." + if total_steps + else "The backend did not return any executed steps for this run." + ), + status="pass" if total_steps > 0 and success_count == total_steps else "warn" if success_count > 0 else "fail", + ), + ValidationCheck( + id="result_availability", + label="Result availability", + detail=( + f"The final artifact {primary_artifact.alias} returned {primary_artifact.row_count} row{'' if primary_artifact.row_count == 1 else 's'} and is available for inspection." + if primary_artifact and primary_artifact.row_count + else "No non-empty preview artifact was returned by the backend response." + ), + status="pass" if primary_artifact and primary_artifact.row_count else "warn", + ), + ValidationCheck( + id="recovery_path", + label="Recovery path", + detail=( + f"The workflow used {retry_count} retry attempt{'' if retry_count == 1 else 's'} and reported {recoverable_errors} recoverable issue{'' if recoverable_errors == 1 else 's'}." + if retry_count or recoverable_errors + else "The workflow completed without repair or retry events." + ), + status="warn" if retry_count or recoverable_errors else "pass", + ), + ValidationCheck( + id="execution_confidence", + label="Execution confidence", + detail="Confidence is derived from successful step coverage and artifact completeness for this run.", + status="pass" if confidence >= 0.8 else "warn" if confidence >= 0.6 else "fail", + ), + ] + + +def _build_metadata( + response: AnalyzeResponse, + primary_artifact: ArtifactSummary | None, + rows_returned: int, + column_count: int, + verified: bool, + runtime_ms: int | None, +) -> list[MetadataItem]: + success_count = sum(1 for step in response.executed_steps if step.status == "success") + total_steps = len(response.executed_steps) + + return [ + MetadataItem( + label="Execution status", + value=( + "Failed" + if any(not item.recoverable for item in response.errors) + else "Completed with review notes" + if response.errors or any(step.attempt > 1 for step in response.executed_steps) + else "Complete" + ), + ), + MetadataItem(label="Verification", value="Verified" if verified else "Needs analyst review"), + MetadataItem( + label="Output shape", + value=f"{rows_returned} rows x {column_count} columns" if rows_returned > 0 else "No preview rows returned", + ), + MetadataItem( + label="Step coverage", + value=f"{success_count}/{total_steps} successful" if total_steps else "No executed steps", + ), + MetadataItem( + label="Runtime", + value="Not reported by backend" if runtime_ms is None else f"{runtime_ms} ms", + ), + MetadataItem(label="Primary artifact", value=primary_artifact.alias if primary_artifact else "Unavailable"), + ] + + +def _build_trace_entries(response: AnalyzeResponse) -> list[TraceEntry]: + return [ + TraceEntry( + id=f"{event.step}_{index}", + label=_humanize_step_name(event.step), + description=_build_trace_description(event.step, event.status, event.details), + detail=_format_trace_details(event.details), + durationLabel=_status_label(event.status), + status=_map_trace_status(event.status), + ) + for index, event in enumerate(response.trace) + ] + + +def _build_trace_description(step: str, event_status: str, details: dict[str, Any]) -> str: + message = details.get("message") + if isinstance(message, str) and message: + return message + if event_status == "completed": + return f"{_humanize_step_name(step)} completed successfully." + if event_status == "failed": + return f"{_humanize_step_name(step)} reported a workflow issue." + if event_status == "skipped": + return f"{_humanize_step_name(step)} was skipped by the workflow." + return f"{_humanize_step_name(step)} started running." + + +def _format_trace_details(details: dict[str, Any]) -> str: + if not details: + return "No additional structured details were returned for this step." + return " | ".join(f"{_humanize_key(key)}: {_format_unknown_value(value)}" for key, value in details.items()) + + +def _artifact_to_table(artifact: ArtifactSummary | None) -> ResultTableData: + if not artifact or not artifact.preview_rows: + return ResultTableData(columns=["status"], rows=[{"status": "No preview rows returned"}]) + + first_row = artifact.preview_rows[0] if artifact.preview_rows else None + columns = artifact.columns or (list(first_row.keys()) if first_row else ["status"]) + rows = [ + {column: _normalize_cell(row.get(column)) for column in columns} + for row in artifact.preview_rows + ] + return ResultTableData(columns=columns, rows=rows) + + +def _pick_primary_artifact(response: AnalyzeResponse) -> ArtifactSummary | None: + successful = [step for step in reversed(response.executed_steps) if step.status == "success" and step.artifact is not None] + for step in successful: + if step.artifact and step.artifact.row_count > 0: + return step.artifact + return successful[0].artifact if successful else None + + +def _build_code_bundle(response: AnalyzeResponse) -> str: + if not response.executed_steps: + return "-- No executed query text was returned by the backend." + + blocks: list[str] = [] + for index, step in enumerate(response.executed_steps, start=1): + attempt_suffix = f" | attempt {step.attempt}" if step.attempt > 1 else "" + header = f"-- Step {index} | {step.purpose} | {step.status}{attempt_suffix}" + blocks.append(f"{header}\n{step.code.strip()}") + return "\n\n".join(blocks) + + +def _derive_inspection_status(response: AnalyzeResponse) -> str: + steps = response.executed_steps + if any(not item.recoverable for item in response.errors) or (steps and not any(step.status == "success" for step in steps)): + return "error" + if ( + response.errors + or any(event.status in {"failed", "skipped"} for event in response.trace) + or any(step.status == "failed" or step.attempt > 1 for step in steps) + ): + return "warning" + return "valid" + + +def _derive_confidence(response: AnalyzeResponse, primary_artifact: ArtifactSummary | None) -> float: + total_steps = len(response.executed_steps) + success_count = sum(1 for step in response.executed_steps if step.status == "success") + success_ratio = success_count / total_steps if total_steps > 0 else 0.0 + preview_bonus = 0.16 if primary_artifact and primary_artifact.row_count else 0.0 + trace_bonus = 0.07 if any(event.status == "completed" for event in response.trace) else 0.0 + error_penalty = 0.18 if any(not item.recoverable for item in response.errors) else 0.08 if response.errors else 0.0 + score = 0.46 + success_ratio * 0.24 + preview_bonus + trace_bonus - error_penalty + return _clamp(score, 0.35, 0.95) + + +def _derive_data_source(response: AnalyzeResponse) -> str: + for step in response.executed_steps: + match = re.search(r"\bfrom\s+([a-zA-Z0-9_.\"-]+)", step.code, flags=re.IGNORECASE) + if match and match.group(1): + return match.group(1).replace('"', "") + return "Planera semantic model" + + +def _derive_query_type(response: AnalyzeResponse) -> str: + if not response.executed_steps: + return "SQL" + kinds = {step.kind for step in response.executed_steps} + if len(kinds) == 1: + return next(iter(kinds)).upper() + return "Mixed execution" + + +def _humanize_step_name(step: str) -> str: + if step in STEP_LABELS: + return STEP_LABELS[step] + return " ".join(part.capitalize() for part in step.removesuffix("_node").split("_") if part) + + +def _humanize_key(key: str) -> str: + return " ".join(part.capitalize() for part in key.split("_") if part) + + +def _status_label(event_status: str) -> str: + if event_status == "completed": + return "Complete" + if event_status == "failed": + return "Failed" + if event_status == "skipped": + return "Skipped" + return "Started" + + +def _map_trace_status(event_status: str) -> str: + if event_status == "completed": + return "complete" + if event_status == "failed": + return "error" + if event_status == "skipped": + return "warning" + return "running" + + +def _normalize_cell(value: Any) -> str | int | float | None: + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float, str)): + return value + return json.dumps(value, default=str) + + +def _format_unknown_value(value: Any) -> str: + if isinstance(value, list): + return ", ".join(_format_unknown_value(item) for item in value) + if value is None: + return "n/a" + if isinstance(value, dict): + return json.dumps(value, default=str) + return str(value) + + +def _dedupe(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for raw in values: + value = raw.strip() + if not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _clamp(value: float, minimum: float, maximum: float) -> float: + return min(maximum, max(minimum, value)) + + +def _short_id(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:8]}" + + +def _derive_file_type(filename: str) -> str: + suffix = Path(filename).suffix.lstrip(".").upper() + return suffix or "FILE" + + +def _bytes_to_size(num_bytes: int) -> str: + if num_bytes < 1024: + return f"{num_bytes} B" + if num_bytes < 1024 * 1024: + return f"{num_bytes / 1024:.1f} KB" + if num_bytes < 1024 * 1024 * 1024: + return f"{num_bytes / (1024 * 1024):.1f} MB" + return f"{num_bytes / (1024 * 1024 * 1024):.1f} GB" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") diff --git a/app/config.py b/app/config.py index 54a1437..1e3ad13 100644 --- a/app/config.py +++ b/app/config.py @@ -19,7 +19,6 @@ class Settings(BaseSettings): app_env: str = "development" api_host: str = "0.0.0.0" api_port: int = 8000 - streamlit_port: int = 8501 data_dir: Path = Field(default=BASE_DIR / "data") crm_path: Path = Field(default=BASE_DIR / "data" / "crm.csv") subscriptions_path: Path = Field(default=BASE_DIR / "data" / "subscriptions.csv") diff --git a/app/llm/__init__.py b/app/llm/__init__.py index 3508ef0..34691ef 100644 --- a/app/llm/__init__.py +++ b/app/llm/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations from app.config import get_settings -from app.llm.gemini import GeminiClient -from app.llm.openai_client import OpenAIClient def get_llm_client(): @@ -12,7 +10,11 @@ def get_llm_client(): provider = get_settings().llm_provider.lower().strip() if provider == "openai": + from app.llm.openai_client import OpenAIClient + return OpenAIClient() if provider == "gemini": + from app.llm.gemini import GeminiClient + return GeminiClient() raise ValueError(f"Unsupported LLM_PROVIDER '{get_settings().llm_provider}'. Use 'openai' or 'gemini'.") diff --git a/app/llm/gemini.py b/app/llm/gemini.py index 8d08465..02881d7 100644 --- a/app/llm/gemini.py +++ b/app/llm/gemini.py @@ -2,12 +2,15 @@ from __future__ import annotations -from typing import Any +from typing import TypeVar from google import genai +from pydantic import BaseModel from app.config import get_settings -from app.llm.json_response import parse_llm_json_object +from app.llm.json_response import validate_structured_output + +SchemaT = TypeVar("SchemaT", bound=BaseModel) class GeminiClient: @@ -20,12 +23,19 @@ def __init__(self) -> None: self.model = settings.gemini_model self.client = genai.Client(api_key=settings.gemini_api_key) - def generate_json(self, prompt: str) -> dict[str, Any]: - """Generate JSON and parse the model response.""" - - response = self.client.models.generate_content(model=self.model, contents=prompt) + def generate_json(self, prompt: str, schema: type[SchemaT]) -> SchemaT: + """Generate schema-constrained JSON and return a validated model.""" + + response = self.client.models.generate_content( + model=self.model, + contents=prompt, + config={ + "response_mime_type": "application/json", + "response_schema": schema, + }, + ) text = response.text or "" - return parse_llm_json_object(text, source="gemini") + return validate_structured_output(text, schema=schema, source="gemini") def generate_text(self, prompt: str) -> str: """Generate free text for final user-facing output.""" diff --git a/app/llm/json_response.py b/app/llm/json_response.py index a6b01ce..937cf42 100644 --- a/app/llm/json_response.py +++ b/app/llm/json_response.py @@ -1,77 +1,63 @@ -"""Extract JSON objects from LLM text; tolerate unescaped newlines in strings (e.g. SQL).""" +"""Schema-based validation helpers for structured LLM responses.""" from __future__ import annotations -import json -import re -from typing import Any +from typing import Any, TypeVar + +from pydantic import BaseModel, ValidationError from app.utils.logging import get_logger logger = get_logger(__name__) -_MAX_SNIPPET = 8000 +SchemaT = TypeVar("SchemaT", bound=BaseModel) +_MAX_EXCERPT = 500 -def _truncate(s: str, max_len: int = _MAX_SNIPPET) -> str: - if len(s) <= max_len: - return s +def _truncate(text: str, max_len: int = _MAX_EXCERPT) -> str: + if len(text) <= max_len: + return text half = max_len // 2 - return s[:half] + "\n...[truncated]...\n" + s[-half:] + return text[:half] + "\n...[truncated]...\n" + text[-half:] -def parse_llm_json_object(text: str, *, source: str = "llm") -> dict[str, Any]: - """Parse the first JSON object from model output. +def validate_structured_output( + payload: str | bytes | bytearray | dict[str, Any] | BaseModel | None, + *, + schema: type[SchemaT], + source: str, +) -> SchemaT: + """Validate a provider response against a concrete Pydantic schema.""" - Uses :func:`json.loads` with ``strict=False`` so literal control characters inside - string values are accepted. Models often emit multi-line SQL without ``\\n`` escapes, - which strict JSON rejects with "Invalid control character". - """ + if payload is None: + logger.warning("[%s] Empty structured payload for %s", source, schema.__name__) + raise ValueError(f"{source}: empty structured response for {schema.__name__}") - stripped = (text or "").strip() - if not stripped: - logger.warning("[%s] Empty body for JSON parse", source) - raise ValueError(f"{source}: empty response for JSON parsing") + try: + if isinstance(payload, schema): + return payload - def as_dict(parsed: Any) -> dict[str, Any]: - if not isinstance(parsed, dict): - logger.warning("[%s] Top-level JSON is %s, not an object", source, type(parsed).__name__) - raise ValueError(f"{source}: expected JSON object, got {type(parsed).__name__}") - return parsed + if isinstance(payload, BaseModel): + payload = payload.model_dump() - try: - parsed = json.loads(stripped, strict=False) - out = as_dict(parsed) - logger.debug("[%s] Parsed JSON (%d chars) keys=%s", source, len(stripped), list(out.keys())) - return out - except json.JSONDecodeError as e: - logger.warning( - "[%s] JSON parse failed (primary): %s at line %s col %s (pos %s)", - source, - e.msg, - e.lineno, - e.colno, - e.pos, - ) + if isinstance(payload, (bytes, bytearray)): + payload = payload.decode("utf-8") - match = re.search(r"\{.*\}", stripped, re.DOTALL) - if not match: - logger.warning("[%s] No JSON object found; excerpt=%s", source, repr(_truncate(stripped))) - raise ValueError(f"{source}: response was not a JSON object: {_truncate(stripped, 500)}") + if isinstance(payload, str): + stripped = payload.strip() + if not stripped: + logger.warning("[%s] Blank structured payload for %s", source, schema.__name__) + raise ValueError(f"{source}: empty structured response for {schema.__name__}") + return schema.model_validate_json(stripped) - fragment = match.group(0) - try: - parsed = json.loads(fragment, strict=False) - out = as_dict(parsed) - logger.info("[%s] Parsed JSON after extracting object fragment (%d chars)", source, len(fragment)) - return out - except json.JSONDecodeError as e2: + return schema.model_validate(payload) + except ValidationError as exc: + excerpt = _truncate(str(payload)) logger.warning( - "[%s] JSON parse failed (fragment): %s at line %s col %s; excerpt=%s", + "[%s] Structured validation failed for %s: %s; payload=%s", source, - e2.msg, - e2.lineno, - e2.colno, - repr(_truncate(fragment, 1200)), + schema.__name__, + exc.errors(), + repr(excerpt), ) - raise ValueError(f"{source}: could not parse JSON from model output: {e2.msg}") from e2 + raise diff --git a/app/llm/openai_client.py b/app/llm/openai_client.py index c9148c0..72b09ea 100644 --- a/app/llm/openai_client.py +++ b/app/llm/openai_client.py @@ -2,12 +2,15 @@ from __future__ import annotations -from typing import Any +from typing import TypeVar from openai import OpenAI +from pydantic import BaseModel from app.config import get_settings -from app.llm.json_response import parse_llm_json_object +from app.llm.json_response import validate_structured_output + +SchemaT = TypeVar("SchemaT", bound=BaseModel) class OpenAIClient: @@ -20,12 +23,14 @@ def __init__(self) -> None: self.model = settings.openai_model self.client = OpenAI(api_key=settings.openai_api_key) - def generate_json(self, prompt: str) -> dict[str, Any]: - """Generate JSON and parse the model response.""" + def generate_json(self, prompt: str, schema: type[SchemaT]) -> SchemaT: + """Generate schema-constrained JSON and return a validated model.""" - response = self.client.responses.create(model=self.model, input=prompt) + response = self.client.responses.parse(model=self.model, input=prompt, text_format=schema) + if response.output_parsed is not None: + return validate_structured_output(response.output_parsed, schema=schema, source="openai") text = response.output_text or "" - return parse_llm_json_object(text, source="openai") + return validate_structured_output(text, schema=schema, source="openai") def generate_text(self, prompt: str) -> str: """Generate free text for final user-facing output.""" diff --git a/app/schemas.py b/app/schemas.py index a60b45e..3329bab 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -4,7 +4,7 @@ from typing import Any, Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator class AnalyzeRequest(BaseModel): @@ -44,28 +44,25 @@ class ArtifactSummary(BaseModel): class CompiledPlanStep(BaseModel): """One row in a compiled multi-step SQL plan.""" + model_config = ConfigDict(extra="forbid") + id: int - purpose: str - type: Literal["sql"] = "sql" - query: str + purpose: str = Field(..., min_length=1) + type: Literal["sql"] + query: str = Field(..., min_length=1) output_alias: str | None = None class CompiledPlan(BaseModel): """Full planner output: up to three SQL steps in one response.""" - objective: str - plan: list[CompiledPlanStep] = Field(default_factory=list) - max_steps: int = 3 - metric: str = "" - metric_direction: str = "" + model_config = ConfigDict(extra="forbid") - @field_validator("plan") - @classmethod - def cap_plan_length(cls, v: list[CompiledPlanStep]) -> list[CompiledPlanStep]: - if len(v) > 3: - raise ValueError("plan must contain at most 3 steps") - return v + objective: str = Field(..., min_length=1) + plan: list[CompiledPlanStep] = Field(..., min_length=1, max_length=3) + max_steps: int + metric: str + metric_direction: str @field_validator("max_steps", mode="before") @classmethod @@ -77,10 +74,20 @@ def normalize_max_steps(cls, v: Any) -> int: class RepairDecision(BaseModel): """Planner repair output: replace one failed step.""" + model_config = ConfigDict(extra="forbid") + repair_action: Literal["replace_step"] updated_step: CompiledPlanStep +class AnalysisNarrativeResponse(BaseModel): + """Structured LLM output for the final user-facing narrative.""" + + model_config = ConfigDict(extra="forbid") + + analysis: str = Field(..., min_length=1) + + class ExecutedStep(BaseModel): """Execution log for one attempted step.""" @@ -102,6 +109,7 @@ class AnalyzeResponse(BaseModel): trace: list[TraceEvent] executed_steps: list[ExecutedStep] errors: list[ErrorItem] + inspection_id: str | None = None class HealthResponse(BaseModel): @@ -115,3 +123,88 @@ class SampleQuestionsResponse(BaseModel): """Sample questions endpoint response.""" questions: list[str] + + +class UploadedAsset(BaseModel): + """Workspace upload summary returned to the React UI.""" + + id: str + name: str + type: str + source: str + sizeLabel: str + uploadedAt: str + status: Literal["uploaded", "profiling", "verified", "error"] + rows: int | None = None + columns: int | None = None + summary: str | None = None + + +class UploadResponse(BaseModel): + """Upload endpoint response.""" + + asset: UploadedAsset + fallback: bool = False + + +class ResultTableData(BaseModel): + """Tabular preview data for the inspection drawer.""" + + columns: list[str] + rows: list[dict[str, str | int | float | None]] + + +class TraceEntry(BaseModel): + """One user-facing trace row for the inspection drawer.""" + + id: str + label: str + description: str + detail: str + durationLabel: str + status: Literal["valid", "warning", "error", "running", "complete"] + + +class ValidationCheck(BaseModel): + """One validation summary item for the inspection drawer.""" + + id: str + label: str + detail: str + status: Literal["pass", "warn", "fail"] + + +class MetadataItem(BaseModel): + """Compact metadata label/value pair for the inspection drawer.""" + + label: str + value: str + + +class InspectionData(BaseModel): + """Detailed inspection payload used by the React UI.""" + + id: str + title: str + query: str + status: Literal["valid", "warning", "error", "running"] + rowsReturned: int + runtimeMs: int | None = None + filters: list[str] + confidence: float + verified: bool + dataSource: str + lastUpdated: str + engine: str + queryType: str + results: ResultTableData + trace: list[TraceEntry] + validation: list[ValidationCheck] + metadata: list[MetadataItem] + + +class InspectionResponse(BaseModel): + """Inspection endpoint response.""" + + inspection: InspectionData + fallback: bool = False diff --git a/docker-compose.yml b/docker-compose.yml index 1d24222..e38acb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,19 +6,15 @@ services: - .env ports: - "8000:8000" - volumes: - - .:/app ui: - build: . - command: streamlit run ui/streamlit_app.py --server.address 0.0.0.0 --server.port 8501 - env_file: - - .env - environment: - API_BASE_URL: http://backend:8000 + build: + context: . + dockerfile: ui/Dockerfile + args: + VITE_API_BASE_URL: http://localhost:8000 + VITE_API_FALLBACK_MODE: live depends_on: - backend ports: - - "8501:8501" - volumes: - - .:/app + - "5173:5173" diff --git a/requirements.txt b/requirements.txt index 40b1e55..dd131e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,10 +7,10 @@ pandas==2.3.2 duckdb==1.4.0 pydantic==2.11.7 pydantic-settings==2.10.1 -streamlit==1.49.1 requests==2.32.5 google-genai==1.30.0 openai==1.107.1 pytest==8.4.1 httpx==0.28.1 - +nodeenv==1.9.1 +python-multipart==0.0.20 diff --git a/tests/test_api.py b/tests/test_api.py index 9b4337c..e8a7368 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,7 +1,11 @@ """API response structure tests.""" +from io import BytesIO + +import pytest from fastapi.testclient import TestClient +from app.api.workspace import clear_workspace_state from app.main import app from app.schemas import AnalyzeResponse @@ -9,6 +13,13 @@ client = TestClient(app) +@pytest.fixture(autouse=True) +def reset_workspace_state() -> None: + clear_workspace_state() + yield + clear_workspace_state() + + def test_analyze_response_accepts_skipped_trace() -> None: """execute_plan_node emits skipped when there is no plan; API must still serialize.""" resp = AnalyzeResponse( @@ -73,11 +84,103 @@ def fake_run_analysis(query: str) -> dict: # noqa: ARG001 original = routes.run_analysis routes.run_analysis = fake_run_analysis - response = client.post("/analyze", json={"query": "Why did pipeline velocity drop this week?"}) - routes.run_analysis = original + try: + response = client.post("/analyze", json={"query": "Why did pipeline velocity drop this week?"}) + finally: + routes.run_analysis = original assert response.status_code == 200 payload = response.json() - assert {"analysis", "trace", "executed_steps", "errors"} <= payload.keys() + assert {"analysis", "trace", "executed_steps", "errors", "inspection_id"} <= payload.keys() assert isinstance(payload["trace"], list) assert isinstance(payload["executed_steps"], list) assert isinstance(payload["analysis"], str) + assert isinstance(payload["inspection_id"], str) + + +def test_analyze_endpoint_returns_http_500_on_failure() -> None: + def fake_run_analysis(query: str) -> dict: # noqa: ARG001 + raise RuntimeError("planner exploded") + + import app.api.routes as routes + + original = routes.run_analysis + routes.run_analysis = fake_run_analysis + try: + response = client.post("/analyze", json={"query": "Why did pipeline velocity drop this week?"}) + finally: + routes.run_analysis = original + + assert response.status_code == 500 + payload = response.json() + assert payload["detail"]["message"] == "The analysis could not complete successfully. Inspect the server logs and retry." + assert payload["detail"]["error"] == "planner exploded" + + +def test_upload_endpoint_profiles_csv() -> None: + response = client.post( + "/uploads", + files={"file": ("pipeline.csv", BytesIO(b"stage,amount\nopen,10\nwon,25\n"), "text/csv")}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["fallback"] is False + assert payload["asset"]["name"] == "pipeline.csv" + assert payload["asset"]["type"] == "CSV" + assert payload["asset"]["rows"] == 2 + assert payload["asset"]["columns"] == 2 + assert payload["asset"]["status"] == "verified" + + +def test_inspection_endpoint_returns_stored_inspection() -> None: + def fake_run_analysis(query: str) -> dict: # noqa: ARG001 + return { + "analysis": "## Summary\nPipeline velocity improved.\n", + "trace": [{"step": "planner_compiled_node", "status": "completed", "details": {"objective": "x"}}], + "executed_steps": [ + { + "id": "step_1", + "kind": "sql", + "purpose": "Compare current and previous velocity.", + "code": "SELECT segment, 1 AS value FROM opportunities_enriched", + "output_alias": "comparison_result", + "attempt": 1, + "status": "success", + "artifact": { + "alias": "comparison_result", + "artifact_type": "table", + "row_count": 1, + "columns": ["segment", "value"], + "preview_rows": [{"segment": "SMB", "value": 1}], + "summary": {}, + }, + "error": None, + } + ], + "errors": [], + } + + import app.api.routes as routes + + original = routes.run_analysis + routes.run_analysis = fake_run_analysis + try: + analyze_response = client.post("/analyze", json={"query": "Why did pipeline velocity drop this week?"}) + finally: + routes.run_analysis = original + + inspection_id = analyze_response.json()["inspection_id"] + response = client.get(f"/inspections/{inspection_id}") + + assert response.status_code == 200 + payload = response.json() + assert payload["fallback"] is False + assert payload["inspection"]["id"] == inspection_id + assert payload["inspection"]["results"]["columns"] == ["segment", "value"] + assert payload["inspection"]["trace"][0]["label"] == "Query Planning" + + +def test_inspection_endpoint_returns_404_for_unknown_id() -> None: + response = client.get("/inspections/inspect_missing") + assert response.status_code == 404 + assert response.json()["detail"]["message"] == "Inspection not found." diff --git a/tests/test_intent.py b/tests/test_intent.py index 9fb3034..02da8fb 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -8,7 +8,7 @@ class FakeLLM: """Minimal stub for planner and analysis tests.""" - def generate_json(self, prompt: str): # noqa: ANN001 + def generate_json(self, prompt: str, schema=None): # noqa: ANN001, ARG002 if '"max_steps": 3' in prompt and "metric_direction" in prompt: return { "objective": "Compare current and previous pipeline velocity.", diff --git a/tests/test_llm_json.py b/tests/test_llm_json.py index 53c6a6d..de520a8 100644 --- a/tests/test_llm_json.py +++ b/tests/test_llm_json.py @@ -1,13 +1,33 @@ -"""Regression tests for LLM JSON parsing.""" +"""Regression tests for structured LLM response validation.""" from __future__ import annotations -from app.llm.json_response import parse_llm_json_object +import pytest +from pydantic import BaseModel, ConfigDict, ValidationError +from app.llm.json_response import validate_structured_output -def test_parse_llm_json_allows_literal_newlines_in_strings() -> None: - """Models often emit multi-line SQL without \\n escapes; strict JSON rejects that.""" - raw = '{"query": "SELECT\n1", "id": 1}' - out = parse_llm_json_object(raw, source="test") - assert out["query"] == "SELECT\n1" - assert out["id"] == 1 + +class DemoPayload(BaseModel): + """Small schema used to test structured JSON validation.""" + + model_config = ConfigDict(extra="forbid") + + query: str + id: int + + +def test_validate_structured_output_accepts_json_string() -> None: + raw = '{"query": "SELECT\\n1", "id": 1}' + out = validate_structured_output(raw, schema=DemoPayload, source="test") + assert out.query == "SELECT\n1" + assert out.id == 1 + + +def test_validate_structured_output_rejects_extra_keys() -> None: + with pytest.raises(ValidationError): + validate_structured_output( + {"query": "SELECT 1", "id": 1, "unexpected": "nope"}, + schema=DemoPayload, + source="test", + ) diff --git a/tests/test_planner_schema.py b/tests/test_planner_schema.py index 4a3cafe..0560371 100644 --- a/tests/test_planner_schema.py +++ b/tests/test_planner_schema.py @@ -53,7 +53,7 @@ class FlakyPlannerLLM: def __init__(self) -> None: self.calls = 0 - def generate_json(self, prompt: str): # noqa: ANN001 + def generate_json(self, prompt: str, schema=None): # noqa: ANN001, ARG002 self.calls += 1 if self.calls == 1: return bad diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..5c84cd0 --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app/ui + +COPY ui/package*.json ./ +RUN npm ci + +COPY ui ./ + +ARG VITE_API_BASE_URL=http://localhost:8000 +ARG VITE_API_FALLBACK_MODE=live + +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} +ENV VITE_API_FALLBACK_MODE=${VITE_API_FALLBACK_MODE} + +RUN npm run build + +EXPOSE 5173 + +CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/ui/README.md b/ui/README.md index 67676be..b54ef91 100644 --- a/ui/README.md +++ b/ui/README.md @@ -32,9 +32,9 @@ cp .env.example .env npm run dev ``` -4. Start the backend API separately on port `8000` +4. Start the backend API from this repo separately on port `8000` -The current live integration expects the FastAPI backend from [`/Users/ayushgaur/MLH_UV/gtm-copilot`](/Users/ayushgaur/MLH_UV/gtm-copilot) to be running at `http://localhost:8000`. +The current live integration expects the FastAPI backend in [`/Users/ayushgaur/MLH_UV/planera`](/Users/ayushgaur/MLH_UV/planera) to be running at `http://localhost:8000`. 4. Build for production @@ -42,6 +42,14 @@ The current live integration expects the FastAPI backend from [`/Users/ayushgaur npm run build ``` +## Quality Checks + +```bash +npm run lint +npm run test:run +npm run build +``` + ## Environment Required: @@ -106,12 +114,12 @@ The frontend keeps request logic out of presentational components. Update endpoi Current live contract: - `POST /analyze` is used for real chat submissions +- `POST /uploads` profiles CSV and TSV workspace uploads +- `GET /inspections/:id` fetches a stored inspection payload when it is not already cached client-side - `GET /sample-questions` can be added to the UI later for dynamic prompt suggestions -- live inspection data is derived from the `/analyze` response and cached client-side for the inspection drawer Current gaps in the backend contract: -- uploads still fall back to demo behavior unless a real `/uploads` endpoint is added - conversation history is currently local/demo until a backend history endpoint is introduced If you want the frontend to use only real backend data, set: diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..b3c72dd --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,35 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist"], + }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + }, + }, +); diff --git a/ui/package.json b/ui/package.json index cc7832b..ce9d949 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,8 +5,12 @@ "type": "module", "scripts": { "dev": "vite", + "lint": "eslint . --max-warnings=0", + "test": "vitest", + "test:run": "vitest run", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "check": "npm run lint && npm run test:run && npm run build" }, "dependencies": { "react": "^18.3.1", @@ -14,13 +18,23 @@ "react-router-dom": "^6.28.0" }, "devDependencies": { + "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.4.0", + "jsdom": "^26.1.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "typescript": "^5.6.2", - "vite": "^5.4.8" + "typescript-eslint": "^8.44.1", + "vite": "^5.4.8", + "vitest": "^2.1.8" } } diff --git a/ui/requirements.txt b/ui/requirements.txt deleted file mode 100644 index f69a126..0000000 --- a/ui/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -nodeenv diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index 25147b1..e0d61b4 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -3,15 +3,15 @@ import { cacheInspection } from "@/api/inspections"; import { conversationTitleFromPrompt, mapAnalyzeResponseToUi } from "@/api/mappers"; import type { AnalyzeApiRequest, AnalyzeApiResponse, ChatRequest, ChatResponse, ConversationsResponse } from "@/api/types"; import { seededConversations, buildAssistantMessage } from "@/data/mockChats"; -import { shouldFallbackToDemo } from "@/config/env"; +import { isDemoOnlyMode, shouldFallbackToDemo } from "@/config/env"; import { shortId, sleep } from "@/lib/utils"; import type { Conversation } from "@/types/chat"; export async function fetchConversations(): Promise { await sleep(120); return { - conversations: shouldFallbackToDemo ? seededConversations : [], - fallback: shouldFallbackToDemo, + conversations: isDemoOnlyMode ? seededConversations : [], + fallback: isDemoOnlyMode, }; } @@ -52,8 +52,8 @@ export function createConversationShell(title = "New analysis"): Conversation { id: shortId("chat"), title, updatedAt: new Date().toISOString(), - sourceLabel: "Untitled workspace", - mode: "demo", + sourceLabel: isDemoOnlyMode ? "Demo workspace" : "Workspace session", + mode: isDemoOnlyMode ? "demo" : "live", messages: [], }; } diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index f58e99e..d870b2b 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -19,6 +19,28 @@ function buildUrl(path: string) { return `${env.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; } +async function buildErrorMessage(response: Response) { + const text = await response.text(); + + if (!text) { + return "API request failed."; + } + + try { + const payload = JSON.parse(text) as { detail?: unknown; message?: unknown }; + if (typeof payload.detail === "string") return payload.detail; + if (payload.detail && typeof payload.detail === "object" && "message" in payload.detail) { + const message = (payload.detail as { message?: unknown }).message; + if (typeof message === "string") return message; + } + if (typeof payload.message === "string") return payload.message; + } catch { + // Fall through to the raw text body when the response is not JSON. + } + + return text; +} + export async function request(path: string, options: RequestOptions = {}): Promise { if (isDemoOnlyMode) { throw new ApiError("Demo-only mode enabled."); @@ -36,8 +58,7 @@ export async function request(path: string, options: RequestOptions = {}): Pr }); if (!response.ok) { - const text = await response.text(); - throw new ApiError(text || "API request failed.", response.status); + throw new ApiError(await buildErrorMessage(response), response.status); } if (options.raw) { diff --git a/ui/src/api/mappers.ts b/ui/src/api/mappers.ts index 621fc71..23a636e 100644 --- a/ui/src/api/mappers.ts +++ b/ui/src/api/mappers.ts @@ -29,7 +29,7 @@ export function conversationTitleFromPrompt(prompt: string) { } export function mapAnalyzeResponseToUi(prompt: string, response: AnalyzeApiResponse) { - const inspectionId = shortId("inspect"); + const inspectionId = response.inspection_id ?? shortId("inspect"); const inspection = buildInspection(inspectionId, prompt, response); const payload = buildAssistantPayload(response, inspection); diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 773aaa7..8153d72 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -74,4 +74,5 @@ export interface AnalyzeApiResponse { trace: AnalyzeTraceEvent[]; executed_steps: AnalyzeExecutedStep[]; errors: AnalyzeErrorItem[]; + inspection_id?: string; } diff --git a/ui/src/api/uploads.ts b/ui/src/api/uploads.ts index 145a008..c443f3c 100644 --- a/ui/src/api/uploads.ts +++ b/ui/src/api/uploads.ts @@ -20,10 +20,11 @@ export async function uploadDataset(file: File): Promise { return { asset: { ...createUploadedAsset(file), + source: "Demo fallback", status: "verified", rows: 12840, columns: 9, - summary: "Demo profiling completed. The uploaded file is ready for natural-language analysis.", + summary: "Demo fallback profiling completed. The uploaded file is shown with generated preview statistics.", }, fallback: true, }; diff --git a/ui/src/components/app/AppHeader.test.tsx b/ui/src/components/app/AppHeader.test.tsx new file mode 100644 index 0000000..745098e --- /dev/null +++ b/ui/src/components/app/AppHeader.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AppHeader } from "@/components/app/AppHeader"; + +describe("AppHeader", () => { + it("renders workspace status badges from props", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Revenue workspace" })).toBeInTheDocument(); + expect(screen.getByText("Connected backend")).toBeInTheDocument(); + expect(screen.getByText("Live mode")).toBeInTheDocument(); + expect(screen.getByText("Uploaded CSV")).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/app/AppHeader.tsx b/ui/src/components/app/AppHeader.tsx index 878f0d2..2e53571 100644 --- a/ui/src/components/app/AppHeader.tsx +++ b/ui/src/components/app/AppHeader.tsx @@ -5,7 +5,10 @@ interface AppHeaderProps { title: string; subtitle: string; uploadedLabel?: string; - demoMode?: boolean; + connectionLabel: string; + connectionTone?: "neutral" | "accent" | "success" | "warning" | "danger"; + modeLabel?: string; + modeTone?: "neutral" | "accent" | "success" | "warning" | "danger"; onToggleSidebar: () => void; showMenuButton?: boolean; } @@ -14,7 +17,10 @@ export function AppHeader({ title, subtitle, uploadedLabel, - demoMode = false, + connectionLabel, + connectionTone = "neutral", + modeLabel, + modeTone = "neutral", onToggleSidebar, showMenuButton = false, }: AppHeaderProps) { @@ -42,9 +48,9 @@ export function AppHeader({
- + {uploadedLabel ? : null} - {demoMode ? : null} + {modeLabel ? : null}
diff --git a/ui/src/hooks/useUpload.test.tsx b/ui/src/hooks/useUpload.test.tsx new file mode 100644 index 0000000..9334a06 --- /dev/null +++ b/ui/src/hooks/useUpload.test.tsx @@ -0,0 +1,32 @@ +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadUseUpload(mode: "demo" | "hybrid" | "live") { + vi.resetModules(); + vi.stubEnv("VITE_API_FALLBACK_MODE", mode); + const module = await import("@/hooks/useUpload"); + return module.useUpload; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("useUpload", () => { + it("seeds demo uploads only in demo-only mode", async () => { + const useUpload = await loadUseUpload("demo"); + const { result } = renderHook(() => useUpload()); + + expect(result.current.uploads.length).toBeGreaterThan(0); + expect(result.current.latestUploadMode).toBe("demo"); + }); + + it("starts with an empty upload list outside demo-only mode", async () => { + const useUpload = await loadUseUpload("hybrid"); + const { result } = renderHook(() => useUpload()); + + expect(result.current.uploads).toHaveLength(0); + expect(result.current.latestUploadMode).toBeNull(); + }); +}); diff --git a/ui/src/hooks/useUpload.ts b/ui/src/hooks/useUpload.ts index 022e75d..dacd1eb 100644 --- a/ui/src/hooks/useUpload.ts +++ b/ui/src/hooks/useUpload.ts @@ -1,12 +1,16 @@ import { useState } from "react"; import { uploadDataset } from "@/api/uploads"; +import { isDemoOnlyMode } from "@/config/env"; import { seededUploads } from "@/data/mockUploads"; import type { UploadedAsset } from "@/types/upload"; export function useUpload() { - const [uploads, setUploads] = useState(seededUploads); + const [uploads, setUploads] = useState(() => (isDemoOnlyMode ? seededUploads : [])); const [isUploading, setIsUploading] = useState(false); const [error, setError] = useState(null); + const [latestUploadMode, setLatestUploadMode] = useState<"live" | "demo" | null>(() => + isDemoOnlyMode && seededUploads.length > 0 ? "demo" : null, + ); const uploadFile = async (file: File) => { setIsUploading(true); @@ -15,6 +19,7 @@ export function useUpload() { try { const response = await uploadDataset(file); setUploads((current) => [response.asset, ...current]); + setLatestUploadMode(response.fallback ? "demo" : "live"); return response.asset; } catch (err) { const message = err instanceof Error ? err.message : "Unable to upload file."; @@ -29,6 +34,7 @@ export function useUpload() { uploads, isUploading, error, + latestUploadMode, uploadFile, }; } diff --git a/ui/src/lib/classNames.test.ts b/ui/src/lib/classNames.test.ts new file mode 100644 index 0000000..2a6eab2 --- /dev/null +++ b/ui/src/lib/classNames.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { classNames } from "@/lib/classNames"; + +describe("classNames", () => { + it("joins only truthy class values", () => { + expect(classNames("base", false, undefined, "active", null, "wide")).toBe("base active wide"); + }); + + it("returns an empty string when nothing is provided", () => { + expect(classNames(undefined, false, null)).toBe(""); + }); +}); diff --git a/ui/src/pages/AppPage.tsx b/ui/src/pages/AppPage.tsx index 3fd8178..ac361c1 100644 --- a/ui/src/pages/AppPage.tsx +++ b/ui/src/pages/AppPage.tsx @@ -14,6 +14,7 @@ import { ErrorState } from "@/components/shared/ErrorState"; import { PageContainer } from "@/components/shared/PageContainer"; import { Spinner } from "@/components/shared/Spinner"; import { savedAnalyses } from "@/data/mockInsights"; +import { env, isDemoOnlyMode } from "@/config/env"; import { useChat } from "@/hooks/useChat"; import { useInspectionPanel } from "@/hooks/useInspectionPanel"; import { useResponsiveSidebar } from "@/hooks/useResponsiveSidebar"; @@ -34,7 +35,7 @@ const dashboardMetrics = [ export function AppPage() { const { isMobile, collapsed, mobileOpen, closeMobileSidebar, toggleSidebar } = useResponsiveSidebar(); - const { uploads, isUploading, error: uploadError, uploadFile } = useUpload(); + const { uploads, isUploading, error: uploadError, latestUploadMode, uploadFile } = useUpload(); const { conversations, activeConversation, activeConversationId, loading, isSubmitting, error, startNewChat, selectConversation, sendPrompt } = useChat(); const inspection = useInspectionPanel(); const [draft, setDraft] = useState(""); @@ -55,7 +56,33 @@ export function AppPage() { return "Chat with data, inspect the execution path, and keep the technical details one click away."; }, [activeSection]); - const latestUploadLabel = uploads[0] ? `Uploaded ${uploads[0].type}` : undefined; + const workspaceStatus = useMemo(() => { + const hasAssistantMessage = activeConversation?.messages.some((message) => message.role === "assistant") ?? false; + const hasDemoActivity = isDemoOnlyMode || (activeConversation?.mode === "demo" && hasAssistantMessage) || latestUploadMode === "demo"; + const hasLiveActivity = (activeConversation?.mode === "live" && hasAssistantMessage) || latestUploadMode === "live"; + + if (isDemoOnlyMode) { + return { + connectionLabel: "Demo data source", + connectionTone: "warning" as const, + modeLabel: "Demo mode", + modeTone: "warning" as const, + }; + } + + return { + connectionLabel: hasDemoActivity ? "Demo fallback active" : hasLiveActivity ? "Connected backend" : "Backend configured", + connectionTone: hasDemoActivity ? ("warning" as const) : hasLiveActivity ? ("accent" as const) : ("neutral" as const), + modeLabel: env.apiFallbackMode === "hybrid" ? "Hybrid mode" : "Live mode", + modeTone: env.apiFallbackMode === "hybrid" ? ("neutral" as const) : ("success" as const), + }; + }, [activeConversation?.messages, activeConversation?.mode, latestUploadMode]); + + const latestUploadLabel = useMemo(() => { + if (!uploads[0]) return undefined; + const prefix = latestUploadMode === "demo" ? "Demo" : "Uploaded"; + return `${prefix} ${uploads[0].type}`; + }, [latestUploadMode, uploads]); const handleSectionChange = (section: SidebarSection) => { setActiveSection(section); @@ -123,11 +150,18 @@ export function AppPage() { const uploadsView = ( {uploadError ? : null} -
- {uploads.map((asset) => ( - - ))} -
+ {uploads.length ? ( +
+ {uploads.map((asset) => ( + + ))} +
+ ) : ( + + )}
); @@ -172,7 +206,7 @@ export function AppPage() {
diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/ui/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 564dad0..d409e75 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], @@ -9,4 +9,9 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + css: true, + }, }); From 98666ba9e79ff3ffa355ba28280ffbcb9b2c23d0 Mon Sep 17 00:00:00 2001 From: Ayush Gaur Date: Sat, 4 Apr 2026 15:08:19 -0400 Subject: [PATCH 2/3] Harden analytics grounding and schema-aware planning --- app/agent/analysis.py | 83 +- app/agent/analysis_grounding.py | 387 +++++ app/agent/executor.py | 29 + app/agent/planner.py | 239 ++- app/data/semantic_model.py | 158 +- app/prompts/__init__.py | 29 + app/prompts/analysis_render.j2 | 39 + app/prompts/planner_compiled.j2 | 44 + app/prompts/planner_repair.j2 | 33 + app/schemas.py | 106 +- requirements.txt | 1 + tests/test_analysis_grounding.py | 78 + tests/test_intent.py | 32 +- tests/test_planner_schema.py | 69 + ui/package-lock.json | 2555 +++++++++++++++++++++++++++++- ui/src/api/inspections.ts | 8 +- 16 files changed, 3722 insertions(+), 168 deletions(-) create mode 100644 app/agent/analysis_grounding.py create mode 100644 app/prompts/__init__.py create mode 100644 app/prompts/analysis_render.j2 create mode 100644 app/prompts/planner_compiled.j2 create mode 100644 app/prompts/planner_repair.j2 create mode 100644 tests/test_analysis_grounding.py diff --git a/app/agent/analysis.py b/app/agent/analysis.py index 8b48c61..f13e160 100644 --- a/app/agent/analysis.py +++ b/app/agent/analysis.py @@ -4,55 +4,60 @@ import json +from app.agent.analysis_grounding import build_analysis_evidence, build_approved_claims, validate_rendered_analysis from app.agent.state import AnalysisState from app.llm import get_llm_client -from app.schemas import AnalysisNarrativeResponse +from app.prompts import render_prompt +from app.schemas import AnalysisRenderResponse + +_ANALYSIS_RENDER_ATTEMPTS = 2 + + +def _build_analysis_render_prompt( + question: str, + approved_claims_json: str, + validation_feedback: str | None = None, +) -> str: + return render_prompt( + "analysis_render.j2", + question=question, + approved_claims_json=approved_claims_json, + validation_feedback=validation_feedback, + ) def run_analysis_narrative(state: AnalysisState) -> AnalysisState: """Produce markdown-friendly analysis from query, objective, and step outputs.""" - plan = state.get("compiled_plan") or {} - objective = plan.get("objective") or "" - metric = plan.get("metric") or state.get("metric") or "" - metric_direction = plan.get("metric_direction") or "" - workflow = state.get("workflow_status", "") - failure_note = "" if workflow in ("planner_failed", "execution_failed"): - errs = state.get("errors") or [] - summary = "; ".join(e.get("message", "") for e in errs[-3:]) if errs else "See trace and errors." - failure_note = f"\nWorkflow note: execution did not complete successfully ({workflow}). {summary}\n" - - prompt = f""" -You are a GTM analytics analyst. Explain what the data shows in response to the user's question. - -Rules: -- Base conclusions only on the executed steps and their artifacts below. Do not invent numbers. -- If there were no successful steps or data is insufficient, say so clearly. -- Use markdown: short headings, bullets where helpful. -- Follow the response schema exactly. The content should match this shape: -{{ "analysis": "" }} - -User question: -{state["query"]} - -Analytical objective (from planner): -{objective} - -Primary metric (if provided): {metric} -Metric directionality (if provided): {metric_direction} -{failure_note} -Dataset schema (reference): -{json.dumps(state["dataset_context"], indent=2)} - -Executed steps (with previews where available): -{json.dumps(state["executed_steps"], indent=2)} -""" + state["analysis"] = "The available evidence is insufficient because the workflow did not complete successfully." + return state + + evidence = build_analysis_evidence(state) + approved_claims, expected_status = build_approved_claims(evidence) + if not approved_claims: + state["analysis"] = "The approved claims are insufficient to answer the question with the available evidence." + return state + + approved_claims_json = json.dumps([claim.model_dump() for claim in approved_claims], indent=2) + feedback: str | None = None + try: - result = get_llm_client().generate_json(prompt, schema=AnalysisNarrativeResponse) - parsed = result if isinstance(result, AnalysisNarrativeResponse) else AnalysisNarrativeResponse.model_validate(result) - state["analysis"] = parsed.analysis.strip() or "No analysis text was returned." + for attempt in range(1, _ANALYSIS_RENDER_ATTEMPTS + 1): + prompt = _build_analysis_render_prompt(state["query"], approved_claims_json, validation_feedback=feedback) + result = get_llm_client().generate_json(prompt, schema=AnalysisRenderResponse) + parsed = result if isinstance(result, AnalysisRenderResponse) else AnalysisRenderResponse.model_validate(result) + try: + validate_rendered_analysis(parsed, approved_claims, expected_status) + except ValueError as exc: + feedback = str(exc) + if attempt >= _ANALYSIS_RENDER_ATTEMPTS: + raise + continue + + state["analysis"] = parsed.analysis_markdown.strip() or "No analysis text was returned." + return state except Exception as exc: # pragma: no cover - defensive state["analysis"] = ( f"The analysis step could not complete ({exc!s}). " diff --git a/app/agent/analysis_grounding.py b/app/agent/analysis_grounding.py new file mode 100644 index 0000000..6911a9e --- /dev/null +++ b/app/agent/analysis_grounding.py @@ -0,0 +1,387 @@ +"""Deterministic evidence, claims, and validation for grounded analysis output.""" + +from __future__ import annotations + +import re +from collections import defaultdict +from difflib import SequenceMatcher +from numbers import Real +from typing import Any + +from app.agent.state import AnalysisState +from app.schemas import AnalysisEvidence, AnalysisRenderResponse, ApprovedClaim, EvidenceItem, EvidenceValue + +_NEGATIVE_PREMISE_TERMS = ( + "drop", + "decline", + "decrease", + "down", + "worse", + "slower", + "underperform", + "fall", +) +_POSITIVE_PREMISE_TERMS = ( + "improve", + "increase", + "growth", + "better", + "faster", + "higher", + "gain", + "rise", +) +_CURRENT_TERMS = ("current", "latest", "this") +_PREVIOUS_TERMS = ("previous", "prior", "last") +_BLOCKED_TERMS = ( + "stable", + "strong", + "healthy", + "significant", + "improving", + "worsening", + "improved", + "worsened", + "cause", + "caused", + "driver", + "drivers", + "because", + "due to", + "root cause", +) +_GENERIC_PROPER_NOUN_ALLOWLIST = { + "Summary", + "Conclusion", + "Key Findings", + "Analysis", + "Evidence", + "Question", +} + + +def _is_number(value: Any) -> bool: + return isinstance(value, Real) and not isinstance(value, bool) + + +def _format_value(value: Any) -> str: + if value is None: + return "null" + if _is_number(value): + if float(value).is_integer(): + return str(int(value)) + return f"{float(value):.2f}".rstrip("0").rstrip(".") + return str(value) + + +def _infer_premise_hint(question: str) -> str: + lowered = question.lower() + if any(term in lowered for term in _NEGATIVE_PREMISE_TERMS): + return "deterioration" + if any(term in lowered for term in _POSITIVE_PREMISE_TERMS): + return "improvement" + return "" + + +def _row_label(row: dict[str, Any], non_numeric_columns: list[str], fallback: str) -> str: + values = [str(row[column]) for column in non_numeric_columns if row.get(column) not in (None, "")] + if not values: + return fallback + if len(values) == 1: + return values[0] + return " | ".join(values) + + +def _extract_entities(row: dict[str, Any], non_numeric_columns: list[str]) -> list[str]: + seen: list[str] = [] + for column in non_numeric_columns: + value = row.get(column) + if value in (None, ""): + continue + as_text = str(value) + if as_text not in seen: + seen.append(as_text) + return seen + + +def build_analysis_evidence(state: AnalysisState) -> AnalysisEvidence: + """Build a compact, domain-agnostic evidence packet from executed step previews.""" + + items: list[EvidenceItem] = [] + allowed_entities: list[str] = [] + + for step in state.get("executed_steps") or []: + if step.get("status") != "success": + continue + artifact = step.get("artifact") or {} + preview_rows = artifact.get("preview_rows") or [] + columns = artifact.get("columns") or [] + if not preview_rows or not columns: + continue + + numeric_columns = [ + column + for column in columns + if any(_is_number(row.get(column)) for row in preview_rows) + ] + non_numeric_columns = [column for column in columns if column not in numeric_columns] + + for index, row in enumerate(preview_rows, start=1): + entities = _extract_entities(row, non_numeric_columns) + for entity in entities: + if entity not in allowed_entities: + allowed_entities.append(entity) + values = [EvidenceValue(label=column, value=_format_value(row.get(column))) for column in columns if row.get(column) is not None] + items.append( + EvidenceItem( + id=f"{artifact.get('alias', step['output_alias'])}_row_{index}", + source_alias=artifact.get("alias", step["output_alias"]), + source_purpose=step["purpose"], + row_label=_row_label(row, non_numeric_columns, fallback=f"row_{index}"), + entities=entities, + metrics=numeric_columns, + values=values, + ) + ) + + return AnalysisEvidence( + question=state["query"], + primary_metric=state.get("metric", ""), + metric_direction=(state.get("compiled_plan") or {}).get("metric_direction", ""), + premise_hint=_infer_premise_hint(state["query"]), + items=items, + allowed_entities=allowed_entities, + ) + + +def _value_map(item: EvidenceItem) -> dict[str, str]: + return {value.label: value.value for value in item.values} + + +def _group_items_by_source(evidence: AnalysisEvidence) -> dict[str, list[EvidenceItem]]: + grouped: dict[str, list[EvidenceItem]] = defaultdict(list) + for item in evidence.items: + grouped[item.source_alias].append(item) + return grouped + + +def _sort_period_pair(items: list[EvidenceItem]) -> list[EvidenceItem]: + def score(item: EvidenceItem) -> int: + lowered = item.row_label.lower() + if any(term in lowered for term in _PREVIOUS_TERMS): + return 0 + if any(term in lowered for term in _CURRENT_TERMS): + return 1 + return 2 + + return sorted(items, key=score) + + +def _build_premise_claim(evidence: AnalysisEvidence) -> tuple[ApprovedClaim | None, str | None]: + if not evidence.primary_metric or not evidence.metric_direction or not evidence.premise_hint: + return None, None + + for source_alias, items in _group_items_by_source(evidence).items(): + matching = [item for item in items if evidence.primary_metric in item.metrics] + if len(matching) < 2: + continue + ordered = _sort_period_pair(matching[:2]) + left, right = ordered[0], ordered[1] + left_value = _value_map(left).get(evidence.primary_metric) + right_value = _value_map(right).get(evidence.primary_metric) + if left_value is None or right_value is None: + continue + + left_number = float(left_value) + right_number = float(right_value) + if evidence.metric_direction == "lower_is_better": + performance_change = "improved" if right_number < left_number else "deteriorated" if right_number > left_number else "flat" + elif evidence.metric_direction == "higher_is_better": + performance_change = "improved" if right_number > left_number else "deteriorated" if right_number < left_number else "flat" + else: + performance_change = "flat" + + contradicted = ( + (evidence.premise_hint == "deterioration" and performance_change == "improved") + or (evidence.premise_hint == "improvement" and performance_change == "deteriorated") + ) + statement = ( + f"The primary metric {evidence.primary_metric} was {left_value} for {left.row_label} and {right_value} for {right.row_label}. " + f"The metric direction is {evidence.metric_direction}." + ) + if contradicted: + statement += f" This does not support a {evidence.premise_hint} premise." + + return ( + ApprovedClaim( + id="claim_premise_check", + kind="premise_check", + statement=statement, + entities=[entity for entity in [left.row_label, right.row_label, *left.entities, *right.entities] if entity], + metrics=[evidence.primary_metric], + source_aliases=[source_alias], + values=[ + EvidenceValue(label=f"{left.row_label}.{evidence.primary_metric}", value=left_value), + EvidenceValue(label=f"{right.row_label}.{evidence.primary_metric}", value=right_value), + ], + ), + "contradicted_premise" if contradicted else None, + ) + return None, None + + +def _build_comparison_claims(evidence: AnalysisEvidence) -> list[ApprovedClaim]: + claims: list[ApprovedClaim] = [] + for source_alias, items in _group_items_by_source(evidence).items(): + if len(items) != 2: + continue + ordered = _sort_period_pair(items) + left, right = ordered[0], ordered[1] + common_metrics = [metric for metric in left.metrics if metric in right.metrics] + for metric in common_metrics[:6]: + left_value = _value_map(left).get(metric) + right_value = _value_map(right).get(metric) + if left_value is None or right_value is None: + continue + claim_id = f"claim_{source_alias}_{metric}" + claims.append( + ApprovedClaim( + id=claim_id, + kind="comparison", + statement=f"In {source_alias}, {metric} was {left_value} for {left.row_label} and {right_value} for {right.row_label}.", + entities=[entity for entity in [left.row_label, right.row_label, *left.entities, *right.entities] if entity], + metrics=[metric], + source_aliases=[source_alias], + values=[ + EvidenceValue(label=f"{left.row_label}.{metric}", value=left_value), + EvidenceValue(label=f"{right.row_label}.{metric}", value=right_value), + ], + ) + ) + return claims + + +def _build_row_observation_claims(evidence: AnalysisEvidence) -> list[ApprovedClaim]: + claims: list[ApprovedClaim] = [] + for item in evidence.items: + if not item.metrics: + continue + value_map = _value_map(item) + metric_pairs = [f"{metric} = {value_map[metric]}" for metric in item.metrics if metric in value_map][:4] + if not metric_pairs: + continue + metric_text = ", ".join(metric_pairs[:-1]) + (f", and {metric_pairs[-1]}" if len(metric_pairs) > 1 else metric_pairs[0]) + claims.append( + ApprovedClaim( + id=f"claim_{item.id}", + kind="row_observation", + statement=f"For {item.row_label}, {metric_text}.", + entities=item.entities or [item.row_label], + metrics=item.metrics, + source_aliases=[item.source_alias], + values=[EvidenceValue(label=metric, value=value_map[metric]) for metric in item.metrics if metric in value_map], + ) + ) + return claims[:8] + + +def build_approved_claims(evidence: AnalysisEvidence) -> tuple[list[ApprovedClaim], str]: + """Build deterministic claims and the expected final answer status.""" + + if not evidence.items: + return [], "insufficient_evidence" + + claims: list[ApprovedClaim] = [] + expected_status = "answered" + + premise_claim, premise_status = _build_premise_claim(evidence) + if premise_claim is not None: + claims.append(premise_claim) + if premise_status is not None: + expected_status = premise_status + + claims.extend(_build_comparison_claims(evidence)) + claims.extend(_build_row_observation_claims(evidence)) + + deduped: list[ApprovedClaim] = [] + seen_statements: set[str] = set() + for claim in claims: + if claim.statement in seen_statements: + continue + seen_statements.add(claim.statement) + deduped.append(claim) + + return deduped, expected_status + + +def _extract_numeric_tokens(text: str) -> set[str]: + return set(re.findall(r"\b\d+(?:\.\d+)?\b", text)) + + +def _candidate_entity_phrases(text: str) -> set[str]: + candidates: set[str] = set() + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + for match in re.findall(r"\b[A-Za-z][A-Za-z]*(?:\s+[A-Za-z][A-Za-z]*)+\b", stripped): + candidates.add(match.strip()) + return candidates + + +def validate_rendered_analysis( + response: AnalysisRenderResponse, + approved_claims: list[ApprovedClaim], + expected_status: str, +) -> None: + """Reject rendered analysis that steps outside the approved claim set.""" + + claim_by_id = {claim.id: claim for claim in approved_claims} + used_ids = response.used_claim_ids or [] + if expected_status != "insufficient_evidence" and not used_ids: + raise ValueError("The final analysis must cite at least one approved claim id.") + unknown_ids = [claim_id for claim_id in used_ids if claim_id not in claim_by_id] + if unknown_ids: + raise ValueError(f"Unknown claim ids in used_claim_ids: {unknown_ids}") + if response.answer_status != expected_status: + raise ValueError(f"answer_status must be {expected_status}, got {response.answer_status}") + + selected_claims = [claim_by_id[claim_id] for claim_id in used_ids if claim_id in claim_by_id] + allowed_numbers = { + token + for claim in selected_claims + for token in _extract_numeric_tokens(claim.statement) + } + unexpected_numbers = sorted(_extract_numeric_tokens(response.analysis_markdown) - allowed_numbers) + if unexpected_numbers: + raise ValueError(f"Analysis introduced numbers not present in the approved claims: {unexpected_numbers}") + + lowered_analysis = response.analysis_markdown.lower() + for term in _BLOCKED_TERMS: + if term in lowered_analysis: + raise ValueError(f"Analysis used unsupported wording: {term}") + + allowed_entities = { + entity + for claim in selected_claims + for entity in claim.entities + if entity + } + for candidate in _candidate_entity_phrases(response.analysis_markdown): + if candidate in allowed_entities or candidate in _GENERIC_PROPER_NOUN_ALLOWLIST: + continue + similar = max( + ( + SequenceMatcher(None, candidate.lower(), allowed.lower()).ratio() + for allowed in allowed_entities + ), + default=0.0, + ) + if similar >= 0.82: + raise ValueError(f"Analysis changed an approved entity name: {candidate}") + + if expected_status == "contradicted_premise": + first_line = next((line.strip() for line in response.analysis_markdown.splitlines() if line.strip()), "") + lowered_first_line = first_line.lower() + if "does not support" not in lowered_first_line and "contradict" not in lowered_first_line: + raise ValueError("A contradicted premise must be stated clearly in the first sentence.") diff --git a/app/agent/executor.py b/app/agent/executor.py index 5c00835..b9915e5 100644 --- a/app/agent/executor.py +++ b/app/agent/executor.py @@ -105,6 +105,35 @@ def compiled_plan_row_to_internal(row: dict[str, Any] | CompiledPlanStep) -> dic } +def preflight_compiled_plan(state: AnalysisState, compiled_plan: dict[str, Any]) -> dict[str, Any]: + """ + Validate compiled SQL steps against the active runtime before execution. + + Steps are checked in order so later queries can reference earlier output aliases. + """ + + conn = new_duckdb_connection() + _register_artifacts(conn, state) + rows = list(compiled_plan.get("plan") or []) + rows.sort(key=lambda r: r["id"] if isinstance(r, dict) else r.id) + + for row in rows: + internal = compiled_plan_row_to_internal(row) + sql = internal["code"].strip().rstrip(";") + try: + preview = conn.execute(f"SELECT * FROM ({sql}) AS __planera_preflight LIMIT 0").fetchdf() + conn.register(internal["output_alias"], preview) + except Exception as exc: + return { + "status": "failed", + "failed_step_id": internal["id"], + "error": str(exc), + "query": internal["code"], + } + + return {"status": "success"} + + def _try_sql_step( state: AnalysisState, internal: dict[str, Any], diff --git a/app/agent/planner.py b/app/agent/planner.py index c0fc727..0c9892a 100644 --- a/app/agent/planner.py +++ b/app/agent/planner.py @@ -3,105 +3,169 @@ from __future__ import annotations import json +import re +from copy import deepcopy +from typing import Any from pydantic import ValidationError +from app.agent.executor import preflight_compiled_plan from app.agent.state import AnalysisState from app.llm import get_llm_client +from app.prompts import render_prompt from app.schemas import CompiledPlan, RepairDecision from app.utils.logging import get_logger logger = get_logger(__name__) _COMPILED_PLANNER_ATTEMPTS = 3 +_MAX_PROMPT_RELATIONS = 4 +_MAX_COLUMNS_PER_RELATION = 18 + + +def _query_terms(question: str) -> set[str]: + return {token.lower() for token in re.findall(r"[a-zA-Z0-9_]+", question) if len(token) >= 3} + + +def _field_terms(*values: str) -> set[str]: + terms: set[str] = set() + for value in values: + normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value) + for token in re.findall(r"[a-zA-Z0-9]+", normalized.lower().replace("_", " ")): + if len(token) >= 3: + terms.add(token) + return terms + + +def _column_relevance_score(column: dict[str, Any], question_terms: set[str]) -> int: + column_terms = _field_terms(column.get("name", "")) + for hint in column.get("semantic_hints") or []: + column_terms.update(_field_terms(hint)) + + overlap = len(column_terms & question_terms) + score = overlap * 3 + if column.get("name", "").lower() in question_terms: + score += 4 + return score + + +def _relation_relevance_score(relation: dict[str, Any], question_terms: set[str]) -> int: + score = len(_field_terms(relation.get("name", ""), relation.get("grain", "")) & question_terms) * 4 + score += sum(_column_relevance_score(column, question_terms) for column in relation.get("columns") or []) + for mapping in relation.get("semantic_mappings") or []: + score += len(_field_terms(mapping.get("concept", "")) & question_terms) * 5 + return score + + +def _trim_relation_for_prompt(relation: dict[str, Any], question_terms: set[str]) -> dict[str, Any]: + trimmed = deepcopy(relation) + columns = list(trimmed.get("columns") or []) + if len(columns) <= _MAX_COLUMNS_PER_RELATION: + return trimmed + + ranked = sorted( + columns, + key=lambda column: ( + _column_relevance_score(column, question_terms), + column.get("name") in (trimmed.get("identifier_columns") or []), + column.get("name") in (trimmed.get("time_columns") or []), + ), + reverse=True, + ) + selected: list[dict[str, Any]] = [] + selected_names: set[str] = set() + for column in ranked: + name = column.get("name", "") + if not name or name in selected_names: + continue + selected.append(column) + selected_names.add(name) + if len(selected) >= _MAX_COLUMNS_PER_RELATION: + break + + trimmed["columns"] = selected + trimmed["identifier_columns"] = [name for name in trimmed.get("identifier_columns") or [] if name in selected_names] + trimmed["time_columns"] = [name for name in trimmed.get("time_columns") or [] if name in selected_names] + trimmed["measure_columns"] = [name for name in trimmed.get("measure_columns") or [] if name in selected_names] + trimmed["dimension_columns"] = [name for name in trimmed.get("dimension_columns") or [] if name in selected_names] + trimmed["semantic_mappings"] = [ + mapping + for mapping in (trimmed.get("semantic_mappings") or []) + if any(name in selected_names for name in mapping.get("columns") or []) + ][:12] + trimmed["omitted_column_count"] = max(len(columns) - len(selected), 0) + return trimmed + + +def _schema_subset_for_question(dataset_context: dict[str, Any], question: str) -> dict[str, Any]: + relations = list(dataset_context.get("relations") or []) + if not relations: + return dataset_context + + total_columns = sum(len(relation.get("columns") or []) for relation in relations) + if len(relations) <= _MAX_PROMPT_RELATIONS and total_columns <= (_MAX_PROMPT_RELATIONS * _MAX_COLUMNS_PER_RELATION): + return { + "reference_date": dataset_context.get("reference_date", ""), + "source": dataset_context.get("source", ""), + "dialect": dataset_context.get("dialect", ""), + "relations": relations, + } + + question_terms = _query_terms(question) + ranked_relations = sorted(relations, key=lambda relation: _relation_relevance_score(relation, question_terms), reverse=True) + selected = ranked_relations[:_MAX_PROMPT_RELATIONS] + return { + "reference_date": dataset_context.get("reference_date", ""), + "source": dataset_context.get("source", ""), + "dialect": dataset_context.get("dialect", ""), + "relations": [_trim_relation_for_prompt(relation, question_terms) for relation in selected], + } + + +def _relation_names(dataset_context: dict[str, Any]) -> list[str]: + relations = dataset_context.get("relations") or [] + if relations: + return [relation["name"] for relation in relations] + return [view["name"] for view in dataset_context.get("views", [])] + + +def _planner_preflight_feedback(outcome: dict[str, Any], schema_subset: dict[str, Any]) -> str: + return ( + "Your previous plan failed SQL preflight validation.\n" + f"Failed step id: {outcome.get('failed_step_id', '')}\n" + f"Error: {outcome.get('error', '')}\n" + f"SQL:\n{outcome.get('query', '').strip()}\n\n" + "Fix guidance:\n" + "- Use only exact relation and column names from the schema subset.\n" + "- Resolve business-language terms through semantic mappings, then use the mapped exact field names in SQL.\n" + "- Do not invent fields or rename columns.\n" + "- If the question premise might be wrong, start with an overall comparison before segment-level breakdowns.\n" + f"- Target SQL dialect: {schema_subset.get('dialect', '') or 'unknown'}.\n" + ) def _build_compiled_planner_prompt(state: AnalysisState, validation_feedback: str | None = None) -> str: - view_names = [view["name"] for view in state["dataset_context"].get("views", [])] - - prompt = f""" -You are the planning component of a GTM analytics agent. - -Return a single JSON object that describes a full multi-step plan to answer the user's question using only the dataset described below. - -Rules: -- Follow the response schema exactly. -- Produce 1 to 3 items in "plan" (at most three SQL steps). Each step must add incremental explanatory value; avoid redundant segmentation. -- CRITICAL — the "max_steps" field: set it to the integer 3 always. It is the platform's fixed ceiling, not the count of steps you return. Do not set max_steps to 1 or 2 even if the plan has only one or two queries. -- Every step must use "type": "sql" and put the full SQL statement in "query". -- Use only these registered view/table names: {json.dumps(view_names)} -- Prefer SQL over multiple trivial splits; combine logic when one query suffices. -- No imports, file I/O, network calls, or plotting. -- Optional "output_alias" per step for stable names; if omitted, the executor uses `step_`. - -Return JSON in this exact shape: -{{ - "objective": "string — what the plan will establish end-to-end", - "plan": [ - {{ - "id": 1, - "purpose": "string", - "type": "sql", - "query": "SQL query string" - }} - ], - "max_steps": 3, - "metric": "optional short label for the primary metric, or empty string", - "metric_direction": "optional: e.g. higher_is_better or lower_is_better, or empty string" -}} - -User query: -{state["query"]} - -Dataset schema (tables, columns, dtypes, row counts): -{json.dumps(state["dataset_context"], indent=2)} -""" - if validation_feedback: - prompt += f""" - -Your previous JSON was rejected by validation. Fix the structure and try again. -Validation errors: -{validation_feedback} -""" - return prompt.strip() + schema_subset = _schema_subset_for_question(state["dataset_context"], state["query"]) + relation_names = _relation_names(schema_subset) + return render_prompt( + "planner_compiled.j2", + query=state["query"], + relation_names_json=json.dumps(relation_names), + schema_subset_json=json.dumps(schema_subset, indent=2), + validation_feedback=validation_feedback, + ) def _build_repair_prompt(state: AnalysisState, failed_step_id: str, error_message: str) -> str: plan = state.get("compiled_plan") or {} - prompt = f""" -You are the planning component of a GTM analytics agent. A SQL step from an existing plan failed execution. - -Repair the failed step only: return JSON that replaces that step with corrected SQL. Do not add new steps. - -Rules: -- Follow the response schema exactly. -- repair_action must be "replace_step". -- updated_step must use "type": "sql", the same id as the failed step ({failed_step_id}), and a fixed "query". -- Use only registered view names from the schema manifest. - -Original plan: -{json.dumps(plan, indent=2)} - -Failed step id: {failed_step_id} - -Error message: -{error_message} - -Schema manifest: -{json.dumps(state["dataset_context"], indent=2)} - -Return JSON in this shape: -{{ - "repair_action": "replace_step", - "updated_step": {{ - "id": , - "purpose": "string", - "type": "sql", - "query": "corrected SQL" - }} -}} -""" - return prompt.strip() + schema_subset = _schema_subset_for_question(state["dataset_context"], state["query"]) + return render_prompt( + "planner_repair.j2", + failed_step_id=failed_step_id, + error_message=error_message, + plan_json=json.dumps(plan, indent=2), + schema_subset_json=json.dumps(schema_subset, indent=2), + ) def plan_compiled_query(state: AnalysisState) -> AnalysisState: @@ -127,6 +191,19 @@ def plan_compiled_query(state: AnalysisState) -> AnalysisState: raise continue + preflight = preflight_compiled_plan(state, parsed.model_dump()) + if preflight["status"] == "failed": + feedback = _planner_preflight_feedback(preflight, _schema_subset_for_question(state["dataset_context"], state["query"])) + logger.warning( + "Compiled plan preflight failed (attempt %s/%s): %s", + attempt, + _COMPILED_PLANNER_ATTEMPTS, + preflight["error"], + ) + if attempt >= _COMPILED_PLANNER_ATTEMPTS: + raise ValueError(preflight["error"]) + continue + state["compiled_plan"] = parsed.model_dump() state["planner_reasoning"] = parsed.objective state["metric"] = parsed.metric diff --git a/app/data/semantic_model.py b/app/data/semantic_model.py index 0588788..3cdcef6 100644 --- a/app/data/semantic_model.py +++ b/app/data/semantic_model.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from functools import lru_cache from typing import Any @@ -10,6 +11,7 @@ import pandas as pd from app.data.loader import load_data +from app.schemas import SchemaColumn, SchemaConceptMapping, SchemaManifest, SchemaRelation @dataclass(frozen=True) @@ -23,12 +25,136 @@ class SemanticContext: schema_manifest: dict[str, Any] -def _schema_for_frame(name: str, frame: pd.DataFrame) -> dict[str, Any]: - return { - "name": name, - "row_count": int(len(frame)), - "columns": [{"name": col, "dtype": str(frame[col].dtype)} for col in frame.columns], - } +_SEMANTIC_ALIAS_LEXICON: dict[str, list[str]] = { + "owner": ["agent", "rep", "representative", "sales rep", "assignee"], + "manager": ["manager", "lead", "supervisor", "team lead"], + "regional_office": ["region", "regional office", "office", "territory"], + "account_id": ["account", "customer", "client", "account identifier"], + "deal_id": ["deal", "opportunity", "opportunity identifier"], + "deal_value": ["revenue", "deal size", "amount", "value"], + "stage": ["status stage", "pipeline stage"], + "segment": ["customer segment", "market segment"], + "pipeline_velocity_days": ["pipeline velocity", "cycle time", "sales cycle length"], +} + + +def _split_identifier(value: str) -> list[str]: + cleaned = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value) + tokens = [token.lower() for token in re.split(r"[^a-zA-Z0-9]+", cleaned) if token] + return tokens + + +def _type_family(dtype: str) -> str: + lower = dtype.lower() + if any(token in lower for token in ("int", "float", "double", "decimal")): + return "number" + if "bool" in lower: + return "boolean" + if any(token in lower for token in ("datetime", "timestamp", "date")): + return "datetime" + if any(token in lower for token in ("object", "string", "category")): + return "string" + return "unknown" + + +def _semantic_hints(column_name: str) -> list[str]: + tokens = _split_identifier(column_name) + hints = {column_name, column_name.replace("_", " ")} + hints.update(tokens) + if column_name.endswith("_id"): + base = column_name[: -len("_id")].replace("_", " ").strip() + if base: + hints.add(f"{base} id") + hints.add(f"{base} identifier") + + for key, aliases in _SEMANTIC_ALIAS_LEXICON.items(): + key_tokens = set(_split_identifier(key)) + if column_name == key or key_tokens.issubset(set(tokens)): + hints.update(aliases) + + return sorted(hint for hint in hints if hint) + + +def _is_identifier_column(column_name: str, series: pd.Series) -> bool: + lowered = column_name.lower() + if lowered == "id" or lowered.endswith("_id"): + return True + non_null = series.dropna() + return bool(len(non_null) == len(series) and len(non_null) > 0 and non_null.nunique(dropna=False) == len(series)) + + +def _infer_grain(name: str, frame: pd.DataFrame, identifier_columns: list[str]) -> str: + if identifier_columns: + primary = identifier_columns[0] + if primary.lower().endswith("_id"): + entity = primary[: -len("_id")].replace("_", " ").strip() + if entity: + return f"Approximately one row per {entity}" + return f"Rows can be keyed by {primary}" + return f"Rows represent records in {name}" + + +def _build_semantic_mappings(columns: list[SchemaColumn]) -> list[SchemaConceptMapping]: + concept_to_columns: dict[str, set[str]] = {} + for column in columns: + for hint in column.semantic_hints: + normalized_hint = hint.strip().lower() + if not normalized_hint or normalized_hint == column.name.lower(): + continue + concept_to_columns.setdefault(normalized_hint, set()).add(column.name) + + mappings: list[SchemaConceptMapping] = [] + for concept, mapped_columns in sorted(concept_to_columns.items()): + if len(concept) < 4: + continue + mappings.append( + SchemaConceptMapping( + concept=concept, + columns=sorted(mapped_columns), + ) + ) + return mappings[:20] + + +def _relation_for_frame(name: str, frame: pd.DataFrame, kind: str = "view") -> SchemaRelation: + columns: list[SchemaColumn] = [] + identifier_columns: list[str] = [] + time_columns: list[str] = [] + measure_columns: list[str] = [] + dimension_columns: list[str] = [] + + for column_name in frame.columns: + dtype = str(frame[column_name].dtype) + family = _type_family(dtype) + column = SchemaColumn( + name=column_name, + dtype=dtype, + type_family=family, + semantic_hints=_semantic_hints(column_name), + ) + columns.append(column) + + if _is_identifier_column(column_name, frame[column_name]): + identifier_columns.append(column_name) + if family == "datetime": + time_columns.append(column_name) + elif family == "number": + measure_columns.append(column_name) + else: + dimension_columns.append(column_name) + + return SchemaRelation( + name=name, + kind=kind, + row_count=int(len(frame)), + grain=_infer_grain(name, frame, identifier_columns), + identifier_columns=identifier_columns, + time_columns=time_columns, + measure_columns=measure_columns, + dimension_columns=dimension_columns, + columns=columns, + semantic_mappings=_build_semantic_mappings(columns), + ) @lru_cache(maxsize=1) @@ -39,11 +165,21 @@ def get_semantic_context() -> SemanticContext: raw_views = {name: frame.copy() for name, frame in bundle.raw_views.items()} semantic_views = {"opportunities_enriched": bundle.crm.copy()} all_frames = {**raw_views, **semantic_views} - schema_manifest: dict[str, Any] = { - "reference_date": bundle.reference_date, - "source": bundle.source, - "views": [_schema_for_frame(name, frame) for name, frame in all_frames.items()], - } + relations = [_relation_for_frame(name, frame, kind="view") for name, frame in all_frames.items()] + schema_manifest = SchemaManifest( + reference_date=bundle.reference_date, + source=bundle.source, + dialect="duckdb", + relations=relations, + views=[ + { + "name": relation.name, + "row_count": relation.row_count, + "columns": [{"name": column.name, "dtype": column.dtype} for column in relation.columns], + } + for relation in relations + ], + ).model_dump() return SemanticContext( reference_date=bundle.reference_date, source=bundle.source, diff --git a/app/prompts/__init__.py b/app/prompts/__init__.py new file mode 100644 index 0000000..baf40c6 --- /dev/null +++ b/app/prompts/__init__.py @@ -0,0 +1,29 @@ +"""Jinja-backed prompt rendering helpers.""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + + +@lru_cache(maxsize=1) +def _prompt_environment() -> Environment: + """Create a strict Jinja environment for agent prompts.""" + + templates_dir = Path(__file__).resolve().parent + return Environment( + loader=FileSystemLoader(str(templates_dir)), + autoescape=False, + trim_blocks=False, + lstrip_blocks=False, + undefined=StrictUndefined, + ) + + +def render_prompt(template_name: str, **context: object) -> str: + """Render a prompt template with the provided context.""" + + template = _prompt_environment().get_template(template_name) + return template.render(**context).strip() diff --git a/app/prompts/analysis_render.j2 b/app/prompts/analysis_render.j2 new file mode 100644 index 0000000..9121462 --- /dev/null +++ b/app/prompts/analysis_render.j2 @@ -0,0 +1,39 @@ +You are a careful, domain-agnostic data analyst. + +Task: +Answer the question using only the approved claims provided. + +Hard rules: +- Use only the approved claims. +- Do not add new facts, names, labels, metrics, dates, or interpretations. +- Do not restate any entity, label, or metric unless it appears exactly in the approved claims. +- Use entity strings exactly as provided. Do not abbreviate, normalize, paraphrase, or rename them. +- Do not infer causes, drivers, intent, explanations, or business meaning unless explicitly stated in an approved claim. +- Do not derive new metrics, percentages, rankings, or comparisons unless they are explicitly stated in an approved claim. +- If an approved claim contradicts the question premise, state that in the first sentence clearly and directly. +- Prefer direct numeric comparisons that already appear in approved claims. +- Do not use unsupported adjectives such as "stable", "strong", "healthy", "significant", "improving", or "worsening" unless the exact wording appears in an approved claim. +- If the approved claims are insufficient to answer the question, say so explicitly. +- If the approved claims conflict with each other, say the evidence is internally inconsistent. +- Use only the minimum set of approved claims needed to answer the question. + +Output rules: +- Return only valid JSON. +- The JSON must match this schema exactly: + { + "answer_status": "answered" | "insufficient_evidence" | "contradicted_premise" | "conflicting_evidence", + "analysis_markdown": string, + "used_claim_ids": string[] + } + +Question: +{{ question }} + +Approved claims: +{{ approved_claims_json }} +{% if validation_feedback %} + +Your previous response was rejected by validation. +Validation feedback: +{{ validation_feedback }} +{% endif %} diff --git a/app/prompts/planner_compiled.j2 b/app/prompts/planner_compiled.j2 new file mode 100644 index 0000000..d57d691 --- /dev/null +++ b/app/prompts/planner_compiled.j2 @@ -0,0 +1,44 @@ +You are the planning component of a domain-agnostic data analysis agent. + +Return a single JSON object that describes a full multi-step plan to answer the user's question using only the normalized schema manifest described below. + +Rules: +- Follow the response schema exactly. +- Produce 1 to 3 items in "plan" (at most three SQL steps). Each step must add incremental explanatory value; avoid redundant segmentation. +- CRITICAL — the "max_steps" field: set it to the integer 3 always. It is the platform's fixed ceiling, not the count of steps you return. Do not set max_steps to 1 or 2 even if the plan has only one or two queries. +- Every step must use "type": "sql" and put the full SQL statement in "query". +- Use only these registered relation names: {{ relation_names_json }} +- Use exact column names only. Never invent, normalize, paraphrase, or rename a column. +- Use semantic mappings only to translate user language into exact schema fields. +- Do not assume the question premise is true. First verify the main metric or comparison before planning causal or grouped breakdowns. +- Prefer SQL over multiple trivial splits; combine logic when one query suffices. +- No imports, file I/O, network calls, or plotting. +- Optional "output_alias" per step for stable names; if omitted, the executor uses `step_`. + +Return JSON in this exact shape: +{ + "objective": "string — what the plan will establish end-to-end", + "plan": [ + { + "id": 1, + "purpose": "string", + "type": "sql", + "query": "SQL query string" + } + ], + "max_steps": 3, + "metric": "optional short label for the primary metric, or empty string", + "metric_direction": "optional: e.g. higher_is_better or lower_is_better, or empty string" +} + +User query: +{{ query }} + +Normalized schema subset (relations, exact fields, types, grain, semantic mappings): +{{ schema_subset_json }} +{% if validation_feedback %} + +Your previous attempt was rejected. Fix it and try again. +Feedback: +{{ validation_feedback }} +{% endif %} diff --git a/app/prompts/planner_repair.j2 b/app/prompts/planner_repair.j2 new file mode 100644 index 0000000..8e697d2 --- /dev/null +++ b/app/prompts/planner_repair.j2 @@ -0,0 +1,33 @@ +You are the planning component of a domain-agnostic data analysis agent. A SQL step from an existing plan failed execution. + +Repair the failed step only: return JSON that replaces that step with corrected SQL. Do not add new steps. + +Rules: +- Follow the response schema exactly. +- repair_action must be "replace_step". +- updated_step must use "type": "sql", the same id as the failed step ({{ failed_step_id }}), and a fixed "query". +- Use only exact relation and column names from the schema manifest. +- Use semantic mappings only to translate user language into exact schema fields. +- Do not invent fields or rename columns. + +Original plan: +{{ plan_json }} + +Failed step id: {{ failed_step_id }} + +Error message: +{{ error_message }} + +Relevant schema subset: +{{ schema_subset_json }} + +Return JSON in this shape: +{ + "repair_action": "replace_step", + "updated_step": { + "id": , + "purpose": "string", + "type": "sql", + "query": "corrected SQL" + } +} diff --git a/app/schemas.py b/app/schemas.py index 3329bab..279b89c 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -80,12 +80,114 @@ class RepairDecision(BaseModel): updated_step: CompiledPlanStep -class AnalysisNarrativeResponse(BaseModel): +class SchemaConceptMapping(BaseModel): + """Heuristic business-language alias mapped to exact schema fields.""" + + model_config = ConfigDict(extra="forbid") + + concept: str = Field(..., min_length=1) + columns: list[str] = Field(default_factory=list) + confidence: Literal["heuristic", "explicit"] = "heuristic" + + +class SchemaColumn(BaseModel): + """Normalized schema field description used by the planner.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(..., min_length=1) + dtype: str = Field(..., min_length=1) + type_family: Literal["string", "number", "boolean", "datetime", "unknown"] = "unknown" + semantic_hints: list[str] = Field(default_factory=list) + + +class SchemaRelation(BaseModel): + """One normalized table or view available to the planner.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(..., min_length=1) + kind: Literal["table", "view"] = "view" + row_count: int = 0 + grain: str = "" + identifier_columns: list[str] = Field(default_factory=list) + time_columns: list[str] = Field(default_factory=list) + measure_columns: list[str] = Field(default_factory=list) + dimension_columns: list[str] = Field(default_factory=list) + columns: list[SchemaColumn] = Field(default_factory=list) + semantic_mappings: list[SchemaConceptMapping] = Field(default_factory=list) + + +class SchemaManifest(BaseModel): + """Source-agnostic schema manifest consumed by the planner.""" + + model_config = ConfigDict(extra="forbid") + + reference_date: str = "" + source: str = "" + dialect: str = "" + relations: list[SchemaRelation] = Field(default_factory=list) + views: list[dict[str, Any]] = Field(default_factory=list) + + +class EvidenceValue(BaseModel): + """One exact label/value pair carried into the analysis evidence packet.""" + + model_config = ConfigDict(extra="forbid") + + label: str = Field(..., min_length=1) + value: str = Field(..., min_length=1) + + +class EvidenceItem(BaseModel): + """One deterministic evidence row extracted from a successful artifact preview.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., min_length=1) + source_alias: str = Field(..., min_length=1) + source_purpose: str = Field(..., min_length=1) + row_label: str = Field(..., min_length=1) + entities: list[str] = Field(default_factory=list) + metrics: list[str] = Field(default_factory=list) + values: list[EvidenceValue] = Field(default_factory=list) + + +class AnalysisEvidence(BaseModel): + """Compact, domain-agnostic evidence passed into the analysis layer.""" + + model_config = ConfigDict(extra="forbid") + + question: str = Field(..., min_length=1) + primary_metric: str = "" + metric_direction: str = "" + premise_hint: str = "" + items: list[EvidenceItem] = Field(default_factory=list) + allowed_entities: list[str] = Field(default_factory=list) + + +class ApprovedClaim(BaseModel): + """Deterministic claim approved for final narrative rendering.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(..., min_length=1) + kind: Literal["premise_check", "comparison", "row_observation", "caveat"] + statement: str = Field(..., min_length=1) + entities: list[str] = Field(default_factory=list) + metrics: list[str] = Field(default_factory=list) + source_aliases: list[str] = Field(default_factory=list) + values: list[EvidenceValue] = Field(default_factory=list) + + +class AnalysisRenderResponse(BaseModel): """Structured LLM output for the final user-facing narrative.""" model_config = ConfigDict(extra="forbid") - analysis: str = Field(..., min_length=1) + answer_status: Literal["answered", "insufficient_evidence", "contradicted_premise", "conflicting_evidence"] + analysis_markdown: str = Field(..., min_length=1) + used_claim_ids: list[str] = Field(default_factory=list) class ExecutedStep(BaseModel): diff --git a/requirements.txt b/requirements.txt index dd131e7..c26faee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ pytest==8.4.1 httpx==0.28.1 nodeenv==1.9.1 python-multipart==0.0.20 +Jinja2==3.1.6 diff --git a/tests/test_analysis_grounding.py b/tests/test_analysis_grounding.py new file mode 100644 index 0000000..7135bea --- /dev/null +++ b/tests/test_analysis_grounding.py @@ -0,0 +1,78 @@ +"""Tests for deterministic analysis evidence, approved claims, and rendering validation.""" + +from __future__ import annotations + +import pytest + +from app.agent.analysis_grounding import build_analysis_evidence, build_approved_claims, validate_rendered_analysis +from app.agent.state import create_initial_state +from app.schemas import AnalysisRenderResponse, ApprovedClaim, EvidenceValue + + +def test_build_approved_claims_marks_contradicted_premise() -> None: + state = create_initial_state("Why did pipeline velocity drop this week?") + state["metric"] = "avg_pipeline_velocity_days" + state["compiled_plan"] = {"metric_direction": "lower_is_better"} + state["executed_steps"] = [ + { + "id": "1", + "purpose": "Compare weekly metrics", + "status": "success", + "output_alias": "weekly_pipeline_metrics", + "artifact": { + "alias": "weekly_pipeline_metrics", + "columns": ["period", "avg_pipeline_velocity_days"], + "preview_rows": [ + {"period": "Previous Week", "avg_pipeline_velocity_days": 69.9423076923077}, + {"period": "Current Week", "avg_pipeline_velocity_days": 64.13291139240506}, + ], + }, + } + ] + + evidence = build_analysis_evidence(state) + claims, status = build_approved_claims(evidence) + + assert status == "contradicted_premise" + assert claims[0].kind == "premise_check" + assert "does not support a deterioration premise" in claims[0].statement + + +def test_validate_rendered_analysis_rejects_changed_entity_name() -> None: + claim = ApprovedClaim( + id="claim_manager", + kind="row_observation", + statement="For Celia Rouche, current_week_avg_velocity = 64.65.", + entities=["Celia Rouche"], + metrics=["current_week_avg_velocity"], + source_aliases=["regional_manager_velocity"], + values=[EvidenceValue(label="current_week_avg_velocity", value="64.65")], + ) + response = AnalysisRenderResponse( + answer_status="answered", + analysis_markdown="## Summary\nFor eCelia Rouche, current_week_avg_velocity = 64.65.", + used_claim_ids=["claim_manager"], + ) + + with pytest.raises(ValueError, match="changed an approved entity name"): + validate_rendered_analysis(response, [claim], expected_status="answered") + + +def test_validate_rendered_analysis_rejects_unapproved_numbers() -> None: + claim = ApprovedClaim( + id="claim_manager", + kind="row_observation", + statement="For Celia Rouche, current_week_avg_velocity = 64.65.", + entities=["Celia Rouche"], + metrics=["current_week_avg_velocity"], + source_aliases=["regional_manager_velocity"], + values=[EvidenceValue(label="current_week_avg_velocity", value="64.65")], + ) + response = AnalysisRenderResponse( + answer_status="answered", + analysis_markdown="## Summary\nFor Celia Rouche, current_week_avg_velocity = 60.", + used_claim_ids=["claim_manager"], + ) + + with pytest.raises(ValueError, match="introduced numbers not present"): + validate_rendered_analysis(response, [claim], expected_status="answered") diff --git a/tests/test_intent.py b/tests/test_intent.py index 02da8fb..db126b0 100644 --- a/tests/test_intent.py +++ b/tests/test_intent.py @@ -25,11 +25,17 @@ def generate_json(self, prompt: str, schema=None): # noqa: ANN001, ARG002 "metric": "pipeline_velocity", "metric_direction": "lower_is_better", } - if '"analysis":' in prompt and "markdown" in prompt.lower(): + if '"analysis_markdown": string' in prompt and "approved claims" in prompt.lower(): return { - "analysis": "## Summary\nPipeline velocity improved from 69.77 to 66.14 days week over week.\n\n**Focus:** Enterprise Stage 2.", + "answer_status": "answered", + "analysis_markdown": "## Summary\nThe available evidence shows value = 1 for SMB.", + "used_claim_ids": ["claim_comparison_result_row_1"], } - return {"analysis": "Fallback analysis."} + return { + "answer_status": "insufficient_evidence", + "analysis_markdown": "The approved claims are insufficient to answer the question.", + "used_claim_ids": [], + } def test_planner_returns_compiled_plan(monkeypatch) -> None: @@ -48,7 +54,21 @@ def test_analysis_narrative_uses_llm(monkeypatch) -> None: state = create_initial_state("Why did pipeline velocity drop this week?") state["dataset_context"] = {"reference_date": "2017-12-31", "views": []} state["compiled_plan"] = {"objective": "Test", "metric": "", "metric_direction": ""} - state["executed_steps"] = [{"id": "step_1", "purpose": "Compare", "status": "success", "output_alias": "comparison_result", "artifact": {"row_count": 2}}] + state["executed_steps"] = [ + { + "id": "step_1", + "purpose": "Compare", + "status": "success", + "output_alias": "comparison_result", + "artifact": { + "alias": "comparison_result", + "row_count": 1, + "columns": ["segment", "value"], + "preview_rows": [{"segment": "SMB", "value": 1}], + "summary": {}, + }, + } + ] state = run_analysis_narrative(state) - assert "Pipeline velocity improved" in state["analysis"] - assert "Enterprise" in state["analysis"] + assert "value = 1" in state["analysis"] + assert "SMB" in state["analysis"] diff --git a/tests/test_planner_schema.py b/tests/test_planner_schema.py index 0560371..a82500e 100644 --- a/tests/test_planner_schema.py +++ b/tests/test_planner_schema.py @@ -2,6 +2,7 @@ from app.agent.planner import plan_compiled_query from app.agent.state import create_initial_state +from app.data.semantic_model import get_semantic_context from app.schemas import CompiledPlan @@ -69,3 +70,71 @@ def generate_json(self, prompt: str, schema=None): # noqa: ANN001, ARG002 assert stub.calls == 2 assert state["compiled_plan"] is not None assert len(state["compiled_plan"]["plan"]) == 1 + + +def test_semantic_context_exposes_normalized_manifest() -> None: + manifest = get_semantic_context().schema_manifest + + assert manifest["dialect"] == "duckdb" + assert manifest["relations"] + relation = next(relation for relation in manifest["relations"] if relation["name"] == "opportunities_enriched") + assert relation["columns"] + assert relation["identifier_columns"] + assert relation["grain"] + + +def test_planner_retries_after_sql_preflight_failure(monkeypatch) -> None: + bad = { + "objective": "Analyze by sales agent", + "plan": [ + { + "id": 1, + "purpose": "Break out velocity by agent", + "type": "sql", + "query": "SELECT sales_agent, AVG(pipeline_velocity_days) AS avg_velocity_days FROM opportunities_enriched GROUP BY sales_agent", + "output_alias": "velocity_by_agent", + } + ], + "max_steps": 3, + "metric": "pipeline_velocity_days", + "metric_direction": "lower_is_better", + } + good = { + "objective": "Analyze by owner", + "plan": [ + { + "id": 1, + "purpose": "Break out velocity by owner", + "type": "sql", + "query": "SELECT owner, AVG(pipeline_velocity_days) AS avg_velocity_days FROM opportunities_enriched GROUP BY owner", + "output_alias": "velocity_by_owner", + } + ], + "max_steps": 3, + "metric": "pipeline_velocity_days", + "metric_direction": "lower_is_better", + } + + class FlakyPlannerLLM: + def __init__(self) -> None: + self.calls = 0 + self.prompts: list[str] = [] + + def generate_json(self, prompt: str, schema=None): # noqa: ANN001, ARG002 + self.calls += 1 + self.prompts.append(prompt) + if self.calls == 1: + return bad + return good + + stub = FlakyPlannerLLM() + monkeypatch.setattr("app.agent.planner.get_llm_client", lambda: stub) + + state = create_initial_state("Why did pipeline velocity drop this week by sales agent?") + state["dataset_context"] = get_semantic_context().schema_manifest + state = plan_compiled_query(state) + + assert stub.calls == 2 + assert "failed SQL preflight validation" in stub.prompts[1] + assert state["compiled_plan"] is not None + assert "owner" in state["compiled_plan"]["plan"][0]["query"] diff --git a/ui/package-lock.json b/ui/package-lock.json index 66c2d09..86b6773 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -13,16 +13,33 @@ "react-router-dom": "^6.28.0" }, "devDependencies": { + "@eslint/js": "^9.36.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.4.0", + "jsdom": "^26.1.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "typescript": "^5.6.2", - "vite": "^5.4.8" + "typescript-eslint": "^8.44.1", + "vite": "^5.4.8", + "vitest": "^2.1.8" } }, + "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/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -36,6 +53,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -270,6 +308,16 @@ "@babel/core": "^7.0.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", @@ -318,6 +366,121 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -709,6 +872,215 @@ "node": ">=12" } }, + "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.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.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/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1163,6 +1535,90 @@ "win32" ] }, + "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/@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/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1215,6 +1671,13 @@ "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/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1243,28 +1706,513 @@ "@types/react": "^18.0.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/any-promise": { + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "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/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/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/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/@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.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.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/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "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/@typescript-eslint/visitor-keys/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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.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.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", @@ -1292,6 +2240,33 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/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/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1329,6 +2304,13 @@ "postcss": "^8.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", @@ -1355,6 +2337,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1402,6 +2395,26 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1433,6 +2446,50 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1471,6 +2528,26 @@ "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/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1481,6 +2558,13 @@ "node": ">= 6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1488,6 +2572,28 @@ "dev": true, "license": "MIT" }, + "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.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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1501,6 +2607,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1508,6 +2628,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1526,6 +2660,40 @@ } } }, + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1540,6 +2708,14 @@ "dev": true, "license": "MIT" }, + "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.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -1547,6 +2723,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1596,6 +2792,223 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.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", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.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-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1626,6 +3039,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -1636,6 +3063,19 @@ "reusify": "^1.0.4" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1649,6 +3089,44 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -1708,20 +3186,144 @@ "is-glob": "^4.0.3" }, "engines": { - "node": ">=10.13.0" + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "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/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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", - "dependencies": { - "function-bind": "^1.1.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" } }, "node_modules/is-binary-path": { @@ -1786,6 +3388,20 @@ "node": ">=0.12.0" } }, + "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": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -1802,6 +3418,59 @@ "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": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1815,6 +3484,27 @@ "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", @@ -1828,6 +3518,30 @@ "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/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1848,6 +3562,29 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1860,6 +3597,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1870,6 +3614,27 @@ "yallist": "^3.0.2" } }, + "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1894,6 +3659,29 @@ "node": ">=8.6" } }, + "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": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1932,6 +3720,13 @@ "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.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -1949,6 +3744,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1969,6 +3771,102 @@ "node": ">= 6" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.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-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1976,6 +3874,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2179,6 +4094,56 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/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/pretty-format/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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2225,6 +4190,14 @@ "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/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2290,6 +4263,20 @@ "node": ">=8.10.0" } }, + "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/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -2311,6 +4298,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2367,6 +4364,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -2391,6 +4395,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "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", @@ -2410,6 +4434,36 @@ "semver": "bin/semver.js" } }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2420,6 +4474,46 @@ "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/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -2443,6 +4537,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2456,6 +4563,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -2517,6 +4631,20 @@ "node": ">=0.8" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2565,6 +4693,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2578,6 +4756,45 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "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/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -2585,6 +4802,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2599,6 +4829,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.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/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2630,6 +4884,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2697,12 +4961,257 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/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/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" + } } } } diff --git a/ui/src/api/inspections.ts b/ui/src/api/inspections.ts index 84d42dd..e7af710 100644 --- a/ui/src/api/inspections.ts +++ b/ui/src/api/inspections.ts @@ -28,10 +28,6 @@ export async function fetchInspection(inspectionId: string): Promise(`/inspections/${inspectionId}`); - return { ...response, fallback: false }; - } catch (error) { - throw error; - } + const response = await request(`/inspections/${inspectionId}`); + return { ...response, fallback: false }; } From 9b0be647c1e36d74ddddae352152daab49f34e7c Mon Sep 17 00:00:00 2001 From: Ayush Gaur Date: Sat, 4 Apr 2026 15:26:13 -0400 Subject: [PATCH 3/3] Fix frontend build and lint configuration --- ui/package-lock.json | 18 ++++++++++++++++++ ui/package.json | 3 ++- ui/src/api/mappers.ts | 2 +- ui/tsconfig.json | 3 +-- ui/tsconfig.node.json | 12 ++++++++++-- ui/vite.config.ts | 3 +++ 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 86b6773..bf10269 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -16,6 +16,7 @@ "@eslint/js": "^9.36.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@types/node": "^22.19.17", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -1678,6 +1679,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4853,6 +4864,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", diff --git a/ui/package.json b/ui/package.json index ce9d949..081f1b6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --max-warnings=0", "test": "vitest", "test:run": "vitest run", - "build": "tsc -b && vite build", + "build": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json && vite build", "preview": "vite preview", "check": "npm run lint && npm run test:run && npm run build" }, @@ -21,6 +21,7 @@ "@eslint/js": "^9.36.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@types/node": "^22.19.17", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/ui/src/api/mappers.ts b/ui/src/api/mappers.ts index 23a636e..b81bf9d 100644 --- a/ui/src/api/mappers.ts +++ b/ui/src/api/mappers.ts @@ -558,7 +558,7 @@ function normalizeCell(value: unknown): string | number | null { return JSON.stringify(value); } -function formatUnknownValue(value: unknown) { +function formatUnknownValue(value: unknown): string { if (Array.isArray(value)) return value.map((item) => formatUnknownValue(item)).join(", "); if (value == null) return "n/a"; if (typeof value === "object") return JSON.stringify(value); diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 7b4ea2f..0505dd6 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -20,6 +20,5 @@ "@/*": ["src/*"] } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": ["src"] } diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json index 9d31e2a..6ff3ba8 100644 --- a/ui/tsconfig.node.json +++ b/ui/tsconfig.node.json @@ -1,9 +1,17 @@ { "compilerOptions": { "composite": true, + "target": "ES2022", + "lib": ["ES2023"], "module": "ESNext", - "moduleResolution": "Node", - "allowSyntheticDefaultImports": true + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] }, "include": ["vite.config.ts"] } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d409e75..9db220f 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,7 +1,10 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ plugins: [react()], resolve: {