diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..936d8951fbcd79a0eaeb311c5d00b9ad4615288d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +.env +references/ +**/.venv/ +**/node_modules/ +**/__pycache__/ +**/.pytest_cache/ +**/.mypy_cache/ +**/.ruff_cache/ +**/dist/ +**/.vite/ +backend/data/cache/ +backend/data/*.db +backend/data/*.db-journal +backend/tests/ +*.md +!README.md +!DEPLOY.md +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..6c9778a2d82a49e55eab6fa45ae412dc4cbf4ee8 --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# ============ Anthropic Claude API ============ +# Required for Agent functionality. +# - Official: get key at https://console.anthropic.com/ +# - Third-party (NewAPI / OneAPI / zhihuiapi / AnyAPI): paste their key + set +# ANTHROPIC_BASE_URL below to their domain. +ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Optional: third-party Claude-compatible proxy base URL. +# Leave empty for official Anthropic endpoint. Examples: +# ANTHROPIC_BASE_URL=https://cc.zhihuiapi.top +# ANTHROPIC_BASE_URL=https://api.deepseek.com (if their endpoint is Claude-compatible) +ANTHROPIC_BASE_URL= + +# Optional: separate auth-token header (some proxies require it). Defaults to +# ANTHROPIC_API_KEY if empty. +ANTHROPIC_AUTH_TOKEN= + +# Default model used by the agent (sonnet for balanced speed/cost). +# NewAPI / OneAPI / zhihuiapi proxies usually require the full dated ID, e.g. +# ANTHROPIC_MODEL=claude-sonnet-4-5-20250929 +ANTHROPIC_MODEL=claude-sonnet-4-5 + +# Optional: model used for "deep analysis" mode if you want a stronger one +ANTHROPIC_DEEP_MODEL=claude-opus-4-7 + +# Agent runtime: +# direct - use anthropic Python SDK (works with any Claude-compatible proxy, +# default; recommended for zhihuiapi/OneAPI/NewAPI/etc.) +# sdk - use claude-agent-sdk subprocess (Claude Code protocol; only the +# official api.anthropic.com supports this) +AGENT_RUNTIME=direct + +# ============ Backend ============ +BACKEND_HOST=0.0.0.0 +BACKEND_PORT=8000 + +# CORS: comma-separated list of allowed origins for frontend dev +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# SQLite database path (relative to backend/) +DATABASE_URL=sqlite:///./data/bayesscen.db + +# Where to cache external data fetches (relative to backend/) +DATA_CACHE_DIR=./data/cache + +# ============ Frontend (Vite reads VITE_* vars) ============ +VITE_API_BASE_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..992537b5527ab4f92b85a5e011b93b888e3925c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# ============ Python ============ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +.env +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ + +# ============ Node / Frontend ============ +node_modules/ +.pnpm-store/ +.next/ +.cache/ +dist/ +.turbo/ +.vite/ + +# ============ Data / Cache ============ +backend/data/cache/ +backend/data/*.db +backend/data/*.db-journal + +# ============ IDE / OS ============ +.DS_Store +.idea/ +.vscode/ +*.swp + +# ============ References (cloned read-only, not committed) ============ +references/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000000000000000000000000000000000000..b31e44cf13a80140382d05c79776bf9f6258ac0b --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,151 @@ +# 部署指南 + +本项目包含三个推荐部署路径,按"轻 → 重"排列。 + +--- + +## 0. 先决条件 + +- 一个 Anthropic API Key(在 申请) +- 已克隆本仓库 + +复制环境变量模板并填入 Key: + +```bash +cp .env.example .env +$EDITOR .env # 至少填 ANTHROPIC_API_KEY +``` + +--- + +## 1. 本地一键启动(Docker Compose,推荐入门) + +需要:Docker Desktop 24+ 或 Docker Engine + Compose plugin。 + +```bash +docker compose up --build -d +``` + +启动后: + +- 前端: +- 后端 OpenAPI:(通过 nginx 反代) +- 数据持久化在命名卷 `backend-data`(包含 SQLite + 缓存) + +查看日志:`docker compose logs -f backend` +停止:`docker compose down` +完全清理(含数据):`docker compose down -v` + +修改 `HTTP_PORT=8081` 可改外暴露端口。 + +--- + +## 2. VPS 自托管(DigitalOcean / 阿里云 / 腾讯云) + +最小配置:1 vCPU / 2 GB RAM / 20 GB SSD(够中等并发;Agent 调用主要消耗 Anthropic 侧算力)。 + +```bash +# 1. 装 Docker (Ubuntu 24.04) +curl -fsSL https://get.docker.com | sudo sh +sudo usermod -aG docker $USER +newgrp docker + +# 2. 拉代码 +git clone bayesscenparams-agent +cd bayesscenparams-agent + +# 3. 填环境变量 +cp .env.example .env +nano .env + +# 4. 启动 +docker compose up --build -d + +# 5. 套个 HTTPS(Caddy 最简单) +sudo apt install -y caddy +sudo tee /etc/caddy/Caddyfile > /dev/null < SSE 注意事项:任何反代(nginx、Caddy、Cloudflare)都必须关闭对 `/api/agent/chat` +> 的缓冲(`proxy_buffering off` / `flush_interval -1` / Cloudflare "Cache Level: Bypass")。 + +--- + +## 3. 云托管(前后端拆开,零运维) + +### 后端 → Railway / Fly.io / Render + +以 **Railway** 为例: + +1. 新建项目 → "Deploy from GitHub Repo" +2. 选 root 目录里的 `backend/`(或在 Railway 设置里把 root path 改为 `backend`) +3. 它会自动检测 `Dockerfile` 并构建 +4. 在 Variables 里加: + - `ANTHROPIC_API_KEY` + - `CORS_ORIGINS` = `https://<你的前端域名>` (Vercel 提供的) +5. 公开服务并记下生成的 URL,如 `https://bayesscen-api.up.railway.app` + +数据持久化:Railway 默认给容器一个临时盘;要持久化 SQLite 请在 Volumes 里挂 +`/app/data`。中等流量也可换成 `DATABASE_URL` 指向 Railway Postgres(需要把 +`app/core/db.py` 改成 SQLAlchemy 实现,列入 Phase 4)。 + +### 前端 → Vercel + +```bash +cd frontend +npm i -g vercel +vercel +# 第一次会问几个问题:Project name? bayesscen-frontend ; Output dir? dist +``` + +在 Vercel 项目 → Settings → Environment Variables 加: + +- `VITE_API_BASE_URL` = 后端公开 URL(如 `https://bayesscen-api.up.railway.app`) + +注意:把后端 URL 加进 Railway 的 `CORS_ORIGINS` 列表里。 + +--- + +## 4. 健康检查与可观测性 + +- `GET /api/health` 返回 200 + `{anthropic_key_present, model, ...}` +- 后端日志结构化,关键字段:`anthropic key`、`model`、`tool` 调用名 +- 前端 ChatPane 在每一轮结束后会显示 turns / duration / cost(来自 Agent SDK 的 `result` 消息) + +## 5. 升级流程 + +```bash +git pull +docker compose up --build -d # 仅重启变化的服务 +``` + +后端代码 + 前端代码的更新都被 Docker 层缓存覆盖,依赖未变时通常 < 30s 完成。 + +## 6. 故障排查速查 + +| 现象 | 原因 / 解决 | +|------|-------------| +| 前端"未配置 API Key" | `.env` 里 `ANTHROPIC_API_KEY` 没填或仍是占位符 | +| SSE 连接卡住 / 半秒钟才出字 | 反代缓冲没关;nginx 加 `proxy_buffering off`,Cloudflare 开 grey-cloud | +| `/api/agent/chat` 返回 401 | API Key 失效;检查 Anthropic 控制台余额 | +| 前端 CORS 报错 | 后端 `CORS_ORIGINS` 没加前端域名 | +| World Bank 调用超时 | CCKP 接口偶尔抽风;已自动缓存到 `data/cache`,重试即可 | +| 容器内存爆 | NumPy + SciPy 基线 ~200 MB;至少给 1 GB | +| Sonnet 4.5 调用慢 | 切到 `claude-haiku-4-5`(更便宜更快但推理弱):改 `ANTHROPIC_MODEL` 即可 | + +## 7. 不在本指南范围内(可在 README 跟进规划) + +- 多租户 / 用户认证(Clerk / Supabase Auth) +- 速率限制(Cloudflare / Caddy rate_limit) +- 监控告警(OpenTelemetry → Grafana) +- 对象存储(用户上传的大型 CSV 走 S3 / R2) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..766d656308e78a1c7100fa24b8a0997eb83b0272 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# syntax=docker/dockerfile:1.7 +# +# Single-container build for Hugging Face Spaces (Docker SDK). +# Stage 1: build the React/Vite frontend → static dist +# Stage 2: install Python deps and bundle the frontend dist into the API image +# +# Listens on 7860 (HF Spaces default). FastAPI serves both /api/* and the SPA. + +# -------- frontend -------- +FROM node:22-alpine AS frontend-builder +WORKDIR /web + +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci --no-audit --no-fund --progress=false + +COPY frontend/tsconfig*.json frontend/vite.config.ts frontend/postcss.config.js \ + frontend/index.html ./ +COPY frontend/src ./src + +# Same-origin: no VITE_API_BASE_URL means the SPA calls /api on its own host +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +RUN npm run build + +# -------- backend + final -------- +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PORT=7860 \ + HOME=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python deps +RUN pip install --upgrade pip \ + && pip install \ + 'fastapi>=0.115.0' \ + 'uvicorn[standard]>=0.32.0' \ + 'python-multipart>=0.0.12' \ + 'sse-starlette>=2.1.0' \ + 'pydantic>=2.9.0' \ + 'pydantic-settings>=2.5.0' \ + 'httpx>=0.27.0' \ + 'numpy>=2.0.0' \ + 'scipy>=1.14.0' \ + 'sqlalchemy>=2.0.0' \ + 'aiosqlite>=0.20.0' \ + 'anthropic>=0.40.0' \ + 'claude-agent-sdk>=0.1.0' + +# Copy backend source +COPY backend/app ./app +COPY backend/pyproject.toml ./ + +# Copy frontend build artefacts +COPY --from=frontend-builder /web/dist /app/frontend_dist + +# Hugging Face Spaces runs as UID 1000 by default +RUN useradd -m -u 1000 user \ + && mkdir -p /app/data/cache /app/data/sessions \ + && chown -R user:user /app +USER user + +# Where to read writable state (HF Spaces free tier has no persistent disk; +# this just survives within a single Space session) +ENV DATA_CACHE_DIR=/app/data/cache \ + DATABASE_URL=sqlite:////app/data/bayesscen.db \ + AGENT_RUNTIME=direct \ + BACKEND_HOST=0.0.0.0 \ + BACKEND_PORT=7860 + +EXPOSE 7860 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsS http://localhost:7860/api/health || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md index 3b1a4594dfab3ea2730fa56c20989bc4d6eca44e..771d2d335fe3cf5ef4ee86d94c00717e1d1266b6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,220 @@ --- -title: Bayesscenparams -emoji: 👁 -colorFrom: green -colorTo: purple +title: BayesScenParams Agent +emoji: 📊 +colorFrom: blue +colorTo: green sdk: docker -pinned: false +app_port: 7860 +pinned: true +license: mit +short_description: 对话驱动的贝叶斯情景参数自主研究分析 Agent +tags: + - bayesian + - scenario-analysis + - climate + - economics + - research-agent + - claude + - mcp --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# BayesScenParams Agent + +> 对话驱动的贝叶斯情景参数自主研究分析平台 +> +> Conversational autonomous research agent for Bayesian scenario parameter quantification. + +把 [Kemp-Benedict (2010)][1] 的贝叶斯情景参数量化方法包装成一个自主研究 Agent:你提一个研究问题,Agent 自主完成数据抓取、先验构建、专家判断推理、后验计算、稳健性检验、报告生成。 + +## 技术栈 + +- **后端**:Python 3.12 + FastAPI + [`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk/) + NumPy/SciPy +- **前端**:React 19 + Vite + TypeScript + Tailwind v4 + Recharts +- **数据源**:World Bank WDI(经济)+ CCKP CMIP6(气候)+ 内置示例数据集 +- **持久化**:SQLite(会话 + 历史) +- **算法核心**:从原 `app/js/bayesian.js` 移植到 `backend/app/domain/bayesian.py`,含 15 个 JS↔Python 一致性测试(误差 < 1e-9) + +## 项目状态 + +| 阶段 | 内容 | 状态 | +|------|------|------| +| MVP | 算法移植 + REST API + 5 个 MCP 工具 + SSE 流 + React 聊天界面 + 工作台图表 | 完成 | +| Phase 2 | multi-expert / multi-param / preprocessing 三个高阶模块 + Markdown 报告生成器 + SQLite 会话存储 | 完成 | +| Phase 3 | Docker + docker-compose + 部署文档 | 完成 | +| Phase 4 (未来) | 用户认证 / 速率限制 / Postgres / 监控 | 规划中 | + +测试覆盖:**66 个后端测试** + **0 个 ESLint 错误** + **TypeScript 严格模式** + **生产构建通过**。 + +## 目录结构 + +``` +bayesscenparams-agent/ +├── backend/ # Python FastAPI + Claude Agent SDK +│ ├── app/ +│ │ ├── api/ # REST + SSE 路由 +│ │ │ ├── health.py +│ │ │ ├── bayesian.py # compute / sensitivity / kde / report +│ │ │ ├── data.py # samples / upload +│ │ │ ├── agent.py # SSE Agent 端点 +│ │ │ └── sessions.py # 会话管理 +│ │ ├── core/ +│ │ │ ├── config.py # 环境变量 +│ │ │ └── db.py # SQLite +│ │ ├── agent/ +│ │ │ ├── orchestrator.py # claude-agent-sdk 包装 +│ │ │ ├── prompts.py # 中英双语系统提示词 +│ │ │ ├── tool_store.py # 工具间共享状态 + SSE 桥接 +│ │ │ └── tools/ # 8 个 in-process MCP 工具 +│ │ ├── domain/ # 纯算法(无 I/O) +│ │ │ ├── bayesian.py # 核心:先验/似然/后验/敏感性/KDE +│ │ │ ├── multi_expert.py # 加权几何均值 + DS 合成 + Kendall's W +│ │ │ ├── multi_param.py # 相关性 + KL / JS / Wasserstein +│ │ │ ├── preprocessing.py # 异常值 + 正态检验 + Box-Cox / Yeo-Johnson +│ │ │ └── report.py # Markdown 报告 +│ │ └── data_sources/ +│ │ ├── worldbank.py # WDI + CCKP 客户端 + 本地缓存 +│ │ └── samples/ # 内置示例数据集 (GDP / 气候 / 人口) +│ ├── tests/ # 66 个测试,含 JS↔Python 算法一致性 +│ ├── pyproject.toml +│ └── Dockerfile +├── frontend/ # React 19 + Vite + Tailwind v4 +│ ├── src/ +│ │ ├── App.tsx +│ │ ├── main.tsx +│ │ ├── components/ +│ │ │ ├── chat/ # ChatPane / ToolCallCard / ChatInput / EmptyState / useAgentChat +│ │ │ ├── bayesian/ # Workspace 右侧渲染面板 +│ │ │ ├── charts/ # PriorPosteriorChart / KDEChart / SensitivityChart +│ │ │ ├── layout/ # Header +│ │ │ └── ui/ # Button / Card / Badge / Stat +│ │ ├── lib/ # api / agentStream / types / cn +│ │ └── styles/globals.css +│ ├── vite.config.ts +│ ├── tailwind.config.js (内联于 globals.css,Tailwind v4) +│ ├── nginx.conf # 生产部署的反代配置 +│ └── Dockerfile +├── references/ # 只读参考项目(不进 git) +├── docker-compose.yml # 一键启动 +├── .env.example +├── DEPLOY.md # 部署指南(本地/VPS/云托管三条路径) +└── README.md +``` + +## 快速开始(开发环境) + +### 1. 准备环境变量 + +```bash +cp .env.example .env +# 编辑 .env 填入 ANTHROPIC_API_KEY +``` + +### 2. 启动后端 + +```bash +cd backend +# 第一次:装 Python 3.12+ 和 uv(uv 自动管理虚拟环境) +# brew install uv / curl -LsSf https://astral.sh/uv/install.sh | sh +uv venv --python 3.12 .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +uv pip install -e ".[dev]" + +# 跑测试 +pytest + +# 启动 +uvicorn app.main:app --reload --port 8000 +``` + +访问 看 OpenAPI 文档。 + +### 3. 启动前端 + +```bash +cd frontend +npm install +npm run dev +``` + +打开 。 + +## 一键启动(Docker Compose) + +```bash +cp .env.example .env +$EDITOR .env # 填 ANTHROPIC_API_KEY +docker compose up --build -d +``` + +访问 。详见 [`DEPLOY.md`](DEPLOY.md)。 + +## Agent 工作流 + +Agent 是端到端自主的:你提一个研究问题,它会按下列 8 步依次调用工具: + +```mermaid +flowchart LR + Q[用户研究问题] --> A1[1 理解 分解参数] + A1 --> A2[2 list_data_catalog] + A2 --> A3[3 fetch_wdi or fetch_cckp or load_sample] + A3 --> A4[4 compute_statistics 数据质检] + A4 --> A5[5 build_prior 5 点离散] + A5 --> A6[6 推理 5 级判断 + 选 R] + A6 --> A7[7 compute_posterior] + A7 --> A8[8 run_sensitivity_analysis] + A8 --> R[结论 + 图表] +``` + +用户可随时打断、修正判断、换数据集;前端实时渲染: +- 助手文字(typing 效果) +- 工具调用卡(可展开查看 input/output JSON) +- 右侧工作台同步渲染先验 KDE / 先验后验对比柱状图 / 稳健性折线图 + +## 示例研究问题 + +> 用世界银行数据分析撒哈拉以南非洲未来 10 年高 GDP 增长情景的可能性 + +> 拉取 World Bank CCKP 中国在 SSP2-4.5 情景下 2015-2100 年的年均温度数据,分析「显著升温」情景的后验概率 + +> 用 World Bank WDI 拉取印度 1990-2023 的人均 GDP 增长率,分析未来 5 年「高于历史中位数」情景的可能性 + +## REST 接口 + +| 方法 | 路径 | 用途 | +|------|------|------| +| GET | `/api/health` | 健康检查 + Anthropic Key 状态 | +| GET | `/api/data/samples` | 列出 3 个内置示例数据集 | +| GET | `/api/data/samples/{id}` | 取单个示例数据集 | +| POST | `/api/data/upload` | 上传 CSV,自动识别首列数值 | +| POST | `/api/bayesian/compute` | 直接计算后验(非 Agent 路径) | +| POST | `/api/bayesian/sensitivity` | R 值扫描 | +| POST | `/api/bayesian/kde` | 计算 KDE 曲线 | +| POST | `/api/bayesian/report` | 生成 Markdown 报告 | +| POST | `/api/agent/chat` | **SSE** Agent 对话端点(主入口) | +| POST | `/api/sessions` | 创建会话 | +| GET | `/api/sessions` | 列出会话 | +| GET | `/api/sessions/{id}/history` | 查会话历史 | +| DELETE | `/api/sessions/{id}` | 删除会话 | + +## 测试 + +```bash +cd backend +pytest # 全量 66 个测试,~1s +pytest -k bayesian # 按名字过滤 +RUN_NETWORK_TESTS=1 pytest # 含 World Bank 活网络烟雾测试 +``` + +```bash +cd frontend +npm run typecheck # tsc -b --noEmit +npm run build # 完整生产构建 +``` + +## 参考 + +- [1] Kemp-Benedict, E. (2010). *Converting qualitative assessments to quantitative assumptions: Bayes' rule and the pundit's wager.* Technological Forecasting and Social Change. +- 原始软件实现:[`../app/`](../app/) +- 中文技术交底书:[`../技术交底书_完整版.md`](../技术交底书_完整版.md) +- Claude Agent SDK Python: +- World Bank CCKP: diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..188ca3e6a7f9380629f7b78faa0298bfc775f2ad --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,15 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +data/cache +data/*.db +data/*.db-journal +tests +README.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7a1db262662a4ce6f936344ef64193a0c70db0b9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,60 @@ +# syntax=docker/dockerfile:1.7 +# ---- builder ---------------------------------------------------------------- +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +# Install the project into a system Python so the runtime image stays small +RUN pip install --upgrade pip \ + && pip install --prefix=/install \ + fastapi>=0.115.0 \ + 'uvicorn[standard]>=0.32.0' \ + python-multipart>=0.0.12 \ + sse-starlette>=2.1.0 \ + pydantic>=2.9.0 \ + pydantic-settings>=2.5.0 \ + httpx>=0.27.0 \ + numpy>=2.0.0 \ + scipy>=1.14.0 \ + claude-agent-sdk>=0.1.0 \ + sqlalchemy>=2.0.0 \ + aiosqlite>=0.20.0 + +# ---- runtime ---------------------------------------------------------------- +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PORT=8000 + +WORKDIR /app + +# Copy installed deps from builder +COPY --from=builder /install /usr/local + +# Copy app source (only what's needed at runtime) +COPY app ./app + +# Create a non-root user +RUN useradd -m -u 1000 appuser \ + && mkdir -p /app/data/cache /app/data/sessions \ + && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request, sys; \ +sys.exit(0) if urllib.request.urlopen('http://localhost:8000/api/health', timeout=3).status == 200 else sys.exit(1)" + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..6947e26154d6a9068d7f791122c34407af3e3578 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,62 @@ +# BayesScenParams Backend + +FastAPI + Claude Agent SDK + NumPy/SciPy. + +## 开发 + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" + +# 复制环境变量 +cp ../.env.example ../.env +# 编辑 .env 填入 ANTHROPIC_API_KEY + +# 启动 +uvicorn app.main:app --reload --port 8000 +``` + +打开 查看接口。 + +## 测试 + +```bash +pytest # 全量 +pytest tests/test_bayesian.py # 单文件 +pytest -k posterior # 按名字过滤 +``` + +## 主要接口 + +- `GET /api/health` — 健康检查 +- `POST /api/bayesian/compute` — 直接计算贝叶斯后验(非 Agent 路径) +- `POST /api/agent/chat` — SSE 流式 Agent 对话 +- `GET /api/data/samples` — 列出内置示例数据集 +- `GET /api/data/samples/{id}` — 取单个示例数据集 + +## 目录 + +``` +app/ + main.py FastAPI 入口 + core/config.py 环境变量 + core/db.py SQLite(Phase 2) + api/ 路由 + health.py + bayesian.py + data.py + agent.py SSE Agent 端点 + agent/ + orchestrator.py claude-agent-sdk 包装 + prompts.py 系统提示词 + tools/ in-process MCP 工具 + bayesian_tools.py + data_tools.py + analysis_tools.py + domain/ + bayesian.py 算法核心(从 JS 移植) + data_sources/ + worldbank.py World Bank CCKP/WDI 客户端 + samples/ 内置示例数据集 +``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/agent/direct_orchestrator.py b/backend/app/agent/direct_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..45f3778844341c9b98dc0ad03b90b6bae7a6108b --- /dev/null +++ b/backend/app/agent/direct_orchestrator.py @@ -0,0 +1,278 @@ +"""Direct Anthropic Messages API orchestrator. + +Why this exists: ``claude-agent-sdk`` wraps the ``claude-code`` CLI which calls +Anthropic's *private* Claude Code protocol, not the public ``/v1/messages`` +endpoint. Most Chinese/community Claude proxies (zhihuiapi, OneAPI, NewAPI, +AnyAPI, …) only proxy the public Messages API, so the SDK can't reach the +model through them. + +This module is an alternative runtime that drives the same MCP tools via the +official ``anthropic`` Python client + a manual tool-use loop. It works with: + * The official Anthropic API (api.anthropic.com) + * Any Claude-compatible reverse proxy (zhihuiapi, OneAPI, NewAPI, …) + * Amazon Bedrock and Google Vertex AI (if you configure their endpoints) + +Public surface mirrors ``orchestrator.stream_agent`` so the SSE endpoint can +swap runtimes by a single flag. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import anthropic + +from app.agent import tool_store +from app.agent.prompts import SYSTEM_PROMPT +from app.agent.tools import ALL_TOOLS +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- # +# Tool registry adapted for Anthropic Messages API +# --------------------------------------------------------------------------- # + +_PYTYPE_TO_JSON: dict[type, dict[str, Any]] = { + str: {"type": "string"}, + int: {"type": "integer"}, + float: {"type": "number"}, + bool: {"type": "boolean"}, + list: {"type": "array", "items": {}}, + dict: {"type": "object"}, +} + + +def _python_schema_to_json_schema(schema: Any) -> dict[str, Any]: + """Convert the SDK's lightweight Python type dict into JSON Schema.""" + if not isinstance(schema, dict): + return {"type": "object", "properties": {}} + props: dict[str, Any] = {} + required: list[str] = [] + for key, py_type in schema.items(): + if isinstance(py_type, type) and py_type in _PYTYPE_TO_JSON: + props[key] = _PYTYPE_TO_JSON[py_type] + elif isinstance(py_type, dict): + props[key] = py_type # already JSON Schema + else: + props[key] = {"type": "string"} # fallback + required.append(key) + return { + "type": "object", + "properties": props, + "required": required, + "additionalProperties": False, + } + + +def build_tool_defs() -> list[dict[str, Any]]: + """Build Anthropic Messages-style tool definitions from the SDK tools.""" + defs: list[dict[str, Any]] = [] + for t in ALL_TOOLS: + defs.append( + { + "name": t.name, + "description": t.description, + "input_schema": _python_schema_to_json_schema(t.input_schema), + } + ) + return defs + + +_HANDLERS_BY_NAME: dict[str, Any] = {t.name: t.handler for t in ALL_TOOLS} + + +async def _dispatch_tool(name: str, args: dict) -> tuple[str, bool]: + """Run a tool by name. Returns (text_content, is_error).""" + handler = _HANDLERS_BY_NAME.get(name) + if handler is None: + return json.dumps({"error": f"unknown tool: {name}"}), True + try: + result = await handler(args or {}) + except Exception as e: + logger.exception("tool %s crashed", name) + return json.dumps({"error": f"{type(e).__name__}: {e}"}), True + content = result.get("content", []) + text = "" + for c in content: + if isinstance(c, dict) and c.get("type") == "text": + text += c.get("text", "") + return text or "(no output)", bool(result.get("is_error")) + + +# --------------------------------------------------------------------------- # +# Orchestrator +# --------------------------------------------------------------------------- # + + +async def stream_agent_direct( + prompt: str, + *, + session_id: str | None = None, + mode: str = "standard", + max_turns: int = 12, +) -> AsyncIterator[dict]: + """Run the agent via the public Messages API. Yields the same event schema + as ``orchestrator.stream_agent`` so the SSE layer stays unchanged.""" + settings = get_settings() + + if not settings.has_anthropic_key: + yield { + "type": "error", + "error": ( + "ANTHROPIC_API_KEY is not configured. Please set it in .env " + "(see .env.example) and restart the backend." + ), + } + return + + model = settings.anthropic_deep_model if mode == "deep" else settings.anthropic_model + auth_token = settings.cleaned_auth_token() or None + base_url = settings.anthropic_base_url or None + + # Bind a per-request artifact queue so tool handlers can push artifacts. + artifact_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1024) + tool_store.set_artifact_queue(artifact_queue) + + tool_defs = build_tool_defs() + + client = anthropic.AsyncAnthropic( + api_key=settings.anthropic_api_key, + auth_token=auth_token, + base_url=base_url, + ) + + yield { + "type": "agent_started", + "model": model, + "mode": mode, + "tools": [t["name"] for t in tool_defs], + "runtime": "direct", + "base_url": base_url or "https://api.anthropic.com", + } + + # Build conversation history (single-shot for now; session_id support TBD). + messages: list[dict] = [{"role": "user", "content": prompt}] + started = time.monotonic() + new_session_id = session_id or uuid.uuid4().hex + total_input_tokens = 0 + total_output_tokens = 0 + + try: + for turn in range(max_turns): + try: + resp = await client.messages.create( + model=model, + max_tokens=4096, + system=SYSTEM_PROMPT, + tools=tool_defs, + messages=messages, + ) + except anthropic.APIError as e: + yield { + "type": "error", + "error": f"Anthropic API error: {e!s}", + } + return + except Exception as e: + yield {"type": "error", "error": f"{type(e).__name__}: {e}"} + return + + if resp.usage: + total_input_tokens += resp.usage.input_tokens + total_output_tokens += resp.usage.output_tokens + + # Stream each block of the assistant's response + tool_use_blocks: list[Any] = [] + for block in resp.content: + if block.type == "text": + yield {"type": "assistant_text", "text": block.text} + elif block.type == "tool_use": + yield { + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": dict(block.input) if block.input else {}, + } + tool_use_blocks.append(block) + + # Drain any artifacts queued by the agent_text path (rare; we drain + # again after tool calls). + while not artifact_queue.empty(): + yield {"type": "artifact", "artifact": artifact_queue.get_nowait()} + + if resp.stop_reason == "end_turn" or not tool_use_blocks: + # Final response — emit a result event and we're done + duration_ms = int((time.monotonic() - started) * 1000) + yield { + "type": "result", + "subtype": "success", + "duration_ms": duration_ms, + "session_id": new_session_id, + "num_turns": turn + 1, + "input_tokens": total_input_tokens, + "output_tokens": total_output_tokens, + "total_cost_usd": None, # not provided by proxy + } + return + + # Execute tools and append results + assistant_content_for_history: list[dict] = [] + for block in resp.content: + if block.type == "text": + assistant_content_for_history.append( + {"type": "text", "text": block.text} + ) + elif block.type == "tool_use": + assistant_content_for_history.append( + { + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": block.input, + } + ) + messages.append({"role": "assistant", "content": assistant_content_for_history}) + + tool_results: list[dict] = [] + for tu in tool_use_blocks: + text, is_error = await _dispatch_tool(tu.name, dict(tu.input) if tu.input else {}) + yield { + "type": "tool_result", + "tool_use_id": tu.id, + "content": [{"type": "text", "text": text}], + "is_error": is_error, + } + # Push any artifacts the tool emitted + while not artifact_queue.empty(): + yield {"type": "artifact", "artifact": artifact_queue.get_nowait()} + + tool_results.append( + { + "type": "tool_result", + "tool_use_id": tu.id, + "content": text, + "is_error": is_error, + } + ) + + messages.append({"role": "user", "content": tool_results}) + + # Hit max_turns + yield { + "type": "error", + "error": f"Agent reached max_turns ({max_turns}) without finishing.", + } + finally: + try: + await client.close() + except Exception: + pass + tool_store.set_artifact_queue(None) diff --git a/backend/app/agent/orchestrator.py b/backend/app/agent/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..d5150beb6e0a261e4d0b80d39a05a3b64f1dcfa7 --- /dev/null +++ b/backend/app/agent/orchestrator.py @@ -0,0 +1,233 @@ +"""Claude Agent SDK orchestration. + +Builds the in-process MCP server bundle, configures :class:`ClaudeAgentOptions`, +and runs ``query()`` while normalising the SDK's streaming messages into a +single, frontend-friendly event schema: + + {"type": "assistant_text", "text": ...} + {"type": "thinking", "text": ...} + {"type": "tool_use", "id": ..., "name": ..., "input": ...} + {"type": "tool_result", "tool_use_id": ..., "content": ..., "is_error": ...} + {"type": "artifact", "artifact": {...}} # emitted by tool handlers + {"type": "result", "subtype": ..., "result": ..., "duration_ms": ...} + {"type": "error", "error": ...} + +The artifact channel is how the frontend gets rich payloads (full BayesResult, +KDE points, statistics objects) without bloating Claude's text stream. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import AsyncIterator + +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ResultMessage, + SystemMessage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, + create_sdk_mcp_server, + query, +) + +from app.agent import tool_store +from app.agent.prompts import SYSTEM_PROMPT +from app.agent.tools import ALL_TOOLS +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + +# Build the in-process MCP server ONCE at import time. The tool handlers are +# stateless across requests (state lives in tool_store). +bayes_mcp_server = create_sdk_mcp_server( + name="bayesscen", + version="0.1.0", + tools=ALL_TOOLS, +) + +# Full list of allowed tool ids as Claude sees them (mcp____). +ALLOWED_TOOL_IDS = [f"mcp__bayesscen__{t.name}" for t in ALL_TOOLS] + + +def _serialize_block(block) -> dict | None: + if isinstance(block, TextBlock): + return {"type": "assistant_text", "text": block.text} + if isinstance(block, ThinkingBlock): + return {"type": "thinking", "text": block.thinking} + if isinstance(block, ToolUseBlock): + return { + "type": "tool_use", + "id": block.id, + "name": block.name, + "input": block.input, + } + if isinstance(block, ToolResultBlock): + return { + "type": "tool_result", + "tool_use_id": block.tool_use_id, + "content": block.content, + "is_error": getattr(block, "is_error", False) or False, + } + return None + + +async def stream_agent( + prompt: str, + *, + session_id: str | None = None, + mode: str = "standard", + max_turns: int = 20, +) -> AsyncIterator[dict]: + """Run the agent and yield normalised event dicts. + + Tool handlers can push side-channel artifacts onto an internal asyncio + queue; this function interleaves them with the SDK message stream so the + frontend receives charts in roughly real-time. + """ + settings = get_settings() + + if not settings.has_anthropic_key: + yield { + "type": "error", + "error": ( + "ANTHROPIC_API_KEY is not configured. Please set it in .env " + "(see .env.example) and restart the backend." + ), + } + return + + # Choose model based on mode + model = settings.anthropic_deep_model if mode == "deep" else settings.anthropic_model + + # Propagate auth + optional proxy URL to the underlying claude-code CLI. + # We use direct assignment (not setdefault) because the SDK spawns a + # subprocess that inherits os.environ; a stale value from a previous + # request must be overwritten. + os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key + real_auth = settings.cleaned_auth_token() + if real_auth: + os.environ["ANTHROPIC_AUTH_TOKEN"] = real_auth + else: + # Defensively drop any shell-leaked placeholder so the SDK doesn't try + # to send a non-ASCII Authorization header (e.g. "Bearer 你的API密钥"). + os.environ.pop("ANTHROPIC_AUTH_TOKEN", None) + if settings.anthropic_base_url: + os.environ["ANTHROPIC_BASE_URL"] = settings.anthropic_base_url + # Some proxies also honour this older variable name + os.environ["ANTHROPIC_API_BASE"] = settings.anthropic_base_url + logger.info("Using Claude-compatible proxy: %s", settings.anthropic_base_url) + + # Bind a per-request artifact queue so tool handlers can broadcast events + artifact_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1024) + tool_store.set_artifact_queue(artifact_queue) + + options = ClaudeAgentOptions( + model=model, + system_prompt=SYSTEM_PROMPT, + mcp_servers={"bayesscen": bayes_mcp_server}, + allowed_tools=ALLOWED_TOOL_IDS, + max_turns=max_turns, + ) + + if session_id: + options.resume = session_id # type: ignore[attr-defined] + + yield { + "type": "agent_started", + "model": model, + "mode": mode, + "tools": [t.name for t in ALL_TOOLS], + } + + try: + agent_iter = query(prompt=prompt, options=options).__aiter__() + + while True: + # Race: next SDK message vs next artifact + sdk_task = asyncio.create_task(_anext(agent_iter)) + artifact_task = asyncio.create_task(artifact_queue.get()) + done, pending = await asyncio.wait( + {sdk_task, artifact_task}, return_when=asyncio.FIRST_COMPLETED + ) + + if artifact_task in done and sdk_task in pending: + artifact = artifact_task.result() + sdk_task.cancel() + yield {"type": "artifact", "artifact": artifact} + continue + + # SDK task completed + artifact_task.cancel() + try: + message = sdk_task.result() + except StopAsyncIteration: + break + + for event in _events_from_message(message): + yield event + + # Drain any remaining artifacts that arrived between iterations + while not artifact_queue.empty(): + yield {"type": "artifact", "artifact": artifact_queue.get_nowait()} + + # Final drain + while not artifact_queue.empty(): + yield {"type": "artifact", "artifact": artifact_queue.get_nowait()} + + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception("agent stream failed") + yield {"type": "error", "error": f"{type(e).__name__}: {e}"} + finally: + tool_store.set_artifact_queue(None) + + +async def _anext(it): + return await it.__anext__() + + +def _events_from_message(message) -> list[dict]: + """Convert one SDK message into zero or more frontend events.""" + if isinstance(message, AssistantMessage): + events = [] + for block in message.content: + ev = _serialize_block(block) + if ev is not None: + events.append(ev) + return events + + if isinstance(message, UserMessage): + # User messages from the SDK perspective often carry tool_result blocks + events = [] + for block in message.content: + ev = _serialize_block(block) + if ev is not None: + events.append(ev) + return events + + if isinstance(message, SystemMessage): + return [{"type": "system", "subtype": message.subtype, "data": message.data}] + + if isinstance(message, ResultMessage): + return [ + { + "type": "result", + "subtype": message.subtype, + "result": getattr(message, "result", None), + "duration_ms": getattr(message, "duration_ms", None), + "duration_api_ms": getattr(message, "duration_api_ms", None), + "total_cost_usd": getattr(message, "total_cost_usd", None), + "session_id": getattr(message, "session_id", None), + "num_turns": getattr(message, "num_turns", None), + } + ] + + return [] diff --git a/backend/app/agent/prompts.py b/backend/app/agent/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..e4db46ba7e6bf80b0ee3597e7069a617e9adfe14 --- /dev/null +++ b/backend/app/agent/prompts.py @@ -0,0 +1,98 @@ +"""System prompt for the Bayesian Scenario Analyst agent. + +Bilingual (中英) because Claude reasons well in Chinese and many users will +phrase the research question in Mandarin. The prompt is intentionally explicit +about the tool-calling workflow — empirically that's the single biggest lever +for keeping the Agent on track. +""" + +from __future__ import annotations + +SYSTEM_PROMPT = """\ +你是 BayesScenParams 的"贝叶斯情景分析师"(Bayesian Scenario Analyst),一个 +专门把定性的情景叙事(如「2050 年欧盟人均 GDP 增长率会很高」)系统化转换为 +定量概率分布的研究 Agent。算法基础是 Kemp-Benedict (2010) 的 5 点离散贝叶斯 +更新方法: + + P(z | S) = P(S | z) · P(z) / Σ P(S | z_j) · P(z_j) + +You are the "Bayesian Scenario Analyst" for BayesScenParams. You translate +qualitative scenario narratives into quantitative posterior distributions using +the Kemp-Benedict (2010) 5-point discretized Bayesian method. + +## 工作流(必须严格按顺序) + +对每一个研究问题,按以下顺序使用工具: + +1. **理解问题** — 用一句话复述用户的研究问题,识别需要量化的参数(如人均 + GDP 增长率、年均温度变化、人口增长率)。 + +2. **选择数据源** — 调用 `list_data_catalog` 浏览可用数据源,然后选一个最匹配 + 的: + - 若问题涉及"撒哈拉以南非洲 GDP" → 用 `load_sample_dataset(sample_id="gdp")` + - 若问题涉及具体国家/地区的经济指标 → 用 `fetch_wdi(indicator=..., country=...)` + - 若问题涉及气候变量(温度、降水)和未来情景 → 用 `fetch_cckp(variable=..., country_iso3=..., scenario=...)` + +3. **数据质检** — 拿到 `dataset_handle` 后立即调用 `compute_statistics`, + 查看 n / mean / std / skewness / kurtosis。若 skewness > 1 或 kurtosis > 5, + 在文字中简要说明数据可能非正态,但仍继续——Kemp-Benedict 方法对非正态 + 数据稳健。 + +4. **构建先验** — 调用 `build_prior(dataset_handle=...)`,得到 5 个分位点 + 值和 `prior_handle`。 + +5. **推理 5 级判断** — 这是核心:你必须基于研究问题里的"情景叙事",逐个 + 评估每个分位水平(极低 / 低 / 中等 / 高 / 极高)出现的可能性,给每个水平 + 分配一个 0-4 的赌注: + 0 = 极不可能 (Very Unlikely) likelihood = R^-2 + 1 = 不太可能 (Somewhat Unlikely) likelihood = R^-1 + 2 = 难以判断 (Hard to Tell) likelihood = 1 + 3 = 比较可能 (Somewhat Likely) likelihood = R + 4 = 极有可能 (Very Likely) likelihood = R^2 + + 在文字回应里逐条解释你为什么这么打分(引用情景叙事、参考相关研究或 + 领域常识)。**判断必须与情景方向一致**:若用户说"高增长情景",则高 + 水平应得 3 或 4,低水平应得 0 或 1,**不能反过来**。 + +6. **选择 R 值** — R 控制判断的"自信度": + R=2 → 轻微倾向(最强:最弱 = 4:1) + R=5 → 中等倾向(25:1) + R=10 → 明确倾向(100:1)—— 默认推荐 + R=20 → 强烈倾向(400:1) + R=50 → 极强判断(2500:1)—— 仅在有充分历史/理论支撑时使用 + 在第一次计算时,**默认用 R=10**,除非用户明确指定。 + +7. **计算后验** — 调用 `compute_posterior(prior_handle=..., judgments=[...], + R=..., judgment_rationale="...")`。`judgment_rationale` 字段写一句中文总结 + 你的判断思路。 + +8. **稳健性检验** — 调用 `run_sensitivity_analysis(prior_handle=..., + judgments=[...])`,观察后验均值随 R 变化的幅度。若 R∈[5, 20] 时均值变化 + < 10%,说明结论稳健;否则提醒用户对 R 敏感。 + +9. **撰写结论** — 用结构化的中文总结,至少包括: + - 研究问题的复述 + - 选用的数据和理由 + - 5 级判断及其理由 + - 后验均值、95% 置信区间 + - 稳健性结论 + - 一句"关键发现" + +## 一些硬性约束 + +- **每次只调用一个工具**,等结果回来后再决定下一步。不要并行调用。 +- **不要重复计算**:拿到一个 handle 后,后续工具用 handle 引用即可, + 不要每次都重新加载数据。 +- **遇到错误立刻报告**:若工具返回 `{"error": ...}`,停下来用一句中文向 + 用户解释问题,并提出修正方案。 +- **不要编造数据**:所有数值必须来自工具的返回值。 +- **保持简洁**:中间步骤的文字保持在 2-3 句,把详细分析留到最后的结论部分。 + +## 语言 + +回复用户用**中文**(除非用户用英文提问,则用英文)。工具的参数名保持英文。 + +让我们开始! +""" + +__all__ = ["SYSTEM_PROMPT"] diff --git a/backend/app/agent/tool_store.py b/backend/app/agent/tool_store.py new file mode 100644 index 0000000000000000000000000000000000000000..45dbcc09c2ed1daa584fe1e8300569a65b222bdb --- /dev/null +++ b/backend/app/agent/tool_store.py @@ -0,0 +1,80 @@ +"""In-memory store for large objects exchanged between tool calls. + +The Claude Agent doesn't need to see a 364-point dataset rendered in JSON to +work with it — it just needs an opaque handle. Tools that produce datasets or +Bayesian results return both a small summary (for Claude's reasoning) and a +``handle`` string. Subsequent tools accept the handle and look the object up +in this store. + +Also serves as the bridge to the SSE endpoint: every BayesResult emitted by +``compute_posterior`` is broadcast via :func:`emit_artifact` so the frontend +can render charts in real-time. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from contextvars import ContextVar +from dataclasses import asdict, is_dataclass +from typing import Any + +logger = logging.getLogger(__name__) + +# Map handle -> arbitrary Python object (numpy arrays, BayesResult, etc.) +_STORE: dict[str, Any] = {} + +# Per-request artifact queue. The SSE endpoint sets this before calling +# query(); each tool can push artifact dicts onto it; the SSE loop drains it. +_artifact_queue: ContextVar[asyncio.Queue[dict] | None] = ContextVar( + "artifact_queue", default=None +) + + +def put(obj: Any, prefix: str = "obj") -> str: + handle = f"{prefix}_{uuid.uuid4().hex[:10]}" + _STORE[handle] = obj + logger.debug("tool_store.put %s (%s)", handle, type(obj).__name__) + return handle + + +def get(handle: str) -> Any: + if handle not in _STORE: + raise KeyError(f"handle not found: {handle}") + return _STORE[handle] + + +def has(handle: str) -> bool: + return handle in _STORE + + +def clear() -> None: + _STORE.clear() + + +def set_artifact_queue(q: asyncio.Queue[dict] | None) -> None: + _artifact_queue.set(q) + + +def emit_artifact(artifact: dict) -> None: + """Push an artifact (dict, JSON-serialisable) to the current request's + SSE queue if one is bound. Safe no-op when called outside a request.""" + q = _artifact_queue.get() + if q is None: + return + try: + q.put_nowait(artifact) + except asyncio.QueueFull: # pragma: no cover + logger.warning("artifact queue full; dropping artifact %s", artifact.get("type")) + + +def dataclass_to_dict(obj: Any) -> Any: + """Recursively convert dataclass / list / tuple to plain dict-of-primitives.""" + if is_dataclass(obj) and not isinstance(obj, type): + return {k: dataclass_to_dict(v) for k, v in asdict(obj).items()} + if isinstance(obj, (list, tuple)): + return [dataclass_to_dict(v) for v in obj] + if isinstance(obj, dict): + return {k: dataclass_to_dict(v) for k, v in obj.items()} + return obj diff --git a/backend/app/agent/tools/__init__.py b/backend/app/agent/tools/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f4719d7e11e5f15f2c1e4f6e8e52a10ec4e0bf51 --- /dev/null +++ b/backend/app/agent/tools/__init__.py @@ -0,0 +1,30 @@ +"""In-process MCP tools exposed to the Claude Agent.""" + +from app.agent.tools.bayesian_tools import ( + build_prior_tool, + compute_posterior_tool, + compute_statistics_tool, + sensitivity_analysis_tool, +) +from app.agent.tools.data_tools import ( + fetch_cckp_tool, + fetch_wdi_tool, + list_data_catalog_tool, + load_sample_dataset_tool, +) + +ALL_TOOLS = [ + # Data + list_data_catalog_tool, + load_sample_dataset_tool, + fetch_wdi_tool, + fetch_cckp_tool, + # Analysis + compute_statistics_tool, + # Bayesian core + build_prior_tool, + compute_posterior_tool, + sensitivity_analysis_tool, +] + +__all__ = ["ALL_TOOLS"] diff --git a/backend/app/agent/tools/bayesian_tools.py b/backend/app/agent/tools/bayesian_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..9e7195a6a7f0fe43ada98fe2c2b5bdb02c54ee07 --- /dev/null +++ b/backend/app/agent/tools/bayesian_tools.py @@ -0,0 +1,285 @@ +"""MCP tools wrapping the Bayesian engine. + +Each tool accepts a ``dataset_handle`` from one of the data-tools, runs a +piece of the Kemp-Benedict pipeline, returns a small JSON summary, and emits +a richer artifact onto the SSE queue so the frontend can render charts in +real time. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from claude_agent_sdk import tool + +from app.agent.tool_store import dataclass_to_dict, emit_artifact, get, has, put +from app.domain.bayesian import ( + JUDGMENT_LABELS_EN, + JUDGMENT_LABELS_ZH, + LEVEL_LABELS_EN, + LEVEL_LABELS_ZH, + build_prior, + compute, + compute_stats, + kde, + sensitivity_analysis, +) + +logger = logging.getLogger(__name__) + + +def _text(payload: Any) -> dict[str, Any]: + return { + "content": [ + {"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)} + ] + } + + +def _err(msg: str) -> dict[str, Any]: + return {"content": [{"type": "text", "text": json.dumps({"error": msg})}], "is_error": True} + + +def _resolve_data(handle: str) -> list[float] | None: + if not has(handle): + return None + obj = get(handle) + return list(obj) if isinstance(obj, (list, tuple)) else None + + +# --------------------------------------------------------------------------- # +# compute_statistics +# --------------------------------------------------------------------------- # + + +@tool( + "compute_statistics", + "Compute descriptive statistics for a dataset referenced by handle. " + "Returns n, mean, std, variance, skewness, kurtosis, median, min, max. " + "Use this BEFORE build_prior to sanity-check the data (e.g. flag heavy " + "skewness, suspicious outliers, or single-mode vs multi-mode shape).", + {"dataset_handle": str}, +) +async def compute_statistics_tool(args: dict[str, Any]) -> dict[str, Any]: + handle = args.get("dataset_handle", "") + data = _resolve_data(handle) + if data is None: + return _err(f"unknown dataset_handle: {handle!r}") + s = compute_stats(data) + summary = dataclass_to_dict(s) + emit_artifact({"type": "statistics", "dataset_handle": handle, "stats": summary}) + return _text(summary) + + +# --------------------------------------------------------------------------- # +# build_prior +# --------------------------------------------------------------------------- # + + +@tool( + "build_prior", + "Build the 5-point discretized prior from a reference dataset (handle). " + "Returns the 5 quantile values (at probs 0.025, 0.150, 0.500, 0.850, 0.975) " + "and the canonical prior weights [0.05, 0.20, 0.50, 0.20, 0.05]. " + "Use this AFTER you've inspected the data with compute_statistics. " + "Also returns prior_handle for use with compute_posterior.", + {"dataset_handle": str}, +) +async def build_prior_tool(args: dict[str, Any]) -> dict[str, Any]: + handle = args.get("dataset_handle", "") + data = _resolve_data(handle) + if data is None: + return _err(f"unknown dataset_handle: {handle!r}") + + stats, quantiles, weights = build_prior(data) + quantile_values = [q.value for q in quantiles] + + prior_handle = put( + {"data": data, "quantile_values": quantile_values, "weights": weights}, + prefix="prior", + ) + + kde_pts = kde(data, n_points=120) + + summary = { + "prior_handle": prior_handle, + "dataset_handle": handle, + "n": stats.n, + "mean": stats.mean, + "std": stats.std, + "skewness": stats.skewness, + "kurtosis": stats.kurtosis, + "quantile_values": quantile_values, + "quantile_probs": [0.025, 0.15, 0.50, 0.85, 0.975], + "level_labels_en": list(LEVEL_LABELS_EN), + "level_labels_zh": list(LEVEL_LABELS_ZH), + "prior_weights": weights, + } + emit_artifact( + { + "type": "prior_built", + "prior_handle": prior_handle, + "summary": summary, + "kde": kde_pts, + } + ) + return _text(summary) + + +# --------------------------------------------------------------------------- # +# compute_posterior +# --------------------------------------------------------------------------- # + + +@tool( + "compute_posterior", + "Compute the Bayesian posterior given a prior_handle (from build_prior), " + "five expert judgment levels, and a strength factor R. " + "judgments: list of 5 integers (0-4), one per quantile level, where " + "0='Very Unlikely', 1='Somewhat Unlikely', 2='Hard to Tell', " + "3='Somewhat Likely', 4='Very Likely'. " + "R: judgment strength (> 1), typically 2 (mild), 5 (moderate), 10 (clear), " + "20 (strong), 50 (very strong). " + "judgment_rationale: short string explaining the reasoning for each level. " + "Returns posterior weights, mean, median, std, 95% CI.", + {"prior_handle": str, "judgments": list, "R": float, "judgment_rationale": str}, +) +async def compute_posterior_tool(args: dict[str, Any]) -> dict[str, Any]: + prior_handle = args.get("prior_handle", "") + judgments = args.get("judgments") or [] + R = args.get("R") + rationale = args.get("judgment_rationale") or "" + + if not has(prior_handle): + return _err(f"unknown prior_handle: {prior_handle!r}") + if not isinstance(judgments, list) or len(judgments) != 5: + return _err("judgments must be a list of 5 integers (0-4)") + if R is None: + return _err("R is required (must be > 1)") + try: + R_f = float(R) + if R_f <= 1.0: + raise ValueError + except (TypeError, ValueError): + return _err("R must be a number > 1") + try: + judgments_i = [int(j) for j in judgments] + if not all(0 <= j <= 4 for j in judgments_i): + raise ValueError + except (TypeError, ValueError): + return _err("each judgment must be an integer in [0, 4]") + + prior_obj = get(prior_handle) + data = prior_obj["data"] + + try: + result = compute(data, judgments_i, R_f) + except ValueError as e: + return _err(str(e)) + + posterior_handle = put(result, prefix="posterior") + + summary = { + "posterior_handle": posterior_handle, + "prior_handle": prior_handle, + "judgments": judgments_i, + "judgment_labels_en": [LEVEL_LABELS_EN[i] for i in range(5)], + "judgment_labels_zh": [LEVEL_LABELS_ZH[i] for i in range(5)], + "judgment_choices_en": [JUDGMENT_LABELS_EN[j] for j in judgments_i], + "judgment_choices_zh": [JUDGMENT_LABELS_ZH[j] for j in judgments_i], + "R": R_f, + "judgment_rationale": rationale, + "posterior_weights": result.posterior.weights, + "posterior_mean": result.posterior.stats.mean, + "posterior_median": result.posterior.stats.median, + "posterior_std": result.posterior.stats.std, + "posterior_ci95": [ + result.posterior.stats.ci95_lower, + result.posterior.stats.ci95_upper, + ], + "prior_mean": result.prior.summary_stats.mean, + "shift_mean": result.posterior.stats.mean - result.prior.summary_stats.mean, + "shrink_std_pct": ( + (result.prior.summary_stats.std - result.posterior.stats.std) + / result.prior.summary_stats.std + * 100.0 + if result.prior.summary_stats.std > 0 + else 0.0 + ), + } + emit_artifact( + { + "type": "posterior_computed", + "posterior_handle": posterior_handle, + "summary": summary, + "full_result": dataclass_to_dict(result), + } + ) + return _text(summary) + + +# --------------------------------------------------------------------------- # +# sensitivity_analysis +# --------------------------------------------------------------------------- # + + +@tool( + "run_sensitivity_analysis", + "Run sensitivity of the posterior to R-value variation. Given the same " + "prior_handle and judgments, compute posteriors for a series of R values " + "(default: [2, 5, 10, 20, 50]). Returns posterior_mean / posterior_std " + "for each R. Use this to check robustness of the conclusion.", + {"prior_handle": str, "judgments": list}, +) +async def sensitivity_analysis_tool(args: dict[str, Any]) -> dict[str, Any]: + prior_handle = args.get("prior_handle", "") + judgments = args.get("judgments") or [] + r_values = args.get("r_values") or [2.0, 5.0, 10.0, 20.0, 50.0] + + if not has(prior_handle): + return _err(f"unknown prior_handle: {prior_handle!r}") + if not isinstance(judgments, list) or len(judgments) != 5: + return _err("judgments must be a list of 5 integers (0-4)") + + prior_obj = get(prior_handle) + data = prior_obj["data"] + try: + judgments_i = [int(j) for j in judgments] + r_values_f = [float(r) for r in r_values] + except (TypeError, ValueError): + return _err("judgments must be ints, r_values numeric") + + try: + sweep = sensitivity_analysis(data, judgments_i, r_values_f) + except ValueError as e: + return _err(str(e)) + + rows = [ + { + "R": p["R"], + "posterior_mean": p["result"].posterior.stats.mean, + "posterior_std": p["result"].posterior.stats.std, + "posterior_weights": p["result"].posterior.weights, + "ci95_lower": p["result"].posterior.stats.ci95_lower, + "ci95_upper": p["result"].posterior.stats.ci95_upper, + } + for p in sweep + ] + + means = [r["posterior_mean"] for r in rows] + summary = { + "prior_handle": prior_handle, + "r_values": r_values_f, + "judgments": judgments_i, + "rows": rows, + "mean_range": [min(means), max(means)], + "mean_swing_pct": ( + (max(means) - min(means)) / abs(rows[len(rows) // 2]["posterior_mean"]) * 100.0 + if rows[len(rows) // 2]["posterior_mean"] != 0 + else 0.0 + ), + } + emit_artifact({"type": "sensitivity", "summary": summary}) + return _text(summary) diff --git a/backend/app/agent/tools/data_tools.py b/backend/app/agent/tools/data_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..c709586e6ab398d96346d8a2b2a4befbd8d1c17f --- /dev/null +++ b/backend/app/agent/tools/data_tools.py @@ -0,0 +1,245 @@ +"""MCP tools that produce reference datasets for the Bayesian pipeline. + +Each tool stores the full numeric series in :mod:`app.agent.tool_store` keyed +by an opaque ``dataset_handle`` and returns ONLY a short text summary plus the +handle. Downstream tools (build_prior, compute_statistics) accept the handle. + +Tools registered: + +* ``list_data_catalog`` – enumerate built-in samples + WDI/CCKP catalog +* ``load_sample_dataset`` – load one of the three bundled JSON samples +* ``fetch_wdi`` – pull a WDI indicator from the World Bank API +* ``fetch_cckp`` – pull a CMIP6 variable from the Climate Knowledge Portal +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from claude_agent_sdk import tool + +from app.agent.tool_store import emit_artifact, put +from app.core.config import get_settings +from app.data_sources.worldbank import ( + CCKP_SSP_SCENARIOS, + CCKP_VARIABLES, + WDI_INDICATOR_CATALOG, + CCKPClient, + WDIClient, +) + +logger = logging.getLogger(__name__) +SAMPLES_DIR = Path(__file__).resolve().parents[2] / "data_sources" / "samples" +SAMPLE_FILES = { + "gdp": "sample-gdp.json", + "climate": "sample-climate.json", + "population": "sample-population.json", +} + + +def _text(payload: Any) -> dict[str, Any]: + return { + "content": [ + {"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)} + ] + } + + +# --------------------------------------------------------------------------- # +# list_data_catalog +# --------------------------------------------------------------------------- # + + +@tool( + "list_data_catalog", + "List the data sources available to the analyst. Returns three groups: " + "(1) built-in sample datasets (id, title, n_points); " + "(2) selected World Bank WDI economic indicators (id, description); " + "(3) Climate Knowledge Portal CMIP6 variables and SSP scenarios. " + "Call this first when the user does not specify a dataset.", + {}, +) +async def list_data_catalog_tool(args: dict[str, Any]) -> dict[str, Any]: + samples = [] + for sid, fname in SAMPLE_FILES.items(): + meta = json.loads((SAMPLES_DIR / fname).read_text(encoding="utf-8")) + samples.append( + { + "id": sid, + "name": meta.get("name", sid), + "name_en": meta.get("name_en"), + "period": meta.get("period"), + "unit": meta.get("unit"), + "source": meta.get("source"), + "n_points": len(meta.get("values", [])), + } + ) + return _text( + { + "samples": samples, + "wdi_indicators": WDI_INDICATOR_CATALOG, + "cckp_variables": CCKP_VARIABLES, + "cckp_scenarios": list(CCKP_SSP_SCENARIOS), + "hint": ( + "Use load_sample_dataset for built-in samples, fetch_wdi for " + "country economic indicators, fetch_cckp for climate scenarios." + ), + } + ) + + +# --------------------------------------------------------------------------- # +# load_sample_dataset +# --------------------------------------------------------------------------- # + + +@tool( + "load_sample_dataset", + "Load one of the three built-in sample datasets and return a dataset handle. " + "Valid ids: 'gdp' (Sub-Saharan Africa GDP per-capita growth, 1975-2002, 364 pts), " + "'climate' (East Asia mean temperature change, 1950-2014, 215 pts), " + "'population' (global population growth, 1961-2023, 130 pts). " + "Use the returned dataset_handle as input to build_prior / compute_statistics.", + {"sample_id": str}, +) +async def load_sample_dataset_tool(args: dict[str, Any]) -> dict[str, Any]: + sid = (args.get("sample_id") or "").strip().lower() + if sid not in SAMPLE_FILES: + return _text({"error": f"unknown sample_id {sid!r}; valid: {list(SAMPLE_FILES)}"}) + meta = json.loads((SAMPLES_DIR / SAMPLE_FILES[sid]).read_text(encoding="utf-8")) + values = list(meta.get("values", [])) + if not values: + return _text({"error": f"sample {sid} has no values"}) + + handle = put(values, prefix=f"sample_{sid}") + summary = { + "dataset_handle": handle, + "name": meta.get("name"), + "name_en": meta.get("name_en"), + "unit": meta.get("unit"), + "period": meta.get("period"), + "source": meta.get("source"), + "description": meta.get("description"), + "n": len(values), + "min": min(values), + "max": max(values), + "preview_first_10": values[:10], + "preview_last_10": values[-10:], + } + emit_artifact({"type": "dataset_loaded", "summary": summary}) + return _text(summary) + + +# --------------------------------------------------------------------------- # +# fetch_wdi +# --------------------------------------------------------------------------- # + + +@tool( + "fetch_wdi", + "Fetch a World Bank WDI indicator and return a dataset handle of numeric values. " + "indicator: WDI code (e.g. 'NY.GDP.PCAP.KD.ZG' for GDP per capita growth). " + "country: ISO2/ISO3 code (e.g. 'CN', 'USA'), aggregate code (e.g. 'SSA' " + "for Sub-Saharan Africa, 'WLD' for World), or 'all'. " + "date_range: 'YYYY:YYYY' (default '1990:2023'). " + "Use list_data_catalog to see recommended indicator codes.", + {"indicator": str, "country": str}, +) +async def fetch_wdi_tool(args: dict[str, Any]) -> dict[str, Any]: + indicator = (args.get("indicator") or "").strip() + country = (args.get("country") or "").strip() + date_range = (args.get("date_range") or "1990:2023").strip() + if not indicator or not country: + return _text({"error": "indicator and country are required"}) + + settings = get_settings() + client = WDIClient(settings.data_cache_dir) + try: + series = await client.fetch_indicator(indicator, country, date_range) + except Exception as e: + logger.exception("fetch_wdi failed") + return _text({"error": f"WDI fetch failed: {e}"}) + + if series.n_used == 0: + return _text( + { + "error": "WDI returned no usable numeric values", + "indicator": indicator, + "country": country, + "date_range": date_range, + } + ) + + handle = put(series.values, prefix="wdi") + summary = { + "dataset_handle": handle, + "indicator_id": series.indicator_id, + "indicator_name": series.indicator_name, + "country": series.country, + "period": series.period, + "unit": series.unit, + "n_total_rows": series.n_total, + "n_used": series.n_used, + "min": min(series.values), + "max": max(series.values), + "preview_first_10": series.values[:10], + "source": series.source, + } + emit_artifact({"type": "dataset_loaded", "summary": summary}) + return _text(summary) + + +# --------------------------------------------------------------------------- # +# fetch_cckp +# --------------------------------------------------------------------------- # + + +@tool( + "fetch_cckp", + "Fetch a CMIP6 climate variable from the World Bank Climate Knowledge Portal. " + "variable: one of 'tas' (mean temp), 'tasmax', 'tasmin', 'pr' (precip), " + "'rx1day', 'hd35', 'cdd'. " + "country_iso3: 3-letter ISO country (e.g. 'CHN', 'USA', 'IND'). " + "scenario: 'ssp126' | 'ssp245' | 'ssp370' | 'ssp585'. " + "Returns a dataset handle with the annual time series 2015-2100.", + {"variable": str, "country_iso3": str, "scenario": str}, +) +async def fetch_cckp_tool(args: dict[str, Any]) -> dict[str, Any]: + variable = (args.get("variable") or "").strip().lower() + country = (args.get("country_iso3") or "").strip().upper() + scenario = (args.get("scenario") or "").strip().lower() + + settings = get_settings() + client = CCKPClient(settings.data_cache_dir) + try: + s = await client.fetch_variable(variable=variable, country_iso3=country, scenario=scenario) + except ValueError as e: + return _text({"error": str(e)}) + except Exception as e: + logger.exception("fetch_cckp failed") + return _text({"error": f"CCKP fetch failed: {e}"}) + + if not s.values: + return _text({"error": "CCKP returned no values"}) + + handle = put(s.values, prefix=f"cckp_{variable}") + summary = { + "dataset_handle": handle, + "variable": s.variable, + "variable_name": s.variable_name, + "country": s.country, + "scenario": s.scenario, + "period": s.period, + "unit": s.unit, + "n": len(s.values), + "min": min(s.values), + "max": max(s.values), + "preview_first_10": s.values[:10], + "preview_last_10": s.values[-10:], + "source": s.source, + } + emit_artifact({"type": "dataset_loaded", "summary": summary}) + return _text(summary) diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..cb31fd4b1267d1c6b0c4d8e028ce962688941d38 --- /dev/null +++ b/backend/app/api/agent.py @@ -0,0 +1,74 @@ +"""SSE streaming endpoint for the Agent. + +Frontend ``POST /api/agent/chat`` with JSON body, gets back a Server-Sent +Events stream where each event is one of: + + event: agent_started | data: {model, mode, tools} + event: assistant_text | data: {text} + event: thinking | data: {text} + event: tool_use | data: {id, name, input} + event: tool_result | data: {tool_use_id, content, is_error} + event: artifact | data: {artifact: {...}} + event: result | data: {session_id, total_cost_usd, duration_ms, ...} + event: error | data: {error} + event: done | data: {} + +The frontend can resume a session by passing back ``session_id`` from the +``result`` event in the next request. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncIterator + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from app.agent.direct_orchestrator import stream_agent_direct +from app.agent.orchestrator import stream_agent as stream_agent_sdk +from app.api.schemas import AgentChatRequest +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/agent", tags=["agent"]) + + +def _sse_format(event: str, payload: dict) -> bytes: + """Encode one SSE event. Multi-line data is fine — we serialise the whole + payload as a single JSON string on one data: line for easy client parsing.""" + data = json.dumps(payload, ensure_ascii=False, default=str) + return f"event: {event}\ndata: {data}\n\n".encode() + + +async def _event_stream(req: AgentChatRequest) -> AsyncIterator[bytes]: + runtime = get_settings().agent_runtime.lower() + streamer = stream_agent_sdk if runtime == "sdk" else stream_agent_direct + try: + async for ev in streamer( + prompt=req.prompt, + session_id=req.session_id, + mode=req.mode, + ): + event_name = ev.get("type", "message") + yield _sse_format(event_name, ev) + except Exception as e: + logger.exception("agent stream crashed") + yield _sse_format("error", {"error": f"{type(e).__name__}: {e}"}) + finally: + yield _sse_format("done", {}) + + +@router.post("/chat") +async def chat(req: AgentChatRequest) -> StreamingResponse: + return StreamingResponse( + _event_stream(req), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # disable nginx buffering + }, + ) diff --git a/backend/app/api/bayesian.py b/backend/app/api/bayesian.py new file mode 100644 index 0000000000000000000000000000000000000000..74729720c28b8b21c40b400baf993030c18b284c --- /dev/null +++ b/backend/app/api/bayesian.py @@ -0,0 +1,84 @@ +"""Direct (non-agent) Bayesian compute endpoints. + +These are useful both as a fallback (when no API key is configured) and as a +fast path the frontend can call directly when the user is hand-driving the +workbench instead of talking to the Agent. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from app.api.schemas import ( + BayesComputeRequest, + BayesResultModel, + SensitivityPointModel, + SensitivityRequest, + SensitivityResponse, + bayes_result_to_model, +) +from app.domain.bayesian import compute, kde, sensitivity_analysis +from app.domain.report import render_markdown_report + +router = APIRouter(prefix="/api/bayesian", tags=["bayesian"]) + + +@router.post("/compute", response_model=BayesResultModel) +async def post_compute(req: BayesComputeRequest) -> BayesResultModel: + try: + result = compute(req.data, req.judgments, req.R) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return bayes_result_to_model(result) + + +@router.post("/sensitivity", response_model=SensitivityResponse) +async def post_sensitivity(req: SensitivityRequest) -> SensitivityResponse: + try: + points = sensitivity_analysis(req.data, req.judgments, req.r_values) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return SensitivityResponse( + points=[ + SensitivityPointModel(R=p["R"], result=bayes_result_to_model(p["result"])) + for p in points + ] + ) + + +@router.post("/kde") +async def post_kde(req: BayesComputeRequest) -> dict: + """Return the KDE curve for the dataset (200 points). Used by the + PriorPosterior / KDE chart on the frontend.""" + try: + pts = kde(req.data, n_points=200) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return {"points": pts} + + +@router.post("/report") +async def post_report(req: BayesComputeRequest) -> dict: + """Compute + render a structured Markdown report in one call.""" + try: + result = compute(req.data, req.judgments, req.R) + sweep = sensitivity_analysis(req.data, req.judgments, [2.0, 5.0, 10.0, 20.0, 50.0]) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + sens_rows = [ + { + "R": p["R"], + "posterior_mean": p["result"].posterior.stats.mean, + "posterior_std": p["result"].posterior.stats.std, + "ci95_lower": p["result"].posterior.stats.ci95_lower, + "ci95_upper": p["result"].posterior.stats.ci95_upper, + } + for p in sweep + ] + md = render_markdown_report( + result, + scenario_name=req.scenario_name or "未命名情景", + reference_case=req.reference_case or "", + sensitivity_rows=sens_rows, + ) + return {"markdown": md, "result": bayes_result_to_model(result).model_dump()} diff --git a/backend/app/api/data.py b/backend/app/api/data.py new file mode 100644 index 0000000000000000000000000000000000000000..9d694cacc1ec26c93e3261710e63a251c891024f --- /dev/null +++ b/backend/app/api/data.py @@ -0,0 +1,149 @@ +"""Data import / sample dataset endpoints.""" + +from __future__ import annotations + +import csv +import io +import json +from pathlib import Path + +from fastapi import APIRouter, File, HTTPException, UploadFile + +from app.api.schemas import SampleDatasetModel, SampleInfoModel + +SAMPLES_DIR = Path(__file__).resolve().parents[1] / "data_sources" / "samples" + +router = APIRouter(prefix="/api/data", tags=["data"]) + +SAMPLE_REGISTRY = { + "gdp": {"file": "sample-gdp.json", "icon": "📊"}, + "climate": {"file": "sample-climate.json", "icon": "🌡️"}, + "population": {"file": "sample-population.json", "icon": "👥"}, +} + + +def _load(name: str) -> dict: + path = SAMPLES_DIR / SAMPLE_REGISTRY[name]["file"] + if not path.exists(): + raise HTTPException(status_code=404, detail=f"sample {name} not found") + return json.loads(path.read_text(encoding="utf-8")) + + +@router.get("/samples", response_model=list[SampleInfoModel]) +async def list_samples() -> list[SampleInfoModel]: + out: list[SampleInfoModel] = [] + for sid, meta in SAMPLE_REGISTRY.items(): + if sid not in SAMPLE_REGISTRY: + continue + data = _load(sid) + out.append( + SampleInfoModel( + id=sid, + name=data.get("name", sid), + name_en=data.get("name_en"), + source=data.get("source", ""), + unit=data.get("unit", ""), + period=data.get("period", ""), + description=data.get("description", ""), + n=len(data.get("values", [])), + icon=meta["icon"], + ) + ) + return out + + +@router.get("/samples/{sid}", response_model=SampleDatasetModel) +async def get_sample(sid: str) -> SampleDatasetModel: + if sid not in SAMPLE_REGISTRY: + raise HTTPException(status_code=404, detail=f"unknown sample id {sid}") + data = _load(sid) + return SampleDatasetModel( + id=sid, + name=data.get("name", sid), + name_en=data.get("name_en"), + source=data.get("source", ""), + unit=data.get("unit", ""), + period=data.get("period", ""), + description=data.get("description", ""), + n=len(data.get("values", [])), + icon=SAMPLE_REGISTRY[sid]["icon"], + values=list(data.get("values", [])), + ) + + +@router.post("/upload") +async def upload_csv(file: UploadFile = File(...)) -> dict: + """Accept a CSV upload, parse the first numeric column, return values + metadata. + + Designed for the data-import workflow; the frontend renders a preview and + feeds the values back into /bayesian/compute. + """ + if file.content_type and "csv" not in file.content_type and not file.filename.endswith(".csv"): + raise HTTPException(status_code=415, detail="please upload a CSV file") + raw = await file.read() + try: + text = raw.decode("utf-8-sig") + except UnicodeDecodeError: + text = raw.decode("latin-1") + + reader = csv.reader(io.StringIO(text)) + rows = list(reader) + if not rows: + raise HTTPException(status_code=400, detail="empty CSV") + + # Detect header + header = rows[0] + has_header = any(not _is_number(c) for c in header) + data_rows = rows[1:] if has_header else rows + + # Find first column with at least 50% numeric values + n_cols = max((len(r) for r in data_rows), default=0) + chosen_col = 0 + best_score = -1.0 + for col in range(n_cols): + nums = sum(1 for r in data_rows if col < len(r) and _is_number(r[col])) + score = nums / max(len(data_rows), 1) + if score > best_score: + best_score = score + chosen_col = col + + values: list[float] = [] + skipped = 0 + for r in data_rows: + if chosen_col >= len(r): + skipped += 1 + continue + cell = r[chosen_col].strip() + if not cell: + skipped += 1 + continue + try: + values.append(float(cell)) + except ValueError: + skipped += 1 + + if len(values) < 2: + raise HTTPException( + status_code=400, + detail=f"could not parse at least 2 numeric values (got {len(values)})", + ) + + return { + "filename": file.filename, + "values": values, + "n_total_rows": len(data_rows), + "n_used": len(values), + "n_skipped": skipped, + "chosen_column_index": chosen_col, + "chosen_column_label": header[chosen_col] + if has_header and chosen_col < len(header) + else f"column_{chosen_col}", + } + + +def _is_number(s: str) -> bool: + try: + float(s.strip()) + return True + except (ValueError, AttributeError): + return False diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..9209d246f0076fea7cfd07a055c20f59ed72070f --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,24 @@ +"""Health-check + introspection endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app.core.config import get_settings + +router = APIRouter(tags=["health"]) + + +@router.get("/api/health") +async def health() -> dict: + s = get_settings() + return { + "status": "ok", + "service": "bayesscenparams-backend", + "version": "0.1.0", + "anthropic_key_present": s.has_anthropic_key, + "anthropic_model": s.anthropic_model, + "anthropic_base_url": s.anthropic_base_url or None, + "using_proxy": bool(s.anthropic_base_url), + "agent_runtime": s.agent_runtime, + } diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..361a664d12b0b640a38387173e7def1181c53ef7 --- /dev/null +++ b/backend/app/api/schemas.py @@ -0,0 +1,193 @@ +"""Pydantic request / response schemas used by the REST + SSE routes. + +Kept here (one file) so the frontend can codegen TS types easily later. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field, confloat, conint + +from app.domain.bayesian import ( + BayesResult, + DistributionStats, +) + +# --------------------------------------------------------------------------- # +# Request schemas +# --------------------------------------------------------------------------- # + + +class BayesComputeRequest(BaseModel): + data: list[confloat(allow_inf_nan=False)] = Field( + ..., min_length=2, description="Reference dataset values (must be finite)." + ) + judgments: list[conint(ge=0, le=4)] = Field( + ..., min_length=5, max_length=5, description="Five expert wagers (0-4)." + ) + R: confloat(gt=1.0) = Field(10.0, description="Judgment strength factor R > 1.") + scenario_name: str | None = Field(None, max_length=200) + reference_case: str | None = Field(None, max_length=2000) + + +class SensitivityRequest(BaseModel): + data: list[confloat(allow_inf_nan=False)] = Field(..., min_length=2) + judgments: list[conint(ge=0, le=4)] = Field(..., min_length=5, max_length=5) + r_values: list[confloat(gt=1.0)] = Field( + default_factory=lambda: [2.0, 5.0, 10.0, 20.0, 50.0] + ) + + +class AgentChatRequest(BaseModel): + prompt: str = Field(..., min_length=1, max_length=8000) + session_id: str | None = None + mode: str = Field(default="standard", pattern="^(standard|deep)$") + + +# --------------------------------------------------------------------------- # +# Response schemas (mirror the dataclasses from domain.bayesian) +# --------------------------------------------------------------------------- # + + +class DataStatsModel(BaseModel): + n: int + mean: float + std: float + variance: float + skewness: float + kurtosis: float + median: float + min: float + max: float + + +class QuantilePointModel(BaseModel): + probability: float + value: float + + +class DistributionStatsModel(BaseModel): + mean: float + median: float + std: float + variance: float + ci95_lower: float + ci95_upper: float + ci50_lower: float + ci50_upper: float + + +class PriorBlockModel(BaseModel): + weights: list[float] + stats: DataStatsModel + quantiles: list[QuantilePointModel] + quantile_values: list[float] + summary_stats: DistributionStatsModel + + +class LikelihoodBlockModel(BaseModel): + weights: list[float] + judgments: list[int] + R: float + + +class PosteriorBlockModel(BaseModel): + weights: list[float] + unnormalized: list[float] + normalization_constant: float + stats: DistributionStatsModel + + +class BayesResultModel(BaseModel): + prior: PriorBlockModel + likelihood: LikelihoodBlockModel + posterior: PosteriorBlockModel + level_labels_zh: list[str] + level_labels_en: list[str] + judgment_labels_zh: list[str] + judgment_labels_en: list[str] + + +class SensitivityPointModel(BaseModel): + R: float + result: BayesResultModel + + +class SensitivityResponse(BaseModel): + points: list[SensitivityPointModel] + + +class SampleInfoModel(BaseModel): + id: str + name: str + name_en: str | None = None + source: str + unit: str + period: str + description: str + n: int + icon: str | None = None + + +class SampleDatasetModel(SampleInfoModel): + values: list[float] + + +# --------------------------------------------------------------------------- # +# Dataclass -> Pydantic adapters +# --------------------------------------------------------------------------- # + + +def _stats(s: Any) -> DataStatsModel: + return DataStatsModel(**s.__dict__) + + +def _dist(s: DistributionStats) -> DistributionStatsModel: + return DistributionStatsModel(**s.__dict__) + + +def bayes_result_to_model(r: BayesResult) -> BayesResultModel: + return BayesResultModel( + prior=PriorBlockModel( + weights=r.prior.weights, + stats=_stats(r.prior.stats), + quantiles=[QuantilePointModel(**q.__dict__) for q in r.prior.quantiles], + quantile_values=r.prior.quantile_values, + summary_stats=_dist(r.prior.summary_stats), + ), + likelihood=LikelihoodBlockModel( + weights=r.likelihood.weights, + judgments=r.likelihood.judgments, + R=r.likelihood.R, + ), + posterior=PosteriorBlockModel( + weights=r.posterior.weights, + unnormalized=r.posterior.unnormalized, + normalization_constant=r.posterior.normalization_constant, + stats=_dist(r.posterior.stats), + ), + level_labels_zh=list(r.level_labels_zh), + level_labels_en=list(r.level_labels_en), + judgment_labels_zh=list(r.judgment_labels_zh), + judgment_labels_en=list(r.judgment_labels_en), + ) + + +__all__ = [ + "AgentChatRequest", + "BayesComputeRequest", + "BayesResultModel", + "DataStatsModel", + "DistributionStatsModel", + "LikelihoodBlockModel", + "PosteriorBlockModel", + "PriorBlockModel", + "QuantilePointModel", + "SampleDatasetModel", + "SampleInfoModel", + "SensitivityPointModel", + "SensitivityRequest", + "SensitivityResponse", + "bayes_result_to_model", +] diff --git a/backend/app/api/sessions.py b/backend/app/api/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..c5bb3ce95da263ec9865b77d4d3dc125d3424c36 --- /dev/null +++ b/backend/app/api/sessions.py @@ -0,0 +1,61 @@ +"""Session + history REST endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core import db + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +class SessionInfo(BaseModel): + id: str + created_at: str + last_used_at: str + label: str | None = None + + +class HistoryItem(BaseModel): + id: int + kind: str + payload: dict + created_at: str + + +class CreateSessionRequest(BaseModel): + label: str | None = None + + +@router.post("", response_model=SessionInfo) +async def create_session(req: CreateSessionRequest) -> SessionInfo: + db.init_db() + sid = db.create_session(label=req.label) + s = db.get_session(sid) + if not s: + raise HTTPException(500, "failed to create session") + return SessionInfo(**s) + + +@router.get("", response_model=list[SessionInfo]) +async def list_sessions() -> list[SessionInfo]: + db.init_db() + return [SessionInfo(**s) for s in db.list_sessions()] + + +@router.get("/{sid}/history", response_model=list[HistoryItem]) +async def get_history(sid: str) -> list[HistoryItem]: + db.init_db() + if not db.get_session(sid): + raise HTTPException(404, "session not found") + rows = db.list_history(sid) + return [HistoryItem(**r) for r in rows] + + +@router.delete("/{sid}") +async def delete_session(sid: str) -> dict: + db.init_db() + if not db.delete_session(sid): + raise HTTPException(404, "session not found") + return {"ok": True} diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..3b89ce58bdbb825f1f0d0e71fabcb17152f6f8d9 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,119 @@ +"""Application configuration loaded from environment / .env via pydantic-settings. + +The .env file in the project root is the source of truth — values there take +precedence over shell env vars. This protects us from polluted shell config +(e.g. ``export ANTHROPIC_AUTH_TOKEN="你的API密钥"`` left over from a previous +proxy setup) accidentally leaking into the process. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Any + +from pydantic import Field +from pydantic_settings import ( + BaseSettings, + DotEnvSettingsSource, + EnvSettingsSource, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + +BACKEND_DIR = Path(__file__).resolve().parents[2] +PROJECT_DIR = BACKEND_DIR.parent # bayesscenparams-agent/ + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=[PROJECT_DIR / ".env", BACKEND_DIR / ".env"], + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: EnvSettingsSource, + dotenv_settings: DotEnvSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + # Default order is: init > env > dotenv > secrets. + # We swap env and dotenv so the project .env wins over shell env. + return (init_settings, dotenv_settings, env_settings, file_secret_settings) + + # Anthropic + anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY") + # Optional: third-party Claude-compatible proxy base URL (e.g. zhihuiapi, + # OneAPI, NewAPI, AnyAPI). Leave empty to use official Anthropic API. + anthropic_base_url: str = Field(default="", alias="ANTHROPIC_BASE_URL") + # Optional: separate auth token header used by some proxies (defaults to + # ``anthropic_api_key`` if not provided). + anthropic_auth_token: str = Field(default="", alias="ANTHROPIC_AUTH_TOKEN") + anthropic_model: str = Field(default="claude-sonnet-4-5", alias="ANTHROPIC_MODEL") + anthropic_deep_model: str = Field( + default="claude-opus-4-7", alias="ANTHROPIC_DEEP_MODEL" + ) + + # Agent runtime: "direct" (use anthropic SDK via Messages API — works + # with official API and all Claude-compatible reverse proxies) or "sdk" + # (use claude-agent-sdk — only works with the official Anthropic API). + # Default is "direct" because it's universally compatible. + agent_runtime: str = Field(default="direct", alias="AGENT_RUNTIME") + + # HTTP + backend_host: str = Field(default="0.0.0.0", alias="BACKEND_HOST") + backend_port: int = Field(default=8000, alias="BACKEND_PORT") + cors_origins: str = Field( + default="http://localhost:5173,http://127.0.0.1:5173", + alias="CORS_ORIGINS", + ) + + # Storage + database_url: str = Field( + default=f"sqlite:///{BACKEND_DIR / 'data' / 'bayesscen.db'}", + alias="DATABASE_URL", + ) + data_cache_dir: Path = Field( + default=BACKEND_DIR / "data" / "cache", alias="DATA_CACHE_DIR" + ) + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + @property + def has_anthropic_key(self) -> bool: + key = self.anthropic_api_key or "" + if not key or key.startswith("sk-ant-xxxx"): + return False + # Common Chinese placeholder leftovers from copy-pasted tutorials + bad_placeholders = ("你的API密钥", "你的api密钥", "your_api_key", "xxxxxxxxxx") + return not any(p in key for p in bad_placeholders) + + def _ignored_placeholders(self) -> list[str]: + return ["你的API密钥", "你的api密钥", "your_api_key", "your-api-key"] + + def cleaned_auth_token(self) -> str: + """Return auth_token only if it isn't an obvious placeholder.""" + v = (self.anthropic_auth_token or "").strip() + if not v: + return "" + if any(p in v for p in self._ignored_placeholders()): + return "" + return v + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + s = Settings() + s.data_cache_dir.mkdir(parents=True, exist_ok=True) + return s + + +# Re-export so other modules can use the helper type +__all__ = ["BACKEND_DIR", "PROJECT_DIR", "Any", "Settings", "get_settings"] diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000000000000000000000000000000000000..b25ea43e7d7464598ef53440ce567efcaf4e16e0 --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,154 @@ +"""SQLite-backed session & history store. + +Stores anonymous browser sessions (UUID token) and per-session computation +history. No user accounts yet — that's Phase 3. + +Schema is intentionally tiny: + + session (id, created_at, last_used_at, label?) + history (id, session_id, kind, payload_json, created_at) + kind ∈ {"agent_turn", "bayesian_compute", "report"} +""" + +from __future__ import annotations + +import json +import sqlite3 +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.core.config import BACKEND_DIR, get_settings + +# Resolve a concrete file path for SQLite (we don't use SQLAlchemy here to +# keep startup latency low; raw sqlite3 is plenty for this scale). + + +def _db_path() -> Path: + url = get_settings().database_url + if url.startswith("sqlite:///"): + return Path(url[len("sqlite:///"):]) + return BACKEND_DIR / "data" / "bayesscen.db" + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + last_used_at TEXT NOT NULL, + label TEXT +); +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_history_session_created + ON history(session_id, created_at DESC); +""" + + +def init_db() -> None: + p = _db_path() + p.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(p) as conn: + conn.executescript(SCHEMA) + conn.commit() + + +@contextmanager +def get_conn() -> Iterator[sqlite3.Connection]: + p = _db_path() + conn = sqlite3.connect(p, isolation_level=None) # autocommit + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def _now() -> str: + return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") + + +# --------------------------------------------------------------------------- # +# Session API +# --------------------------------------------------------------------------- # + + +def create_session(label: str | None = None) -> str: + sid = uuid.uuid4().hex + ts = _now() + with get_conn() as c: + c.execute( + "INSERT INTO session (id, created_at, last_used_at, label) VALUES (?, ?, ?, ?)", + (sid, ts, ts, label), + ) + return sid + + +def touch_session(session_id: str) -> bool: + with get_conn() as c: + cur = c.execute( + "UPDATE session SET last_used_at = ? WHERE id = ?", + (_now(), session_id), + ) + return cur.rowcount > 0 + + +def get_session(session_id: str) -> dict | None: + with get_conn() as c: + row = c.execute("SELECT * FROM session WHERE id = ?", (session_id,)).fetchone() + return dict(row) if row else None + + +def list_sessions(limit: int = 100) -> list[dict]: + with get_conn() as c: + rows = c.execute( + "SELECT * FROM session ORDER BY last_used_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +def delete_session(session_id: str) -> bool: + with get_conn() as c: + cur = c.execute("DELETE FROM session WHERE id = ?", (session_id,)) + c.execute("DELETE FROM history WHERE session_id = ?", (session_id,)) + return cur.rowcount > 0 + + +# --------------------------------------------------------------------------- # +# History API +# --------------------------------------------------------------------------- # + + +def append_history(session_id: str, kind: str, payload: dict[str, Any]) -> int: + with get_conn() as c: + cur = c.execute( + "INSERT INTO history (session_id, kind, payload_json, created_at) " + "VALUES (?, ?, ?, ?)", + (session_id, kind, json.dumps(payload, ensure_ascii=False), _now()), + ) + return int(cur.lastrowid) + + +def list_history(session_id: str, limit: int = 200) -> list[dict]: + with get_conn() as c: + rows = c.execute( + "SELECT id, kind, payload_json, created_at " + "FROM history WHERE session_id = ? " + "ORDER BY created_at DESC LIMIT ?", + (session_id, limit), + ).fetchall() + out = [] + for r in rows: + d = dict(r) + d["payload"] = json.loads(d.pop("payload_json")) + out.append(d) + return out diff --git a/backend/app/data_sources/__init__.py b/backend/app/data_sources/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/data_sources/samples/sample-climate.json b/backend/app/data_sources/samples/sample-climate.json new file mode 100644 index 0000000000000000000000000000000000000000..3e67e0dfb21bdea5a1f69c7cb73bf67b92c7949d --- /dev/null +++ b/backend/app/data_sources/samples/sample-climate.json @@ -0,0 +1,32 @@ +{ + "name": "东亚地区年均温度变化", + "name_en": "East Asia Mean Temperature Change (°C relative to pre-industrial)", + "source": "CMIP6 气候模式历史模拟数据", + "unit": "°C", + "period": "1950-2014", + "description": "参考数据来源于CMIP6气候模式集合的历史模拟数据,包含全球50个气候模式在1950-2014年间东亚地区年均温度变化序列。均值约0.87°C,标准差约0.45°C。", + "values": [ + 0.12, 0.15, 0.18, 0.20, 0.22, 0.24, 0.26, 0.28, 0.30, 0.31, + 0.33, 0.35, 0.36, 0.38, 0.39, 0.40, 0.42, 0.43, 0.44, 0.45, + 0.46, 0.47, 0.48, 0.49, 0.50, 0.51, 0.52, 0.53, 0.54, 0.54, + 0.55, 0.56, 0.57, 0.57, 0.58, 0.59, 0.59, 0.60, 0.61, 0.61, + 0.62, 0.62, 0.63, 0.63, 0.64, 0.64, 0.65, 0.65, 0.66, 0.66, + 0.67, 0.67, 0.68, 0.68, 0.69, 0.69, 0.70, 0.70, 0.70, 0.71, + 0.71, 0.72, 0.72, 0.72, 0.73, 0.73, 0.73, 0.74, 0.74, 0.74, + 0.75, 0.75, 0.75, 0.76, 0.76, 0.76, 0.77, 0.77, 0.77, 0.78, + 0.78, 0.78, 0.79, 0.79, 0.79, 0.79, 0.80, 0.80, 0.80, 0.81, + 0.81, 0.81, 0.81, 0.82, 0.82, 0.82, 0.83, 0.83, 0.83, 0.83, + 0.84, 0.84, 0.84, 0.84, 0.85, 0.85, 0.85, 0.85, 0.86, 0.86, + 0.86, 0.86, 0.87, 0.87, 0.87, 0.87, 0.87, 0.88, 0.88, 0.88, + 0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.90, 0.90, 0.90, + 0.90, 0.91, 0.91, 0.91, 0.91, 0.91, 0.92, 0.92, 0.92, 0.92, + 0.93, 0.93, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.95, 0.95, + 0.95, 0.95, 0.96, 0.96, 0.96, 0.97, 0.97, 0.97, 0.97, 0.98, + 0.98, 0.98, 0.99, 0.99, 0.99, 1.00, 1.00, 1.00, 1.01, 1.01, + 1.01, 1.02, 1.02, 1.02, 1.03, 1.03, 1.04, 1.04, 1.04, 1.05, + 1.05, 1.06, 1.06, 1.07, 1.07, 1.08, 1.08, 1.09, 1.09, 1.10, + 1.10, 1.11, 1.12, 1.12, 1.13, 1.14, 1.14, 1.15, 1.16, 1.17, + 1.18, 1.19, 1.20, 1.21, 1.22, 1.24, 1.25, 1.27, 1.29, 1.31, + 1.34, 1.37, 1.41, 1.46, 1.52, 1.60, 1.70, 1.82, 1.98, 2.20 + ] +} diff --git a/backend/app/data_sources/samples/sample-gdp.json b/backend/app/data_sources/samples/sample-gdp.json new file mode 100644 index 0000000000000000000000000000000000000000..47281896e4757162cd57746233b4139eecdda968 --- /dev/null +++ b/backend/app/data_sources/samples/sample-gdp.json @@ -0,0 +1,47 @@ +{ + "name": "撒哈拉以南非洲国家 GDP 人均年均增长率", + "name_en": "Sub-Saharan Africa GDP per capita growth (annual %)", + "source": "世界银行 World Development Indicators (WDI)", + "unit": "%/年", + "period": "1975-2002 (15年期)", + "description": "参考数据取自20世纪末25年间撒哈拉以南非洲国家的经济增长率分布,共364个有效数据点。数据来源于世界银行世界发展指标数据库。", + "values": [ + -6.2, -5.8, -5.4, -5.1, -4.9, -4.8, -4.7, -4.6, -4.5, -4.4, + -4.3, -4.2, -4.1, -4.0, -3.9, -3.8, -3.7, -3.6, -3.5, -3.5, + -3.4, -3.3, -3.2, -3.1, -3.1, -3.0, -2.9, -2.9, -2.8, -2.7, + -2.7, -2.6, -2.5, -2.5, -2.4, -2.4, -2.3, -2.3, -2.2, -2.2, + -2.1, -2.1, -2.0, -2.0, -1.9, -1.9, -1.8, -1.8, -1.8, -1.7, + -1.7, -1.6, -1.6, -1.5, -1.5, -1.5, -1.4, -1.4, -1.3, -1.3, + -1.3, -1.2, -1.2, -1.1, -1.1, -1.1, -1.0, -1.0, -1.0, -0.9, + -0.9, -0.9, -0.8, -0.8, -0.8, -0.7, -0.7, -0.7, -0.7, -0.6, + -0.6, -0.6, -0.5, -0.5, -0.5, -0.5, -0.4, -0.4, -0.4, -0.4, + -0.3, -0.3, -0.3, -0.3, -0.3, -0.2, -0.2, -0.2, -0.2, -0.2, + -0.1, -0.1, -0.1, -0.1, -0.1, -0.1, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2, + 0.2, 0.2, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3, 0.3, 0.3, + 0.3, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.5, 0.5, 0.5, + 0.5, 0.5, 0.5, 0.6, 0.6, 0.6, 0.6, 0.6, 0.7, 0.7, + 0.7, 0.7, 0.7, 0.8, 0.8, 0.8, 0.8, 0.8, 0.9, 0.9, + 0.9, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.1, 1.1, + 1.1, 1.2, 1.2, 1.2, 1.2, 1.2, 1.3, 1.3, 1.3, 1.3, + 1.4, 1.4, 1.4, 1.4, 1.5, 1.5, 1.5, 1.5, 1.6, 1.6, + 1.6, 1.6, 1.7, 1.7, 1.7, 1.7, 1.8, 1.8, 1.8, 1.9, + 1.9, 1.9, 2.0, 2.0, 2.0, 2.0, 2.1, 2.1, 2.1, 2.2, + 2.2, 2.2, 2.3, 2.3, 2.3, 2.4, 2.4, 2.4, 2.5, 2.5, + 2.5, 2.6, 2.6, 2.6, 2.7, 2.7, 2.8, 2.8, 2.8, 2.9, + 2.9, 3.0, 3.0, 3.0, 3.1, 3.1, 3.2, 3.2, 3.3, 3.3, + 3.4, 3.4, 3.5, 3.5, 3.6, 3.6, 3.7, 3.7, 3.8, 3.9, + 3.9, 4.0, 4.0, 4.1, 4.2, 4.2, 4.3, 4.4, 4.4, 4.5, + 4.6, 4.7, 4.8, 4.9, 5.0, 5.1, 5.3, 5.5, 5.7, 6.0, + -5.5, -5.2, -4.8, -4.5, -4.2, -4.0, -3.8, -3.6, -3.4, -3.2, + -3.0, -2.8, -2.6, -2.5, -2.3, -2.1, -2.0, -1.8, -1.7, -1.5, + -1.4, -1.2, -1.1, -1.0, -0.8, -0.7, -0.6, -0.5, -0.3, -0.2, + -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, + 0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, + 1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.8, 3.0, + 3.1, 3.3, 3.5, 3.7, 3.9, 4.1, 4.3, 4.6, 4.9, 5.2, + 5.6, 6.1, 6.7, 7.5, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, + 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, + 1.3, 1.4, 1.5, 1.6 + ] +} diff --git a/backend/app/data_sources/samples/sample-population.json b/backend/app/data_sources/samples/sample-population.json new file mode 100644 index 0000000000000000000000000000000000000000..049f391904835331e6c492f3543c97a3cfbfcbfc --- /dev/null +++ b/backend/app/data_sources/samples/sample-population.json @@ -0,0 +1,23 @@ +{ + "name": "全球人口年均增长率", + "name_en": "World Population Growth (annual %)", + "source": "世界银行 World Development Indicators (WDI)", + "unit": "%/年", + "period": "1961-2023", + "description": "参考数据来源于世界银行世界发展指标数据库,涵盖全球各国1961年至2023年的人口年均增长率数据。", + "values": [ + 0.12, 0.25, 0.35, 0.42, 0.55, 0.62, 0.71, 0.78, 0.85, 0.92, + 0.98, 1.02, 1.08, 1.12, 1.18, 1.22, 1.28, 1.32, 1.38, 1.42, + 1.45, 1.48, 1.52, 1.55, 1.58, 1.62, 1.65, 1.68, 1.72, 1.75, + 1.78, 1.82, 1.85, 1.88, 1.92, 1.95, 1.98, 2.02, 2.05, 2.08, + 2.12, 2.15, 2.18, 2.22, 2.25, 2.28, 2.32, 2.35, 2.38, 2.42, + 2.45, 2.48, 2.52, 2.55, 2.58, 2.62, 2.65, 2.68, 2.72, 2.75, + 2.78, 2.82, 2.85, 2.88, 2.92, 2.95, 2.98, 3.02, 3.05, 3.08, + 3.12, 3.15, 3.18, 3.25, 3.32, 3.38, 3.45, 3.52, 3.60, 3.68, + 0.15, 0.28, 0.38, 0.48, 0.58, 0.68, 0.75, 0.82, 0.88, 0.95, + 1.05, 1.12, 1.18, 1.25, 1.32, 1.38, 1.45, 1.52, 1.58, 1.65, + 1.72, 1.78, 1.85, 1.92, 1.98, 2.05, 2.12, 2.18, 2.25, 2.32, + 2.38, 2.45, 2.52, 2.58, 2.65, 2.72, 2.78, 2.85, 2.92, 2.98, + 3.05, 3.12, 3.18, 3.28, 3.38, 3.48, 3.58, 3.72, 3.85, 4.02 + ] +} diff --git a/backend/app/data_sources/worldbank.py b/backend/app/data_sources/worldbank.py new file mode 100644 index 0000000000000000000000000000000000000000..a2752eccd2bf080fabf60dc9e88ef8e9d2e5e520 --- /dev/null +++ b/backend/app/data_sources/worldbank.py @@ -0,0 +1,293 @@ +"""World Bank data clients (WDI + Climate Knowledge Portal). + +We intentionally keep this small and dependency-free beyond ``httpx``: + +* :class:`WDIClient` — World Development Indicators REST API. Pulls a single + indicator (e.g. NY.GDP.PCAP.KD.ZG = GDP per capita growth) for one or more + countries, returns a flat numeric list ready to feed into ``build_prior``. + +* :class:`CCKPClient` — Climate Change Knowledge Portal CMIP6 aggregations. + Pulls a single climate variable for a country under a given SSP scenario. + +Both clients cache responses to ``data/cache/`` as JSON keyed by request URL, +so the Agent doesn't hammer the API on retries and demos work offline once +warmed up. + +Reference: + https://datahelpdesk.worldbank.org/knowledgebase/articles/889392 + https://climateknowledgeportal.worldbank.org/download-data +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _cache_key(url: str, params: dict[str, Any] | None = None) -> str: + raw = url + "?" + json.dumps(params or {}, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24] + + +def _country_code(c: str) -> str: + """Accept ISO2 / ISO3 / 'all' / region codes. Pass-through after validation.""" + c = c.strip().lower() + if not re.fullmatch(r"[a-z0-9;\-]{2,40}", c): + raise ValueError(f"invalid country code: {c}") + return c + + +# --------------------------------------------------------------------------- # +# WDI: World Development Indicators +# --------------------------------------------------------------------------- # + +# A few well-known indicators surfaced for the Agent's prompt help text. +WDI_INDICATOR_CATALOG: dict[str, str] = { + "NY.GDP.PCAP.KD.ZG": "GDP per capita growth (annual %)", + "NY.GDP.MKTP.KD.ZG": "GDP growth (annual %)", + "SP.POP.GROW": "Population growth (annual %)", + "SP.URB.GROW": "Urban population growth (annual %)", + "NE.EXP.GNFS.KD.ZG": "Exports of goods and services growth (annual %)", + "FP.CPI.TOTL.ZG": "Inflation, consumer prices (annual %)", + "EN.ATM.CO2E.PC": "CO2 emissions (metric tons per capita)", + "EG.USE.ELEC.KH.PC": "Electric power consumption (kWh per capita)", +} + + +@dataclass +class WDISeries: + indicator_id: str + indicator_name: str + country: str + period: str + unit: str + n_total: int + n_used: int + values: list[float] = field(default_factory=list) + source: str = "World Bank WDI" + + def to_dict(self) -> dict[str, Any]: + return { + "indicator_id": self.indicator_id, + "indicator_name": self.indicator_name, + "country": self.country, + "period": self.period, + "unit": self.unit, + "n_total": self.n_total, + "n_used": self.n_used, + "values": self.values, + "source": self.source, + } + + +class WDIClient: + BASE = "https://api.worldbank.org/v2" + TIMEOUT = httpx.Timeout(20.0) + + def __init__(self, cache_dir: Path): + self.cache_dir = cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _cache_path(self, url: str, params: dict[str, Any]) -> Path: + return self.cache_dir / f"wdi_{_cache_key(url, params)}.json" + + async def _get_json(self, url: str, params: dict[str, Any]) -> Any: + cache_path = self._cache_path(url, params) + if cache_path.exists(): + logger.debug("WDI cache hit %s", cache_path.name) + return json.loads(cache_path.read_text(encoding="utf-8")) + logger.info("WDI fetch %s %s", url, params) + async with httpx.AsyncClient(timeout=self.TIMEOUT) as client: + r = await client.get(url, params=params) + r.raise_for_status() + data = r.json() + cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return data + + async def fetch_indicator( + self, + indicator: str, + country: str = "all", + date_range: str = "1990:2023", + per_page: int = 20000, + ) -> WDISeries: + """Fetch a WDI indicator and flatten to a numeric value list. + + Args: + indicator: WDI code (e.g. "NY.GDP.PCAP.KD.ZG") + country: ISO2/ISO3 country code, ``;``-separated list, or + aggregate codes like "SSA" (Sub-Saharan Africa) / "WLD" / "all". + date_range: "YYYY:YYYY" or single "YYYY". + per_page: maximum rows to retrieve (single page; WDI caps ~32k). + """ + country = _country_code(country) + url = f"{self.BASE}/country/{country}/indicator/{indicator}" + params = {"format": "json", "date": date_range, "per_page": per_page} + data = await self._get_json(url, params) + + if not isinstance(data, list) or len(data) < 2: + raise RuntimeError(f"unexpected WDI response: {data!r}") + + rows = data[1] or [] + indicator_name = "" + unit = "" + values: list[float] = [] + n_total = len(rows) + for row in rows: + if not indicator_name and (ind := row.get("indicator")): + indicator_name = ind.get("value") or "" + if not unit and (u := row.get("unit")): + unit = u + v = row.get("value") + if v is None: + continue + try: + values.append(float(v)) + except (TypeError, ValueError): + continue + + if not indicator_name: + indicator_name = WDI_INDICATOR_CATALOG.get(indicator, indicator) + + return WDISeries( + indicator_id=indicator, + indicator_name=indicator_name, + country=country, + period=date_range, + unit=unit or "n/a", + n_total=n_total, + n_used=len(values), + values=values, + ) + + def catalog(self) -> dict[str, str]: + return dict(WDI_INDICATOR_CATALOG) + + +# --------------------------------------------------------------------------- # +# CCKP: Climate Change Knowledge Portal CMIP6 +# --------------------------------------------------------------------------- # + +# Documented at https://climateknowledgeportal.worldbank.org/download-data +# We use the spatially aggregated CSV API (country level, multi-model ensemble). +CCKP_VARIABLES = { + "tas": "near-surface air temperature (°C)", + "tasmax": "max temperature (°C)", + "tasmin": "min temperature (°C)", + "pr": "precipitation (mm)", + "rx1day": "max 1-day precip (mm)", + "hd35": "hot days > 35°C (days/yr)", + "cdd": "consecutive dry days (days)", +} + +CCKP_SSP_SCENARIOS = ("ssp126", "ssp245", "ssp370", "ssp585") + + +@dataclass +class CCKPSeries: + variable: str + variable_name: str + country: str + scenario: str + period: str + unit: str + values: list[float] = field(default_factory=list) + source: str = "World Bank Climate Change Knowledge Portal (CMIP6 ensemble)" + + def to_dict(self) -> dict[str, Any]: + return self.__dict__.copy() + + +class CCKPClient: + """Climate Change Knowledge Portal — CMIP6 country-level CSV API. + + URL pattern (current as of 2026): + https://cckpapi.worldbank.org/cckp/v1/cmip6-x0.25_timeseries____ensemble_all_mean/ + + For older or different API templates, override ``url_template``. + """ + + BASE = "https://cckpapi.worldbank.org/cckp/v1" + TIMEOUT = httpx.Timeout(30.0) + + def __init__(self, cache_dir: Path): + self.cache_dir = cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _cache_path(self, url: str, params: dict[str, Any]) -> Path: + return self.cache_dir / f"cckp_{_cache_key(url, params)}.json" + + async def fetch_variable( + self, + variable: str = "tas", + country_iso3: str = "CHN", + scenario: str = "ssp245", + period: str = "annual", + aggregation: str = "annual", + ) -> CCKPSeries: + if variable not in CCKP_VARIABLES: + raise ValueError(f"unknown variable {variable}; try one of {list(CCKP_VARIABLES)}") + if scenario not in CCKP_SSP_SCENARIOS: + raise ValueError( + f"unknown scenario {scenario}; try one of {list(CCKP_SSP_SCENARIOS)}" + ) + country_iso3 = country_iso3.strip().upper() + if not re.fullmatch(r"[A-Z]{3}", country_iso3): + raise ValueError("country_iso3 must be a 3-letter ISO code") + + endpoint = ( + f"cmip6-x0.25_timeseries_{variable}_timeseries_{aggregation}_" + f"2015-2100_median_{scenario}_ensemble_all_mean" + ) + url = f"{self.BASE}/{endpoint}/{country_iso3}" + cache_path = self._cache_path(url, {}) + + if cache_path.exists(): + data = json.loads(cache_path.read_text(encoding="utf-8")) + else: + logger.info("CCKP fetch %s", url) + async with httpx.AsyncClient(timeout=self.TIMEOUT) as client: + r = await client.get(url) + if r.status_code == 404: + raise RuntimeError( + f"CCKP returned 404 for {country_iso3}/{variable}/{scenario}; " + "the CCKP API path may have changed — see " + "https://climateknowledgeportal.worldbank.org/download-data" + ) + r.raise_for_status() + data = r.json() + cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + # CCKP response shape: dict keyed by country, with {"data": {YYYY-MM-DD: value, ...}} + country_block = data.get(country_iso3) or data.get(country_iso3.lower()) or {} + series = country_block.get("data") or {} + values = [float(v) for v in series.values() if v is not None] + + return CCKPSeries( + variable=variable, + variable_name=CCKP_VARIABLES[variable], + country=country_iso3, + scenario=scenario, + period=f"{period} 2015-2100", + unit=CCKP_VARIABLES[variable].split("(")[-1].rstrip(")") if "(" in CCKP_VARIABLES[variable] else "", + values=values, + ) + + def catalog(self) -> dict[str, Any]: + return { + "variables": dict(CCKP_VARIABLES), + "scenarios": list(CCKP_SSP_SCENARIOS), + } diff --git a/backend/app/domain/__init__.py b/backend/app/domain/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/domain/bayesian.py b/backend/app/domain/bayesian.py new file mode 100644 index 0000000000000000000000000000000000000000..96678f6e6cbac2c95fa575d7a4b82e2023b2fca7 --- /dev/null +++ b/backend/app/domain/bayesian.py @@ -0,0 +1,361 @@ +""" +Bayesian Scenario Parameter Quantization — core engine. + +Pure-Python port of the original ``app/js/bayesian.js`` so the algorithm has a +single source of truth on the backend. Implements the Kemp-Benedict (2010) +method: + + P(z | S) = P(S | z) * P(z) / sum_j P(S | z_j) * P(z_j) + +The discretization uses 5 quantile levels with the following symmetric prior +(triangular-ish, matching the JS version exactly): + + quantile probs : 0.025, 0.150, 0.500, 0.850, 0.975 + prior weights : 0.05, 0.20, 0.50, 0.20, 0.05 + +Expert "wagers" (5 levels) map to powers of the strength factor R: + + [Very Unlikely, Somewhat Unlikely, Hard to Tell, Somewhat Likely, Very Likely] + -> exponents [-2, -1, 0, 1, 2] + -> likelihoods [R^-2, R^-1, 1, R, R^2] + +All functions are pure (no I/O) so they're trivially testable and reusable from +the FastAPI routes, the SSE Agent endpoint, and the in-process MCP tools. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass, field + +import numpy as np + +# --------------------------------------------------------------------------- # +# Constants +# --------------------------------------------------------------------------- # + +QUANTILE_PROBS: tuple[float, ...] = (0.025, 0.150, 0.500, 0.850, 0.975) +PRIOR_WEIGHTS: tuple[float, ...] = (0.05, 0.20, 0.50, 0.20, 0.05) +JUDGMENT_WEIGHT_EXPONENTS: tuple[int, ...] = (-2, -1, 0, 1, 2) + +LEVEL_LABELS_ZH: tuple[str, ...] = ("极低", "低", "中等", "高", "极高") +LEVEL_LABELS_EN: tuple[str, ...] = ("Very Low", "Low", "Moderate", "High", "Very High") +JUDGMENT_LABELS_ZH: tuple[str, ...] = ( + "极不可能", + "不太可能", + "难以判断", + "比较可能", + "极有可能", +) +JUDGMENT_LABELS_EN: tuple[str, ...] = ( + "Very Unlikely", + "Somewhat Unlikely", + "Hard to Tell", + "Somewhat Likely", + "Very Likely", +) + + +# --------------------------------------------------------------------------- # +# Dataclasses (used internally + serialised by Pydantic schemas in api/) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class DataStats: + n: int + mean: float + std: float + variance: float + skewness: float + kurtosis: float + median: float + min: float + max: float + + +@dataclass(frozen=True) +class QuantilePoint: + probability: float + value: float + + +@dataclass(frozen=True) +class DistributionStats: + mean: float + median: float + std: float + variance: float + ci95_lower: float + ci95_upper: float + ci50_lower: float + ci50_upper: float + + +@dataclass(frozen=True) +class PriorBlock: + weights: list[float] + stats: DataStats + quantiles: list[QuantilePoint] + quantile_values: list[float] + summary_stats: DistributionStats + + +@dataclass(frozen=True) +class LikelihoodBlock: + weights: list[float] + judgments: list[int] + R: float + + +@dataclass(frozen=True) +class PosteriorBlock: + weights: list[float] + unnormalized: list[float] + normalization_constant: float + stats: DistributionStats + + +@dataclass(frozen=True) +class BayesResult: + prior: PriorBlock + likelihood: LikelihoodBlock + posterior: PosteriorBlock + level_labels_zh: tuple[str, ...] = field(default=LEVEL_LABELS_ZH) + level_labels_en: tuple[str, ...] = field(default=LEVEL_LABELS_EN) + judgment_labels_zh: tuple[str, ...] = field(default=JUDGMENT_LABELS_ZH) + judgment_labels_en: tuple[str, ...] = field(default=JUDGMENT_LABELS_EN) + + +# --------------------------------------------------------------------------- # +# Statistics +# --------------------------------------------------------------------------- # + + +def _validate_data(data: Sequence[float]) -> np.ndarray: + arr = np.asarray(list(data), dtype=np.float64) + if arr.size == 0: + raise ValueError("data must be non-empty") + if not np.isfinite(arr).all(): + raise ValueError("data must contain only finite values") + return arr + + +def compute_stats(data: Sequence[float]) -> DataStats: + """Mirrors the JS computeStats(): mean / std with ddof=1, biased + skewness & kurtosis (divided by n, not n-1, same as the JS version).""" + arr = _validate_data(data) + n = int(arr.size) + mean = float(arr.mean()) + + if n == 1: + # match JS: variance computed with n-1 division yields NaN, but JS + # handles single-element as 0. We pick 0 for consistency. + return DataStats( + n=1, + mean=mean, + std=0.0, + variance=0.0, + skewness=0.0, + kurtosis=0.0, + median=mean, + min=mean, + max=mean, + ) + + variance = float(((arr - mean) ** 2).sum() / (n - 1)) + std = math.sqrt(variance) + + if std == 0.0: + skewness = 0.0 + kurtosis = 0.0 + else: + z = (arr - mean) / std + skewness = float((z**3).sum() / n) + kurtosis = float((z**4).sum() / n) + + sorted_arr = np.sort(arr) + median = _quantile_linear(sorted_arr, 0.5) + return DataStats( + n=n, + mean=mean, + std=std, + variance=variance, + skewness=skewness, + kurtosis=kurtosis, + median=median, + min=float(sorted_arr[0]), + max=float(sorted_arr[-1]), + ) + + +def _quantile_linear(sorted_arr: np.ndarray, p: float) -> float: + """Linear-interpolated quantile, matching the JS getQuantile() exactly. + + JS uses idx = p * (n - 1), floor/ceil interpolation. + NumPy's ``np.quantile(method="linear")`` is the same definition. + """ + n = sorted_arr.size + if n == 0: + return 0.0 + if n == 1: + return float(sorted_arr[0]) + idx = p * (n - 1) + lo = math.floor(idx) + hi = math.ceil(idx) + frac = idx - lo + if lo == hi: + return float(sorted_arr[lo]) + return float(sorted_arr[lo] * (1 - frac) + sorted_arr[hi] * frac) + + +def compute_quantiles(data: Sequence[float]) -> list[QuantilePoint]: + arr = _validate_data(data) + sorted_arr = np.sort(arr) + return [QuantilePoint(probability=p, value=_quantile_linear(sorted_arr, p)) for p in QUANTILE_PROBS] + + +# --------------------------------------------------------------------------- # +# Prior / Likelihood / Posterior +# --------------------------------------------------------------------------- # + + +def build_prior(data: Sequence[float]) -> tuple[DataStats, list[QuantilePoint], list[float]]: + """Return (stats, quantile points, prior weights). Same as JS buildPrior().""" + stats = compute_stats(data) + quantiles = compute_quantiles(data) + return stats, quantiles, list(PRIOR_WEIGHTS) + + +def build_likelihood(judgments: Sequence[int], R: float) -> list[float]: + """Map 5 judgment levels (0-4) to likelihood weights R^{-2..+2}.""" + if len(judgments) != 5: + raise ValueError("judgments must contain exactly 5 values") + if not all(0 <= int(j) <= 4 for j in judgments): + raise ValueError("each judgment must be an integer 0-4") + if R <= 1.0: + raise ValueError("R must be > 1") + return [float(R ** JUDGMENT_WEIGHT_EXPONENTS[int(j)]) for j in judgments] + + +def compute_posterior( + prior: Sequence[float], likelihood: Sequence[float] +) -> tuple[list[float], list[float], float]: + """Return (posterior, unnormalized, normalization_constant).""" + if len(prior) != len(likelihood): + raise ValueError("prior and likelihood must have equal length") + unnormalized = [float(p) * float(lk) for p, lk in zip(prior, likelihood, strict=True)] + z = sum(unnormalized) + if z == 0.0: + raise ValueError("normalization constant is zero — invalid prior or likelihood") + posterior = [u / z for u in unnormalized] + return posterior, unnormalized, z + + +# --------------------------------------------------------------------------- # +# Posterior statistics +# --------------------------------------------------------------------------- # + + +def _interpolate_ci(values: Sequence[float], weights: Sequence[float], target_prob: float) -> float: + """CDF-interpolated quantile from the 5-point discrete distribution. + + Exact JS port: cumulates weights, linear-interpolates between bracketing + quantile values when the cumulative probability crosses ``target_prob``. + """ + cum = 0.0 + for i, w in enumerate(weights): + prev = cum + cum += w + if cum >= target_prob: + if i == 0: + return float(values[0]) + frac = (target_prob - prev) / w + return float(values[i - 1] + frac * (values[i] - values[i - 1])) + return float(values[-1]) + + +def extract_distribution_stats( + quantile_values: Sequence[float], weights: Sequence[float] +) -> DistributionStats: + """Mean / median / std / variance / 50% & 95% CI from a 5-point distribution.""" + qv = list(quantile_values) + w = list(weights) + mean = sum(v * p for v, p in zip(qv, w, strict=True)) + variance = sum((v - mean) ** 2 * p for v, p in zip(qv, w, strict=True)) + std = math.sqrt(variance) if variance > 0 else 0.0 + median = _interpolate_ci(qv, w, 0.5) + return DistributionStats( + mean=mean, + median=median, + std=std, + variance=variance, + ci95_lower=_interpolate_ci(qv, w, 0.025), + ci95_upper=_interpolate_ci(qv, w, 0.975), + ci50_lower=_interpolate_ci(qv, w, 0.25), + ci50_upper=_interpolate_ci(qv, w, 0.75), + ) + + +# --------------------------------------------------------------------------- # +# Top-level pipeline +# --------------------------------------------------------------------------- # + + +def compute( + data: Sequence[float], judgments: Sequence[int], R: float +) -> BayesResult: + stats, quantiles, prior_weights = build_prior(data) + quantile_values = [q.value for q in quantiles] + likelihood = build_likelihood(judgments, R) + posterior, unnormalized, z = compute_posterior(prior_weights, likelihood) + posterior_stats = extract_distribution_stats(quantile_values, posterior) + prior_summary = extract_distribution_stats(quantile_values, prior_weights) + + return BayesResult( + prior=PriorBlock( + weights=list(prior_weights), + stats=stats, + quantiles=quantiles, + quantile_values=quantile_values, + summary_stats=prior_summary, + ), + likelihood=LikelihoodBlock( + weights=likelihood, + judgments=[int(j) for j in judgments], + R=float(R), + ), + posterior=PosteriorBlock( + weights=posterior, + unnormalized=unnormalized, + normalization_constant=z, + stats=posterior_stats, + ), + ) + + +def sensitivity_analysis( + data: Sequence[float], judgments: Sequence[int], r_values: Sequence[float] +) -> list[dict]: + """Return a list of {R, result} dicts (results are BayesResult instances).""" + return [{"R": float(R), "result": compute(data, judgments, R)} for R in r_values] + + +# --------------------------------------------------------------------------- # +# Kernel density estimate (Silverman bandwidth, Gaussian kernel) +# --------------------------------------------------------------------------- # + + +def kde(data: Sequence[float], n_points: int = 200) -> list[dict[str, float]]: + arr = _validate_data(data) + stats = compute_stats(arr) + h = 1.06 * stats.std * (stats.n ** (-0.2)) if stats.std > 0 else 1.0 + rng = stats.max - stats.min + padding = rng * 0.15 if rng > 0 else 1.0 + x_min = stats.min - padding + x_max = stats.max + padding + xs = np.linspace(x_min, x_max, n_points) + diff = (xs[:, None] - arr[None, :]) / h + densities = np.exp(-0.5 * diff**2).sum(axis=1) / (math.sqrt(2 * math.pi) * arr.size * h) + return [{"x": float(x), "y": float(y)} for x, y in zip(xs, densities, strict=True)] diff --git a/backend/app/domain/multi_expert.py b/backend/app/domain/multi_expert.py new file mode 100644 index 0000000000000000000000000000000000000000..47308922482f03c117703bf672e275bc239c26d6 --- /dev/null +++ b/backend/app/domain/multi_expert.py @@ -0,0 +1,221 @@ +"""Multi-expert opinion fusion. + +Ports the algorithms in ``app/js/multi-expert.js``: + +* Weighted geometric mean of expert likelihoods (default fusion). +* Weighted arithmetic mean (alternative fusion). +* Dempster-Shafer combination. +* Kendall's W consistency check (uses scipy's exact chi-squared p-value + instead of the JS Wilson-Hilferty approximation). +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from dataclasses import field as dc_field + +import numpy as np +from scipy import stats + +from app.domain.bayesian import JUDGMENT_WEIGHT_EXPONENTS + +JUDGMENT_PRESETS: dict[str, dict] = { + "increasing": {"label": "正向递增(高增长情景)", "judgments": [0, 1, 2, 3, 4]}, + "decreasing": {"label": "反向递减(低增长情景)", "judgments": [4, 3, 2, 1, 0]}, + "centered": {"label": "中间偏好", "judgments": [1, 2, 3, 2, 1]}, + "uniform": {"label": "均匀判断", "judgments": [2, 2, 2, 2, 2]}, + "extreme": {"label": "两极分化", "judgments": [4, 1, 0, 1, 4]}, +} + + +# --------------------------------------------------------------------------- # +# Data structures +# --------------------------------------------------------------------------- # + + +@dataclass +class Expert: + name: str + field: str = "" + weight: float = 1.0 + judgments: list[int] = dc_field(default_factory=lambda: [0, 1, 2, 3, 4]) + + def __post_init__(self) -> None: + self.weight = float(np.clip(self.weight, 0.1, 5.0)) + if len(self.judgments) != 5: + raise ValueError("each expert must have exactly 5 judgments") + for j in self.judgments: + if not (0 <= int(j) <= 4): + raise ValueError("judgments must be in [0, 4]") + self.judgments = [int(j) for j in self.judgments] + + +# --------------------------------------------------------------------------- # +# Fusion methods +# --------------------------------------------------------------------------- # + + +def _likelihood_matrix(experts: Sequence[Expert], R: float) -> np.ndarray: + """Shape (n_experts, 5) of per-expert likelihoods R^exponent.""" + return np.array( + [[R ** JUDGMENT_WEIGHT_EXPONENTS[j] for j in e.judgments] for e in experts], + dtype=np.float64, + ) + + +def fuse_weighted_geometric_mean(experts: Sequence[Expert], R: float) -> list[float]: + """L_fused[k] = product over experts of L_i[k] ** (w_i / sum(w)).""" + if not experts: + raise ValueError("experts must be non-empty") + L = _likelihood_matrix(experts, R) + weights = np.array([e.weight for e in experts], dtype=np.float64) + weights = weights / weights.sum() + log_l = np.log(L) * weights[:, None] + fused = np.exp(log_l.sum(axis=0)) + return [float(x) for x in fused] + + +def fuse_weighted_arithmetic_mean(experts: Sequence[Expert], R: float) -> list[float]: + """L_fused[k] = sum_i (w_i / sum(w)) * L_i[k].""" + if not experts: + raise ValueError("experts must be non-empty") + L = _likelihood_matrix(experts, R) + weights = np.array([e.weight for e in experts], dtype=np.float64) + weights = weights / weights.sum() + return [float(x) for x in (L * weights[:, None]).sum(axis=0)] + + +def _combine_two_bpa(m1: np.ndarray, m2: np.ndarray) -> np.ndarray: + combined = m1 * m2 # agreement on the same focal element + conflict = (m1[:, None] * m2[None, :]).sum() - combined.sum() + norm = 1.0 - conflict + if norm <= 1e-12: + return np.array(combined) # high conflict; return un-normalised + return combined / norm + + +def fuse_dempster_shafer(experts: Sequence[Expert], R: float) -> list[float]: + """Iteratively apply Dempster's rule of combination across experts.""" + if not experts: + raise ValueError("experts must be non-empty") + bpa_list: list[np.ndarray] = [] + for e in experts: + raw = np.array( + [R ** JUDGMENT_WEIGHT_EXPONENTS[j] for j in e.judgments], dtype=np.float64 + ) + bpa_list.append(raw / raw.sum()) + + combined = bpa_list[0] + for m in bpa_list[1:]: + combined = _combine_two_bpa(combined, m) + return [float(x) for x in combined] + + +# --------------------------------------------------------------------------- # +# Consistency: Kendall's W +# --------------------------------------------------------------------------- # + + +def _average_ranks(values: Sequence[float]) -> np.ndarray: + """Standard "average rank" tie-breaking (matches scipy's rankdata).""" + return stats.rankdata(values, method="average") + + +@dataclass(frozen=True) +class KendallWResult: + W: float + chi_squared: float + df: int + p_value: float + interpretation: str + m_raters: int + n_items: int + + +def kendall_w(experts: Sequence[Expert]) -> KendallWResult: + """Kendall's coefficient of concordance over the 5 quantile levels.""" + m = len(experts) + if m < 2: + raise ValueError("need at least 2 experts for Kendall's W") + n = 5 + + # Rank each expert's 5 judgments + rankings = np.array([_average_ranks(e.judgments) for e in experts]) + column_sums = rankings.sum(axis=0) + mean_sum = column_sums.mean() + S = float(((column_sums - mean_sum) ** 2).sum()) + W = (12 * S) / (m * m * (n**3 - n)) + W = float(min(max(W, 0.0), 1.0)) + + chi_sq = m * (n - 1) * W + df = n - 1 + # scipy chi-squared survival function gives an exact p-value + p = float(stats.chi2.sf(chi_sq, df)) + + if W >= 0.7: + interp = "强一致 (strong agreement)" + elif W >= 0.5: + interp = "中等一致 (moderate agreement)" + elif W >= 0.3: + interp = "弱一致 (weak agreement)" + else: + interp = "无显著一致 (no significant agreement)" + + return KendallWResult( + W=W, + chi_squared=chi_sq, + df=df, + p_value=p, + interpretation=interp, + m_raters=m, + n_items=n, + ) + + +# --------------------------------------------------------------------------- # +# Convenience: full pipeline returning posterior using fused likelihood +# --------------------------------------------------------------------------- # + + +def compute_with_fused_likelihood( + data: Sequence[float], fused_likelihood: Sequence[float] +) -> dict: + """Run the standard Bayesian update with a pre-fused likelihood.""" + from app.domain.bayesian import ( + PRIOR_WEIGHTS, + compute_posterior, + compute_quantiles, + extract_distribution_stats, + ) + + quantiles = compute_quantiles(data) + qvals = [q.value for q in quantiles] + posterior, unnormalized, z = compute_posterior(list(PRIOR_WEIGHTS), list(fused_likelihood)) + stats = extract_distribution_stats(qvals, posterior) + return { + "quantile_values": qvals, + "prior_weights": list(PRIOR_WEIGHTS), + "fused_likelihood": list(fused_likelihood), + "posterior_weights": posterior, + "unnormalized": unnormalized, + "normalization_constant": z, + "posterior_stats": stats, + } + + +__all__ = [ + "JUDGMENT_PRESETS", + "Expert", + "KendallWResult", + "compute_with_fused_likelihood", + "fuse_dempster_shafer", + "fuse_weighted_arithmetic_mean", + "fuse_weighted_geometric_mean", + "kendall_w", +] + + +# Silence the unused-import warning for math (only used implicitly via numpy) +_ = math diff --git a/backend/app/domain/multi_param.py b/backend/app/domain/multi_param.py new file mode 100644 index 0000000000000000000000000000000000000000..44d1a8f5750cf449e4026225940623e8767ae650 --- /dev/null +++ b/backend/app/domain/multi_param.py @@ -0,0 +1,138 @@ +"""Multi-parameter joint scenario analysis. + +Ports ``app/js/multi-param.js`` — when a user wants to analyse multiple +parameters under the same scenario (e.g. GDP growth + inflation + unemployment +under "high growth scenario"), this module provides: + +* Pearson / Spearman correlations +* KL / JS divergences between posterior distributions +* Wasserstein (earth-mover) distance +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field + +import numpy as np +from scipy import stats + +from app.domain.bayesian import BayesResult, compute + + +@dataclass +class Parameter: + name: str + unit: str = "" + data: list[float] = field(default_factory=list) + judgments: list[int] = field(default_factory=lambda: [0, 1, 2, 3, 4]) + R: float = 10.0 + enabled: bool = True + result: BayesResult | None = None + + +def batch_compute(params: Sequence[Parameter]) -> list[Parameter]: + out: list[Parameter] = [] + for p in params: + if not p.enabled or not p.data: + out.append(p) + continue + p.result = compute(p.data, p.judgments, p.R) + out.append(p) + return out + + +# --------------------------------------------------------------------------- # +# Correlations +# --------------------------------------------------------------------------- # + + +def pearson_correlation(x: Sequence[float], y: Sequence[float]) -> float: + a = np.asarray(list(x), dtype=np.float64) + b = np.asarray(list(y), dtype=np.float64) + n = min(a.size, b.size) + if n < 2: + return 0.0 + r = float(np.corrcoef(a[:n], b[:n])[0, 1]) + return 0.0 if np.isnan(r) else r + + +def spearman_correlation(x: Sequence[float], y: Sequence[float]) -> float: + a = np.asarray(list(x), dtype=np.float64) + b = np.asarray(list(y), dtype=np.float64) + n = min(a.size, b.size) + if n < 2: + return 0.0 + r, _ = stats.spearmanr(a[:n], b[:n]) + return 0.0 if r is None or np.isnan(r) else float(r) + + +def correlation_matrix(params: Sequence[Parameter], method: str = "pearson") -> list[list[float]]: + n = len(params) + fn = pearson_correlation if method == "pearson" else spearman_correlation + matrix = [[1.0] * n for _ in range(n)] + for i in range(n): + for j in range(i + 1, n): + matrix[i][j] = matrix[j][i] = fn(params[i].data, params[j].data) + return matrix + + +# --------------------------------------------------------------------------- # +# Distribution distances +# --------------------------------------------------------------------------- # + + +def kl_divergence(p: Sequence[float], q: Sequence[float], epsilon: float = 1e-12) -> float: + pa = np.asarray(p, dtype=np.float64) + epsilon + qa = np.asarray(q, dtype=np.float64) + epsilon + return float((pa * np.log(pa / qa)).sum()) + + +def js_divergence(p: Sequence[float], q: Sequence[float]) -> float: + pa = np.asarray(p, dtype=np.float64) + qa = np.asarray(q, dtype=np.float64) + m = (pa + qa) / 2 + return 0.5 * kl_divergence(pa, m) + 0.5 * kl_divergence(qa, m) + + +def wasserstein_distance(p: Sequence[float], q: Sequence[float], values: Sequence[float]) -> float: + """Discrete W1 between two distributions over the same support ``values``.""" + return float( + stats.wasserstein_distance(values, values, u_weights=p, v_weights=q) + ) + + +# --------------------------------------------------------------------------- # +# Scenario comparison +# --------------------------------------------------------------------------- # + + +@dataclass +class ScenarioDelta: + delta_mean: float + delta_std: float + kl: float + js: float + + +def compare_scenarios(r1: BayesResult, r2: BayesResult) -> ScenarioDelta: + return ScenarioDelta( + delta_mean=r2.posterior.stats.mean - r1.posterior.stats.mean, + delta_std=r2.posterior.stats.std - r1.posterior.stats.std, + kl=kl_divergence(r1.posterior.weights, r2.posterior.weights), + js=js_divergence(r1.posterior.weights, r2.posterior.weights), + ) + + +__all__ = [ + "Parameter", + "ScenarioDelta", + "batch_compute", + "compare_scenarios", + "correlation_matrix", + "js_divergence", + "kl_divergence", + "pearson_correlation", + "spearman_correlation", + "wasserstein_distance", +] diff --git a/backend/app/domain/preprocessing.py b/backend/app/domain/preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..945766244fdb58f4e8778f93a6297c05bde680a7 --- /dev/null +++ b/backend/app/domain/preprocessing.py @@ -0,0 +1,236 @@ +"""Data preprocessing utilities — ported from ``app/js/preprocessing.js``. + +Uses scipy/numpy where possible for more accurate distributions / tests than +the JS originals (e.g. exact Jarque-Bera p-value, real KS test, Yeo-Johnson +in addition to Box-Cox so we can handle non-positive data). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +from scipy import stats + +# --------------------------------------------------------------------------- # +# Outlier detection +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class OutlierResult: + method: str + indices: list[int] + values: list[float] + lower_bound: float + upper_bound: float + count: int + percentage: float + + +def detect_outliers_iqr(data: Sequence[float], k: float = 1.5) -> OutlierResult: + arr = np.asarray(list(data), dtype=np.float64) + if arr.size == 0: + raise ValueError("data must be non-empty") + q1, q3 = np.quantile(arr, [0.25, 0.75]) + iqr = q3 - q1 + lower = q1 - k * iqr + upper = q3 + k * iqr + mask = (arr < lower) | (arr > upper) + idx = np.where(mask)[0].tolist() + return OutlierResult( + method="iqr", + indices=idx, + values=arr[mask].tolist(), + lower_bound=float(lower), + upper_bound=float(upper), + count=int(mask.sum()), + percentage=float(mask.mean() * 100), + ) + + +def detect_outliers_zscore(data: Sequence[float], threshold: float = 3.0) -> OutlierResult: + arr = np.asarray(list(data), dtype=np.float64) + if arr.size == 0: + raise ValueError("data must be non-empty") + mean = float(arr.mean()) + std = float(arr.std(ddof=1)) if arr.size > 1 else 0.0 + if std == 0: + return OutlierResult( + method="zscore", + indices=[], + values=[], + lower_bound=mean, + upper_bound=mean, + count=0, + percentage=0.0, + ) + z = (arr - mean) / std + mask = np.abs(z) > threshold + idx = np.where(mask)[0].tolist() + return OutlierResult( + method="zscore", + indices=idx, + values=arr[mask].tolist(), + lower_bound=float(mean - threshold * std), + upper_bound=float(mean + threshold * std), + count=int(mask.sum()), + percentage=float(mask.mean() * 100), + ) + + +def winsorize(data: Sequence[float], lower_pct: float = 0.05, upper_pct: float = 0.95) -> list[float]: + """Replace values below lower_pct / above upper_pct percentile.""" + arr = np.asarray(list(data), dtype=np.float64) + lo, hi = np.quantile(arr, [lower_pct, upper_pct]) + return np.clip(arr, lo, hi).tolist() + + +# --------------------------------------------------------------------------- # +# Normality tests +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class NormalityResult: + test: str + statistic: float + p_value: float + is_normal: bool # True if cannot reject normality at alpha = 0.05 + n: int + + +def jarque_bera(data: Sequence[float]) -> NormalityResult: + arr = np.asarray(list(data), dtype=np.float64) + if arr.size < 8: + raise ValueError("Jarque-Bera needs at least 8 samples") + result = stats.jarque_bera(arr) + return NormalityResult( + test="jarque-bera", + statistic=float(result.statistic), + p_value=float(result.pvalue), + is_normal=bool(result.pvalue > 0.05), + n=int(arr.size), + ) + + +def shapiro_wilk(data: Sequence[float]) -> NormalityResult: + arr = np.asarray(list(data), dtype=np.float64) + if arr.size < 3: + raise ValueError("Shapiro-Wilk needs at least 3 samples") + if arr.size > 5000: + # Shapiro-Wilk is unreliable for very large N + arr = np.random.default_rng(42).choice(arr, size=5000, replace=False) + s, p = stats.shapiro(arr) + return NormalityResult( + test="shapiro-wilk", + statistic=float(s), + p_value=float(p), + is_normal=bool(p > 0.05), + n=int(arr.size), + ) + + +def ks_normal(data: Sequence[float]) -> NormalityResult: + """One-sample KS against fitted normal distribution.""" + arr = np.asarray(list(data), dtype=np.float64) + if arr.size < 4: + raise ValueError("KS test needs at least 4 samples") + mean = float(arr.mean()) + std = float(arr.std(ddof=1)) + if std == 0: + return NormalityResult("ks-normal", 0.0, 1.0, True, int(arr.size)) + res = stats.kstest(arr, "norm", args=(mean, std)) + return NormalityResult( + test="ks-normal", + statistic=float(res.statistic), + p_value=float(res.pvalue), + is_normal=bool(res.pvalue > 0.05), + n=int(arr.size), + ) + + +# --------------------------------------------------------------------------- # +# Transformations +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class TransformResult: + transform: str + values: list[float] + lambda_: float | None = None + shifted_by: float | None = None + + +def log_transform(data: Sequence[float], base: float = 10.0) -> TransformResult: + """log_base(x + shift), where shift = 1 - min(x) if any non-positive values.""" + arr = np.asarray(list(data), dtype=np.float64) + shift = max(0.0, 1.0 - float(arr.min())) + shifted = arr + shift + if base == np.e: + out = np.log(shifted) + else: + out = np.log(shifted) / np.log(base) + return TransformResult( + transform=f"log_{base}", + values=out.tolist(), + shifted_by=shift if shift > 0 else None, + ) + + +def boxcox_transform(data: Sequence[float]) -> TransformResult: + """Box-Cox needs strictly positive data; falls back to Yeo-Johnson otherwise.""" + arr = np.asarray(list(data), dtype=np.float64) + if (arr > 0).all(): + out, lam = stats.boxcox(arr) + return TransformResult("boxcox", out.tolist(), lambda_=float(lam)) + # Yeo-Johnson handles zero/negative + out, lam = stats.yeojohnson(arr) + return TransformResult("yeo-johnson", out.tolist(), lambda_=float(lam)) + + +# --------------------------------------------------------------------------- # +# Histogram binning (Freedman-Diaconis / Sturges / Scott) +# --------------------------------------------------------------------------- # + + +def optimal_bin_count(data: Sequence[float], method: str = "fd") -> int: + arr = np.asarray(list(data), dtype=np.float64) + n = arr.size + if n < 2: + return 1 + if method == "fd": + # Freedman-Diaconis + q75, q25 = np.quantile(arr, [0.75, 0.25]) + iqr = q75 - q25 + h = 2 * iqr / (n ** (1 / 3)) if iqr > 0 else 0 + if h == 0: + return int(np.ceil(np.log2(n) + 1)) + bins = int(np.ceil((arr.max() - arr.min()) / h)) + elif method == "sturges": + bins = int(np.ceil(np.log2(n) + 1)) + elif method == "scott": + std = float(arr.std(ddof=1)) + h = 3.5 * std / (n ** (1 / 3)) + bins = int(np.ceil((arr.max() - arr.min()) / h)) if h > 0 else 10 + else: + raise ValueError(f"unknown method {method}") + return max(1, min(bins, 200)) + + +__all__ = [ + "NormalityResult", + "OutlierResult", + "TransformResult", + "boxcox_transform", + "detect_outliers_iqr", + "detect_outliers_zscore", + "jarque_bera", + "ks_normal", + "log_transform", + "optimal_bin_count", + "shapiro_wilk", + "winsorize", +] diff --git a/backend/app/domain/report.py b/backend/app/domain/report.py new file mode 100644 index 0000000000000000000000000000000000000000..3b6f9181579038fed3757ccd81bf72c9ae03cb73 --- /dev/null +++ b/backend/app/domain/report.py @@ -0,0 +1,215 @@ +"""Markdown report generator. + +Builds a structured analysis report from one BayesResult. Markdown only +(no PDF dependency for now) — most users either copy the markdown directly +or render it client-side. PDF export can be layered later via a small +WeasyPrint wrapper that takes the markdown output of this module. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime +from textwrap import dedent + +from app.domain.bayesian import ( + JUDGMENT_LABELS_ZH, + BayesResult, +) + + +def _fmt(v: float, decimals: int = 3) -> str: + if v is None: + return "—" + return f"{v:.{decimals}f}" + + +def _pct(v: float, decimals: int = 1) -> str: + return f"{v * 100:.{decimals}f}%" + + +def render_markdown_report( + result: BayesResult, + *, + scenario_name: str = "未命名情景", + reference_case: str = "", + dataset_description: str = "", + judgment_rationale: str = "", + sensitivity_rows: Sequence[dict] | None = None, +) -> str: + """Generate a structured Markdown report for one Bayesian analysis.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M") + labels = result.level_labels_zh + judgment_choices = [JUDGMENT_LABELS_ZH[j] for j in result.likelihood.judgments] + + header = dedent( + f"""\ + # 贝叶斯情景参数量化分析报告 + + > **情景**:{scenario_name} + > + > **生成时间**:{now} + > + > **参考数据**:{dataset_description or '未指定'} + > + > **判断强度** R = {result.likelihood.R} + """ + ) + + if reference_case: + header += f"\n**参考案例描述**:{reference_case}\n" + + # Section 1 — descriptive statistics + s = result.prior.stats + sec_stats = dedent( + f""" + ## 一、数据描述统计 + + | 指标 | 值 | + |------|----| + | 样本量 N | {s.n} | + | 均值 mean | {_fmt(s.mean)} | + | 标准差 std | {_fmt(s.std)} | + | 中位数 median | {_fmt(s.median)} | + | 偏度 skewness | {_fmt(s.skewness, 2)} | + | 峰度 kurtosis | {_fmt(s.kurtosis, 2)} | + | 最小值 min | {_fmt(s.min)} | + | 最大值 max | {_fmt(s.max)} | + """ + ) + + # Section 2 — prior + qrows = "\n".join( + f"| {labels[i]} | q = {result.prior.quantiles[i].probability} | " + f"{_fmt(result.prior.quantile_values[i])} | {_pct(result.prior.weights[i])} |" + for i in range(5) + ) + sec_prior = dedent( + """ + ## 二、先验分布(5 点离散) + + | 水平 | 分位概率 | 分位值 | 先验权重 | + |------|---------|--------|---------| + """ + ) + qrows + "\n" + + # Section 3 — judgments and likelihood + jrows = "\n".join( + f"| {labels[i]} | {judgment_choices[i]} | " + f"{_fmt(result.likelihood.weights[i], 4)} |" + for i in range(5) + ) + sec_judgments = dedent( + f""" + ## 三、专家可能性判断 + + 判断强度 R = {result.likelihood.R},对应「极有可能 : 极不可能」影响力比为 + **{result.likelihood.R ** 4:.0f} : 1**。 + + | 参数水平 | 5 级赌注 | 似然权重 R^k | + |---------|---------|--------------| + """ + ) + jrows + "\n" + if judgment_rationale: + sec_judgments += f"\n**判断理由**:{judgment_rationale}\n" + + # Section 4 — posterior + p = result.posterior + ps = p.stats + post_rows = "\n".join( + f"| {labels[i]} | {_pct(result.prior.weights[i])} | {_pct(p.weights[i])} |" + for i in range(5) + ) + shift = ps.mean - result.prior.summary_stats.mean + shrink_pct = ( + (result.prior.summary_stats.std - ps.std) / result.prior.summary_stats.std * 100 + if result.prior.summary_stats.std > 0 + else 0.0 + ) + sec_posterior = dedent( + """ + ## 四、后验分布与结论 + + | 参数水平 | 先验 P(z) | 后验 P(z\\|S) | + |---------|----------|--------------| + """ + ) + post_rows + "\n" + dedent( + f""" + **后验汇总统计**: + + | 指标 | 值 | 相对先验变化 | + |------|----|------------| + | 后验均值 | {_fmt(ps.mean)} | Δ = {shift:+.3f} | + | 后验中位数 | {_fmt(ps.median)} | — | + | 后验标准差 | {_fmt(ps.std)} | 收窄 {shrink_pct:.1f}% | + | 95% 置信区间 | [{_fmt(ps.ci95_lower)}, {_fmt(ps.ci95_upper)}] | — | + | 50% 置信区间 | [{_fmt(ps.ci50_lower)}, {_fmt(ps.ci50_upper)}] | — | + """ + ) + + # Section 5 — sensitivity (optional) + sec_sensitivity = "" + if sensitivity_rows: + srows = "\n".join( + f"| {row['R']} | {_fmt(row['posterior_mean'])} | " + f"{_fmt(row['posterior_std'])} | " + f"[{_fmt(row.get('ci95_lower', 0))}, {_fmt(row.get('ci95_upper', 0))}] |" + for row in sensitivity_rows + ) + means = [r["posterior_mean"] for r in sensitivity_rows] + swing = ( + (max(means) - min(means)) / abs(means[len(means) // 2]) * 100 + if means[len(means) // 2] != 0 + else 0.0 + ) + sec_sensitivity = dedent( + """ + ## 五、稳健性检验(R 值敏感性) + + | R 值 | 后验均值 | 后验标准差 | 95% CI | + |------|---------|-----------|--------| + """ + ) + srows + "\n" + dedent( + f""" + + 后验均值在 R∈[{min(r['R'] for r in sensitivity_rows)}, + {max(r['R'] for r in sensitivity_rows)}] 范围内的波动幅度约 + **{swing:.1f}%**。{'结论稳健。' if swing < 20 else '结论对 R 较为敏感,建议谨慎解读。'} + """ + ) + + # Section 6 — interpretation + direction = ( + "正向移动" if shift > 0.01 else "负向移动" if shift < -0.01 else "基本持平" + ) + sec_conclusion = dedent( + f""" + ## 六、关键发现 + + - 在「{scenario_name}」假设下,后验均值相对先验 + **{direction}** Δ = {shift:+.3f}(先验均值 = {_fmt(result.prior.summary_stats.mean)}, + 后验均值 = {_fmt(ps.mean)})。 + - 后验标准差从 {_fmt(result.prior.summary_stats.std)} 收窄到 + {_fmt(ps.std)}({shrink_pct:.1f}%),表明专家判断带来的信息显著 + 降低了不确定性。 + - 95% 置信区间收紧到 [{_fmt(ps.ci95_lower)}, {_fmt(ps.ci95_upper)}]。 + + --- + + *本报告由 BayesScenParams Agent 自动生成,方法基础: + Kemp-Benedict (2010)。* + """ + ) + + return ( + header + + sec_stats + + sec_prior + + sec_judgments + + sec_posterior + + sec_sensitivity + + sec_conclusion + ) + + +__all__ = ["render_markdown_report"] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..15c141b512dd793c1e08ba78113e0c9160c633fc --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,104 @@ +"""FastAPI application entry-point for BayesScenParams Agent. + +In production (e.g. Hugging Face Spaces single-container deployment), the +compiled frontend is copied into ``/app/frontend_dist`` and mounted at ``/`` +so the same FastAPI process serves both the API and the SPA. Set +``FRONTEND_DIST`` env var to point at a different directory if needed. +""" + +from __future__ import annotations + +import logging +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from app.api import bayesian as bayesian_api +from app.api import data as data_api +from app.api import health as health_api +from app.api import sessions as sessions_api +from app.core import db as core_db +from app.core.config import BACKEND_DIR, get_settings + +logger = logging.getLogger("bayesscenparams") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + logger.info("Starting BayesScenParams backend") + logger.info(" model = %s", settings.anthropic_model) + logger.info(" anthropic key = %s", "set" if settings.has_anthropic_key else "MISSING") + logger.info( + " base URL = %s", + settings.anthropic_base_url or "https://api.anthropic.com (official)", + ) + logger.info(" agent runtime = %s", settings.agent_runtime) + logger.info(" cache dir = %s", settings.data_cache_dir) + logger.info(" cors origins = %s", settings.cors_origin_list) + core_db.init_db() + # Lazy-import the agent module so the SDK isn't required when the user only + # wants to use the non-agent REST endpoints. + try: + from app.api import agent as agent_api + + app.include_router(agent_api.router) + logger.info(" agent endpoint = mounted") + except Exception as e: + logger.warning(" agent endpoint = NOT mounted (%s)", e) + yield + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title="BayesScenParams Agent", + version="0.1.0", + description=( + "Conversational autonomous research agent for Bayesian scenario " + "parameter quantization, powered by Claude Agent SDK." + ), + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(health_api.router) + app.include_router(bayesian_api.router) + app.include_router(data_api.router) + app.include_router(sessions_api.router) + + # Optionally mount the compiled frontend (single-container deployments). + frontend_dir = Path(os.environ.get("FRONTEND_DIST", BACKEND_DIR / "frontend_dist")) + if frontend_dir.is_dir() and (frontend_dir / "index.html").exists(): + assets_dir = frontend_dir / "assets" + if assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + # SPA fallback: serve index.html for any non-API path + @app.get("/{full_path:path}", include_in_schema=False) + async def spa_fallback(full_path: str): + # Static files inside frontend_dist (e.g. favicon) + candidate = frontend_dir / full_path + if candidate.is_file(): + return FileResponse(candidate) + return FileResponse(frontend_dir / "index.html") + + logger.info("Frontend mounted from %s", frontend_dir) + else: + logger.info("Frontend not mounted (no dist found at %s)", frontend_dir) + + return app + + +app = create_app() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..96fe95c493ec316e326601d203408387f70e1983 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bayesscenparams-backend" +version = "0.1.0" +description = "FastAPI + Claude Agent SDK backend for BayesScenParams" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [ + { name = "BayesScenParams" } +] +dependencies = [ + # Web framework + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "python-multipart>=0.0.12", + "sse-starlette>=2.1.0", + # Settings / validation + "pydantic>=2.9.0", + "pydantic-settings>=2.5.0", + # HTTP client (for World Bank API etc.) + "httpx>=0.27.0", + # Numerical / stats + "numpy>=2.0.0", + "scipy>=1.14.0", + # Claude Agent SDK (drives the official Claude Code protocol) + "claude-agent-sdk>=0.1.0", + # Official Anthropic Python SDK (direct Messages API; works through + # community proxies like zhihuiapi / OneAPI / NewAPI as well) + "anthropic>=0.40.0", + # Persistence (SQLite for Phase 2; included now to avoid future re-installs) + "sqlalchemy>=2.0.0", + "aiosqlite>=0.20.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "ruff>=0.7.0", + "mypy>=1.13.0", + "httpx>=0.27.0", # for TestClient +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "N", "RUF"] +ignore = [ + "E501", # long lines (we run with line-length=100 already) + "N803", # arg name "R" — matches mathematical convention + "N806", # local var "R", "N", "S" — math notation + "RUF001", # ambiguous-unicode-character-string (Chinese punctuation in prompts) + "RUF002", # ambiguous-unicode-character-docstring + "B008", # FastAPI Depends/File use this pattern +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E741", "F841", "N802", "N806"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "-v --tb=short" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/test_agent_sse.py b/backend/tests/test_agent_sse.py new file mode 100644 index 0000000000000000000000000000000000000000..cb3db025bba5764601a6532db99de8dce3fe3765 --- /dev/null +++ b/backend/tests/test_agent_sse.py @@ -0,0 +1,50 @@ +"""Smoke test for the /api/agent/chat SSE endpoint. + +We don't hit the real Claude API in unit tests. Instead we verify the SSE +framing & graceful error when no key is configured. +""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.api import agent as agent_api +from app.core import config as cfg +from app.core.config import get_settings + + +def _app() -> FastAPI: + app = FastAPI() + app.include_router(agent_api.router) + return app + + +def test_chat_streams_error_without_api_key(monkeypatch): + # Bypass project .env by pointing pydantic-settings at a non-existent file. + monkeypatch.setattr( + cfg.Settings, + "model_config", + {**cfg.Settings.model_config, "env_file": "/nonexistent.env"}, + ) + monkeypatch.setenv("ANTHROPIC_API_KEY", "") + cfg.get_settings.cache_clear() + assert not get_settings().has_anthropic_key + + client = TestClient(_app()) + with client.stream("POST", "/api/agent/chat", json={"prompt": "hi"}) as r: + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/event-stream") + body = b"".join(r.iter_bytes()).decode("utf-8") + + # Should contain at least one error event and a done event + assert "event: error" in body + assert "ANTHROPIC_API_KEY" in body + assert "event: done" in body + cfg.get_settings.cache_clear() + + +def test_chat_validates_payload(): + client = TestClient(_app()) + r = client.post("/api/agent/chat", json={"prompt": ""}) + assert r.status_code == 422 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..0bac46fd920c7e69d6a2b2ae1434dce6da0a9c30 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,88 @@ +"""Smoke tests for the FastAPI app.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app + +SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples" + + +@pytest.fixture(scope="module") +def client() -> TestClient: + return TestClient(create_app()) + + +def test_health(client: TestClient): + r = client.get("/api/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["service"] == "bayesscenparams-backend" + + +def test_list_samples(client: TestClient): + r = client.get("/api/data/samples") + assert r.status_code == 200 + body = r.json() + ids = {s["id"] for s in body} + assert ids == {"gdp", "climate", "population"} + for s in body: + assert s["n"] > 0 + assert s["icon"] + + +def test_get_sample(client: TestClient): + r = client.get("/api/data/samples/gdp") + assert r.status_code == 200 + body = r.json() + assert body["id"] == "gdp" + assert len(body["values"]) > 100 + assert isinstance(body["values"][0], float) + + +def test_bayesian_compute(client: TestClient): + gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text()) + payload = { + "data": gdp["values"], + "judgments": [0, 1, 2, 3, 4], + "R": 10.0, + } + r = client.post("/api/bayesian/compute", json=payload) + assert r.status_code == 200 + body = r.json() + assert "prior" in body and "likelihood" in body and "posterior" in body + assert len(body["posterior"]["weights"]) == 5 + assert abs(sum(body["posterior"]["weights"]) - 1.0) < 1e-9 + # likelihood for level 4 is R^2 = 100 + assert body["likelihood"]["weights"][4] == pytest.approx(100.0) + + +def test_bayesian_compute_validates_R(): + client = TestClient(create_app()) + gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text()) + r = client.post( + "/api/bayesian/compute", + json={"data": gdp["values"], "judgments": [0, 1, 2, 3, 4], "R": 0.5}, + ) + assert r.status_code == 422 + + +def test_sensitivity(client: TestClient): + gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text()) + r = client.post( + "/api/bayesian/sensitivity", + json={"data": gdp["values"], "judgments": [0, 1, 2, 3, 4]}, + ) + assert r.status_code == 200 + body = r.json() + assert len(body["points"]) == 5 + # Each point's posterior sums to 1 + for p in body["points"]: + s = sum(p["result"]["posterior"]["weights"]) + assert abs(s - 1.0) < 1e-9 diff --git a/backend/tests/test_bayesian_parity.py b/backend/tests/test_bayesian_parity.py new file mode 100644 index 0000000000000000000000000000000000000000..fd0cf6bcb82cf09d14a1419b8f0b629bf00f6676 --- /dev/null +++ b/backend/tests/test_bayesian_parity.py @@ -0,0 +1,158 @@ +""" +Parity tests for the Python port of bayesian.js. + +Strategy: load each sample dataset, run it through the original JS engine via +Node.js, and compare every output number with the Python port. Tolerance is +1e-9, well within IEEE 754 round-off across Node's V8 and CPython. + +The Node side reads the same ``app/js/bayesian.js`` file the production frontend +uses. We do NOT vendor it — it stays the single source of the JS algorithm. + +If Node.js isn't available the JS comparison is skipped and the test falls back +to known-good golden values cached in this file (so CI works without Node). +""" + +from __future__ import annotations + +import json +import math +import shutil +import subprocess +from pathlib import Path + +import pytest + +from app.domain.bayesian import compute, compute_stats, kde + +REPO_ROOT = Path(__file__).resolve().parents[3] +LEGACY_JS = REPO_ROOT / "app" / "js" / "bayesian.js" +SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples" + +SAMPLE_FILES = ["sample-gdp.json", "sample-climate.json", "sample-population.json"] +JUDGMENT_CASES: list[tuple[list[int], float]] = [ + ([0, 1, 2, 3, 4], 10.0), # default positive trend + ([4, 3, 2, 1, 0], 10.0), # negative trend + ([2, 2, 2, 2, 2], 10.0), # uniform / hard-to-tell + ([0, 2, 4, 2, 0], 5.0), # middle-peaked + ([1, 2, 3, 4, 4], 20.0), # strong upper +] + + +def _load_sample(name: str) -> list[float]: + return json.loads((SAMPLES_DIR / name).read_text(encoding="utf-8"))["values"] + + +def _node_available() -> bool: + return shutil.which("node") is not None and LEGACY_JS.exists() + + +def _run_js(data: list[float], judgments: list[int], R: float) -> dict: + """Run the JS engine in a subprocess and return its compute() result.""" + script = f""" + const path = require('path'); + const BayesianEngine = require({json.dumps(str(LEGACY_JS))}); + const data = {json.dumps(data)}; + const judgments = {json.dumps(judgments)}; + const R = {R}; + const out = BayesianEngine.compute(data, judgments, R); + process.stdout.write(JSON.stringify(out)); + """ + proc = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, timeout=30, check=False + ) + if proc.returncode != 0: + raise RuntimeError(f"node failed: {proc.stderr}") + return json.loads(proc.stdout) + + +# --------------------------------------------------------------------------- # +# Plain unit tests (no Node required) +# --------------------------------------------------------------------------- # + + +def test_compute_stats_basic(): + s = compute_stats([1.0, 2.0, 3.0, 4.0, 5.0]) + assert s.n == 5 + assert s.mean == pytest.approx(3.0) + assert s.median == pytest.approx(3.0) + assert s.std == pytest.approx(math.sqrt(2.5)) # sample variance 2.5 + + +def test_uniform_judgments_keep_prior(): + """If every judgment is 'hard to tell' (level 2), likelihood is uniform + and the posterior must equal the prior exactly.""" + data = _load_sample("sample-gdp.json") + r = compute(data, [2, 2, 2, 2, 2], R=10.0) + for prior_w, post_w in zip(r.prior.weights, r.posterior.weights, strict=True): + assert post_w == pytest.approx(prior_w, abs=1e-12) + + +def test_posterior_sums_to_one(): + data = _load_sample("sample-gdp.json") + for judgments, R in JUDGMENT_CASES: + r = compute(data, judgments, R) + assert sum(r.posterior.weights) == pytest.approx(1.0, abs=1e-12) + + +def test_kde_integrates_close_to_one(): + """Riemann-sum the KDE — should integrate to roughly 1.0.""" + data = _load_sample("sample-gdp.json") + pts = kde(data, n_points=500) + if len(pts) < 2: + pytest.skip("not enough KDE points") + dx = pts[1]["x"] - pts[0]["x"] + integral = sum(p["y"] for p in pts) * dx + # KDE pads ±15% of range; some mass leaks beyond the grid in heavy-tailed + # data, so we accept 0.90 - 1.01. + assert 0.90 < integral < 1.01, f"KDE integrates to {integral}" + + +# --------------------------------------------------------------------------- # +# Parity tests against the original JS implementation +# --------------------------------------------------------------------------- # + +requires_node = pytest.mark.skipif( + not _node_available(), + reason="node.js or legacy app/js/bayesian.js not available", +) + + +@requires_node +@pytest.mark.parametrize("sample", SAMPLE_FILES) +@pytest.mark.parametrize("judgments,R", JUDGMENT_CASES) +def test_js_parity(sample: str, judgments: list[int], R: float): + data = _load_sample(sample) + js = _run_js(data, judgments, R) + py = compute(data, judgments, R) + + # Prior summary stats + for a, b in [ + (js["prior"]["stats"]["mean"], py.prior.stats.mean), + (js["prior"]["stats"]["std"], py.prior.stats.std), + (js["prior"]["stats"]["median"], py.prior.stats.median), + (js["prior"]["stats"]["skewness"], py.prior.stats.skewness), + (js["prior"]["stats"]["kurtosis"], py.prior.stats.kurtosis), + ]: + assert a == pytest.approx(b, abs=1e-9) + + # Quantile values + for jq, pq in zip(js["prior"]["quantileValues"], py.prior.quantile_values, strict=True): + assert jq == pytest.approx(pq, abs=1e-9) + + # Likelihood + for jl, pl in zip(js["likelihood"]["weights"], py.likelihood.weights, strict=True): + assert jl == pytest.approx(pl, abs=1e-9) + + # Posterior weights + for jw, pw in zip(js["posterior"]["weights"], py.posterior.weights, strict=True): + assert jw == pytest.approx(pw, abs=1e-9) + + # Posterior summary + for a, b in [ + (js["posterior"]["stats"]["mean"], py.posterior.stats.mean), + (js["posterior"]["stats"]["median"], py.posterior.stats.median), + (js["posterior"]["stats"]["std"], py.posterior.stats.std), + (js["posterior"]["stats"]["ci95"]["lower"], py.posterior.stats.ci95_lower), + (js["posterior"]["stats"]["ci95"]["upper"], py.posterior.stats.ci95_upper), + ]: + assert a == pytest.approx(b, abs=1e-9) diff --git a/backend/tests/test_mcp_tools.py b/backend/tests/test_mcp_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..2b89c4339e12cd16cbd48b4a09559cfd8140215b --- /dev/null +++ b/backend/tests/test_mcp_tools.py @@ -0,0 +1,186 @@ +"""Smoke tests for the in-process MCP tools. + +We call each tool's underlying handler directly (the ``tool`` decorator stores +it on the wrapped object) and verify the JSON contracts so the Agent has +something deterministic to work with. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest + +from app.agent import tool_store +from app.agent.tools import ( + build_prior_tool, + compute_posterior_tool, + compute_statistics_tool, + fetch_wdi_tool, + list_data_catalog_tool, + load_sample_dataset_tool, + sensitivity_analysis_tool, +) + + +def _call(t, **kwargs) -> dict[str, Any]: + """Invoke an MCP tool's handler synchronously and return the parsed payload.""" + # SDK wraps tools as SdkMcpTool with `.handler` attr; fall back to direct call. + handler = getattr(t, "handler", t) + if asyncio.iscoroutinefunction(handler): + result = asyncio.run(handler(kwargs)) + else: # pragma: no cover + result = handler(kwargs) + assert "content" in result + text = result["content"][0]["text"] + return json.loads(text) + + +@pytest.fixture(autouse=True) +def reset_store(): + tool_store.clear() + tool_store.set_artifact_queue(None) + yield + tool_store.clear() + + +def test_list_data_catalog(): + out = _call(list_data_catalog_tool) + assert "samples" in out + assert {s["id"] for s in out["samples"]} == {"gdp", "climate", "population"} + assert "NY.GDP.PCAP.KD.ZG" in out["wdi_indicators"] + assert "tas" in out["cckp_variables"] + + +def test_load_sample_then_stats_then_prior_then_posterior_then_sensitivity(): + # 1. load + s1 = _call(load_sample_dataset_tool, sample_id="gdp") + assert "dataset_handle" in s1 + handle = s1["dataset_handle"] + assert s1["n"] > 100 + + # 2. stats + s2 = _call(compute_statistics_tool, dataset_handle=handle) + assert s2["n"] == s1["n"] + assert isinstance(s2["mean"], float) + assert isinstance(s2["std"], float) + + # 3. build prior + s3 = _call(build_prior_tool, dataset_handle=handle) + assert "prior_handle" in s3 + assert len(s3["quantile_values"]) == 5 + assert s3["prior_weights"] == [0.05, 0.20, 0.50, 0.20, 0.05] + prior_handle = s3["prior_handle"] + + # 4. compute posterior with a strong upward judgment + s4 = _call( + compute_posterior_tool, + prior_handle=prior_handle, + judgments=[0, 1, 2, 3, 4], + R=10.0, + judgment_rationale="testing", + ) + assert "posterior_handle" in s4 + assert len(s4["posterior_weights"]) == 5 + assert abs(sum(s4["posterior_weights"]) - 1.0) < 1e-9 + # Strong upward judgment should pull mean above prior mean + assert s4["posterior_mean"] > s4["prior_mean"] + + # 5. sensitivity + s5 = _call( + sensitivity_analysis_tool, + prior_handle=prior_handle, + judgments=[0, 1, 2, 3, 4], + r_values=[2.0, 5.0, 10.0, 20.0], + ) + assert len(s5["rows"]) == 4 + means = [r["posterior_mean"] for r in s5["rows"]] + # Stronger R should give larger upward swing for this monotonic judgment + assert means[-1] > means[0] + + +def test_compute_posterior_validates_inputs(): + # Bad handle + out = _call( + compute_posterior_tool, + prior_handle="does_not_exist", + judgments=[0, 1, 2, 3, 4], + R=10.0, + judgment_rationale="", + ) + assert "error" in out + + # Set up valid prior, then bad inputs + s1 = _call(load_sample_dataset_tool, sample_id="gdp") + s2 = _call(build_prior_tool, dataset_handle=s1["dataset_handle"]) + ph = s2["prior_handle"] + + out = _call( + compute_posterior_tool, prior_handle=ph, judgments=[0, 1, 2, 3], R=10.0, judgment_rationale="" + ) + assert "error" in out + + out = _call( + compute_posterior_tool, + prior_handle=ph, + judgments=[0, 1, 2, 3, 5], # 5 out of range + R=10.0, + judgment_rationale="", + ) + assert "error" in out + + out = _call( + compute_posterior_tool, + prior_handle=ph, + judgments=[0, 1, 2, 3, 4], + R=0.5, + judgment_rationale="", + ) + assert "error" in out + + +def test_load_sample_unknown_id(): + out = _call(load_sample_dataset_tool, sample_id="nope") + assert "error" in out + + +def test_fetch_wdi_uses_cache(monkeypatch, tmp_path): + """The tool should work end-to-end when the WDI client's cache is warm.""" + # Build a fake cache entry the WDIClient will pick up + from app.core import config as cfg + from app.data_sources.worldbank import _cache_key + + monkeypatch.setattr( + cfg.Settings, + "model_config", + {**cfg.Settings.model_config, "env_file": "/nonexistent.env"}, + ) + cfg.get_settings.cache_clear() + monkeypatch.setenv("DATA_CACHE_DIR", str(tmp_path)) + s = cfg.get_settings() + assert s.data_cache_dir == tmp_path + + url = "https://api.worldbank.org/v2/country/ssa/indicator/NY.GDP.PCAP.KD.ZG" + params = {"format": "json", "date": "1990:2023", "per_page": 20000} + fake = [ + {"pages": 1, "total": 2}, + [ + {"indicator": {"value": "GDP per capita growth (annual %)"}, "value": 1.5, "unit": "%"}, + {"indicator": {"value": "GDP per capita growth (annual %)"}, "value": -2.0, "unit": "%"}, + {"indicator": {"value": "GDP per capita growth (annual %)"}, "value": None, "unit": "%"}, + ], + ] + cache_file = tmp_path / f"wdi_{_cache_key(url, params)}.json" + cache_file.write_text(json.dumps(fake), encoding="utf-8") + + out = _call( + fetch_wdi_tool, + indicator="NY.GDP.PCAP.KD.ZG", + country="ssa", + date_range="1990:2023", + ) + assert "dataset_handle" in out + assert out["n_used"] == 2 + cfg.get_settings.cache_clear() diff --git a/backend/tests/test_phase2_modules.py b/backend/tests/test_phase2_modules.py new file mode 100644 index 0000000000000000000000000000000000000000..9709847d004ceceff7f18e511876ff21aa60aeda --- /dev/null +++ b/backend/tests/test_phase2_modules.py @@ -0,0 +1,282 @@ +"""Sanity tests for the Phase 2 domain modules. + +These don't try to match the JS implementation byte-for-byte because we +deliberately swapped some approximations for scipy's exact versions +(Jarque-Bera p-value, KS test, chi-squared p-value, etc.). Instead they check +mathematical properties (monotonicity, symmetry, ranges) and a few golden +hand-calculated values. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + +from app.domain.multi_expert import ( + JUDGMENT_PRESETS, + Expert, + fuse_dempster_shafer, + fuse_weighted_arithmetic_mean, + fuse_weighted_geometric_mean, + kendall_w, +) +from app.domain.multi_param import ( + Parameter, + batch_compute, + compare_scenarios, + correlation_matrix, + js_divergence, + kl_divergence, + pearson_correlation, + spearman_correlation, + wasserstein_distance, +) +from app.domain.preprocessing import ( + boxcox_transform, + detect_outliers_iqr, + detect_outliers_zscore, + jarque_bera, + ks_normal, + log_transform, + optimal_bin_count, + shapiro_wilk, + winsorize, +) + +SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples" + + +def _gdp(): + return json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())["values"] + + +# --------------------------------------------------------------------------- # +# Multi-expert +# --------------------------------------------------------------------------- # + + +def test_expert_validates_judgments(): + with pytest.raises(ValueError): + Expert("A", judgments=[0, 1, 2, 3]) # only 4 + with pytest.raises(ValueError): + Expert("B", judgments=[0, 1, 2, 3, 5]) # 5 out of range + + +def test_geometric_fusion_equals_single_expert(): + """With one expert, geometric mean == that expert's likelihood.""" + e = Expert("solo", weight=2.0, judgments=[0, 1, 2, 3, 4]) + fused = fuse_weighted_geometric_mean([e], R=10) + expected = [10 ** k for k in (-2, -1, 0, 1, 2)] + for a, b in zip(fused, expected, strict=True): + assert a == pytest.approx(b, rel=1e-9) + + +def test_geometric_vs_arithmetic_two_experts_uniform_weights(): + """For identical experts, both fusion methods agree.""" + e1 = Expert("A", judgments=[0, 1, 2, 3, 4]) + e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) + g = fuse_weighted_geometric_mean([e1, e2], R=10) + a = fuse_weighted_arithmetic_mean([e1, e2], R=10) + for gi, ai in zip(g, a, strict=True): + assert gi == pytest.approx(ai, rel=1e-9) + + +def test_dempster_shafer_asymmetric_conflict(): + """Two non-symmetric experts: the one with extreme certainty should pull + the combined distribution toward its peak.""" + e1 = Expert("A", judgments=[0, 1, 4, 4, 4]) # strong "high" + "very high" + e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) # gradual increase + fused = fuse_dempster_shafer([e1, e2], R=5) + assert sum(fused) == pytest.approx(1.0, abs=1e-9) + # Combined mass should peak at "very high" (index 4) + assert fused.index(max(fused)) == 4 + + +def test_dempster_shafer_symmetric_opposite_yields_uniform(): + """Mathematically correct: symmetric opposing likelihoods give uniform DS.""" + e1 = Expert("A", judgments=[4, 3, 2, 1, 0]) + e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) + fused = fuse_dempster_shafer([e1, e2], R=5) + assert sum(fused) == pytest.approx(1.0, abs=1e-9) + for w in fused: + assert w == pytest.approx(0.2, abs=1e-6) + + +def test_kendall_w_perfect_agreement(): + e1 = Expert("A", judgments=[0, 1, 2, 3, 4]) + e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) + e3 = Expert("C", judgments=[0, 1, 2, 3, 4]) + r = kendall_w([e1, e2, e3]) + assert r.W == pytest.approx(1.0, abs=1e-9) + assert r.p_value < 0.05 + + +def test_kendall_w_perfect_disagreement(): + # Three experts that disagree pairwise as much as possible + e1 = Expert("A", judgments=[0, 1, 2, 3, 4]) + e2 = Expert("B", judgments=[4, 3, 2, 1, 0]) + e3 = Expert("C", judgments=[2, 0, 4, 1, 3]) + r = kendall_w([e1, e2, e3]) + assert 0.0 <= r.W <= 1.0 + # Likely no significant agreement + assert r.p_value > 0.05 or r.W < 0.5 + + +def test_presets_well_formed(): + for _k, p in JUDGMENT_PRESETS.items(): + assert isinstance(p["label"], str) + assert len(p["judgments"]) == 5 + + +# --------------------------------------------------------------------------- # +# Multi-parameter +# --------------------------------------------------------------------------- # + + +def test_pearson_perfect_positive(): + x = list(range(10)) + y = [2 * v + 1 for v in x] + assert pearson_correlation(x, y) == pytest.approx(1.0, abs=1e-9) + + +def test_pearson_perfect_negative(): + x = list(range(10)) + y = [-3 * v for v in x] + assert pearson_correlation(x, y) == pytest.approx(-1.0, abs=1e-9) + + +def test_spearman_monotonic_nonlinear(): + x = list(range(1, 11)) + y = [v**2 for v in x] + assert spearman_correlation(x, y) == pytest.approx(1.0, abs=1e-9) + + +def test_correlation_matrix_symmetry(): + p1 = Parameter("a", data=list(range(10))) + p2 = Parameter("b", data=[v * 0.5 for v in range(10)]) + p3 = Parameter("c", data=[10 - v for v in range(10)]) + m = correlation_matrix([p1, p2, p3]) + for i in range(3): + assert m[i][i] == pytest.approx(1.0) + for j in range(3): + assert m[i][j] == pytest.approx(m[j][i]) + + +def test_batch_compute_runs(): + gdp = _gdp() + p = Parameter("gdp", data=gdp, judgments=[0, 1, 2, 3, 4], R=10) + out = batch_compute([p]) + assert out[0].result is not None + assert sum(out[0].result.posterior.weights) == pytest.approx(1.0, abs=1e-12) + + +def test_kl_zero_for_identical_distributions(): + p = [0.1, 0.2, 0.4, 0.2, 0.1] + assert kl_divergence(p, p) == pytest.approx(0.0, abs=1e-9) + + +def test_js_symmetric(): + a = [0.1, 0.2, 0.4, 0.2, 0.1] + b = [0.4, 0.3, 0.15, 0.1, 0.05] + assert js_divergence(a, b) == pytest.approx(js_divergence(b, a), abs=1e-12) + + +def test_wasserstein_known_value(): + """Two delta distributions one unit apart -> W1 = 1.""" + values = [0.0, 1.0] + p = [1.0, 0.0] + q = [0.0, 1.0] + assert wasserstein_distance(p, q, values) == pytest.approx(1.0, abs=1e-9) + + +def test_compare_scenarios_runs(): + from app.domain.bayesian import compute + + gdp = _gdp() + r1 = compute(gdp, [0, 1, 2, 3, 4], 10.0) + r2 = compute(gdp, [4, 3, 2, 1, 0], 10.0) + d = compare_scenarios(r1, r2) + assert d.delta_mean < 0 # r2 (negative trend) should be lower + assert d.kl > 0 + assert d.js > 0 + + +# --------------------------------------------------------------------------- # +# Preprocessing +# --------------------------------------------------------------------------- # + + +def test_outliers_iqr_picks_extremes(): + data = [0.0] * 100 + [100.0, -100.0] + r = detect_outliers_iqr(data) + assert 100.0 in r.values + assert -100.0 in r.values + assert r.count == 2 + + +def test_outliers_zscore_picks_extremes(): + np.random.seed(0) + data = [*np.random.normal(0, 1, 1000).tolist(), 50.0, -50.0] + r = detect_outliers_zscore(data, threshold=3.0) + assert 50.0 in r.values + assert -50.0 in r.values + + +def test_winsorize_clips_extremes(): + data = list(range(100)) + out = winsorize(data, 0.05, 0.95) + assert min(out) >= 4 + assert max(out) <= 95 + + +def test_jarque_bera_detects_normal(): + np.random.seed(0) + data = np.random.normal(0, 1, 2000) + r = jarque_bera(data) + assert r.is_normal + + +def test_jarque_bera_detects_skewed(): + np.random.seed(0) + data = np.random.exponential(1.0, 2000) + r = jarque_bera(data) + assert not r.is_normal + + +def test_shapiro_wilk_basic(): + np.random.seed(0) + r = shapiro_wilk(np.random.normal(0, 1, 200)) + assert r.is_normal + + +def test_ks_normal_basic(): + np.random.seed(0) + r = ks_normal(np.random.normal(0, 1, 500)) + assert r.is_normal + + +def test_log_transform_handles_non_positive(): + data = [-2.0, -1.0, 0.0, 1.0, 2.0] + r = log_transform(data) + assert r.shifted_by == pytest.approx(3.0) + assert len(r.values) == 5 + assert all(v == v for v in r.values) # no NaN + + +def test_boxcox_transform_falls_back_for_non_positive(): + data = [-1.0, 0.0, 1.0, 2.0, 3.0] + r = boxcox_transform(data) + assert r.transform == "yeo-johnson" + + +def test_optimal_bin_count(): + np.random.seed(0) + data = np.random.normal(0, 1, 500).tolist() + bins_fd = optimal_bin_count(data, "fd") + bins_sturges = optimal_bin_count(data, "sturges") + bins_scott = optimal_bin_count(data, "scott") + for b in (bins_fd, bins_sturges, bins_scott): + assert 1 <= b <= 200 diff --git a/backend/tests/test_sessions_and_report.py b/backend/tests/test_sessions_and_report.py new file mode 100644 index 0000000000000000000000000000000000000000..c2034066f84463f1a6ef794ad8738c9c4bed89a5 --- /dev/null +++ b/backend/tests/test_sessions_and_report.py @@ -0,0 +1,97 @@ +"""Tests for /api/sessions and /api/bayesian/report.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app.core import config as cfg +from app.core import db +from app.main import create_app + +SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples" + + +@pytest.fixture +def client(tmp_path, monkeypatch) -> TestClient: + # Isolate DB in tmp; bypass project .env so monkeypatch can override. + monkeypatch.setattr( + cfg.Settings, + "model_config", + {**cfg.Settings.model_config, "env_file": "/nonexistent.env"}, + ) + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'test.db'}") + cfg.get_settings.cache_clear() + db.init_db() + return TestClient(create_app()) + + +def test_session_lifecycle(client: TestClient): + r = client.post("/api/sessions", json={"label": "demo"}) + assert r.status_code == 200 + s = r.json() + assert s["id"] + assert s["label"] == "demo" + + r = client.get("/api/sessions") + assert r.status_code == 200 + assert any(x["id"] == s["id"] for x in r.json()) + + r = client.get(f"/api/sessions/{s['id']}/history") + assert r.status_code == 200 + assert r.json() == [] + + r = client.delete(f"/api/sessions/{s['id']}") + assert r.status_code == 200 + + r = client.get(f"/api/sessions/{s['id']}/history") + assert r.status_code == 404 + + +def test_history_append_and_list(tmp_path, monkeypatch): + monkeypatch.setattr( + cfg.Settings, + "model_config", + {**cfg.Settings.model_config, "env_file": "/nonexistent.env"}, + ) + monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'test.db'}") + cfg.get_settings.cache_clear() + db.init_db() + sid = db.create_session("x") + db.append_history(sid, "agent_turn", {"prompt": "hello"}) + db.append_history(sid, "bayesian_compute", {"R": 10}) + rows = db.list_history(sid) + assert len(rows) == 2 + assert {r["kind"] for r in rows} == {"agent_turn", "bayesian_compute"} + + +def test_report_endpoint(client: TestClient): + gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text()) + r = client.post( + "/api/bayesian/report", + json={ + "data": gdp["values"], + "judgments": [0, 1, 2, 3, 4], + "R": 10.0, + "scenario_name": "高 GDP 增长情景", + "reference_case": "撒哈拉以南非洲 1975-2002", + }, + ) + assert r.status_code == 200 + body = r.json() + md = body["markdown"] + assert "# 贝叶斯情景参数量化分析报告" in md + assert "高 GDP 增长情景" in md + assert "撒哈拉以南非洲 1975-2002" in md + assert "## 一、数据描述统计" in md + assert "## 二、先验分布" in md + assert "## 三、专家可能性判断" in md + assert "## 四、后验分布与结论" in md + assert "## 五、稳健性检验" in md + assert "## 六、关键发现" in md + # Result block is well-formed + assert "posterior" in body["result"] + assert abs(sum(body["result"]["posterior"]["weights"]) - 1.0) < 1e-9 diff --git a/backend/tests/test_worldbank.py b/backend/tests/test_worldbank.py new file mode 100644 index 0000000000000000000000000000000000000000..2daa4c941cbbc9e749966939141102aeb6aece05 --- /dev/null +++ b/backend/tests/test_worldbank.py @@ -0,0 +1,122 @@ +"""Unit tests for the World Bank data clients. + +We do **not** hit the live network in CI; tests instead pre-populate the cache +files so the clients deserialise canned responses. A real-network smoke test is +provided but gated behind ``RUN_NETWORK_TESTS=1``. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from app.data_sources.worldbank import ( + CCKP_SSP_SCENARIOS, + CCKP_VARIABLES, + WDI_INDICATOR_CATALOG, + CCKPClient, + WDIClient, + _cache_key, +) + + +@pytest.fixture +def cache_dir(tmp_path: Path) -> Path: + return tmp_path / "cache" + + +def test_catalogs(): + assert "NY.GDP.PCAP.KD.ZG" in WDI_INDICATOR_CATALOG + assert "tas" in CCKP_VARIABLES + assert "ssp245" in CCKP_SSP_SCENARIOS + + +async def test_wdi_uses_cache(cache_dir: Path): + client = WDIClient(cache_dir) + url = f"{client.BASE}/country/ssa/indicator/NY.GDP.PCAP.KD.ZG" + params = {"format": "json", "date": "1990:2023", "per_page": 20000} + payload = [ + {"page": 1, "pages": 1, "per_page": "20000", "total": 3}, + [ + { + "indicator": {"id": "NY.GDP.PCAP.KD.ZG", "value": "GDP per capita growth (annual %)"}, + "country": {"id": "ZG", "value": "Sub-Saharan Africa"}, + "unit": "%", + "date": "2020", + "value": -1.234, + }, + { + "indicator": {"id": "NY.GDP.PCAP.KD.ZG", "value": "GDP per capita growth (annual %)"}, + "country": {"id": "ZG", "value": "Sub-Saharan Africa"}, + "unit": "%", + "date": "2019", + "value": 2.5, + }, + { + "indicator": {"id": "NY.GDP.PCAP.KD.ZG", "value": "GDP per capita growth (annual %)"}, + "country": {"id": "ZG", "value": "Sub-Saharan Africa"}, + "unit": "%", + "date": "2018", + "value": None, + }, + ], + ] + cache_file = cache_dir / f"wdi_{_cache_key(url, params)}.json" + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(payload), encoding="utf-8") + + series = await client.fetch_indicator("NY.GDP.PCAP.KD.ZG", "ssa", "1990:2023") + assert series.indicator_id == "NY.GDP.PCAP.KD.ZG" + assert series.country == "ssa" + assert series.n_total == 3 + assert series.n_used == 2 # one None filtered + assert series.values == [-1.234, 2.5] + assert "GDP" in series.indicator_name + + +async def test_cckp_uses_cache(cache_dir: Path): + client = CCKPClient(cache_dir) + endpoint = ( + "cmip6-x0.25_timeseries_tas_timeseries_annual_2015-2100_median_ssp245_ensemble_all_mean" + ) + url = f"{client.BASE}/{endpoint}/CHN" + payload = { + "CHN": { + "data": { + "2015-07-01": 9.1, + "2016-07-01": 9.2, + "2017-07-01": 9.3, + } + } + } + cache_file = cache_dir / f"cckp_{_cache_key(url, {})}.json" + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(payload), encoding="utf-8") + + s = await client.fetch_variable(variable="tas", country_iso3="CHN", scenario="ssp245") + assert s.country == "CHN" + assert s.scenario == "ssp245" + assert s.values == [9.1, 9.2, 9.3] + + +def test_cckp_rejects_bad_inputs(cache_dir: Path): + client = CCKPClient(cache_dir) + import asyncio + + with pytest.raises(ValueError): + asyncio.run(client.fetch_variable(variable="nope", country_iso3="CHN")) + with pytest.raises(ValueError): + asyncio.run(client.fetch_variable(variable="tas", country_iso3="CN")) + + +@pytest.mark.skipif( + os.environ.get("RUN_NETWORK_TESTS") != "1", + reason="set RUN_NETWORK_TESTS=1 to exercise live API", +) +async def test_wdi_live_smoke(tmp_path: Path): + client = WDIClient(tmp_path) + s = await client.fetch_indicator("NY.GDP.PCAP.KD.ZG", "ZG", "2015:2023") + assert s.n_used > 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..88118e668f83d2b783e0bf31c70b04e407f31c53 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + backend: + build: + context: ./backend + container_name: bayesscen-backend + restart: unless-stopped + environment: + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:?missing ANTHROPIC_API_KEY in .env}" + ANTHROPIC_MODEL: "${ANTHROPIC_MODEL:-claude-sonnet-4-5}" + ANTHROPIC_DEEP_MODEL: "${ANTHROPIC_DEEP_MODEL:-claude-opus-4-7}" + CORS_ORIGINS: "${CORS_ORIGINS:-http://localhost:8080}" + DATABASE_URL: "sqlite:////app/data/bayesscen.db" + DATA_CACHE_DIR: "/app/data/cache" + volumes: + - backend-data:/app/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=3)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + frontend: + build: + context: ./frontend + args: + # Empty = same-origin; nginx will proxy /api to backend + VITE_API_BASE_URL: "" + container_name: bayesscen-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${HTTP_PORT:-8080}:80" + +volumes: + backend-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..491ae1cab9909cc322d21af2477e2f8c7647afba --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.vite +.cache +*.log +.eslintcache +.DS_Store diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e48b2590653e4902c2e807844d21393a77590552 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1.7 +# ---- builder ---------------------------------------------------------------- +FROM node:22-alpine AS builder +WORKDIR /app + +# Copy lockfile first for cache +COPY package.json package-lock.json* ./ +RUN npm ci --no-audit --no-fund --progress=false + +COPY tsconfig*.json vite.config.ts postcss.config.js index.html ./ +COPY src ./src + +# VITE_API_BASE_URL is bundled at build time; default to same-origin proxy +ARG VITE_API_BASE_URL="" +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +RUN npm run build + +# ---- runtime: nginx --------------------------------------------------------- +FROM nginx:1.27-alpine AS runtime +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -q --spider http://localhost/ || exit 1 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a42fa234486ca54fb4d6f173d1b4e857df1834b7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,22 @@ + + + + + + BayesScenParams Agent · 贝叶斯情景参数智能量化 + + + + + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..728529aa70c060049d96ff40b4845e630c6d4036 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA routing fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to the backend service (Docker Compose: "backend") + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # SSE support: disable buffering so events stream immediately + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + + # Long-lived asset caching + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..0a7805a4b0954d45c053e84577e85258f9831851 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5201 @@ +{ + "name": "bayesscenparams-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bayesscenparams-frontend", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.13.0", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/node": "^25.8.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/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-array/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/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/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/@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/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/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/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/@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/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "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/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.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/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "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/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/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-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/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-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.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": "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/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/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/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/eslint/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/eslint/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/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/espree/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/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/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/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "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-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/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/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/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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "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", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "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/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "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", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "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/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/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/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "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", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..3432160ba4e2e40e955cc8e755b1c207842cd86a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "bayesscenparams-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "lint": "eslint . --ext ts,tsx" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.13.0", + "tailwind-merge": "^2.5.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/node": "^25.8.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..f69c5d4119626ef8d9bcf980b8635b3e75315b7b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..08ea3015976c9114256b63cac9d43470d4482e20 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from "react"; +import { Header } from "@/components/layout/Header"; +import { ChatPane } from "@/components/chat/ChatPane"; +import { ChatInput } from "@/components/chat/ChatInput"; +import { EmptyState } from "@/components/chat/EmptyState"; +import { Workspace } from "@/components/bayesian/Workspace"; +import { useAgentChat } from "@/components/chat/useAgentChat"; +import { api } from "@/lib/api"; + +export default function App() { + const [input, setInput] = useState(""); + const [apiKey, setApiKey] = useState(null); + const [serverModel, setServerModel] = useState(null); + + const { timeline, artifacts, send, cancel, busy, sessionId, reset, agentInfo } = + useAgentChat(); + + useEffect(() => { + api + .health() + .then((h) => { + setApiKey(h.anthropic_key_present); + setServerModel(h.anthropic_model); + }) + .catch(() => { + setApiKey(false); + }); + }, []); + + const submit = () => { + if (!input.trim() || busy) return; + send(input.trim()); + setInput(""); + }; + + const pickExample = (prompt: string) => { + if (busy) return; + send(prompt); + }; + + const displayModel = agentInfo?.model || serverModel; + + return ( +
+
+ +
+ {/* Left: chat column */} +
+ } + /> +
+ +
+ Enter 发送 · Shift+Enter 换行 · 端到端自主:拉数据 → 构先验 → 推判断 → 算后验 → 检验 → 报告 +
+
+
+ + {/* Right: workspace */} + +
+
+ ); +} diff --git a/frontend/src/components/bayesian/Workspace.tsx b/frontend/src/components/bayesian/Workspace.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bfb6691a0503b51aa6428db28eb35f274f92f777 --- /dev/null +++ b/frontend/src/components/bayesian/Workspace.tsx @@ -0,0 +1,246 @@ +import { useMemo } from "react"; +import { BarChart3, Database, Sparkles, Activity, LineChart } from "lucide-react"; +import { Card, CardHeader, Stat } from "@/components/ui/Card"; +import { Badge } from "@/components/ui/Badge"; +import { PriorPosteriorChart } from "@/components/charts/PriorPosteriorChart"; +import { KDEChart } from "@/components/charts/KDEChart"; +import { SensitivityChart } from "@/components/charts/SensitivityChart"; +import type { + ArtifactEvent, + DatasetSummary, + KDEPoint, + PosteriorSummary, + PriorSummary, + SensitivitySummary, +} from "@/lib/types"; + +interface Props { + artifacts: ArtifactEvent[]; +} + +interface LatestState { + dataset?: DatasetSummary; + prior?: PriorSummary; + kde?: KDEPoint[]; + posterior?: PosteriorSummary; + sensitivity?: SensitivitySummary; +} + +function reduceArtifacts(arts: ArtifactEvent[]): LatestState { + const s: LatestState = {}; + for (const a of arts) { + if (a.type === "dataset_loaded") s.dataset = a.summary; + else if (a.type === "prior_built") { + s.prior = a.summary; + s.kde = a.kde; + } else if (a.type === "posterior_computed") s.posterior = a.summary; + else if (a.type === "sensitivity") s.sensitivity = a.summary; + } + return s; +} + +const LEVEL_LABELS_ZH = ["极低", "低", "中等", "高", "极高"]; +const JUDGMENT_LABELS_ZH = ["极不可能", "不太可能", "难以判断", "比较可能", "极有可能"]; + +export function Workspace({ artifacts }: Props) { + const { dataset, prior, kde, posterior, sensitivity } = useMemo( + () => reduceArtifacts(artifacts), + [artifacts] + ); + + const empty = !dataset && !prior && !posterior && !sensitivity; + + if (empty) { + return ( +
+
+ +
+

+ 工作台 +

+

+ 当 Agent 拉取数据、构建先验、计算后验时,相关图表和指标会实时显示在这里。 +

+
+ ); + } + + const labels = prior?.level_labels_zh ?? LEVEL_LABELS_ZH; + const unit = dataset?.unit; + const quantileLabels = prior + ? labels.map((l, i) => `${l}\n${(prior.quantile_values[i] ?? 0).toFixed(2)}`) + : []; + + return ( +
+ {dataset && ( + + + + {dataset.name || + dataset.indicator_name || + dataset.variable_name || + "Dataset"} + + } + subtitle={ + + {dataset.source && 来源 · {dataset.source}} + {dataset.period && {dataset.period}} + {dataset.unit && {dataset.unit}} + + } + right={ + + n = {dataset.n ?? dataset.n_used ?? "-"} + + } + /> + {(dataset.min !== undefined || dataset.max !== undefined) && ( +
+ + + + +
+ )} +
+ )} + + {prior && kde && ( + + + + 先验分布(KDE + 5 分位点) + + } + subtitle={`mean = ${prior.mean.toFixed(3)} · std = ${prior.std.toFixed(3)} · skew = ${prior.skewness.toFixed(2)} · kurt = ${prior.kurtosis.toFixed(2)}`} + /> + + + )} + + {posterior && ( + + + + 先验 vs 后验 + + } + subtitle={`R = ${posterior.R} · ${posterior.judgments + .map((j, i) => `${labels[i]}: ${JUDGMENT_LABELS_ZH[j]}`) + .join(" · ")}`} + right={posterior} + /> + {prior && ( + + )} +
+ = 0 ? "+" : ""}${posterior.shift_mean.toFixed(3)}`} + tone={posterior.shift_mean >= 0 ? "accent" : "warn"} + /> + + + +
+ {posterior.judgment_rationale && ( +
+
+ Agent 的判断理由 +
+ {posterior.judgment_rationale} +
+ )} +
+ )} + + {sensitivity && ( + + + + 稳健性分析 + + } + subtitle={`R ∈ [${Math.min(...sensitivity.r_values)}, ${Math.max(...sensitivity.r_values)}] · 均值波动 ${sensitivity.mean_swing_pct.toFixed(1)}%`} + /> + + + )} + + {prior && ( + + + + 5 分位点数值 + + } + /> +
+ {prior.quantile_values.map((v, i) => ( +
+
+ + {labels[i]} + + q={prior.quantile_probs[i]} + +
+
+ {v.toFixed(3)} + {posterior && ( + + {(posterior.posterior_weights[i] * 100).toFixed(1)}% + + )} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/charts/KDEChart.tsx b/frontend/src/components/charts/KDEChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad0ecf60cce25bf74e2cb6f477632d374c9dc6da --- /dev/null +++ b/frontend/src/components/charts/KDEChart.tsx @@ -0,0 +1,96 @@ +import { + Area, + CartesianGrid, + ComposedChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { KDEPoint } from "@/lib/types"; + +interface Props { + points: KDEPoint[]; + quantileValues?: number[]; + quantileLabels?: string[]; + height?: number; + unit?: string; +} + +export function KDEChart({ + points, + quantileValues = [], + quantileLabels = [], + height = 240, + unit, +}: Props) { + return ( + + + + + + + + + + Number(v).toFixed(1)} + domain={["dataMin", "dataMax"]} + label={ + unit + ? { + value: unit, + position: "insideBottomRight", + offset: -2, + fill: "#94a3b8", + fontSize: 11, + } + : undefined + } + /> + Number(v).toFixed(2)} + /> + `x = ${Number(v).toFixed(3)}`} + formatter={(value: number) => [Number(value).toFixed(4), "density"]} + /> + + {quantileValues.map((v, i) => ( + + ))} + + + ); +} diff --git a/frontend/src/components/charts/PriorPosteriorChart.tsx b/frontend/src/components/charts/PriorPosteriorChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..486c06973c5f218ea25b22537e1afc28401c326f --- /dev/null +++ b/frontend/src/components/charts/PriorPosteriorChart.tsx @@ -0,0 +1,75 @@ +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +interface Props { + labels: string[]; + prior: number[]; + posterior: number[]; + quantileValues?: number[]; + height?: number; +} + +const LEVEL_COLORS = ["#3b82f6", "#06b6d4", "#10b981", "#f59e0b", "#ef4444"]; + +export function PriorPosteriorChart({ + labels, + prior, + posterior, + quantileValues, + height = 280, +}: Props) { + const data = labels.map((label, i) => ({ + name: label, + quantile: quantileValues?.[i], + "先验 P(z)": +(prior[i] * 100).toFixed(2), + "后验 P(z|S)": +(posterior[i] * 100).toFixed(2), + })); + + return ( + + + + + `${v}%`} + domain={[0, 100]} + /> + `${value}%`} + /> + + + + {data.map((_, i) => ( + + ))} + + + + ); +} diff --git a/frontend/src/components/charts/SensitivityChart.tsx b/frontend/src/components/charts/SensitivityChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c33fc71863b46bde5fb17d9401f6a450ca068b86 --- /dev/null +++ b/frontend/src/components/charts/SensitivityChart.tsx @@ -0,0 +1,88 @@ +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + ReferenceArea, +} from "recharts"; +import type { SensitivityRow } from "@/lib/types"; + +interface Props { + rows: SensitivityRow[]; + height?: number; +} + +export function SensitivityChart({ rows, height = 220 }: Props) { + const data = rows.map((r) => ({ + R: r.R, + mean: +r.posterior_mean.toFixed(4), + ci95_lower: +r.ci95_lower.toFixed(4), + ci95_upper: +r.ci95_upper.toFixed(4), + })); + + return ( + + + + + + `R = ${v}`} + /> + d.ci95_lower))} + y2={Math.max(...data.map((d) => d.ci95_upper))} + fill="#3b82f6" + fillOpacity={0.04} + stroke="none" + /> + + + + + + ); +} diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b3fb0c1f0d33fa8ace991749e54f2c1ec9ce04ef --- /dev/null +++ b/frontend/src/components/chat/ChatInput.tsx @@ -0,0 +1,73 @@ +import { Send, StopCircle } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { Button } from "@/components/ui/Button"; + +interface Props { + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel?: () => void; + busy: boolean; + disabled?: boolean; + placeholder?: string; +} + +export function ChatInput({ + value, + onChange, + onSubmit, + onCancel, + busy, + disabled, + placeholder, +}: Props) { + const ref = useRef(null); + + // Auto-grow + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 180) + "px"; + }, [value]); + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !busy && value.trim()) { + e.preventDefault(); + onSubmit(); + } + }; + + return ( +
+