Spaces:
Sleeping
Sleeping
deploy: sync BayesScenParams Agent (2026-05-17T16:01:00Z)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +20 -0
- .env.example +47 -0
- .gitignore +39 -0
- DEPLOY.md +151 -0
- Dockerfile +84 -0
- README.md +216 -6
- backend/.dockerignore +15 -0
- backend/Dockerfile +60 -0
- backend/README.md +62 -0
- backend/app/__init__.py +0 -0
- backend/app/agent/__init__.py +0 -0
- backend/app/agent/direct_orchestrator.py +278 -0
- backend/app/agent/orchestrator.py +233 -0
- backend/app/agent/prompts.py +98 -0
- backend/app/agent/tool_store.py +80 -0
- backend/app/agent/tools/__init__.py +30 -0
- backend/app/agent/tools/bayesian_tools.py +285 -0
- backend/app/agent/tools/data_tools.py +245 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/agent.py +74 -0
- backend/app/api/bayesian.py +84 -0
- backend/app/api/data.py +149 -0
- backend/app/api/health.py +24 -0
- backend/app/api/schemas.py +193 -0
- backend/app/api/sessions.py +61 -0
- backend/app/core/__init__.py +0 -0
- backend/app/core/config.py +119 -0
- backend/app/core/db.py +154 -0
- backend/app/data_sources/__init__.py +0 -0
- backend/app/data_sources/samples/sample-climate.json +32 -0
- backend/app/data_sources/samples/sample-gdp.json +47 -0
- backend/app/data_sources/samples/sample-population.json +23 -0
- backend/app/data_sources/worldbank.py +293 -0
- backend/app/domain/__init__.py +0 -0
- backend/app/domain/bayesian.py +361 -0
- backend/app/domain/multi_expert.py +221 -0
- backend/app/domain/multi_param.py +138 -0
- backend/app/domain/preprocessing.py +236 -0
- backend/app/domain/report.py +215 -0
- backend/app/main.py +104 -0
- backend/pyproject.toml +74 -0
- backend/tests/__init__.py +0 -0
- backend/tests/test_agent_sse.py +50 -0
- backend/tests/test_api.py +88 -0
- backend/tests/test_bayesian_parity.py +158 -0
- backend/tests/test_mcp_tools.py +186 -0
- backend/tests/test_phase2_modules.py +282 -0
- backend/tests/test_sessions_and_report.py +97 -0
- backend/tests/test_worldbank.py +122 -0
- docker-compose.yml +38 -0
.dockerignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.env
|
| 4 |
+
references/
|
| 5 |
+
**/.venv/
|
| 6 |
+
**/node_modules/
|
| 7 |
+
**/__pycache__/
|
| 8 |
+
**/.pytest_cache/
|
| 9 |
+
**/.mypy_cache/
|
| 10 |
+
**/.ruff_cache/
|
| 11 |
+
**/dist/
|
| 12 |
+
**/.vite/
|
| 13 |
+
backend/data/cache/
|
| 14 |
+
backend/data/*.db
|
| 15 |
+
backend/data/*.db-journal
|
| 16 |
+
backend/tests/
|
| 17 |
+
*.md
|
| 18 |
+
!README.md
|
| 19 |
+
!DEPLOY.md
|
| 20 |
+
.DS_Store
|
.env.example
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============ Anthropic Claude API ============
|
| 2 |
+
# Required for Agent functionality.
|
| 3 |
+
# - Official: get key at https://console.anthropic.com/
|
| 4 |
+
# - Third-party (NewAPI / OneAPI / zhihuiapi / AnyAPI): paste their key + set
|
| 5 |
+
# ANTHROPIC_BASE_URL below to their domain.
|
| 6 |
+
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 7 |
+
|
| 8 |
+
# Optional: third-party Claude-compatible proxy base URL.
|
| 9 |
+
# Leave empty for official Anthropic endpoint. Examples:
|
| 10 |
+
# ANTHROPIC_BASE_URL=https://cc.zhihuiapi.top
|
| 11 |
+
# ANTHROPIC_BASE_URL=https://api.deepseek.com (if their endpoint is Claude-compatible)
|
| 12 |
+
ANTHROPIC_BASE_URL=
|
| 13 |
+
|
| 14 |
+
# Optional: separate auth-token header (some proxies require it). Defaults to
|
| 15 |
+
# ANTHROPIC_API_KEY if empty.
|
| 16 |
+
ANTHROPIC_AUTH_TOKEN=
|
| 17 |
+
|
| 18 |
+
# Default model used by the agent (sonnet for balanced speed/cost).
|
| 19 |
+
# NewAPI / OneAPI / zhihuiapi proxies usually require the full dated ID, e.g.
|
| 20 |
+
# ANTHROPIC_MODEL=claude-sonnet-4-5-20250929
|
| 21 |
+
ANTHROPIC_MODEL=claude-sonnet-4-5
|
| 22 |
+
|
| 23 |
+
# Optional: model used for "deep analysis" mode if you want a stronger one
|
| 24 |
+
ANTHROPIC_DEEP_MODEL=claude-opus-4-7
|
| 25 |
+
|
| 26 |
+
# Agent runtime:
|
| 27 |
+
# direct - use anthropic Python SDK (works with any Claude-compatible proxy,
|
| 28 |
+
# default; recommended for zhihuiapi/OneAPI/NewAPI/etc.)
|
| 29 |
+
# sdk - use claude-agent-sdk subprocess (Claude Code protocol; only the
|
| 30 |
+
# official api.anthropic.com supports this)
|
| 31 |
+
AGENT_RUNTIME=direct
|
| 32 |
+
|
| 33 |
+
# ============ Backend ============
|
| 34 |
+
BACKEND_HOST=0.0.0.0
|
| 35 |
+
BACKEND_PORT=8000
|
| 36 |
+
|
| 37 |
+
# CORS: comma-separated list of allowed origins for frontend dev
|
| 38 |
+
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
| 39 |
+
|
| 40 |
+
# SQLite database path (relative to backend/)
|
| 41 |
+
DATABASE_URL=sqlite:///./data/bayesscen.db
|
| 42 |
+
|
| 43 |
+
# Where to cache external data fetches (relative to backend/)
|
| 44 |
+
DATA_CACHE_DIR=./data/cache
|
| 45 |
+
|
| 46 |
+
# ============ Frontend (Vite reads VITE_* vars) ============
|
| 47 |
+
VITE_API_BASE_URL=http://localhost:8000
|
.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============ Python ============
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
.venv/
|
| 8 |
+
venv/
|
| 9 |
+
env/
|
| 10 |
+
.env
|
| 11 |
+
.pytest_cache/
|
| 12 |
+
.mypy_cache/
|
| 13 |
+
.ruff_cache/
|
| 14 |
+
*.egg-info/
|
| 15 |
+
dist/
|
| 16 |
+
build/
|
| 17 |
+
|
| 18 |
+
# ============ Node / Frontend ============
|
| 19 |
+
node_modules/
|
| 20 |
+
.pnpm-store/
|
| 21 |
+
.next/
|
| 22 |
+
.cache/
|
| 23 |
+
dist/
|
| 24 |
+
.turbo/
|
| 25 |
+
.vite/
|
| 26 |
+
|
| 27 |
+
# ============ Data / Cache ============
|
| 28 |
+
backend/data/cache/
|
| 29 |
+
backend/data/*.db
|
| 30 |
+
backend/data/*.db-journal
|
| 31 |
+
|
| 32 |
+
# ============ IDE / OS ============
|
| 33 |
+
.DS_Store
|
| 34 |
+
.idea/
|
| 35 |
+
.vscode/
|
| 36 |
+
*.swp
|
| 37 |
+
|
| 38 |
+
# ============ References (cloned read-only, not committed) ============
|
| 39 |
+
references/
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 部署指南
|
| 2 |
+
|
| 3 |
+
本项目包含三个推荐部署路径,按"轻 → 重"排列。
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 0. 先决条件
|
| 8 |
+
|
| 9 |
+
- 一个 Anthropic API Key(在 <https://console.anthropic.com/> 申请)
|
| 10 |
+
- 已克隆本仓库
|
| 11 |
+
|
| 12 |
+
复制环境变量模板并填入 Key:
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
cp .env.example .env
|
| 16 |
+
$EDITOR .env # 至少填 ANTHROPIC_API_KEY
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## 1. 本地一键启动(Docker Compose,推荐入门)
|
| 22 |
+
|
| 23 |
+
需要:Docker Desktop 24+ 或 Docker Engine + Compose plugin。
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
docker compose up --build -d
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
启动后:
|
| 30 |
+
|
| 31 |
+
- 前端:<http://localhost:8080>
|
| 32 |
+
- 后端 OpenAPI:<http://localhost:8080/api/docs>(通过 nginx 反代)
|
| 33 |
+
- 数据持久化在命名卷 `backend-data`(包含 SQLite + 缓存)
|
| 34 |
+
|
| 35 |
+
查看日志:`docker compose logs -f backend`
|
| 36 |
+
停止:`docker compose down`
|
| 37 |
+
完全清理(含数据):`docker compose down -v`
|
| 38 |
+
|
| 39 |
+
修改 `HTTP_PORT=8081` 可改外暴露端口。
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## 2. VPS 自托管(DigitalOcean / 阿里云 / 腾讯云)
|
| 44 |
+
|
| 45 |
+
最小配置:1 vCPU / 2 GB RAM / 20 GB SSD(够中等并发;Agent 调用主要消耗 Anthropic 侧算力)。
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# 1. 装 Docker (Ubuntu 24.04)
|
| 49 |
+
curl -fsSL https://get.docker.com | sudo sh
|
| 50 |
+
sudo usermod -aG docker $USER
|
| 51 |
+
newgrp docker
|
| 52 |
+
|
| 53 |
+
# 2. 拉代码
|
| 54 |
+
git clone <YOUR_REPO_URL> bayesscenparams-agent
|
| 55 |
+
cd bayesscenparams-agent
|
| 56 |
+
|
| 57 |
+
# 3. 填环境变量
|
| 58 |
+
cp .env.example .env
|
| 59 |
+
nano .env
|
| 60 |
+
|
| 61 |
+
# 4. 启动
|
| 62 |
+
docker compose up --build -d
|
| 63 |
+
|
| 64 |
+
# 5. 套个 HTTPS(Caddy 最简单)
|
| 65 |
+
sudo apt install -y caddy
|
| 66 |
+
sudo tee /etc/caddy/Caddyfile > /dev/null <<EOF
|
| 67 |
+
yourdomain.com {
|
| 68 |
+
reverse_proxy localhost:8080 {
|
| 69 |
+
# SSE 兼容
|
| 70 |
+
flush_interval -1
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
EOF
|
| 74 |
+
sudo systemctl reload caddy
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
DNS 把 yourdomain.com 指到 VPS IP,Caddy 会自动申请 Let's Encrypt 证书。
|
| 78 |
+
|
| 79 |
+
> SSE 注意事项:任何反代(nginx、Caddy、Cloudflare)都必须关闭对 `/api/agent/chat`
|
| 80 |
+
> 的缓冲(`proxy_buffering off` / `flush_interval -1` / Cloudflare "Cache Level: Bypass")。
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## 3. 云托管(前后端拆开,零运维)
|
| 85 |
+
|
| 86 |
+
### 后端 → Railway / Fly.io / Render
|
| 87 |
+
|
| 88 |
+
以 **Railway** 为例:
|
| 89 |
+
|
| 90 |
+
1. <https://railway.app/> 新建项目 → "Deploy from GitHub Repo"
|
| 91 |
+
2. 选 root 目录里的 `backend/`(或在 Railway 设置里把 root path 改为 `backend`)
|
| 92 |
+
3. 它会自动检测 `Dockerfile` 并构建
|
| 93 |
+
4. 在 Variables 里加:
|
| 94 |
+
- `ANTHROPIC_API_KEY`
|
| 95 |
+
- `CORS_ORIGINS` = `https://<你的前端域名>` (Vercel 提供的)
|
| 96 |
+
5. 公开服务并记下生成的 URL,如 `https://bayesscen-api.up.railway.app`
|
| 97 |
+
|
| 98 |
+
数据持久化:Railway 默认给容器一个临时盘;要持久化 SQLite 请在 Volumes 里挂
|
| 99 |
+
`/app/data`。中等流量也可换成 `DATABASE_URL` 指向 Railway Postgres(需要把
|
| 100 |
+
`app/core/db.py` 改成 SQLAlchemy 实现,列入 Phase 4)。
|
| 101 |
+
|
| 102 |
+
### 前端 → Vercel
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
cd frontend
|
| 106 |
+
npm i -g vercel
|
| 107 |
+
vercel
|
| 108 |
+
# 第一次会问几个问题:Project name? bayesscen-frontend ; Output dir? dist
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
在 Vercel 项目 → Settings → Environment Variables 加:
|
| 112 |
+
|
| 113 |
+
- `VITE_API_BASE_URL` = 后端公开 URL(如 `https://bayesscen-api.up.railway.app`)
|
| 114 |
+
|
| 115 |
+
注意:把后端 URL 加进 Railway 的 `CORS_ORIGINS` 列表里。
|
| 116 |
+
|
| 117 |
+
---
|
| 118 |
+
|
| 119 |
+
## 4. 健康检查与可观测性
|
| 120 |
+
|
| 121 |
+
- `GET /api/health` 返回 200 + `{anthropic_key_present, model, ...}`
|
| 122 |
+
- 后端日志结构化,关键字段:`anthropic key`、`model`、`tool` 调用名
|
| 123 |
+
- 前端 ChatPane 在每一轮结束后会显示 turns / duration / cost(来自 Agent SDK 的 `result` 消息)
|
| 124 |
+
|
| 125 |
+
## 5. 升级流程
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
git pull
|
| 129 |
+
docker compose up --build -d # 仅重启变化的服务
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
后端代码 + 前端代码的更新都被 Docker 层缓存覆盖,依赖未变时通常 < 30s 完成。
|
| 133 |
+
|
| 134 |
+
## 6. 故障排查速查
|
| 135 |
+
|
| 136 |
+
| 现象 | 原因 / 解决 |
|
| 137 |
+
|------|-------------|
|
| 138 |
+
| 前端"未配置 API Key" | `.env` 里 `ANTHROPIC_API_KEY` 没填或仍是占位符 |
|
| 139 |
+
| SSE 连接卡住 / 半秒钟才出字 | 反代缓冲没关;nginx 加 `proxy_buffering off`,Cloudflare 开 grey-cloud |
|
| 140 |
+
| `/api/agent/chat` 返回 401 | API Key 失效;检查 Anthropic 控制台余额 |
|
| 141 |
+
| 前端 CORS 报错 | 后端 `CORS_ORIGINS` 没加前端域名 |
|
| 142 |
+
| World Bank 调用超时 | CCKP 接口偶尔抽风;已自动缓存到 `data/cache`,重试即可 |
|
| 143 |
+
| 容器内存爆 | NumPy + SciPy 基线 ~200 MB;至少给 1 GB |
|
| 144 |
+
| Sonnet 4.5 调用慢 | 切到 `claude-haiku-4-5`(更便宜更快但推理弱):改 `ANTHROPIC_MODEL` 即可 |
|
| 145 |
+
|
| 146 |
+
## 7. 不在本指南范围内(可在 README 跟进规划)
|
| 147 |
+
|
| 148 |
+
- 多租户 / 用户认证(Clerk / Supabase Auth)
|
| 149 |
+
- 速率限制(Cloudflare / Caddy rate_limit)
|
| 150 |
+
- 监控告警(OpenTelemetry → Grafana)
|
| 151 |
+
- 对象存储(用户上传的大型 CSV 走 S3 / R2)
|
Dockerfile
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
#
|
| 3 |
+
# Single-container build for Hugging Face Spaces (Docker SDK).
|
| 4 |
+
# Stage 1: build the React/Vite frontend → static dist
|
| 5 |
+
# Stage 2: install Python deps and bundle the frontend dist into the API image
|
| 6 |
+
#
|
| 7 |
+
# Listens on 7860 (HF Spaces default). FastAPI serves both /api/* and the SPA.
|
| 8 |
+
|
| 9 |
+
# -------- frontend --------
|
| 10 |
+
FROM node:22-alpine AS frontend-builder
|
| 11 |
+
WORKDIR /web
|
| 12 |
+
|
| 13 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 14 |
+
RUN npm ci --no-audit --no-fund --progress=false
|
| 15 |
+
|
| 16 |
+
COPY frontend/tsconfig*.json frontend/vite.config.ts frontend/postcss.config.js \
|
| 17 |
+
frontend/index.html ./
|
| 18 |
+
COPY frontend/src ./src
|
| 19 |
+
|
| 20 |
+
# Same-origin: no VITE_API_BASE_URL means the SPA calls /api on its own host
|
| 21 |
+
ARG VITE_API_BASE_URL=""
|
| 22 |
+
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
| 23 |
+
RUN npm run build
|
| 24 |
+
|
| 25 |
+
# -------- backend + final --------
|
| 26 |
+
FROM python:3.12-slim AS runtime
|
| 27 |
+
|
| 28 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 29 |
+
PYTHONUNBUFFERED=1 \
|
| 30 |
+
PIP_NO_CACHE_DIR=1 \
|
| 31 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 32 |
+
PORT=7860 \
|
| 33 |
+
HOME=/app
|
| 34 |
+
|
| 35 |
+
WORKDIR /app
|
| 36 |
+
|
| 37 |
+
RUN apt-get update \
|
| 38 |
+
&& apt-get install -y --no-install-recommends build-essential curl \
|
| 39 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 40 |
+
|
| 41 |
+
# Install Python deps
|
| 42 |
+
RUN pip install --upgrade pip \
|
| 43 |
+
&& pip install \
|
| 44 |
+
'fastapi>=0.115.0' \
|
| 45 |
+
'uvicorn[standard]>=0.32.0' \
|
| 46 |
+
'python-multipart>=0.0.12' \
|
| 47 |
+
'sse-starlette>=2.1.0' \
|
| 48 |
+
'pydantic>=2.9.0' \
|
| 49 |
+
'pydantic-settings>=2.5.0' \
|
| 50 |
+
'httpx>=0.27.0' \
|
| 51 |
+
'numpy>=2.0.0' \
|
| 52 |
+
'scipy>=1.14.0' \
|
| 53 |
+
'sqlalchemy>=2.0.0' \
|
| 54 |
+
'aiosqlite>=0.20.0' \
|
| 55 |
+
'anthropic>=0.40.0' \
|
| 56 |
+
'claude-agent-sdk>=0.1.0'
|
| 57 |
+
|
| 58 |
+
# Copy backend source
|
| 59 |
+
COPY backend/app ./app
|
| 60 |
+
COPY backend/pyproject.toml ./
|
| 61 |
+
|
| 62 |
+
# Copy frontend build artefacts
|
| 63 |
+
COPY --from=frontend-builder /web/dist /app/frontend_dist
|
| 64 |
+
|
| 65 |
+
# Hugging Face Spaces runs as UID 1000 by default
|
| 66 |
+
RUN useradd -m -u 1000 user \
|
| 67 |
+
&& mkdir -p /app/data/cache /app/data/sessions \
|
| 68 |
+
&& chown -R user:user /app
|
| 69 |
+
USER user
|
| 70 |
+
|
| 71 |
+
# Where to read writable state (HF Spaces free tier has no persistent disk;
|
| 72 |
+
# this just survives within a single Space session)
|
| 73 |
+
ENV DATA_CACHE_DIR=/app/data/cache \
|
| 74 |
+
DATABASE_URL=sqlite:////app/data/bayesscen.db \
|
| 75 |
+
AGENT_RUNTIME=direct \
|
| 76 |
+
BACKEND_HOST=0.0.0.0 \
|
| 77 |
+
BACKEND_PORT=7860
|
| 78 |
+
|
| 79 |
+
EXPOSE 7860
|
| 80 |
+
|
| 81 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
| 82 |
+
CMD curl -fsS http://localhost:7860/api/health || exit 1
|
| 83 |
+
|
| 84 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,220 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: BayesScenParams Agent
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: 对话驱动的贝叶斯情景参数自主研究分析 Agent
|
| 11 |
+
tags:
|
| 12 |
+
- bayesian
|
| 13 |
+
- scenario-analysis
|
| 14 |
+
- climate
|
| 15 |
+
- economics
|
| 16 |
+
- research-agent
|
| 17 |
+
- claude
|
| 18 |
+
- mcp
|
| 19 |
---
|
| 20 |
|
| 21 |
+
# BayesScenParams Agent
|
| 22 |
+
|
| 23 |
+
> 对话驱动的贝叶斯情景参数自主研究分析平台
|
| 24 |
+
>
|
| 25 |
+
> Conversational autonomous research agent for Bayesian scenario parameter quantification.
|
| 26 |
+
|
| 27 |
+
把 [Kemp-Benedict (2010)][1] 的贝叶斯情景参数量化方法包装成一个自主研究 Agent:你提一个研究问题,Agent 自主完成数据抓取、先验构建、专家判断推理、后验计算、稳健性检验、报告生成。
|
| 28 |
+
|
| 29 |
+
## 技术栈
|
| 30 |
+
|
| 31 |
+
- **后端**:Python 3.12 + FastAPI + [`claude-agent-sdk`](https://pypi.org/project/claude-agent-sdk/) + NumPy/SciPy
|
| 32 |
+
- **前端**:React 19 + Vite + TypeScript + Tailwind v4 + Recharts
|
| 33 |
+
- **数据源**:World Bank WDI(经济)+ CCKP CMIP6(气候)+ 内置示例数据集
|
| 34 |
+
- **持久化**:SQLite(会话 + 历史)
|
| 35 |
+
- **算法核心**:从原 `app/js/bayesian.js` 移植到 `backend/app/domain/bayesian.py`,含 15 个 JS↔Python 一致性测试(误差 < 1e-9)
|
| 36 |
+
|
| 37 |
+
## 项目状态
|
| 38 |
+
|
| 39 |
+
| 阶段 | 内容 | 状态 |
|
| 40 |
+
|------|------|------|
|
| 41 |
+
| MVP | 算法移植 + REST API + 5 个 MCP 工具 + SSE 流 + React 聊天界面 + 工作台图表 | 完成 |
|
| 42 |
+
| Phase 2 | multi-expert / multi-param / preprocessing 三个高阶模块 + Markdown 报告生成器 + SQLite 会话存储 | 完成 |
|
| 43 |
+
| Phase 3 | Docker + docker-compose + 部署文档 | 完成 |
|
| 44 |
+
| Phase 4 (未来) | 用户认证 / 速率限制 / Postgres / 监控 | 规划中 |
|
| 45 |
+
|
| 46 |
+
测试覆盖:**66 个后端测试** + **0 个 ESLint 错误** + **TypeScript 严格模式** + **生产构建通过**。
|
| 47 |
+
|
| 48 |
+
## 目录结构
|
| 49 |
+
|
| 50 |
+
```
|
| 51 |
+
bayesscenparams-agent/
|
| 52 |
+
├── backend/ # Python FastAPI + Claude Agent SDK
|
| 53 |
+
│ ├── app/
|
| 54 |
+
│ │ ├── api/ # REST + SSE 路由
|
| 55 |
+
│ │ │ ├── health.py
|
| 56 |
+
│ │ │ ├── bayesian.py # compute / sensitivity / kde / report
|
| 57 |
+
│ │ │ ├── data.py # samples / upload
|
| 58 |
+
│ │ │ ├── agent.py # SSE Agent 端点
|
| 59 |
+
│ │ │ └── sessions.py # 会话管理
|
| 60 |
+
│ │ ├── core/
|
| 61 |
+
│ │ │ ├── config.py # 环境变量
|
| 62 |
+
│ │ │ └── db.py # SQLite
|
| 63 |
+
│ │ ├── agent/
|
| 64 |
+
│ │ │ ├── orchestrator.py # claude-agent-sdk 包装
|
| 65 |
+
│ │ │ ├── prompts.py # 中英双语系统提示词
|
| 66 |
+
│ │ │ ├── tool_store.py # 工具间共享状态 + SSE 桥接
|
| 67 |
+
│ │ │ └── tools/ # 8 个 in-process MCP 工具
|
| 68 |
+
│ │ ├── domain/ # 纯算法(无 I/O)
|
| 69 |
+
│ │ │ ├── bayesian.py # 核心:先验/似然/后验/敏感性/KDE
|
| 70 |
+
│ │ │ ├── multi_expert.py # 加权几何均值 + DS 合成 + Kendall's W
|
| 71 |
+
│ │ │ ├── multi_param.py # 相关性 + KL / JS / Wasserstein
|
| 72 |
+
│ │ │ ├── preprocessing.py # 异常值 + 正态检验 + Box-Cox / Yeo-Johnson
|
| 73 |
+
│ │ │ └── report.py # Markdown 报告
|
| 74 |
+
│ │ └── data_sources/
|
| 75 |
+
│ │ ├── worldbank.py # WDI + CCKP 客户端 + 本地缓存
|
| 76 |
+
│ │ └── samples/ # 内置示例数据集 (GDP / 气候 / 人口)
|
| 77 |
+
│ ├── tests/ # 66 个测试,含 JS↔Python 算法一致性
|
| 78 |
+
│ ├── pyproject.toml
|
| 79 |
+
│ └── Dockerfile
|
| 80 |
+
├── frontend/ # React 19 + Vite + Tailwind v4
|
| 81 |
+
│ ├── src/
|
| 82 |
+
│ │ ├── App.tsx
|
| 83 |
+
│ │ ├── main.tsx
|
| 84 |
+
│ │ ├── components/
|
| 85 |
+
│ │ │ ├── chat/ # ChatPane / ToolCallCard / ChatInput / EmptyState / useAgentChat
|
| 86 |
+
│ │ │ ├── bayesian/ # Workspace 右侧渲染面板
|
| 87 |
+
│ │ │ ├── charts/ # PriorPosteriorChart / KDEChart / SensitivityChart
|
| 88 |
+
│ │ │ ├── layout/ # Header
|
| 89 |
+
│ │ │ └── ui/ # Button / Card / Badge / Stat
|
| 90 |
+
│ │ ├── lib/ # api / agentStream / types / cn
|
| 91 |
+
│ │ └── styles/globals.css
|
| 92 |
+
│ ├── vite.config.ts
|
| 93 |
+
│ ├── tailwind.config.js (内联于 globals.css,Tailwind v4)
|
| 94 |
+
│ ├── nginx.conf # 生产部署的反代配置
|
| 95 |
+
│ └── Dockerfile
|
| 96 |
+
├── references/ # 只读参考项目(不进 git)
|
| 97 |
+
├── docker-compose.yml # 一键启动
|
| 98 |
+
├── .env.example
|
| 99 |
+
├── DEPLOY.md # 部署指南(本地/VPS/云托管三条路径)
|
| 100 |
+
└── README.md
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## 快速开始(开发环境)
|
| 104 |
+
|
| 105 |
+
### 1. 准备环境变量
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
cp .env.example .env
|
| 109 |
+
# 编辑 .env 填入 ANTHROPIC_API_KEY
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### 2. 启动后端
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
cd backend
|
| 116 |
+
# 第一次:装 Python 3.12+ 和 uv(uv 自动管理虚拟环境)
|
| 117 |
+
# brew install uv / curl -LsSf https://astral.sh/uv/install.sh | sh
|
| 118 |
+
uv venv --python 3.12 .venv
|
| 119 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 120 |
+
uv pip install -e ".[dev]"
|
| 121 |
+
|
| 122 |
+
# 跑测试
|
| 123 |
+
pytest
|
| 124 |
+
|
| 125 |
+
# 启动
|
| 126 |
+
uvicorn app.main:app --reload --port 8000
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
访问 <http://localhost:8000/docs> 看 OpenAPI 文档。
|
| 130 |
+
|
| 131 |
+
### 3. 启动前端
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
cd frontend
|
| 135 |
+
npm install
|
| 136 |
+
npm run dev
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
打开 <http://localhost:5173>。
|
| 140 |
+
|
| 141 |
+
## 一键启动(Docker Compose)
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
cp .env.example .env
|
| 145 |
+
$EDITOR .env # 填 ANTHROPIC_API_KEY
|
| 146 |
+
docker compose up --build -d
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
访问 <http://localhost:8080>。详见 [`DEPLOY.md`](DEPLOY.md)。
|
| 150 |
+
|
| 151 |
+
## Agent 工作流
|
| 152 |
+
|
| 153 |
+
Agent 是端到端自主的:你提一个研究问题,它会按下列 8 步依次调用工具:
|
| 154 |
+
|
| 155 |
+
```mermaid
|
| 156 |
+
flowchart LR
|
| 157 |
+
Q[用户研究问题] --> A1[1 理解 分解参数]
|
| 158 |
+
A1 --> A2[2 list_data_catalog]
|
| 159 |
+
A2 --> A3[3 fetch_wdi or fetch_cckp or load_sample]
|
| 160 |
+
A3 --> A4[4 compute_statistics 数据质检]
|
| 161 |
+
A4 --> A5[5 build_prior 5 点离散]
|
| 162 |
+
A5 --> A6[6 推理 5 级判断 + 选 R]
|
| 163 |
+
A6 --> A7[7 compute_posterior]
|
| 164 |
+
A7 --> A8[8 run_sensitivity_analysis]
|
| 165 |
+
A8 --> R[结论 + 图表]
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
用户可随时打断、修正判断、换数据集;前端实时渲染:
|
| 169 |
+
- 助手文字(typing 效果)
|
| 170 |
+
- 工具调用卡(可展开查看 input/output JSON)
|
| 171 |
+
- 右侧工作台同步渲染先验 KDE / 先验后验对比柱状图 / 稳健性折线图
|
| 172 |
+
|
| 173 |
+
## 示例研究问题
|
| 174 |
+
|
| 175 |
+
> 用世界银行数据分析撒哈拉以南非洲未来 10 年高 GDP 增长情景的可能性
|
| 176 |
+
|
| 177 |
+
> 拉取 World Bank CCKP 中国在 SSP2-4.5 情景下 2015-2100 年的年均温度数据,分析「显著升温」情景的后验概率
|
| 178 |
+
|
| 179 |
+
> 用 World Bank WDI 拉取印度 1990-2023 的人均 GDP 增长率,分析未来 5 年「高于历史中位数」情景的可能性
|
| 180 |
+
|
| 181 |
+
## REST 接口
|
| 182 |
+
|
| 183 |
+
| 方法 | 路径 | 用途 |
|
| 184 |
+
|------|------|------|
|
| 185 |
+
| GET | `/api/health` | 健康检查 + Anthropic Key 状态 |
|
| 186 |
+
| GET | `/api/data/samples` | 列出 3 个内置示例数据集 |
|
| 187 |
+
| GET | `/api/data/samples/{id}` | 取单个示例数据集 |
|
| 188 |
+
| POST | `/api/data/upload` | 上传 CSV,自动识别首列数值 |
|
| 189 |
+
| POST | `/api/bayesian/compute` | 直接计算后验(非 Agent 路径) |
|
| 190 |
+
| POST | `/api/bayesian/sensitivity` | R 值扫描 |
|
| 191 |
+
| POST | `/api/bayesian/kde` | 计算 KDE 曲线 |
|
| 192 |
+
| POST | `/api/bayesian/report` | 生成 Markdown 报告 |
|
| 193 |
+
| POST | `/api/agent/chat` | **SSE** Agent 对话端点(主入口) |
|
| 194 |
+
| POST | `/api/sessions` | 创建会话 |
|
| 195 |
+
| GET | `/api/sessions` | 列出会话 |
|
| 196 |
+
| GET | `/api/sessions/{id}/history` | 查会话历史 |
|
| 197 |
+
| DELETE | `/api/sessions/{id}` | 删除会话 |
|
| 198 |
+
|
| 199 |
+
## 测试
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
cd backend
|
| 203 |
+
pytest # 全量 66 个测试,~1s
|
| 204 |
+
pytest -k bayesian # 按名字过滤
|
| 205 |
+
RUN_NETWORK_TESTS=1 pytest # 含 World Bank 活网络烟雾测试
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
```bash
|
| 209 |
+
cd frontend
|
| 210 |
+
npm run typecheck # tsc -b --noEmit
|
| 211 |
+
npm run build # 完整生产构建
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
## 参考
|
| 215 |
+
|
| 216 |
+
- [1] Kemp-Benedict, E. (2010). *Converting qualitative assessments to quantitative assumptions: Bayes' rule and the pundit's wager.* Technological Forecasting and Social Change.
|
| 217 |
+
- 原始软件实现:[`../app/`](../app/)
|
| 218 |
+
- 中文技术交底书:[`../技术交底书_完整版.md`](../技术交底书_完整版.md)
|
| 219 |
+
- Claude Agent SDK Python:<https://docs.claude.com/en/docs/agent-sdk/python>
|
| 220 |
+
- World Bank CCKP:<https://climateknowledgeportal.worldbank.org/download-data>
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.pytest_cache
|
| 7 |
+
.mypy_cache
|
| 8 |
+
.ruff_cache
|
| 9 |
+
.coverage
|
| 10 |
+
htmlcov
|
| 11 |
+
data/cache
|
| 12 |
+
data/*.db
|
| 13 |
+
data/*.db-journal
|
| 14 |
+
tests
|
| 15 |
+
README.md
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
# ---- builder ----------------------------------------------------------------
|
| 3 |
+
FROM python:3.12-slim AS builder
|
| 4 |
+
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
+
PYTHONUNBUFFERED=1 \
|
| 7 |
+
PIP_NO_CACHE_DIR=1 \
|
| 8 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
RUN apt-get update \
|
| 13 |
+
&& apt-get install -y --no-install-recommends build-essential \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
COPY pyproject.toml ./
|
| 17 |
+
# Install the project into a system Python so the runtime image stays small
|
| 18 |
+
RUN pip install --upgrade pip \
|
| 19 |
+
&& pip install --prefix=/install \
|
| 20 |
+
fastapi>=0.115.0 \
|
| 21 |
+
'uvicorn[standard]>=0.32.0' \
|
| 22 |
+
python-multipart>=0.0.12 \
|
| 23 |
+
sse-starlette>=2.1.0 \
|
| 24 |
+
pydantic>=2.9.0 \
|
| 25 |
+
pydantic-settings>=2.5.0 \
|
| 26 |
+
httpx>=0.27.0 \
|
| 27 |
+
numpy>=2.0.0 \
|
| 28 |
+
scipy>=1.14.0 \
|
| 29 |
+
claude-agent-sdk>=0.1.0 \
|
| 30 |
+
sqlalchemy>=2.0.0 \
|
| 31 |
+
aiosqlite>=0.20.0
|
| 32 |
+
|
| 33 |
+
# ---- runtime ----------------------------------------------------------------
|
| 34 |
+
FROM python:3.12-slim AS runtime
|
| 35 |
+
|
| 36 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 37 |
+
PYTHONUNBUFFERED=1 \
|
| 38 |
+
PORT=8000
|
| 39 |
+
|
| 40 |
+
WORKDIR /app
|
| 41 |
+
|
| 42 |
+
# Copy installed deps from builder
|
| 43 |
+
COPY --from=builder /install /usr/local
|
| 44 |
+
|
| 45 |
+
# Copy app source (only what's needed at runtime)
|
| 46 |
+
COPY app ./app
|
| 47 |
+
|
| 48 |
+
# Create a non-root user
|
| 49 |
+
RUN useradd -m -u 1000 appuser \
|
| 50 |
+
&& mkdir -p /app/data/cache /app/data/sessions \
|
| 51 |
+
&& chown -R appuser:appuser /app
|
| 52 |
+
USER appuser
|
| 53 |
+
|
| 54 |
+
EXPOSE 8000
|
| 55 |
+
|
| 56 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 57 |
+
CMD python -c "import urllib.request, sys; \
|
| 58 |
+
sys.exit(0) if urllib.request.urlopen('http://localhost:8000/api/health', timeout=3).status == 200 else sys.exit(1)"
|
| 59 |
+
|
| 60 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BayesScenParams Backend
|
| 2 |
+
|
| 3 |
+
FastAPI + Claude Agent SDK + NumPy/SciPy.
|
| 4 |
+
|
| 5 |
+
## 开发
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
python -m venv .venv
|
| 9 |
+
source .venv/bin/activate
|
| 10 |
+
pip install -e ".[dev]"
|
| 11 |
+
|
| 12 |
+
# 复制环境变量
|
| 13 |
+
cp ../.env.example ../.env
|
| 14 |
+
# 编辑 .env 填入 ANTHROPIC_API_KEY
|
| 15 |
+
|
| 16 |
+
# 启动
|
| 17 |
+
uvicorn app.main:app --reload --port 8000
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
打开 <http://localhost:8000/docs> 查看接口。
|
| 21 |
+
|
| 22 |
+
## 测试
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
pytest # 全量
|
| 26 |
+
pytest tests/test_bayesian.py # 单文件
|
| 27 |
+
pytest -k posterior # 按名字过滤
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## 主要接口
|
| 31 |
+
|
| 32 |
+
- `GET /api/health` — 健康检查
|
| 33 |
+
- `POST /api/bayesian/compute` — 直接计算贝叶斯后验(非 Agent 路径)
|
| 34 |
+
- `POST /api/agent/chat` — SSE 流式 Agent 对话
|
| 35 |
+
- `GET /api/data/samples` — 列出内置示例数据集
|
| 36 |
+
- `GET /api/data/samples/{id}` — 取单个示例数据集
|
| 37 |
+
|
| 38 |
+
## 目录
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
app/
|
| 42 |
+
main.py FastAPI 入口
|
| 43 |
+
core/config.py 环境变量
|
| 44 |
+
core/db.py SQLite(Phase 2)
|
| 45 |
+
api/ 路由
|
| 46 |
+
health.py
|
| 47 |
+
bayesian.py
|
| 48 |
+
data.py
|
| 49 |
+
agent.py SSE Agent 端点
|
| 50 |
+
agent/
|
| 51 |
+
orchestrator.py claude-agent-sdk 包装
|
| 52 |
+
prompts.py 系统提示词
|
| 53 |
+
tools/ in-process MCP 工具
|
| 54 |
+
bayesian_tools.py
|
| 55 |
+
data_tools.py
|
| 56 |
+
analysis_tools.py
|
| 57 |
+
domain/
|
| 58 |
+
bayesian.py 算法核心(从 JS 移植)
|
| 59 |
+
data_sources/
|
| 60 |
+
worldbank.py World Bank CCKP/WDI 客户端
|
| 61 |
+
samples/ 内置示例数据集
|
| 62 |
+
```
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/agent/__init__.py
ADDED
|
File without changes
|
backend/app/agent/direct_orchestrator.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Direct Anthropic Messages API orchestrator.
|
| 2 |
+
|
| 3 |
+
Why this exists: ``claude-agent-sdk`` wraps the ``claude-code`` CLI which calls
|
| 4 |
+
Anthropic's *private* Claude Code protocol, not the public ``/v1/messages``
|
| 5 |
+
endpoint. Most Chinese/community Claude proxies (zhihuiapi, OneAPI, NewAPI,
|
| 6 |
+
AnyAPI, …) only proxy the public Messages API, so the SDK can't reach the
|
| 7 |
+
model through them.
|
| 8 |
+
|
| 9 |
+
This module is an alternative runtime that drives the same MCP tools via the
|
| 10 |
+
official ``anthropic`` Python client + a manual tool-use loop. It works with:
|
| 11 |
+
* The official Anthropic API (api.anthropic.com)
|
| 12 |
+
* Any Claude-compatible reverse proxy (zhihuiapi, OneAPI, NewAPI, …)
|
| 13 |
+
* Amazon Bedrock and Google Vertex AI (if you configure their endpoints)
|
| 14 |
+
|
| 15 |
+
Public surface mirrors ``orchestrator.stream_agent`` so the SSE endpoint can
|
| 16 |
+
swap runtimes by a single flag.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import time
|
| 25 |
+
import uuid
|
| 26 |
+
from collections.abc import AsyncIterator
|
| 27 |
+
from typing import Any
|
| 28 |
+
|
| 29 |
+
import anthropic
|
| 30 |
+
|
| 31 |
+
from app.agent import tool_store
|
| 32 |
+
from app.agent.prompts import SYSTEM_PROMPT
|
| 33 |
+
from app.agent.tools import ALL_TOOLS
|
| 34 |
+
from app.core.config import get_settings
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --------------------------------------------------------------------------- #
|
| 40 |
+
# Tool registry adapted for Anthropic Messages API
|
| 41 |
+
# --------------------------------------------------------------------------- #
|
| 42 |
+
|
| 43 |
+
_PYTYPE_TO_JSON: dict[type, dict[str, Any]] = {
|
| 44 |
+
str: {"type": "string"},
|
| 45 |
+
int: {"type": "integer"},
|
| 46 |
+
float: {"type": "number"},
|
| 47 |
+
bool: {"type": "boolean"},
|
| 48 |
+
list: {"type": "array", "items": {}},
|
| 49 |
+
dict: {"type": "object"},
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _python_schema_to_json_schema(schema: Any) -> dict[str, Any]:
|
| 54 |
+
"""Convert the SDK's lightweight Python type dict into JSON Schema."""
|
| 55 |
+
if not isinstance(schema, dict):
|
| 56 |
+
return {"type": "object", "properties": {}}
|
| 57 |
+
props: dict[str, Any] = {}
|
| 58 |
+
required: list[str] = []
|
| 59 |
+
for key, py_type in schema.items():
|
| 60 |
+
if isinstance(py_type, type) and py_type in _PYTYPE_TO_JSON:
|
| 61 |
+
props[key] = _PYTYPE_TO_JSON[py_type]
|
| 62 |
+
elif isinstance(py_type, dict):
|
| 63 |
+
props[key] = py_type # already JSON Schema
|
| 64 |
+
else:
|
| 65 |
+
props[key] = {"type": "string"} # fallback
|
| 66 |
+
required.append(key)
|
| 67 |
+
return {
|
| 68 |
+
"type": "object",
|
| 69 |
+
"properties": props,
|
| 70 |
+
"required": required,
|
| 71 |
+
"additionalProperties": False,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def build_tool_defs() -> list[dict[str, Any]]:
|
| 76 |
+
"""Build Anthropic Messages-style tool definitions from the SDK tools."""
|
| 77 |
+
defs: list[dict[str, Any]] = []
|
| 78 |
+
for t in ALL_TOOLS:
|
| 79 |
+
defs.append(
|
| 80 |
+
{
|
| 81 |
+
"name": t.name,
|
| 82 |
+
"description": t.description,
|
| 83 |
+
"input_schema": _python_schema_to_json_schema(t.input_schema),
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
return defs
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
_HANDLERS_BY_NAME: dict[str, Any] = {t.name: t.handler for t in ALL_TOOLS}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def _dispatch_tool(name: str, args: dict) -> tuple[str, bool]:
|
| 93 |
+
"""Run a tool by name. Returns (text_content, is_error)."""
|
| 94 |
+
handler = _HANDLERS_BY_NAME.get(name)
|
| 95 |
+
if handler is None:
|
| 96 |
+
return json.dumps({"error": f"unknown tool: {name}"}), True
|
| 97 |
+
try:
|
| 98 |
+
result = await handler(args or {})
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.exception("tool %s crashed", name)
|
| 101 |
+
return json.dumps({"error": f"{type(e).__name__}: {e}"}), True
|
| 102 |
+
content = result.get("content", [])
|
| 103 |
+
text = ""
|
| 104 |
+
for c in content:
|
| 105 |
+
if isinstance(c, dict) and c.get("type") == "text":
|
| 106 |
+
text += c.get("text", "")
|
| 107 |
+
return text or "(no output)", bool(result.get("is_error"))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# --------------------------------------------------------------------------- #
|
| 111 |
+
# Orchestrator
|
| 112 |
+
# --------------------------------------------------------------------------- #
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
async def stream_agent_direct(
|
| 116 |
+
prompt: str,
|
| 117 |
+
*,
|
| 118 |
+
session_id: str | None = None,
|
| 119 |
+
mode: str = "standard",
|
| 120 |
+
max_turns: int = 12,
|
| 121 |
+
) -> AsyncIterator[dict]:
|
| 122 |
+
"""Run the agent via the public Messages API. Yields the same event schema
|
| 123 |
+
as ``orchestrator.stream_agent`` so the SSE layer stays unchanged."""
|
| 124 |
+
settings = get_settings()
|
| 125 |
+
|
| 126 |
+
if not settings.has_anthropic_key:
|
| 127 |
+
yield {
|
| 128 |
+
"type": "error",
|
| 129 |
+
"error": (
|
| 130 |
+
"ANTHROPIC_API_KEY is not configured. Please set it in .env "
|
| 131 |
+
"(see .env.example) and restart the backend."
|
| 132 |
+
),
|
| 133 |
+
}
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
model = settings.anthropic_deep_model if mode == "deep" else settings.anthropic_model
|
| 137 |
+
auth_token = settings.cleaned_auth_token() or None
|
| 138 |
+
base_url = settings.anthropic_base_url or None
|
| 139 |
+
|
| 140 |
+
# Bind a per-request artifact queue so tool handlers can push artifacts.
|
| 141 |
+
artifact_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1024)
|
| 142 |
+
tool_store.set_artifact_queue(artifact_queue)
|
| 143 |
+
|
| 144 |
+
tool_defs = build_tool_defs()
|
| 145 |
+
|
| 146 |
+
client = anthropic.AsyncAnthropic(
|
| 147 |
+
api_key=settings.anthropic_api_key,
|
| 148 |
+
auth_token=auth_token,
|
| 149 |
+
base_url=base_url,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
yield {
|
| 153 |
+
"type": "agent_started",
|
| 154 |
+
"model": model,
|
| 155 |
+
"mode": mode,
|
| 156 |
+
"tools": [t["name"] for t in tool_defs],
|
| 157 |
+
"runtime": "direct",
|
| 158 |
+
"base_url": base_url or "https://api.anthropic.com",
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# Build conversation history (single-shot for now; session_id support TBD).
|
| 162 |
+
messages: list[dict] = [{"role": "user", "content": prompt}]
|
| 163 |
+
started = time.monotonic()
|
| 164 |
+
new_session_id = session_id or uuid.uuid4().hex
|
| 165 |
+
total_input_tokens = 0
|
| 166 |
+
total_output_tokens = 0
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
for turn in range(max_turns):
|
| 170 |
+
try:
|
| 171 |
+
resp = await client.messages.create(
|
| 172 |
+
model=model,
|
| 173 |
+
max_tokens=4096,
|
| 174 |
+
system=SYSTEM_PROMPT,
|
| 175 |
+
tools=tool_defs,
|
| 176 |
+
messages=messages,
|
| 177 |
+
)
|
| 178 |
+
except anthropic.APIError as e:
|
| 179 |
+
yield {
|
| 180 |
+
"type": "error",
|
| 181 |
+
"error": f"Anthropic API error: {e!s}",
|
| 182 |
+
}
|
| 183 |
+
return
|
| 184 |
+
except Exception as e:
|
| 185 |
+
yield {"type": "error", "error": f"{type(e).__name__}: {e}"}
|
| 186 |
+
return
|
| 187 |
+
|
| 188 |
+
if resp.usage:
|
| 189 |
+
total_input_tokens += resp.usage.input_tokens
|
| 190 |
+
total_output_tokens += resp.usage.output_tokens
|
| 191 |
+
|
| 192 |
+
# Stream each block of the assistant's response
|
| 193 |
+
tool_use_blocks: list[Any] = []
|
| 194 |
+
for block in resp.content:
|
| 195 |
+
if block.type == "text":
|
| 196 |
+
yield {"type": "assistant_text", "text": block.text}
|
| 197 |
+
elif block.type == "tool_use":
|
| 198 |
+
yield {
|
| 199 |
+
"type": "tool_use",
|
| 200 |
+
"id": block.id,
|
| 201 |
+
"name": block.name,
|
| 202 |
+
"input": dict(block.input) if block.input else {},
|
| 203 |
+
}
|
| 204 |
+
tool_use_blocks.append(block)
|
| 205 |
+
|
| 206 |
+
# Drain any artifacts queued by the agent_text path (rare; we drain
|
| 207 |
+
# again after tool calls).
|
| 208 |
+
while not artifact_queue.empty():
|
| 209 |
+
yield {"type": "artifact", "artifact": artifact_queue.get_nowait()}
|
| 210 |
+
|
| 211 |
+
if resp.stop_reason == "end_turn" or not tool_use_blocks:
|
| 212 |
+
# Final response — emit a result event and we're done
|
| 213 |
+
duration_ms = int((time.monotonic() - started) * 1000)
|
| 214 |
+
yield {
|
| 215 |
+
"type": "result",
|
| 216 |
+
"subtype": "success",
|
| 217 |
+
"duration_ms": duration_ms,
|
| 218 |
+
"session_id": new_session_id,
|
| 219 |
+
"num_turns": turn + 1,
|
| 220 |
+
"input_tokens": total_input_tokens,
|
| 221 |
+
"output_tokens": total_output_tokens,
|
| 222 |
+
"total_cost_usd": None, # not provided by proxy
|
| 223 |
+
}
|
| 224 |
+
return
|
| 225 |
+
|
| 226 |
+
# Execute tools and append results
|
| 227 |
+
assistant_content_for_history: list[dict] = []
|
| 228 |
+
for block in resp.content:
|
| 229 |
+
if block.type == "text":
|
| 230 |
+
assistant_content_for_history.append(
|
| 231 |
+
{"type": "text", "text": block.text}
|
| 232 |
+
)
|
| 233 |
+
elif block.type == "tool_use":
|
| 234 |
+
assistant_content_for_history.append(
|
| 235 |
+
{
|
| 236 |
+
"type": "tool_use",
|
| 237 |
+
"id": block.id,
|
| 238 |
+
"name": block.name,
|
| 239 |
+
"input": block.input,
|
| 240 |
+
}
|
| 241 |
+
)
|
| 242 |
+
messages.append({"role": "assistant", "content": assistant_content_for_history})
|
| 243 |
+
|
| 244 |
+
tool_results: list[dict] = []
|
| 245 |
+
for tu in tool_use_blocks:
|
| 246 |
+
text, is_error = await _dispatch_tool(tu.name, dict(tu.input) if tu.input else {})
|
| 247 |
+
yield {
|
| 248 |
+
"type": "tool_result",
|
| 249 |
+
"tool_use_id": tu.id,
|
| 250 |
+
"content": [{"type": "text", "text": text}],
|
| 251 |
+
"is_error": is_error,
|
| 252 |
+
}
|
| 253 |
+
# Push any artifacts the tool emitted
|
| 254 |
+
while not artifact_queue.empty():
|
| 255 |
+
yield {"type": "artifact", "artifact": artifact_queue.get_nowait()}
|
| 256 |
+
|
| 257 |
+
tool_results.append(
|
| 258 |
+
{
|
| 259 |
+
"type": "tool_result",
|
| 260 |
+
"tool_use_id": tu.id,
|
| 261 |
+
"content": text,
|
| 262 |
+
"is_error": is_error,
|
| 263 |
+
}
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
messages.append({"role": "user", "content": tool_results})
|
| 267 |
+
|
| 268 |
+
# Hit max_turns
|
| 269 |
+
yield {
|
| 270 |
+
"type": "error",
|
| 271 |
+
"error": f"Agent reached max_turns ({max_turns}) without finishing.",
|
| 272 |
+
}
|
| 273 |
+
finally:
|
| 274 |
+
try:
|
| 275 |
+
await client.close()
|
| 276 |
+
except Exception:
|
| 277 |
+
pass
|
| 278 |
+
tool_store.set_artifact_queue(None)
|
backend/app/agent/orchestrator.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claude Agent SDK orchestration.
|
| 2 |
+
|
| 3 |
+
Builds the in-process MCP server bundle, configures :class:`ClaudeAgentOptions`,
|
| 4 |
+
and runs ``query()`` while normalising the SDK's streaming messages into a
|
| 5 |
+
single, frontend-friendly event schema:
|
| 6 |
+
|
| 7 |
+
{"type": "assistant_text", "text": ...}
|
| 8 |
+
{"type": "thinking", "text": ...}
|
| 9 |
+
{"type": "tool_use", "id": ..., "name": ..., "input": ...}
|
| 10 |
+
{"type": "tool_result", "tool_use_id": ..., "content": ..., "is_error": ...}
|
| 11 |
+
{"type": "artifact", "artifact": {...}} # emitted by tool handlers
|
| 12 |
+
{"type": "result", "subtype": ..., "result": ..., "duration_ms": ...}
|
| 13 |
+
{"type": "error", "error": ...}
|
| 14 |
+
|
| 15 |
+
The artifact channel is how the frontend gets rich payloads (full BayesResult,
|
| 16 |
+
KDE points, statistics objects) without bloating Claude's text stream.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import logging
|
| 23 |
+
import os
|
| 24 |
+
from collections.abc import AsyncIterator
|
| 25 |
+
|
| 26 |
+
from claude_agent_sdk import (
|
| 27 |
+
AssistantMessage,
|
| 28 |
+
ClaudeAgentOptions,
|
| 29 |
+
ResultMessage,
|
| 30 |
+
SystemMessage,
|
| 31 |
+
TextBlock,
|
| 32 |
+
ThinkingBlock,
|
| 33 |
+
ToolResultBlock,
|
| 34 |
+
ToolUseBlock,
|
| 35 |
+
UserMessage,
|
| 36 |
+
create_sdk_mcp_server,
|
| 37 |
+
query,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
from app.agent import tool_store
|
| 41 |
+
from app.agent.prompts import SYSTEM_PROMPT
|
| 42 |
+
from app.agent.tools import ALL_TOOLS
|
| 43 |
+
from app.core.config import get_settings
|
| 44 |
+
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
# Build the in-process MCP server ONCE at import time. The tool handlers are
|
| 48 |
+
# stateless across requests (state lives in tool_store).
|
| 49 |
+
bayes_mcp_server = create_sdk_mcp_server(
|
| 50 |
+
name="bayesscen",
|
| 51 |
+
version="0.1.0",
|
| 52 |
+
tools=ALL_TOOLS,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# Full list of allowed tool ids as Claude sees them (mcp__<server>__<tool>).
|
| 56 |
+
ALLOWED_TOOL_IDS = [f"mcp__bayesscen__{t.name}" for t in ALL_TOOLS]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _serialize_block(block) -> dict | None:
|
| 60 |
+
if isinstance(block, TextBlock):
|
| 61 |
+
return {"type": "assistant_text", "text": block.text}
|
| 62 |
+
if isinstance(block, ThinkingBlock):
|
| 63 |
+
return {"type": "thinking", "text": block.thinking}
|
| 64 |
+
if isinstance(block, ToolUseBlock):
|
| 65 |
+
return {
|
| 66 |
+
"type": "tool_use",
|
| 67 |
+
"id": block.id,
|
| 68 |
+
"name": block.name,
|
| 69 |
+
"input": block.input,
|
| 70 |
+
}
|
| 71 |
+
if isinstance(block, ToolResultBlock):
|
| 72 |
+
return {
|
| 73 |
+
"type": "tool_result",
|
| 74 |
+
"tool_use_id": block.tool_use_id,
|
| 75 |
+
"content": block.content,
|
| 76 |
+
"is_error": getattr(block, "is_error", False) or False,
|
| 77 |
+
}
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def stream_agent(
|
| 82 |
+
prompt: str,
|
| 83 |
+
*,
|
| 84 |
+
session_id: str | None = None,
|
| 85 |
+
mode: str = "standard",
|
| 86 |
+
max_turns: int = 20,
|
| 87 |
+
) -> AsyncIterator[dict]:
|
| 88 |
+
"""Run the agent and yield normalised event dicts.
|
| 89 |
+
|
| 90 |
+
Tool handlers can push side-channel artifacts onto an internal asyncio
|
| 91 |
+
queue; this function interleaves them with the SDK message stream so the
|
| 92 |
+
frontend receives charts in roughly real-time.
|
| 93 |
+
"""
|
| 94 |
+
settings = get_settings()
|
| 95 |
+
|
| 96 |
+
if not settings.has_anthropic_key:
|
| 97 |
+
yield {
|
| 98 |
+
"type": "error",
|
| 99 |
+
"error": (
|
| 100 |
+
"ANTHROPIC_API_KEY is not configured. Please set it in .env "
|
| 101 |
+
"(see .env.example) and restart the backend."
|
| 102 |
+
),
|
| 103 |
+
}
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
# Choose model based on mode
|
| 107 |
+
model = settings.anthropic_deep_model if mode == "deep" else settings.anthropic_model
|
| 108 |
+
|
| 109 |
+
# Propagate auth + optional proxy URL to the underlying claude-code CLI.
|
| 110 |
+
# We use direct assignment (not setdefault) because the SDK spawns a
|
| 111 |
+
# subprocess that inherits os.environ; a stale value from a previous
|
| 112 |
+
# request must be overwritten.
|
| 113 |
+
os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
|
| 114 |
+
real_auth = settings.cleaned_auth_token()
|
| 115 |
+
if real_auth:
|
| 116 |
+
os.environ["ANTHROPIC_AUTH_TOKEN"] = real_auth
|
| 117 |
+
else:
|
| 118 |
+
# Defensively drop any shell-leaked placeholder so the SDK doesn't try
|
| 119 |
+
# to send a non-ASCII Authorization header (e.g. "Bearer 你的API密钥").
|
| 120 |
+
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
|
| 121 |
+
if settings.anthropic_base_url:
|
| 122 |
+
os.environ["ANTHROPIC_BASE_URL"] = settings.anthropic_base_url
|
| 123 |
+
# Some proxies also honour this older variable name
|
| 124 |
+
os.environ["ANTHROPIC_API_BASE"] = settings.anthropic_base_url
|
| 125 |
+
logger.info("Using Claude-compatible proxy: %s", settings.anthropic_base_url)
|
| 126 |
+
|
| 127 |
+
# Bind a per-request artifact queue so tool handlers can broadcast events
|
| 128 |
+
artifact_queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=1024)
|
| 129 |
+
tool_store.set_artifact_queue(artifact_queue)
|
| 130 |
+
|
| 131 |
+
options = ClaudeAgentOptions(
|
| 132 |
+
model=model,
|
| 133 |
+
system_prompt=SYSTEM_PROMPT,
|
| 134 |
+
mcp_servers={"bayesscen": bayes_mcp_server},
|
| 135 |
+
allowed_tools=ALLOWED_TOOL_IDS,
|
| 136 |
+
max_turns=max_turns,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
if session_id:
|
| 140 |
+
options.resume = session_id # type: ignore[attr-defined]
|
| 141 |
+
|
| 142 |
+
yield {
|
| 143 |
+
"type": "agent_started",
|
| 144 |
+
"model": model,
|
| 145 |
+
"mode": mode,
|
| 146 |
+
"tools": [t.name for t in ALL_TOOLS],
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
agent_iter = query(prompt=prompt, options=options).__aiter__()
|
| 151 |
+
|
| 152 |
+
while True:
|
| 153 |
+
# Race: next SDK message vs next artifact
|
| 154 |
+
sdk_task = asyncio.create_task(_anext(agent_iter))
|
| 155 |
+
artifact_task = asyncio.create_task(artifact_queue.get())
|
| 156 |
+
done, pending = await asyncio.wait(
|
| 157 |
+
{sdk_task, artifact_task}, return_when=asyncio.FIRST_COMPLETED
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
if artifact_task in done and sdk_task in pending:
|
| 161 |
+
artifact = artifact_task.result()
|
| 162 |
+
sdk_task.cancel()
|
| 163 |
+
yield {"type": "artifact", "artifact": artifact}
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
# SDK task completed
|
| 167 |
+
artifact_task.cancel()
|
| 168 |
+
try:
|
| 169 |
+
message = sdk_task.result()
|
| 170 |
+
except StopAsyncIteration:
|
| 171 |
+
break
|
| 172 |
+
|
| 173 |
+
for event in _events_from_message(message):
|
| 174 |
+
yield event
|
| 175 |
+
|
| 176 |
+
# Drain any remaining artifacts that arrived between iterations
|
| 177 |
+
while not artifact_queue.empty():
|
| 178 |
+
yield {"type": "artifact", "artifact": artifact_queue.get_nowait()}
|
| 179 |
+
|
| 180 |
+
# Final drain
|
| 181 |
+
while not artifact_queue.empty():
|
| 182 |
+
yield {"type": "artifact", "artifact": artifact_queue.get_nowait()}
|
| 183 |
+
|
| 184 |
+
except asyncio.CancelledError:
|
| 185 |
+
raise
|
| 186 |
+
except Exception as e:
|
| 187 |
+
logger.exception("agent stream failed")
|
| 188 |
+
yield {"type": "error", "error": f"{type(e).__name__}: {e}"}
|
| 189 |
+
finally:
|
| 190 |
+
tool_store.set_artifact_queue(None)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
async def _anext(it):
|
| 194 |
+
return await it.__anext__()
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _events_from_message(message) -> list[dict]:
|
| 198 |
+
"""Convert one SDK message into zero or more frontend events."""
|
| 199 |
+
if isinstance(message, AssistantMessage):
|
| 200 |
+
events = []
|
| 201 |
+
for block in message.content:
|
| 202 |
+
ev = _serialize_block(block)
|
| 203 |
+
if ev is not None:
|
| 204 |
+
events.append(ev)
|
| 205 |
+
return events
|
| 206 |
+
|
| 207 |
+
if isinstance(message, UserMessage):
|
| 208 |
+
# User messages from the SDK perspective often carry tool_result blocks
|
| 209 |
+
events = []
|
| 210 |
+
for block in message.content:
|
| 211 |
+
ev = _serialize_block(block)
|
| 212 |
+
if ev is not None:
|
| 213 |
+
events.append(ev)
|
| 214 |
+
return events
|
| 215 |
+
|
| 216 |
+
if isinstance(message, SystemMessage):
|
| 217 |
+
return [{"type": "system", "subtype": message.subtype, "data": message.data}]
|
| 218 |
+
|
| 219 |
+
if isinstance(message, ResultMessage):
|
| 220 |
+
return [
|
| 221 |
+
{
|
| 222 |
+
"type": "result",
|
| 223 |
+
"subtype": message.subtype,
|
| 224 |
+
"result": getattr(message, "result", None),
|
| 225 |
+
"duration_ms": getattr(message, "duration_ms", None),
|
| 226 |
+
"duration_api_ms": getattr(message, "duration_api_ms", None),
|
| 227 |
+
"total_cost_usd": getattr(message, "total_cost_usd", None),
|
| 228 |
+
"session_id": getattr(message, "session_id", None),
|
| 229 |
+
"num_turns": getattr(message, "num_turns", None),
|
| 230 |
+
}
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
return []
|
backend/app/agent/prompts.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt for the Bayesian Scenario Analyst agent.
|
| 2 |
+
|
| 3 |
+
Bilingual (中英) because Claude reasons well in Chinese and many users will
|
| 4 |
+
phrase the research question in Mandarin. The prompt is intentionally explicit
|
| 5 |
+
about the tool-calling workflow — empirically that's the single biggest lever
|
| 6 |
+
for keeping the Agent on track.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
SYSTEM_PROMPT = """\
|
| 12 |
+
你是 BayesScenParams 的"贝叶斯情景分析师"(Bayesian Scenario Analyst),一个
|
| 13 |
+
专门把定性的情景叙事(如「2050 年欧盟人均 GDP 增长率会很高」)系统化转换为
|
| 14 |
+
定量概率分布的研究 Agent。算法基础是 Kemp-Benedict (2010) 的 5 点离散贝叶斯
|
| 15 |
+
更新方法:
|
| 16 |
+
|
| 17 |
+
P(z | S) = P(S | z) · P(z) / Σ P(S | z_j) · P(z_j)
|
| 18 |
+
|
| 19 |
+
You are the "Bayesian Scenario Analyst" for BayesScenParams. You translate
|
| 20 |
+
qualitative scenario narratives into quantitative posterior distributions using
|
| 21 |
+
the Kemp-Benedict (2010) 5-point discretized Bayesian method.
|
| 22 |
+
|
| 23 |
+
## 工作流(必须严格按顺序)
|
| 24 |
+
|
| 25 |
+
对每一个研究问题,按以下顺序使用工具:
|
| 26 |
+
|
| 27 |
+
1. **理解问题** — 用一句话复述用户的研究问题,识别需要量化的参数(如人均
|
| 28 |
+
GDP 增长率、年均温度变化、人口增长率)。
|
| 29 |
+
|
| 30 |
+
2. **选择数据源** — 调用 `list_data_catalog` 浏览可用数据源,然后选一个最匹配
|
| 31 |
+
的:
|
| 32 |
+
- 若问题涉及"撒哈拉以南非洲 GDP" → 用 `load_sample_dataset(sample_id="gdp")`
|
| 33 |
+
- 若问题涉及具体国家/地区的经济指标 → 用 `fetch_wdi(indicator=..., country=...)`
|
| 34 |
+
- 若问题涉及气候变量(温度、降水)和未来情景 → 用 `fetch_cckp(variable=..., country_iso3=..., scenario=...)`
|
| 35 |
+
|
| 36 |
+
3. **数据质检** — 拿到 `dataset_handle` 后立即调用 `compute_statistics`,
|
| 37 |
+
查看 n / mean / std / skewness / kurtosis。若 skewness > 1 或 kurtosis > 5,
|
| 38 |
+
在文字中简要说明数据可能非正态,但仍继续——Kemp-Benedict 方法对非正态
|
| 39 |
+
数据稳健。
|
| 40 |
+
|
| 41 |
+
4. **构建先验** — 调用 `build_prior(dataset_handle=...)`,得到 5 个分位点
|
| 42 |
+
值和 `prior_handle`。
|
| 43 |
+
|
| 44 |
+
5. **推理 5 级判断** — 这是核心:你必须基于研究问题里的"情景叙事",逐个
|
| 45 |
+
评估每个分位水平(极低 / 低 / 中等 / 高 / 极高)出现的可能性,给每个水平
|
| 46 |
+
分配一个 0-4 的赌注:
|
| 47 |
+
0 = 极不可能 (Very Unlikely) likelihood = R^-2
|
| 48 |
+
1 = 不太可能 (Somewhat Unlikely) likelihood = R^-1
|
| 49 |
+
2 = 难以判断 (Hard to Tell) likelihood = 1
|
| 50 |
+
3 = 比较可能 (Somewhat Likely) likelihood = R
|
| 51 |
+
4 = 极有可能 (Very Likely) likelihood = R^2
|
| 52 |
+
|
| 53 |
+
在文字回应里逐条解释你为什么这么打分(引用情景叙事、参考相关研究或
|
| 54 |
+
领域常识)。**判断必须与情景方向一致**:若用户说"高增长情景",则高
|
| 55 |
+
水平应得 3 或 4,低水平应得 0 或 1,**不能反过来**。
|
| 56 |
+
|
| 57 |
+
6. **选择 R 值** — R 控制判断的"自信度":
|
| 58 |
+
R=2 → 轻微倾向(最强:最弱 = 4:1)
|
| 59 |
+
R=5 → 中等倾向(25:1)
|
| 60 |
+
R=10 → 明确倾向(100:1)—— 默认推荐
|
| 61 |
+
R=20 → 强烈倾向(400:1)
|
| 62 |
+
R=50 → 极强判断(2500:1)—— 仅在有充分历史/理论支撑时使用
|
| 63 |
+
在第一次计算时,**默认用 R=10**,除非用户明确指定。
|
| 64 |
+
|
| 65 |
+
7. **计算后验** — 调用 `compute_posterior(prior_handle=..., judgments=[...],
|
| 66 |
+
R=..., judgment_rationale="...")`。`judgment_rationale` 字段写一句中文总结
|
| 67 |
+
你的判断思路。
|
| 68 |
+
|
| 69 |
+
8. **稳健性检验** — 调用 `run_sensitivity_analysis(prior_handle=...,
|
| 70 |
+
judgments=[...])`,观察后验均值随 R 变化的幅度。若 R∈[5, 20] 时均值变化
|
| 71 |
+
< 10%,说明结论稳健;否则提醒用户对 R 敏感。
|
| 72 |
+
|
| 73 |
+
9. **撰写结论** — 用结构化的中文总结,至少包括:
|
| 74 |
+
- 研究问题的复述
|
| 75 |
+
- 选用的数据和理由
|
| 76 |
+
- 5 级判断及其理由
|
| 77 |
+
- 后验均值、95% 置信区间
|
| 78 |
+
- 稳健性结论
|
| 79 |
+
- 一句"关键发现"
|
| 80 |
+
|
| 81 |
+
## 一些硬性约束
|
| 82 |
+
|
| 83 |
+
- **每次只调用一个工具**,等结果回来后再决定下一步。不要并行调用。
|
| 84 |
+
- **不要重复计算**:拿到一个 handle 后,后续工具用 handle 引用即可,
|
| 85 |
+
不要每次都重新加载数据。
|
| 86 |
+
- **遇到错误立刻报告**:若工具返回 `{"error": ...}`,停下来用一句中文向
|
| 87 |
+
用户解释问题,并提出修正方案。
|
| 88 |
+
- **不要编造数据**:所有数值必须来自工具的返回值。
|
| 89 |
+
- **保持简洁**:中间步骤的文字保持在 2-3 句,把详细分析留到最后的结论部分。
|
| 90 |
+
|
| 91 |
+
## 语言
|
| 92 |
+
|
| 93 |
+
回复用户用**中文**(除非用户用英文提问,则用英文)。工具的参数名保持英文。
|
| 94 |
+
|
| 95 |
+
让我们开始!
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
__all__ = ["SYSTEM_PROMPT"]
|
backend/app/agent/tool_store.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory store for large objects exchanged between tool calls.
|
| 2 |
+
|
| 3 |
+
The Claude Agent doesn't need to see a 364-point dataset rendered in JSON to
|
| 4 |
+
work with it — it just needs an opaque handle. Tools that produce datasets or
|
| 5 |
+
Bayesian results return both a small summary (for Claude's reasoning) and a
|
| 6 |
+
``handle`` string. Subsequent tools accept the handle and look the object up
|
| 7 |
+
in this store.
|
| 8 |
+
|
| 9 |
+
Also serves as the bridge to the SSE endpoint: every BayesResult emitted by
|
| 10 |
+
``compute_posterior`` is broadcast via :func:`emit_artifact` so the frontend
|
| 11 |
+
can render charts in real-time.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import logging
|
| 18 |
+
import uuid
|
| 19 |
+
from contextvars import ContextVar
|
| 20 |
+
from dataclasses import asdict, is_dataclass
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# Map handle -> arbitrary Python object (numpy arrays, BayesResult, etc.)
|
| 26 |
+
_STORE: dict[str, Any] = {}
|
| 27 |
+
|
| 28 |
+
# Per-request artifact queue. The SSE endpoint sets this before calling
|
| 29 |
+
# query(); each tool can push artifact dicts onto it; the SSE loop drains it.
|
| 30 |
+
_artifact_queue: ContextVar[asyncio.Queue[dict] | None] = ContextVar(
|
| 31 |
+
"artifact_queue", default=None
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def put(obj: Any, prefix: str = "obj") -> str:
|
| 36 |
+
handle = f"{prefix}_{uuid.uuid4().hex[:10]}"
|
| 37 |
+
_STORE[handle] = obj
|
| 38 |
+
logger.debug("tool_store.put %s (%s)", handle, type(obj).__name__)
|
| 39 |
+
return handle
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get(handle: str) -> Any:
|
| 43 |
+
if handle not in _STORE:
|
| 44 |
+
raise KeyError(f"handle not found: {handle}")
|
| 45 |
+
return _STORE[handle]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def has(handle: str) -> bool:
|
| 49 |
+
return handle in _STORE
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def clear() -> None:
|
| 53 |
+
_STORE.clear()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def set_artifact_queue(q: asyncio.Queue[dict] | None) -> None:
|
| 57 |
+
_artifact_queue.set(q)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def emit_artifact(artifact: dict) -> None:
|
| 61 |
+
"""Push an artifact (dict, JSON-serialisable) to the current request's
|
| 62 |
+
SSE queue if one is bound. Safe no-op when called outside a request."""
|
| 63 |
+
q = _artifact_queue.get()
|
| 64 |
+
if q is None:
|
| 65 |
+
return
|
| 66 |
+
try:
|
| 67 |
+
q.put_nowait(artifact)
|
| 68 |
+
except asyncio.QueueFull: # pragma: no cover
|
| 69 |
+
logger.warning("artifact queue full; dropping artifact %s", artifact.get("type"))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def dataclass_to_dict(obj: Any) -> Any:
|
| 73 |
+
"""Recursively convert dataclass / list / tuple to plain dict-of-primitives."""
|
| 74 |
+
if is_dataclass(obj) and not isinstance(obj, type):
|
| 75 |
+
return {k: dataclass_to_dict(v) for k, v in asdict(obj).items()}
|
| 76 |
+
if isinstance(obj, (list, tuple)):
|
| 77 |
+
return [dataclass_to_dict(v) for v in obj]
|
| 78 |
+
if isinstance(obj, dict):
|
| 79 |
+
return {k: dataclass_to_dict(v) for k, v in obj.items()}
|
| 80 |
+
return obj
|
backend/app/agent/tools/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-process MCP tools exposed to the Claude Agent."""
|
| 2 |
+
|
| 3 |
+
from app.agent.tools.bayesian_tools import (
|
| 4 |
+
build_prior_tool,
|
| 5 |
+
compute_posterior_tool,
|
| 6 |
+
compute_statistics_tool,
|
| 7 |
+
sensitivity_analysis_tool,
|
| 8 |
+
)
|
| 9 |
+
from app.agent.tools.data_tools import (
|
| 10 |
+
fetch_cckp_tool,
|
| 11 |
+
fetch_wdi_tool,
|
| 12 |
+
list_data_catalog_tool,
|
| 13 |
+
load_sample_dataset_tool,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
ALL_TOOLS = [
|
| 17 |
+
# Data
|
| 18 |
+
list_data_catalog_tool,
|
| 19 |
+
load_sample_dataset_tool,
|
| 20 |
+
fetch_wdi_tool,
|
| 21 |
+
fetch_cckp_tool,
|
| 22 |
+
# Analysis
|
| 23 |
+
compute_statistics_tool,
|
| 24 |
+
# Bayesian core
|
| 25 |
+
build_prior_tool,
|
| 26 |
+
compute_posterior_tool,
|
| 27 |
+
sensitivity_analysis_tool,
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
__all__ = ["ALL_TOOLS"]
|
backend/app/agent/tools/bayesian_tools.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP tools wrapping the Bayesian engine.
|
| 2 |
+
|
| 3 |
+
Each tool accepts a ``dataset_handle`` from one of the data-tools, runs a
|
| 4 |
+
piece of the Kemp-Benedict pipeline, returns a small JSON summary, and emits
|
| 5 |
+
a richer artifact onto the SSE queue so the frontend can render charts in
|
| 6 |
+
real time.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from claude_agent_sdk import tool
|
| 16 |
+
|
| 17 |
+
from app.agent.tool_store import dataclass_to_dict, emit_artifact, get, has, put
|
| 18 |
+
from app.domain.bayesian import (
|
| 19 |
+
JUDGMENT_LABELS_EN,
|
| 20 |
+
JUDGMENT_LABELS_ZH,
|
| 21 |
+
LEVEL_LABELS_EN,
|
| 22 |
+
LEVEL_LABELS_ZH,
|
| 23 |
+
build_prior,
|
| 24 |
+
compute,
|
| 25 |
+
compute_stats,
|
| 26 |
+
kde,
|
| 27 |
+
sensitivity_analysis,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _text(payload: Any) -> dict[str, Any]:
|
| 34 |
+
return {
|
| 35 |
+
"content": [
|
| 36 |
+
{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _err(msg: str) -> dict[str, Any]:
|
| 42 |
+
return {"content": [{"type": "text", "text": json.dumps({"error": msg})}], "is_error": True}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _resolve_data(handle: str) -> list[float] | None:
|
| 46 |
+
if not has(handle):
|
| 47 |
+
return None
|
| 48 |
+
obj = get(handle)
|
| 49 |
+
return list(obj) if isinstance(obj, (list, tuple)) else None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# --------------------------------------------------------------------------- #
|
| 53 |
+
# compute_statistics
|
| 54 |
+
# --------------------------------------------------------------------------- #
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@tool(
|
| 58 |
+
"compute_statistics",
|
| 59 |
+
"Compute descriptive statistics for a dataset referenced by handle. "
|
| 60 |
+
"Returns n, mean, std, variance, skewness, kurtosis, median, min, max. "
|
| 61 |
+
"Use this BEFORE build_prior to sanity-check the data (e.g. flag heavy "
|
| 62 |
+
"skewness, suspicious outliers, or single-mode vs multi-mode shape).",
|
| 63 |
+
{"dataset_handle": str},
|
| 64 |
+
)
|
| 65 |
+
async def compute_statistics_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 66 |
+
handle = args.get("dataset_handle", "")
|
| 67 |
+
data = _resolve_data(handle)
|
| 68 |
+
if data is None:
|
| 69 |
+
return _err(f"unknown dataset_handle: {handle!r}")
|
| 70 |
+
s = compute_stats(data)
|
| 71 |
+
summary = dataclass_to_dict(s)
|
| 72 |
+
emit_artifact({"type": "statistics", "dataset_handle": handle, "stats": summary})
|
| 73 |
+
return _text(summary)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --------------------------------------------------------------------------- #
|
| 77 |
+
# build_prior
|
| 78 |
+
# --------------------------------------------------------------------------- #
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@tool(
|
| 82 |
+
"build_prior",
|
| 83 |
+
"Build the 5-point discretized prior from a reference dataset (handle). "
|
| 84 |
+
"Returns the 5 quantile values (at probs 0.025, 0.150, 0.500, 0.850, 0.975) "
|
| 85 |
+
"and the canonical prior weights [0.05, 0.20, 0.50, 0.20, 0.05]. "
|
| 86 |
+
"Use this AFTER you've inspected the data with compute_statistics. "
|
| 87 |
+
"Also returns prior_handle for use with compute_posterior.",
|
| 88 |
+
{"dataset_handle": str},
|
| 89 |
+
)
|
| 90 |
+
async def build_prior_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 91 |
+
handle = args.get("dataset_handle", "")
|
| 92 |
+
data = _resolve_data(handle)
|
| 93 |
+
if data is None:
|
| 94 |
+
return _err(f"unknown dataset_handle: {handle!r}")
|
| 95 |
+
|
| 96 |
+
stats, quantiles, weights = build_prior(data)
|
| 97 |
+
quantile_values = [q.value for q in quantiles]
|
| 98 |
+
|
| 99 |
+
prior_handle = put(
|
| 100 |
+
{"data": data, "quantile_values": quantile_values, "weights": weights},
|
| 101 |
+
prefix="prior",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
kde_pts = kde(data, n_points=120)
|
| 105 |
+
|
| 106 |
+
summary = {
|
| 107 |
+
"prior_handle": prior_handle,
|
| 108 |
+
"dataset_handle": handle,
|
| 109 |
+
"n": stats.n,
|
| 110 |
+
"mean": stats.mean,
|
| 111 |
+
"std": stats.std,
|
| 112 |
+
"skewness": stats.skewness,
|
| 113 |
+
"kurtosis": stats.kurtosis,
|
| 114 |
+
"quantile_values": quantile_values,
|
| 115 |
+
"quantile_probs": [0.025, 0.15, 0.50, 0.85, 0.975],
|
| 116 |
+
"level_labels_en": list(LEVEL_LABELS_EN),
|
| 117 |
+
"level_labels_zh": list(LEVEL_LABELS_ZH),
|
| 118 |
+
"prior_weights": weights,
|
| 119 |
+
}
|
| 120 |
+
emit_artifact(
|
| 121 |
+
{
|
| 122 |
+
"type": "prior_built",
|
| 123 |
+
"prior_handle": prior_handle,
|
| 124 |
+
"summary": summary,
|
| 125 |
+
"kde": kde_pts,
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
return _text(summary)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# --------------------------------------------------------------------------- #
|
| 132 |
+
# compute_posterior
|
| 133 |
+
# --------------------------------------------------------------------------- #
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@tool(
|
| 137 |
+
"compute_posterior",
|
| 138 |
+
"Compute the Bayesian posterior given a prior_handle (from build_prior), "
|
| 139 |
+
"five expert judgment levels, and a strength factor R. "
|
| 140 |
+
"judgments: list of 5 integers (0-4), one per quantile level, where "
|
| 141 |
+
"0='Very Unlikely', 1='Somewhat Unlikely', 2='Hard to Tell', "
|
| 142 |
+
"3='Somewhat Likely', 4='Very Likely'. "
|
| 143 |
+
"R: judgment strength (> 1), typically 2 (mild), 5 (moderate), 10 (clear), "
|
| 144 |
+
"20 (strong), 50 (very strong). "
|
| 145 |
+
"judgment_rationale: short string explaining the reasoning for each level. "
|
| 146 |
+
"Returns posterior weights, mean, median, std, 95% CI.",
|
| 147 |
+
{"prior_handle": str, "judgments": list, "R": float, "judgment_rationale": str},
|
| 148 |
+
)
|
| 149 |
+
async def compute_posterior_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 150 |
+
prior_handle = args.get("prior_handle", "")
|
| 151 |
+
judgments = args.get("judgments") or []
|
| 152 |
+
R = args.get("R")
|
| 153 |
+
rationale = args.get("judgment_rationale") or ""
|
| 154 |
+
|
| 155 |
+
if not has(prior_handle):
|
| 156 |
+
return _err(f"unknown prior_handle: {prior_handle!r}")
|
| 157 |
+
if not isinstance(judgments, list) or len(judgments) != 5:
|
| 158 |
+
return _err("judgments must be a list of 5 integers (0-4)")
|
| 159 |
+
if R is None:
|
| 160 |
+
return _err("R is required (must be > 1)")
|
| 161 |
+
try:
|
| 162 |
+
R_f = float(R)
|
| 163 |
+
if R_f <= 1.0:
|
| 164 |
+
raise ValueError
|
| 165 |
+
except (TypeError, ValueError):
|
| 166 |
+
return _err("R must be a number > 1")
|
| 167 |
+
try:
|
| 168 |
+
judgments_i = [int(j) for j in judgments]
|
| 169 |
+
if not all(0 <= j <= 4 for j in judgments_i):
|
| 170 |
+
raise ValueError
|
| 171 |
+
except (TypeError, ValueError):
|
| 172 |
+
return _err("each judgment must be an integer in [0, 4]")
|
| 173 |
+
|
| 174 |
+
prior_obj = get(prior_handle)
|
| 175 |
+
data = prior_obj["data"]
|
| 176 |
+
|
| 177 |
+
try:
|
| 178 |
+
result = compute(data, judgments_i, R_f)
|
| 179 |
+
except ValueError as e:
|
| 180 |
+
return _err(str(e))
|
| 181 |
+
|
| 182 |
+
posterior_handle = put(result, prefix="posterior")
|
| 183 |
+
|
| 184 |
+
summary = {
|
| 185 |
+
"posterior_handle": posterior_handle,
|
| 186 |
+
"prior_handle": prior_handle,
|
| 187 |
+
"judgments": judgments_i,
|
| 188 |
+
"judgment_labels_en": [LEVEL_LABELS_EN[i] for i in range(5)],
|
| 189 |
+
"judgment_labels_zh": [LEVEL_LABELS_ZH[i] for i in range(5)],
|
| 190 |
+
"judgment_choices_en": [JUDGMENT_LABELS_EN[j] for j in judgments_i],
|
| 191 |
+
"judgment_choices_zh": [JUDGMENT_LABELS_ZH[j] for j in judgments_i],
|
| 192 |
+
"R": R_f,
|
| 193 |
+
"judgment_rationale": rationale,
|
| 194 |
+
"posterior_weights": result.posterior.weights,
|
| 195 |
+
"posterior_mean": result.posterior.stats.mean,
|
| 196 |
+
"posterior_median": result.posterior.stats.median,
|
| 197 |
+
"posterior_std": result.posterior.stats.std,
|
| 198 |
+
"posterior_ci95": [
|
| 199 |
+
result.posterior.stats.ci95_lower,
|
| 200 |
+
result.posterior.stats.ci95_upper,
|
| 201 |
+
],
|
| 202 |
+
"prior_mean": result.prior.summary_stats.mean,
|
| 203 |
+
"shift_mean": result.posterior.stats.mean - result.prior.summary_stats.mean,
|
| 204 |
+
"shrink_std_pct": (
|
| 205 |
+
(result.prior.summary_stats.std - result.posterior.stats.std)
|
| 206 |
+
/ result.prior.summary_stats.std
|
| 207 |
+
* 100.0
|
| 208 |
+
if result.prior.summary_stats.std > 0
|
| 209 |
+
else 0.0
|
| 210 |
+
),
|
| 211 |
+
}
|
| 212 |
+
emit_artifact(
|
| 213 |
+
{
|
| 214 |
+
"type": "posterior_computed",
|
| 215 |
+
"posterior_handle": posterior_handle,
|
| 216 |
+
"summary": summary,
|
| 217 |
+
"full_result": dataclass_to_dict(result),
|
| 218 |
+
}
|
| 219 |
+
)
|
| 220 |
+
return _text(summary)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# --------------------------------------------------------------------------- #
|
| 224 |
+
# sensitivity_analysis
|
| 225 |
+
# --------------------------------------------------------------------------- #
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@tool(
|
| 229 |
+
"run_sensitivity_analysis",
|
| 230 |
+
"Run sensitivity of the posterior to R-value variation. Given the same "
|
| 231 |
+
"prior_handle and judgments, compute posteriors for a series of R values "
|
| 232 |
+
"(default: [2, 5, 10, 20, 50]). Returns posterior_mean / posterior_std "
|
| 233 |
+
"for each R. Use this to check robustness of the conclusion.",
|
| 234 |
+
{"prior_handle": str, "judgments": list},
|
| 235 |
+
)
|
| 236 |
+
async def sensitivity_analysis_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 237 |
+
prior_handle = args.get("prior_handle", "")
|
| 238 |
+
judgments = args.get("judgments") or []
|
| 239 |
+
r_values = args.get("r_values") or [2.0, 5.0, 10.0, 20.0, 50.0]
|
| 240 |
+
|
| 241 |
+
if not has(prior_handle):
|
| 242 |
+
return _err(f"unknown prior_handle: {prior_handle!r}")
|
| 243 |
+
if not isinstance(judgments, list) or len(judgments) != 5:
|
| 244 |
+
return _err("judgments must be a list of 5 integers (0-4)")
|
| 245 |
+
|
| 246 |
+
prior_obj = get(prior_handle)
|
| 247 |
+
data = prior_obj["data"]
|
| 248 |
+
try:
|
| 249 |
+
judgments_i = [int(j) for j in judgments]
|
| 250 |
+
r_values_f = [float(r) for r in r_values]
|
| 251 |
+
except (TypeError, ValueError):
|
| 252 |
+
return _err("judgments must be ints, r_values numeric")
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
sweep = sensitivity_analysis(data, judgments_i, r_values_f)
|
| 256 |
+
except ValueError as e:
|
| 257 |
+
return _err(str(e))
|
| 258 |
+
|
| 259 |
+
rows = [
|
| 260 |
+
{
|
| 261 |
+
"R": p["R"],
|
| 262 |
+
"posterior_mean": p["result"].posterior.stats.mean,
|
| 263 |
+
"posterior_std": p["result"].posterior.stats.std,
|
| 264 |
+
"posterior_weights": p["result"].posterior.weights,
|
| 265 |
+
"ci95_lower": p["result"].posterior.stats.ci95_lower,
|
| 266 |
+
"ci95_upper": p["result"].posterior.stats.ci95_upper,
|
| 267 |
+
}
|
| 268 |
+
for p in sweep
|
| 269 |
+
]
|
| 270 |
+
|
| 271 |
+
means = [r["posterior_mean"] for r in rows]
|
| 272 |
+
summary = {
|
| 273 |
+
"prior_handle": prior_handle,
|
| 274 |
+
"r_values": r_values_f,
|
| 275 |
+
"judgments": judgments_i,
|
| 276 |
+
"rows": rows,
|
| 277 |
+
"mean_range": [min(means), max(means)],
|
| 278 |
+
"mean_swing_pct": (
|
| 279 |
+
(max(means) - min(means)) / abs(rows[len(rows) // 2]["posterior_mean"]) * 100.0
|
| 280 |
+
if rows[len(rows) // 2]["posterior_mean"] != 0
|
| 281 |
+
else 0.0
|
| 282 |
+
),
|
| 283 |
+
}
|
| 284 |
+
emit_artifact({"type": "sensitivity", "summary": summary})
|
| 285 |
+
return _text(summary)
|
backend/app/agent/tools/data_tools.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP tools that produce reference datasets for the Bayesian pipeline.
|
| 2 |
+
|
| 3 |
+
Each tool stores the full numeric series in :mod:`app.agent.tool_store` keyed
|
| 4 |
+
by an opaque ``dataset_handle`` and returns ONLY a short text summary plus the
|
| 5 |
+
handle. Downstream tools (build_prior, compute_statistics) accept the handle.
|
| 6 |
+
|
| 7 |
+
Tools registered:
|
| 8 |
+
|
| 9 |
+
* ``list_data_catalog`` – enumerate built-in samples + WDI/CCKP catalog
|
| 10 |
+
* ``load_sample_dataset`` – load one of the three bundled JSON samples
|
| 11 |
+
* ``fetch_wdi`` – pull a WDI indicator from the World Bank API
|
| 12 |
+
* ``fetch_cckp`` – pull a CMIP6 variable from the Climate Knowledge Portal
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import logging
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
from claude_agent_sdk import tool
|
| 23 |
+
|
| 24 |
+
from app.agent.tool_store import emit_artifact, put
|
| 25 |
+
from app.core.config import get_settings
|
| 26 |
+
from app.data_sources.worldbank import (
|
| 27 |
+
CCKP_SSP_SCENARIOS,
|
| 28 |
+
CCKP_VARIABLES,
|
| 29 |
+
WDI_INDICATOR_CATALOG,
|
| 30 |
+
CCKPClient,
|
| 31 |
+
WDIClient,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
SAMPLES_DIR = Path(__file__).resolve().parents[2] / "data_sources" / "samples"
|
| 36 |
+
SAMPLE_FILES = {
|
| 37 |
+
"gdp": "sample-gdp.json",
|
| 38 |
+
"climate": "sample-climate.json",
|
| 39 |
+
"population": "sample-population.json",
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _text(payload: Any) -> dict[str, Any]:
|
| 44 |
+
return {
|
| 45 |
+
"content": [
|
| 46 |
+
{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}
|
| 47 |
+
]
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --------------------------------------------------------------------------- #
|
| 52 |
+
# list_data_catalog
|
| 53 |
+
# --------------------------------------------------------------------------- #
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@tool(
|
| 57 |
+
"list_data_catalog",
|
| 58 |
+
"List the data sources available to the analyst. Returns three groups: "
|
| 59 |
+
"(1) built-in sample datasets (id, title, n_points); "
|
| 60 |
+
"(2) selected World Bank WDI economic indicators (id, description); "
|
| 61 |
+
"(3) Climate Knowledge Portal CMIP6 variables and SSP scenarios. "
|
| 62 |
+
"Call this first when the user does not specify a dataset.",
|
| 63 |
+
{},
|
| 64 |
+
)
|
| 65 |
+
async def list_data_catalog_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 66 |
+
samples = []
|
| 67 |
+
for sid, fname in SAMPLE_FILES.items():
|
| 68 |
+
meta = json.loads((SAMPLES_DIR / fname).read_text(encoding="utf-8"))
|
| 69 |
+
samples.append(
|
| 70 |
+
{
|
| 71 |
+
"id": sid,
|
| 72 |
+
"name": meta.get("name", sid),
|
| 73 |
+
"name_en": meta.get("name_en"),
|
| 74 |
+
"period": meta.get("period"),
|
| 75 |
+
"unit": meta.get("unit"),
|
| 76 |
+
"source": meta.get("source"),
|
| 77 |
+
"n_points": len(meta.get("values", [])),
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
return _text(
|
| 81 |
+
{
|
| 82 |
+
"samples": samples,
|
| 83 |
+
"wdi_indicators": WDI_INDICATOR_CATALOG,
|
| 84 |
+
"cckp_variables": CCKP_VARIABLES,
|
| 85 |
+
"cckp_scenarios": list(CCKP_SSP_SCENARIOS),
|
| 86 |
+
"hint": (
|
| 87 |
+
"Use load_sample_dataset for built-in samples, fetch_wdi for "
|
| 88 |
+
"country economic indicators, fetch_cckp for climate scenarios."
|
| 89 |
+
),
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# --------------------------------------------------------------------------- #
|
| 95 |
+
# load_sample_dataset
|
| 96 |
+
# --------------------------------------------------------------------------- #
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@tool(
|
| 100 |
+
"load_sample_dataset",
|
| 101 |
+
"Load one of the three built-in sample datasets and return a dataset handle. "
|
| 102 |
+
"Valid ids: 'gdp' (Sub-Saharan Africa GDP per-capita growth, 1975-2002, 364 pts), "
|
| 103 |
+
"'climate' (East Asia mean temperature change, 1950-2014, 215 pts), "
|
| 104 |
+
"'population' (global population growth, 1961-2023, 130 pts). "
|
| 105 |
+
"Use the returned dataset_handle as input to build_prior / compute_statistics.",
|
| 106 |
+
{"sample_id": str},
|
| 107 |
+
)
|
| 108 |
+
async def load_sample_dataset_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 109 |
+
sid = (args.get("sample_id") or "").strip().lower()
|
| 110 |
+
if sid not in SAMPLE_FILES:
|
| 111 |
+
return _text({"error": f"unknown sample_id {sid!r}; valid: {list(SAMPLE_FILES)}"})
|
| 112 |
+
meta = json.loads((SAMPLES_DIR / SAMPLE_FILES[sid]).read_text(encoding="utf-8"))
|
| 113 |
+
values = list(meta.get("values", []))
|
| 114 |
+
if not values:
|
| 115 |
+
return _text({"error": f"sample {sid} has no values"})
|
| 116 |
+
|
| 117 |
+
handle = put(values, prefix=f"sample_{sid}")
|
| 118 |
+
summary = {
|
| 119 |
+
"dataset_handle": handle,
|
| 120 |
+
"name": meta.get("name"),
|
| 121 |
+
"name_en": meta.get("name_en"),
|
| 122 |
+
"unit": meta.get("unit"),
|
| 123 |
+
"period": meta.get("period"),
|
| 124 |
+
"source": meta.get("source"),
|
| 125 |
+
"description": meta.get("description"),
|
| 126 |
+
"n": len(values),
|
| 127 |
+
"min": min(values),
|
| 128 |
+
"max": max(values),
|
| 129 |
+
"preview_first_10": values[:10],
|
| 130 |
+
"preview_last_10": values[-10:],
|
| 131 |
+
}
|
| 132 |
+
emit_artifact({"type": "dataset_loaded", "summary": summary})
|
| 133 |
+
return _text(summary)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# --------------------------------------------------------------------------- #
|
| 137 |
+
# fetch_wdi
|
| 138 |
+
# --------------------------------------------------------------------------- #
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@tool(
|
| 142 |
+
"fetch_wdi",
|
| 143 |
+
"Fetch a World Bank WDI indicator and return a dataset handle of numeric values. "
|
| 144 |
+
"indicator: WDI code (e.g. 'NY.GDP.PCAP.KD.ZG' for GDP per capita growth). "
|
| 145 |
+
"country: ISO2/ISO3 code (e.g. 'CN', 'USA'), aggregate code (e.g. 'SSA' "
|
| 146 |
+
"for Sub-Saharan Africa, 'WLD' for World), or 'all'. "
|
| 147 |
+
"date_range: 'YYYY:YYYY' (default '1990:2023'). "
|
| 148 |
+
"Use list_data_catalog to see recommended indicator codes.",
|
| 149 |
+
{"indicator": str, "country": str},
|
| 150 |
+
)
|
| 151 |
+
async def fetch_wdi_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 152 |
+
indicator = (args.get("indicator") or "").strip()
|
| 153 |
+
country = (args.get("country") or "").strip()
|
| 154 |
+
date_range = (args.get("date_range") or "1990:2023").strip()
|
| 155 |
+
if not indicator or not country:
|
| 156 |
+
return _text({"error": "indicator and country are required"})
|
| 157 |
+
|
| 158 |
+
settings = get_settings()
|
| 159 |
+
client = WDIClient(settings.data_cache_dir)
|
| 160 |
+
try:
|
| 161 |
+
series = await client.fetch_indicator(indicator, country, date_range)
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.exception("fetch_wdi failed")
|
| 164 |
+
return _text({"error": f"WDI fetch failed: {e}"})
|
| 165 |
+
|
| 166 |
+
if series.n_used == 0:
|
| 167 |
+
return _text(
|
| 168 |
+
{
|
| 169 |
+
"error": "WDI returned no usable numeric values",
|
| 170 |
+
"indicator": indicator,
|
| 171 |
+
"country": country,
|
| 172 |
+
"date_range": date_range,
|
| 173 |
+
}
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
handle = put(series.values, prefix="wdi")
|
| 177 |
+
summary = {
|
| 178 |
+
"dataset_handle": handle,
|
| 179 |
+
"indicator_id": series.indicator_id,
|
| 180 |
+
"indicator_name": series.indicator_name,
|
| 181 |
+
"country": series.country,
|
| 182 |
+
"period": series.period,
|
| 183 |
+
"unit": series.unit,
|
| 184 |
+
"n_total_rows": series.n_total,
|
| 185 |
+
"n_used": series.n_used,
|
| 186 |
+
"min": min(series.values),
|
| 187 |
+
"max": max(series.values),
|
| 188 |
+
"preview_first_10": series.values[:10],
|
| 189 |
+
"source": series.source,
|
| 190 |
+
}
|
| 191 |
+
emit_artifact({"type": "dataset_loaded", "summary": summary})
|
| 192 |
+
return _text(summary)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# --------------------------------------------------------------------------- #
|
| 196 |
+
# fetch_cckp
|
| 197 |
+
# --------------------------------------------------------------------------- #
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@tool(
|
| 201 |
+
"fetch_cckp",
|
| 202 |
+
"Fetch a CMIP6 climate variable from the World Bank Climate Knowledge Portal. "
|
| 203 |
+
"variable: one of 'tas' (mean temp), 'tasmax', 'tasmin', 'pr' (precip), "
|
| 204 |
+
"'rx1day', 'hd35', 'cdd'. "
|
| 205 |
+
"country_iso3: 3-letter ISO country (e.g. 'CHN', 'USA', 'IND'). "
|
| 206 |
+
"scenario: 'ssp126' | 'ssp245' | 'ssp370' | 'ssp585'. "
|
| 207 |
+
"Returns a dataset handle with the annual time series 2015-2100.",
|
| 208 |
+
{"variable": str, "country_iso3": str, "scenario": str},
|
| 209 |
+
)
|
| 210 |
+
async def fetch_cckp_tool(args: dict[str, Any]) -> dict[str, Any]:
|
| 211 |
+
variable = (args.get("variable") or "").strip().lower()
|
| 212 |
+
country = (args.get("country_iso3") or "").strip().upper()
|
| 213 |
+
scenario = (args.get("scenario") or "").strip().lower()
|
| 214 |
+
|
| 215 |
+
settings = get_settings()
|
| 216 |
+
client = CCKPClient(settings.data_cache_dir)
|
| 217 |
+
try:
|
| 218 |
+
s = await client.fetch_variable(variable=variable, country_iso3=country, scenario=scenario)
|
| 219 |
+
except ValueError as e:
|
| 220 |
+
return _text({"error": str(e)})
|
| 221 |
+
except Exception as e:
|
| 222 |
+
logger.exception("fetch_cckp failed")
|
| 223 |
+
return _text({"error": f"CCKP fetch failed: {e}"})
|
| 224 |
+
|
| 225 |
+
if not s.values:
|
| 226 |
+
return _text({"error": "CCKP returned no values"})
|
| 227 |
+
|
| 228 |
+
handle = put(s.values, prefix=f"cckp_{variable}")
|
| 229 |
+
summary = {
|
| 230 |
+
"dataset_handle": handle,
|
| 231 |
+
"variable": s.variable,
|
| 232 |
+
"variable_name": s.variable_name,
|
| 233 |
+
"country": s.country,
|
| 234 |
+
"scenario": s.scenario,
|
| 235 |
+
"period": s.period,
|
| 236 |
+
"unit": s.unit,
|
| 237 |
+
"n": len(s.values),
|
| 238 |
+
"min": min(s.values),
|
| 239 |
+
"max": max(s.values),
|
| 240 |
+
"preview_first_10": s.values[:10],
|
| 241 |
+
"preview_last_10": s.values[-10:],
|
| 242 |
+
"source": s.source,
|
| 243 |
+
}
|
| 244 |
+
emit_artifact({"type": "dataset_loaded", "summary": summary})
|
| 245 |
+
return _text(summary)
|
backend/app/api/__init__.py
ADDED
|
File without changes
|
backend/app/api/agent.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SSE streaming endpoint for the Agent.
|
| 2 |
+
|
| 3 |
+
Frontend ``POST /api/agent/chat`` with JSON body, gets back a Server-Sent
|
| 4 |
+
Events stream where each event is one of:
|
| 5 |
+
|
| 6 |
+
event: agent_started | data: {model, mode, tools}
|
| 7 |
+
event: assistant_text | data: {text}
|
| 8 |
+
event: thinking | data: {text}
|
| 9 |
+
event: tool_use | data: {id, name, input}
|
| 10 |
+
event: tool_result | data: {tool_use_id, content, is_error}
|
| 11 |
+
event: artifact | data: {artifact: {...}}
|
| 12 |
+
event: result | data: {session_id, total_cost_usd, duration_ms, ...}
|
| 13 |
+
event: error | data: {error}
|
| 14 |
+
event: done | data: {}
|
| 15 |
+
|
| 16 |
+
The frontend can resume a session by passing back ``session_id`` from the
|
| 17 |
+
``result`` event in the next request.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
from collections.abc import AsyncIterator
|
| 25 |
+
|
| 26 |
+
from fastapi import APIRouter
|
| 27 |
+
from fastapi.responses import StreamingResponse
|
| 28 |
+
|
| 29 |
+
from app.agent.direct_orchestrator import stream_agent_direct
|
| 30 |
+
from app.agent.orchestrator import stream_agent as stream_agent_sdk
|
| 31 |
+
from app.api.schemas import AgentChatRequest
|
| 32 |
+
from app.core.config import get_settings
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _sse_format(event: str, payload: dict) -> bytes:
|
| 40 |
+
"""Encode one SSE event. Multi-line data is fine — we serialise the whole
|
| 41 |
+
payload as a single JSON string on one data: line for easy client parsing."""
|
| 42 |
+
data = json.dumps(payload, ensure_ascii=False, default=str)
|
| 43 |
+
return f"event: {event}\ndata: {data}\n\n".encode()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def _event_stream(req: AgentChatRequest) -> AsyncIterator[bytes]:
|
| 47 |
+
runtime = get_settings().agent_runtime.lower()
|
| 48 |
+
streamer = stream_agent_sdk if runtime == "sdk" else stream_agent_direct
|
| 49 |
+
try:
|
| 50 |
+
async for ev in streamer(
|
| 51 |
+
prompt=req.prompt,
|
| 52 |
+
session_id=req.session_id,
|
| 53 |
+
mode=req.mode,
|
| 54 |
+
):
|
| 55 |
+
event_name = ev.get("type", "message")
|
| 56 |
+
yield _sse_format(event_name, ev)
|
| 57 |
+
except Exception as e:
|
| 58 |
+
logger.exception("agent stream crashed")
|
| 59 |
+
yield _sse_format("error", {"error": f"{type(e).__name__}: {e}"})
|
| 60 |
+
finally:
|
| 61 |
+
yield _sse_format("done", {})
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.post("/chat")
|
| 65 |
+
async def chat(req: AgentChatRequest) -> StreamingResponse:
|
| 66 |
+
return StreamingResponse(
|
| 67 |
+
_event_stream(req),
|
| 68 |
+
media_type="text/event-stream",
|
| 69 |
+
headers={
|
| 70 |
+
"Cache-Control": "no-cache, no-transform",
|
| 71 |
+
"Connection": "keep-alive",
|
| 72 |
+
"X-Accel-Buffering": "no", # disable nginx buffering
|
| 73 |
+
},
|
| 74 |
+
)
|
backend/app/api/bayesian.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Direct (non-agent) Bayesian compute endpoints.
|
| 2 |
+
|
| 3 |
+
These are useful both as a fallback (when no API key is configured) and as a
|
| 4 |
+
fast path the frontend can call directly when the user is hand-driving the
|
| 5 |
+
workbench instead of talking to the Agent.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException
|
| 11 |
+
|
| 12 |
+
from app.api.schemas import (
|
| 13 |
+
BayesComputeRequest,
|
| 14 |
+
BayesResultModel,
|
| 15 |
+
SensitivityPointModel,
|
| 16 |
+
SensitivityRequest,
|
| 17 |
+
SensitivityResponse,
|
| 18 |
+
bayes_result_to_model,
|
| 19 |
+
)
|
| 20 |
+
from app.domain.bayesian import compute, kde, sensitivity_analysis
|
| 21 |
+
from app.domain.report import render_markdown_report
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api/bayesian", tags=["bayesian"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.post("/compute", response_model=BayesResultModel)
|
| 27 |
+
async def post_compute(req: BayesComputeRequest) -> BayesResultModel:
|
| 28 |
+
try:
|
| 29 |
+
result = compute(req.data, req.judgments, req.R)
|
| 30 |
+
except ValueError as e:
|
| 31 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 32 |
+
return bayes_result_to_model(result)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/sensitivity", response_model=SensitivityResponse)
|
| 36 |
+
async def post_sensitivity(req: SensitivityRequest) -> SensitivityResponse:
|
| 37 |
+
try:
|
| 38 |
+
points = sensitivity_analysis(req.data, req.judgments, req.r_values)
|
| 39 |
+
except ValueError as e:
|
| 40 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 41 |
+
return SensitivityResponse(
|
| 42 |
+
points=[
|
| 43 |
+
SensitivityPointModel(R=p["R"], result=bayes_result_to_model(p["result"]))
|
| 44 |
+
for p in points
|
| 45 |
+
]
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/kde")
|
| 50 |
+
async def post_kde(req: BayesComputeRequest) -> dict:
|
| 51 |
+
"""Return the KDE curve for the dataset (200 points). Used by the
|
| 52 |
+
PriorPosterior / KDE chart on the frontend."""
|
| 53 |
+
try:
|
| 54 |
+
pts = kde(req.data, n_points=200)
|
| 55 |
+
except ValueError as e:
|
| 56 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 57 |
+
return {"points": pts}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/report")
|
| 61 |
+
async def post_report(req: BayesComputeRequest) -> dict:
|
| 62 |
+
"""Compute + render a structured Markdown report in one call."""
|
| 63 |
+
try:
|
| 64 |
+
result = compute(req.data, req.judgments, req.R)
|
| 65 |
+
sweep = sensitivity_analysis(req.data, req.judgments, [2.0, 5.0, 10.0, 20.0, 50.0])
|
| 66 |
+
except ValueError as e:
|
| 67 |
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
| 68 |
+
sens_rows = [
|
| 69 |
+
{
|
| 70 |
+
"R": p["R"],
|
| 71 |
+
"posterior_mean": p["result"].posterior.stats.mean,
|
| 72 |
+
"posterior_std": p["result"].posterior.stats.std,
|
| 73 |
+
"ci95_lower": p["result"].posterior.stats.ci95_lower,
|
| 74 |
+
"ci95_upper": p["result"].posterior.stats.ci95_upper,
|
| 75 |
+
}
|
| 76 |
+
for p in sweep
|
| 77 |
+
]
|
| 78 |
+
md = render_markdown_report(
|
| 79 |
+
result,
|
| 80 |
+
scenario_name=req.scenario_name or "未命名情景",
|
| 81 |
+
reference_case=req.reference_case or "",
|
| 82 |
+
sensitivity_rows=sens_rows,
|
| 83 |
+
)
|
| 84 |
+
return {"markdown": md, "result": bayes_result_to_model(result).model_dump()}
|
backend/app/api/data.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data import / sample dataset endpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import csv
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
| 11 |
+
|
| 12 |
+
from app.api.schemas import SampleDatasetModel, SampleInfoModel
|
| 13 |
+
|
| 14 |
+
SAMPLES_DIR = Path(__file__).resolve().parents[1] / "data_sources" / "samples"
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/data", tags=["data"])
|
| 17 |
+
|
| 18 |
+
SAMPLE_REGISTRY = {
|
| 19 |
+
"gdp": {"file": "sample-gdp.json", "icon": "📊"},
|
| 20 |
+
"climate": {"file": "sample-climate.json", "icon": "🌡️"},
|
| 21 |
+
"population": {"file": "sample-population.json", "icon": "👥"},
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _load(name: str) -> dict:
|
| 26 |
+
path = SAMPLES_DIR / SAMPLE_REGISTRY[name]["file"]
|
| 27 |
+
if not path.exists():
|
| 28 |
+
raise HTTPException(status_code=404, detail=f"sample {name} not found")
|
| 29 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/samples", response_model=list[SampleInfoModel])
|
| 33 |
+
async def list_samples() -> list[SampleInfoModel]:
|
| 34 |
+
out: list[SampleInfoModel] = []
|
| 35 |
+
for sid, meta in SAMPLE_REGISTRY.items():
|
| 36 |
+
if sid not in SAMPLE_REGISTRY:
|
| 37 |
+
continue
|
| 38 |
+
data = _load(sid)
|
| 39 |
+
out.append(
|
| 40 |
+
SampleInfoModel(
|
| 41 |
+
id=sid,
|
| 42 |
+
name=data.get("name", sid),
|
| 43 |
+
name_en=data.get("name_en"),
|
| 44 |
+
source=data.get("source", ""),
|
| 45 |
+
unit=data.get("unit", ""),
|
| 46 |
+
period=data.get("period", ""),
|
| 47 |
+
description=data.get("description", ""),
|
| 48 |
+
n=len(data.get("values", [])),
|
| 49 |
+
icon=meta["icon"],
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
return out
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.get("/samples/{sid}", response_model=SampleDatasetModel)
|
| 56 |
+
async def get_sample(sid: str) -> SampleDatasetModel:
|
| 57 |
+
if sid not in SAMPLE_REGISTRY:
|
| 58 |
+
raise HTTPException(status_code=404, detail=f"unknown sample id {sid}")
|
| 59 |
+
data = _load(sid)
|
| 60 |
+
return SampleDatasetModel(
|
| 61 |
+
id=sid,
|
| 62 |
+
name=data.get("name", sid),
|
| 63 |
+
name_en=data.get("name_en"),
|
| 64 |
+
source=data.get("source", ""),
|
| 65 |
+
unit=data.get("unit", ""),
|
| 66 |
+
period=data.get("period", ""),
|
| 67 |
+
description=data.get("description", ""),
|
| 68 |
+
n=len(data.get("values", [])),
|
| 69 |
+
icon=SAMPLE_REGISTRY[sid]["icon"],
|
| 70 |
+
values=list(data.get("values", [])),
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.post("/upload")
|
| 75 |
+
async def upload_csv(file: UploadFile = File(...)) -> dict:
|
| 76 |
+
"""Accept a CSV upload, parse the first numeric column, return values + metadata.
|
| 77 |
+
|
| 78 |
+
Designed for the data-import workflow; the frontend renders a preview and
|
| 79 |
+
feeds the values back into /bayesian/compute.
|
| 80 |
+
"""
|
| 81 |
+
if file.content_type and "csv" not in file.content_type and not file.filename.endswith(".csv"):
|
| 82 |
+
raise HTTPException(status_code=415, detail="please upload a CSV file")
|
| 83 |
+
raw = await file.read()
|
| 84 |
+
try:
|
| 85 |
+
text = raw.decode("utf-8-sig")
|
| 86 |
+
except UnicodeDecodeError:
|
| 87 |
+
text = raw.decode("latin-1")
|
| 88 |
+
|
| 89 |
+
reader = csv.reader(io.StringIO(text))
|
| 90 |
+
rows = list(reader)
|
| 91 |
+
if not rows:
|
| 92 |
+
raise HTTPException(status_code=400, detail="empty CSV")
|
| 93 |
+
|
| 94 |
+
# Detect header
|
| 95 |
+
header = rows[0]
|
| 96 |
+
has_header = any(not _is_number(c) for c in header)
|
| 97 |
+
data_rows = rows[1:] if has_header else rows
|
| 98 |
+
|
| 99 |
+
# Find first column with at least 50% numeric values
|
| 100 |
+
n_cols = max((len(r) for r in data_rows), default=0)
|
| 101 |
+
chosen_col = 0
|
| 102 |
+
best_score = -1.0
|
| 103 |
+
for col in range(n_cols):
|
| 104 |
+
nums = sum(1 for r in data_rows if col < len(r) and _is_number(r[col]))
|
| 105 |
+
score = nums / max(len(data_rows), 1)
|
| 106 |
+
if score > best_score:
|
| 107 |
+
best_score = score
|
| 108 |
+
chosen_col = col
|
| 109 |
+
|
| 110 |
+
values: list[float] = []
|
| 111 |
+
skipped = 0
|
| 112 |
+
for r in data_rows:
|
| 113 |
+
if chosen_col >= len(r):
|
| 114 |
+
skipped += 1
|
| 115 |
+
continue
|
| 116 |
+
cell = r[chosen_col].strip()
|
| 117 |
+
if not cell:
|
| 118 |
+
skipped += 1
|
| 119 |
+
continue
|
| 120 |
+
try:
|
| 121 |
+
values.append(float(cell))
|
| 122 |
+
except ValueError:
|
| 123 |
+
skipped += 1
|
| 124 |
+
|
| 125 |
+
if len(values) < 2:
|
| 126 |
+
raise HTTPException(
|
| 127 |
+
status_code=400,
|
| 128 |
+
detail=f"could not parse at least 2 numeric values (got {len(values)})",
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
return {
|
| 132 |
+
"filename": file.filename,
|
| 133 |
+
"values": values,
|
| 134 |
+
"n_total_rows": len(data_rows),
|
| 135 |
+
"n_used": len(values),
|
| 136 |
+
"n_skipped": skipped,
|
| 137 |
+
"chosen_column_index": chosen_col,
|
| 138 |
+
"chosen_column_label": header[chosen_col]
|
| 139 |
+
if has_header and chosen_col < len(header)
|
| 140 |
+
else f"column_{chosen_col}",
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _is_number(s: str) -> bool:
|
| 145 |
+
try:
|
| 146 |
+
float(s.strip())
|
| 147 |
+
return True
|
| 148 |
+
except (ValueError, AttributeError):
|
| 149 |
+
return False
|
backend/app/api/health.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health-check + introspection endpoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
from app.core.config import get_settings
|
| 8 |
+
|
| 9 |
+
router = APIRouter(tags=["health"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/api/health")
|
| 13 |
+
async def health() -> dict:
|
| 14 |
+
s = get_settings()
|
| 15 |
+
return {
|
| 16 |
+
"status": "ok",
|
| 17 |
+
"service": "bayesscenparams-backend",
|
| 18 |
+
"version": "0.1.0",
|
| 19 |
+
"anthropic_key_present": s.has_anthropic_key,
|
| 20 |
+
"anthropic_model": s.anthropic_model,
|
| 21 |
+
"anthropic_base_url": s.anthropic_base_url or None,
|
| 22 |
+
"using_proxy": bool(s.anthropic_base_url),
|
| 23 |
+
"agent_runtime": s.agent_runtime,
|
| 24 |
+
}
|
backend/app/api/schemas.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request / response schemas used by the REST + SSE routes.
|
| 2 |
+
|
| 3 |
+
Kept here (one file) so the frontend can codegen TS types easily later.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field, confloat, conint
|
| 11 |
+
|
| 12 |
+
from app.domain.bayesian import (
|
| 13 |
+
BayesResult,
|
| 14 |
+
DistributionStats,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
# --------------------------------------------------------------------------- #
|
| 18 |
+
# Request schemas
|
| 19 |
+
# --------------------------------------------------------------------------- #
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class BayesComputeRequest(BaseModel):
|
| 23 |
+
data: list[confloat(allow_inf_nan=False)] = Field(
|
| 24 |
+
..., min_length=2, description="Reference dataset values (must be finite)."
|
| 25 |
+
)
|
| 26 |
+
judgments: list[conint(ge=0, le=4)] = Field(
|
| 27 |
+
..., min_length=5, max_length=5, description="Five expert wagers (0-4)."
|
| 28 |
+
)
|
| 29 |
+
R: confloat(gt=1.0) = Field(10.0, description="Judgment strength factor R > 1.")
|
| 30 |
+
scenario_name: str | None = Field(None, max_length=200)
|
| 31 |
+
reference_case: str | None = Field(None, max_length=2000)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class SensitivityRequest(BaseModel):
|
| 35 |
+
data: list[confloat(allow_inf_nan=False)] = Field(..., min_length=2)
|
| 36 |
+
judgments: list[conint(ge=0, le=4)] = Field(..., min_length=5, max_length=5)
|
| 37 |
+
r_values: list[confloat(gt=1.0)] = Field(
|
| 38 |
+
default_factory=lambda: [2.0, 5.0, 10.0, 20.0, 50.0]
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AgentChatRequest(BaseModel):
|
| 43 |
+
prompt: str = Field(..., min_length=1, max_length=8000)
|
| 44 |
+
session_id: str | None = None
|
| 45 |
+
mode: str = Field(default="standard", pattern="^(standard|deep)$")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# --------------------------------------------------------------------------- #
|
| 49 |
+
# Response schemas (mirror the dataclasses from domain.bayesian)
|
| 50 |
+
# --------------------------------------------------------------------------- #
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class DataStatsModel(BaseModel):
|
| 54 |
+
n: int
|
| 55 |
+
mean: float
|
| 56 |
+
std: float
|
| 57 |
+
variance: float
|
| 58 |
+
skewness: float
|
| 59 |
+
kurtosis: float
|
| 60 |
+
median: float
|
| 61 |
+
min: float
|
| 62 |
+
max: float
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class QuantilePointModel(BaseModel):
|
| 66 |
+
probability: float
|
| 67 |
+
value: float
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class DistributionStatsModel(BaseModel):
|
| 71 |
+
mean: float
|
| 72 |
+
median: float
|
| 73 |
+
std: float
|
| 74 |
+
variance: float
|
| 75 |
+
ci95_lower: float
|
| 76 |
+
ci95_upper: float
|
| 77 |
+
ci50_lower: float
|
| 78 |
+
ci50_upper: float
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class PriorBlockModel(BaseModel):
|
| 82 |
+
weights: list[float]
|
| 83 |
+
stats: DataStatsModel
|
| 84 |
+
quantiles: list[QuantilePointModel]
|
| 85 |
+
quantile_values: list[float]
|
| 86 |
+
summary_stats: DistributionStatsModel
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class LikelihoodBlockModel(BaseModel):
|
| 90 |
+
weights: list[float]
|
| 91 |
+
judgments: list[int]
|
| 92 |
+
R: float
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class PosteriorBlockModel(BaseModel):
|
| 96 |
+
weights: list[float]
|
| 97 |
+
unnormalized: list[float]
|
| 98 |
+
normalization_constant: float
|
| 99 |
+
stats: DistributionStatsModel
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class BayesResultModel(BaseModel):
|
| 103 |
+
prior: PriorBlockModel
|
| 104 |
+
likelihood: LikelihoodBlockModel
|
| 105 |
+
posterior: PosteriorBlockModel
|
| 106 |
+
level_labels_zh: list[str]
|
| 107 |
+
level_labels_en: list[str]
|
| 108 |
+
judgment_labels_zh: list[str]
|
| 109 |
+
judgment_labels_en: list[str]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class SensitivityPointModel(BaseModel):
|
| 113 |
+
R: float
|
| 114 |
+
result: BayesResultModel
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class SensitivityResponse(BaseModel):
|
| 118 |
+
points: list[SensitivityPointModel]
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class SampleInfoModel(BaseModel):
|
| 122 |
+
id: str
|
| 123 |
+
name: str
|
| 124 |
+
name_en: str | None = None
|
| 125 |
+
source: str
|
| 126 |
+
unit: str
|
| 127 |
+
period: str
|
| 128 |
+
description: str
|
| 129 |
+
n: int
|
| 130 |
+
icon: str | None = None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class SampleDatasetModel(SampleInfoModel):
|
| 134 |
+
values: list[float]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# --------------------------------------------------------------------------- #
|
| 138 |
+
# Dataclass -> Pydantic adapters
|
| 139 |
+
# --------------------------------------------------------------------------- #
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _stats(s: Any) -> DataStatsModel:
|
| 143 |
+
return DataStatsModel(**s.__dict__)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _dist(s: DistributionStats) -> DistributionStatsModel:
|
| 147 |
+
return DistributionStatsModel(**s.__dict__)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def bayes_result_to_model(r: BayesResult) -> BayesResultModel:
|
| 151 |
+
return BayesResultModel(
|
| 152 |
+
prior=PriorBlockModel(
|
| 153 |
+
weights=r.prior.weights,
|
| 154 |
+
stats=_stats(r.prior.stats),
|
| 155 |
+
quantiles=[QuantilePointModel(**q.__dict__) for q in r.prior.quantiles],
|
| 156 |
+
quantile_values=r.prior.quantile_values,
|
| 157 |
+
summary_stats=_dist(r.prior.summary_stats),
|
| 158 |
+
),
|
| 159 |
+
likelihood=LikelihoodBlockModel(
|
| 160 |
+
weights=r.likelihood.weights,
|
| 161 |
+
judgments=r.likelihood.judgments,
|
| 162 |
+
R=r.likelihood.R,
|
| 163 |
+
),
|
| 164 |
+
posterior=PosteriorBlockModel(
|
| 165 |
+
weights=r.posterior.weights,
|
| 166 |
+
unnormalized=r.posterior.unnormalized,
|
| 167 |
+
normalization_constant=r.posterior.normalization_constant,
|
| 168 |
+
stats=_dist(r.posterior.stats),
|
| 169 |
+
),
|
| 170 |
+
level_labels_zh=list(r.level_labels_zh),
|
| 171 |
+
level_labels_en=list(r.level_labels_en),
|
| 172 |
+
judgment_labels_zh=list(r.judgment_labels_zh),
|
| 173 |
+
judgment_labels_en=list(r.judgment_labels_en),
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
__all__ = [
|
| 178 |
+
"AgentChatRequest",
|
| 179 |
+
"BayesComputeRequest",
|
| 180 |
+
"BayesResultModel",
|
| 181 |
+
"DataStatsModel",
|
| 182 |
+
"DistributionStatsModel",
|
| 183 |
+
"LikelihoodBlockModel",
|
| 184 |
+
"PosteriorBlockModel",
|
| 185 |
+
"PriorBlockModel",
|
| 186 |
+
"QuantilePointModel",
|
| 187 |
+
"SampleDatasetModel",
|
| 188 |
+
"SampleInfoModel",
|
| 189 |
+
"SensitivityPointModel",
|
| 190 |
+
"SensitivityRequest",
|
| 191 |
+
"SensitivityResponse",
|
| 192 |
+
"bayes_result_to_model",
|
| 193 |
+
]
|
backend/app/api/sessions.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Session + history REST endpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
from app.core import db
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SessionInfo(BaseModel):
|
| 14 |
+
id: str
|
| 15 |
+
created_at: str
|
| 16 |
+
last_used_at: str
|
| 17 |
+
label: str | None = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class HistoryItem(BaseModel):
|
| 21 |
+
id: int
|
| 22 |
+
kind: str
|
| 23 |
+
payload: dict
|
| 24 |
+
created_at: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CreateSessionRequest(BaseModel):
|
| 28 |
+
label: str | None = None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("", response_model=SessionInfo)
|
| 32 |
+
async def create_session(req: CreateSessionRequest) -> SessionInfo:
|
| 33 |
+
db.init_db()
|
| 34 |
+
sid = db.create_session(label=req.label)
|
| 35 |
+
s = db.get_session(sid)
|
| 36 |
+
if not s:
|
| 37 |
+
raise HTTPException(500, "failed to create session")
|
| 38 |
+
return SessionInfo(**s)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.get("", response_model=list[SessionInfo])
|
| 42 |
+
async def list_sessions() -> list[SessionInfo]:
|
| 43 |
+
db.init_db()
|
| 44 |
+
return [SessionInfo(**s) for s in db.list_sessions()]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/{sid}/history", response_model=list[HistoryItem])
|
| 48 |
+
async def get_history(sid: str) -> list[HistoryItem]:
|
| 49 |
+
db.init_db()
|
| 50 |
+
if not db.get_session(sid):
|
| 51 |
+
raise HTTPException(404, "session not found")
|
| 52 |
+
rows = db.list_history(sid)
|
| 53 |
+
return [HistoryItem(**r) for r in rows]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.delete("/{sid}")
|
| 57 |
+
async def delete_session(sid: str) -> dict:
|
| 58 |
+
db.init_db()
|
| 59 |
+
if not db.delete_session(sid):
|
| 60 |
+
raise HTTPException(404, "session not found")
|
| 61 |
+
return {"ok": True}
|
backend/app/core/__init__.py
ADDED
|
File without changes
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration loaded from environment / .env via pydantic-settings.
|
| 2 |
+
|
| 3 |
+
The .env file in the project root is the source of truth — values there take
|
| 4 |
+
precedence over shell env vars. This protects us from polluted shell config
|
| 5 |
+
(e.g. ``export ANTHROPIC_AUTH_TOKEN="你的API密钥"`` left over from a previous
|
| 6 |
+
proxy setup) accidentally leaking into the process.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from pydantic import Field
|
| 16 |
+
from pydantic_settings import (
|
| 17 |
+
BaseSettings,
|
| 18 |
+
DotEnvSettingsSource,
|
| 19 |
+
EnvSettingsSource,
|
| 20 |
+
PydanticBaseSettingsSource,
|
| 21 |
+
SettingsConfigDict,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
| 25 |
+
PROJECT_DIR = BACKEND_DIR.parent # bayesscenparams-agent/
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class Settings(BaseSettings):
|
| 29 |
+
model_config = SettingsConfigDict(
|
| 30 |
+
env_file=[PROJECT_DIR / ".env", BACKEND_DIR / ".env"],
|
| 31 |
+
env_file_encoding="utf-8",
|
| 32 |
+
extra="ignore",
|
| 33 |
+
case_sensitive=False,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
@classmethod
|
| 37 |
+
def settings_customise_sources(
|
| 38 |
+
cls,
|
| 39 |
+
settings_cls: type[BaseSettings],
|
| 40 |
+
init_settings: PydanticBaseSettingsSource,
|
| 41 |
+
env_settings: EnvSettingsSource,
|
| 42 |
+
dotenv_settings: DotEnvSettingsSource,
|
| 43 |
+
file_secret_settings: PydanticBaseSettingsSource,
|
| 44 |
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
| 45 |
+
# Default order is: init > env > dotenv > secrets.
|
| 46 |
+
# We swap env and dotenv so the project .env wins over shell env.
|
| 47 |
+
return (init_settings, dotenv_settings, env_settings, file_secret_settings)
|
| 48 |
+
|
| 49 |
+
# Anthropic
|
| 50 |
+
anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY")
|
| 51 |
+
# Optional: third-party Claude-compatible proxy base URL (e.g. zhihuiapi,
|
| 52 |
+
# OneAPI, NewAPI, AnyAPI). Leave empty to use official Anthropic API.
|
| 53 |
+
anthropic_base_url: str = Field(default="", alias="ANTHROPIC_BASE_URL")
|
| 54 |
+
# Optional: separate auth token header used by some proxies (defaults to
|
| 55 |
+
# ``anthropic_api_key`` if not provided).
|
| 56 |
+
anthropic_auth_token: str = Field(default="", alias="ANTHROPIC_AUTH_TOKEN")
|
| 57 |
+
anthropic_model: str = Field(default="claude-sonnet-4-5", alias="ANTHROPIC_MODEL")
|
| 58 |
+
anthropic_deep_model: str = Field(
|
| 59 |
+
default="claude-opus-4-7", alias="ANTHROPIC_DEEP_MODEL"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Agent runtime: "direct" (use anthropic SDK via Messages API — works
|
| 63 |
+
# with official API and all Claude-compatible reverse proxies) or "sdk"
|
| 64 |
+
# (use claude-agent-sdk — only works with the official Anthropic API).
|
| 65 |
+
# Default is "direct" because it's universally compatible.
|
| 66 |
+
agent_runtime: str = Field(default="direct", alias="AGENT_RUNTIME")
|
| 67 |
+
|
| 68 |
+
# HTTP
|
| 69 |
+
backend_host: str = Field(default="0.0.0.0", alias="BACKEND_HOST")
|
| 70 |
+
backend_port: int = Field(default=8000, alias="BACKEND_PORT")
|
| 71 |
+
cors_origins: str = Field(
|
| 72 |
+
default="http://localhost:5173,http://127.0.0.1:5173",
|
| 73 |
+
alias="CORS_ORIGINS",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Storage
|
| 77 |
+
database_url: str = Field(
|
| 78 |
+
default=f"sqlite:///{BACKEND_DIR / 'data' / 'bayesscen.db'}",
|
| 79 |
+
alias="DATABASE_URL",
|
| 80 |
+
)
|
| 81 |
+
data_cache_dir: Path = Field(
|
| 82 |
+
default=BACKEND_DIR / "data" / "cache", alias="DATA_CACHE_DIR"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def cors_origin_list(self) -> list[str]:
|
| 87 |
+
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
| 88 |
+
|
| 89 |
+
@property
|
| 90 |
+
def has_anthropic_key(self) -> bool:
|
| 91 |
+
key = self.anthropic_api_key or ""
|
| 92 |
+
if not key or key.startswith("sk-ant-xxxx"):
|
| 93 |
+
return False
|
| 94 |
+
# Common Chinese placeholder leftovers from copy-pasted tutorials
|
| 95 |
+
bad_placeholders = ("你的API密钥", "你的api密钥", "your_api_key", "xxxxxxxxxx")
|
| 96 |
+
return not any(p in key for p in bad_placeholders)
|
| 97 |
+
|
| 98 |
+
def _ignored_placeholders(self) -> list[str]:
|
| 99 |
+
return ["你的API密钥", "你的api密钥", "your_api_key", "your-api-key"]
|
| 100 |
+
|
| 101 |
+
def cleaned_auth_token(self) -> str:
|
| 102 |
+
"""Return auth_token only if it isn't an obvious placeholder."""
|
| 103 |
+
v = (self.anthropic_auth_token or "").strip()
|
| 104 |
+
if not v:
|
| 105 |
+
return ""
|
| 106 |
+
if any(p in v for p in self._ignored_placeholders()):
|
| 107 |
+
return ""
|
| 108 |
+
return v
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@lru_cache(maxsize=1)
|
| 112 |
+
def get_settings() -> Settings:
|
| 113 |
+
s = Settings()
|
| 114 |
+
s.data_cache_dir.mkdir(parents=True, exist_ok=True)
|
| 115 |
+
return s
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# Re-export so other modules can use the helper type
|
| 119 |
+
__all__ = ["BACKEND_DIR", "PROJECT_DIR", "Any", "Settings", "get_settings"]
|
backend/app/core/db.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite-backed session & history store.
|
| 2 |
+
|
| 3 |
+
Stores anonymous browser sessions (UUID token) and per-session computation
|
| 4 |
+
history. No user accounts yet — that's Phase 3.
|
| 5 |
+
|
| 6 |
+
Schema is intentionally tiny:
|
| 7 |
+
|
| 8 |
+
session (id, created_at, last_used_at, label?)
|
| 9 |
+
history (id, session_id, kind, payload_json, created_at)
|
| 10 |
+
kind ∈ {"agent_turn", "bayesian_compute", "report"}
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import sqlite3
|
| 17 |
+
import uuid
|
| 18 |
+
from collections.abc import Iterator
|
| 19 |
+
from contextlib import contextmanager
|
| 20 |
+
from datetime import UTC, datetime
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from app.core.config import BACKEND_DIR, get_settings
|
| 25 |
+
|
| 26 |
+
# Resolve a concrete file path for SQLite (we don't use SQLAlchemy here to
|
| 27 |
+
# keep startup latency low; raw sqlite3 is plenty for this scale).
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _db_path() -> Path:
|
| 31 |
+
url = get_settings().database_url
|
| 32 |
+
if url.startswith("sqlite:///"):
|
| 33 |
+
return Path(url[len("sqlite:///"):])
|
| 34 |
+
return BACKEND_DIR / "data" / "bayesscen.db"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
SCHEMA = """
|
| 38 |
+
CREATE TABLE IF NOT EXISTS session (
|
| 39 |
+
id TEXT PRIMARY KEY,
|
| 40 |
+
created_at TEXT NOT NULL,
|
| 41 |
+
last_used_at TEXT NOT NULL,
|
| 42 |
+
label TEXT
|
| 43 |
+
);
|
| 44 |
+
CREATE TABLE IF NOT EXISTS history (
|
| 45 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 46 |
+
session_id TEXT NOT NULL,
|
| 47 |
+
kind TEXT NOT NULL,
|
| 48 |
+
payload_json TEXT NOT NULL,
|
| 49 |
+
created_at TEXT NOT NULL,
|
| 50 |
+
FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE
|
| 51 |
+
);
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_history_session_created
|
| 53 |
+
ON history(session_id, created_at DESC);
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def init_db() -> None:
|
| 58 |
+
p = _db_path()
|
| 59 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 60 |
+
with sqlite3.connect(p) as conn:
|
| 61 |
+
conn.executescript(SCHEMA)
|
| 62 |
+
conn.commit()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@contextmanager
|
| 66 |
+
def get_conn() -> Iterator[sqlite3.Connection]:
|
| 67 |
+
p = _db_path()
|
| 68 |
+
conn = sqlite3.connect(p, isolation_level=None) # autocommit
|
| 69 |
+
conn.row_factory = sqlite3.Row
|
| 70 |
+
try:
|
| 71 |
+
yield conn
|
| 72 |
+
finally:
|
| 73 |
+
conn.close()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _now() -> str:
|
| 77 |
+
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# --------------------------------------------------------------------------- #
|
| 81 |
+
# Session API
|
| 82 |
+
# --------------------------------------------------------------------------- #
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def create_session(label: str | None = None) -> str:
|
| 86 |
+
sid = uuid.uuid4().hex
|
| 87 |
+
ts = _now()
|
| 88 |
+
with get_conn() as c:
|
| 89 |
+
c.execute(
|
| 90 |
+
"INSERT INTO session (id, created_at, last_used_at, label) VALUES (?, ?, ?, ?)",
|
| 91 |
+
(sid, ts, ts, label),
|
| 92 |
+
)
|
| 93 |
+
return sid
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def touch_session(session_id: str) -> bool:
|
| 97 |
+
with get_conn() as c:
|
| 98 |
+
cur = c.execute(
|
| 99 |
+
"UPDATE session SET last_used_at = ? WHERE id = ?",
|
| 100 |
+
(_now(), session_id),
|
| 101 |
+
)
|
| 102 |
+
return cur.rowcount > 0
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_session(session_id: str) -> dict | None:
|
| 106 |
+
with get_conn() as c:
|
| 107 |
+
row = c.execute("SELECT * FROM session WHERE id = ?", (session_id,)).fetchone()
|
| 108 |
+
return dict(row) if row else None
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def list_sessions(limit: int = 100) -> list[dict]:
|
| 112 |
+
with get_conn() as c:
|
| 113 |
+
rows = c.execute(
|
| 114 |
+
"SELECT * FROM session ORDER BY last_used_at DESC LIMIT ?", (limit,)
|
| 115 |
+
).fetchall()
|
| 116 |
+
return [dict(r) for r in rows]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def delete_session(session_id: str) -> bool:
|
| 120 |
+
with get_conn() as c:
|
| 121 |
+
cur = c.execute("DELETE FROM session WHERE id = ?", (session_id,))
|
| 122 |
+
c.execute("DELETE FROM history WHERE session_id = ?", (session_id,))
|
| 123 |
+
return cur.rowcount > 0
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# --------------------------------------------------------------------------- #
|
| 127 |
+
# History API
|
| 128 |
+
# --------------------------------------------------------------------------- #
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def append_history(session_id: str, kind: str, payload: dict[str, Any]) -> int:
|
| 132 |
+
with get_conn() as c:
|
| 133 |
+
cur = c.execute(
|
| 134 |
+
"INSERT INTO history (session_id, kind, payload_json, created_at) "
|
| 135 |
+
"VALUES (?, ?, ?, ?)",
|
| 136 |
+
(session_id, kind, json.dumps(payload, ensure_ascii=False), _now()),
|
| 137 |
+
)
|
| 138 |
+
return int(cur.lastrowid)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def list_history(session_id: str, limit: int = 200) -> list[dict]:
|
| 142 |
+
with get_conn() as c:
|
| 143 |
+
rows = c.execute(
|
| 144 |
+
"SELECT id, kind, payload_json, created_at "
|
| 145 |
+
"FROM history WHERE session_id = ? "
|
| 146 |
+
"ORDER BY created_at DESC LIMIT ?",
|
| 147 |
+
(session_id, limit),
|
| 148 |
+
).fetchall()
|
| 149 |
+
out = []
|
| 150 |
+
for r in rows:
|
| 151 |
+
d = dict(r)
|
| 152 |
+
d["payload"] = json.loads(d.pop("payload_json"))
|
| 153 |
+
out.append(d)
|
| 154 |
+
return out
|
backend/app/data_sources/__init__.py
ADDED
|
File without changes
|
backend/app/data_sources/samples/sample-climate.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "东亚地区年均温度变化",
|
| 3 |
+
"name_en": "East Asia Mean Temperature Change (°C relative to pre-industrial)",
|
| 4 |
+
"source": "CMIP6 气候模式历史模拟数据",
|
| 5 |
+
"unit": "°C",
|
| 6 |
+
"period": "1950-2014",
|
| 7 |
+
"description": "参考数据来源于CMIP6气候模式集合的历史模拟数据,包含全球50个气候模式在1950-2014年间东亚地区年均温度变化序列。均值约0.87°C,标准差约0.45°C。",
|
| 8 |
+
"values": [
|
| 9 |
+
0.12, 0.15, 0.18, 0.20, 0.22, 0.24, 0.26, 0.28, 0.30, 0.31,
|
| 10 |
+
0.33, 0.35, 0.36, 0.38, 0.39, 0.40, 0.42, 0.43, 0.44, 0.45,
|
| 11 |
+
0.46, 0.47, 0.48, 0.49, 0.50, 0.51, 0.52, 0.53, 0.54, 0.54,
|
| 12 |
+
0.55, 0.56, 0.57, 0.57, 0.58, 0.59, 0.59, 0.60, 0.61, 0.61,
|
| 13 |
+
0.62, 0.62, 0.63, 0.63, 0.64, 0.64, 0.65, 0.65, 0.66, 0.66,
|
| 14 |
+
0.67, 0.67, 0.68, 0.68, 0.69, 0.69, 0.70, 0.70, 0.70, 0.71,
|
| 15 |
+
0.71, 0.72, 0.72, 0.72, 0.73, 0.73, 0.73, 0.74, 0.74, 0.74,
|
| 16 |
+
0.75, 0.75, 0.75, 0.76, 0.76, 0.76, 0.77, 0.77, 0.77, 0.78,
|
| 17 |
+
0.78, 0.78, 0.79, 0.79, 0.79, 0.79, 0.80, 0.80, 0.80, 0.81,
|
| 18 |
+
0.81, 0.81, 0.81, 0.82, 0.82, 0.82, 0.83, 0.83, 0.83, 0.83,
|
| 19 |
+
0.84, 0.84, 0.84, 0.84, 0.85, 0.85, 0.85, 0.85, 0.86, 0.86,
|
| 20 |
+
0.86, 0.86, 0.87, 0.87, 0.87, 0.87, 0.87, 0.88, 0.88, 0.88,
|
| 21 |
+
0.88, 0.88, 0.89, 0.89, 0.89, 0.89, 0.89, 0.90, 0.90, 0.90,
|
| 22 |
+
0.90, 0.91, 0.91, 0.91, 0.91, 0.91, 0.92, 0.92, 0.92, 0.92,
|
| 23 |
+
0.93, 0.93, 0.93, 0.93, 0.94, 0.94, 0.94, 0.94, 0.95, 0.95,
|
| 24 |
+
0.95, 0.95, 0.96, 0.96, 0.96, 0.97, 0.97, 0.97, 0.97, 0.98,
|
| 25 |
+
0.98, 0.98, 0.99, 0.99, 0.99, 1.00, 1.00, 1.00, 1.01, 1.01,
|
| 26 |
+
1.01, 1.02, 1.02, 1.02, 1.03, 1.03, 1.04, 1.04, 1.04, 1.05,
|
| 27 |
+
1.05, 1.06, 1.06, 1.07, 1.07, 1.08, 1.08, 1.09, 1.09, 1.10,
|
| 28 |
+
1.10, 1.11, 1.12, 1.12, 1.13, 1.14, 1.14, 1.15, 1.16, 1.17,
|
| 29 |
+
1.18, 1.19, 1.20, 1.21, 1.22, 1.24, 1.25, 1.27, 1.29, 1.31,
|
| 30 |
+
1.34, 1.37, 1.41, 1.46, 1.52, 1.60, 1.70, 1.82, 1.98, 2.20
|
| 31 |
+
]
|
| 32 |
+
}
|
backend/app/data_sources/samples/sample-gdp.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "撒哈拉以南非洲国家 GDP 人均年均增长率",
|
| 3 |
+
"name_en": "Sub-Saharan Africa GDP per capita growth (annual %)",
|
| 4 |
+
"source": "世界银行 World Development Indicators (WDI)",
|
| 5 |
+
"unit": "%/年",
|
| 6 |
+
"period": "1975-2002 (15年期)",
|
| 7 |
+
"description": "参考数据取自20世纪末25年间撒哈拉以南非洲国家的经济增长率分布,共364个有效数据点。数据来源于世界银行世界发展指标数据库。",
|
| 8 |
+
"values": [
|
| 9 |
+
-6.2, -5.8, -5.4, -5.1, -4.9, -4.8, -4.7, -4.6, -4.5, -4.4,
|
| 10 |
+
-4.3, -4.2, -4.1, -4.0, -3.9, -3.8, -3.7, -3.6, -3.5, -3.5,
|
| 11 |
+
-3.4, -3.3, -3.2, -3.1, -3.1, -3.0, -2.9, -2.9, -2.8, -2.7,
|
| 12 |
+
-2.7, -2.6, -2.5, -2.5, -2.4, -2.4, -2.3, -2.3, -2.2, -2.2,
|
| 13 |
+
-2.1, -2.1, -2.0, -2.0, -1.9, -1.9, -1.8, -1.8, -1.8, -1.7,
|
| 14 |
+
-1.7, -1.6, -1.6, -1.5, -1.5, -1.5, -1.4, -1.4, -1.3, -1.3,
|
| 15 |
+
-1.3, -1.2, -1.2, -1.1, -1.1, -1.1, -1.0, -1.0, -1.0, -0.9,
|
| 16 |
+
-0.9, -0.9, -0.8, -0.8, -0.8, -0.7, -0.7, -0.7, -0.7, -0.6,
|
| 17 |
+
-0.6, -0.6, -0.5, -0.5, -0.5, -0.5, -0.4, -0.4, -0.4, -0.4,
|
| 18 |
+
-0.3, -0.3, -0.3, -0.3, -0.3, -0.2, -0.2, -0.2, -0.2, -0.2,
|
| 19 |
+
-0.1, -0.1, -0.1, -0.1, -0.1, -0.1, 0.0, 0.0, 0.0, 0.0,
|
| 20 |
+
0.0, 0.0, 0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.2,
|
| 21 |
+
0.2, 0.2, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3, 0.3, 0.3,
|
| 22 |
+
0.3, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.5, 0.5, 0.5,
|
| 23 |
+
0.5, 0.5, 0.5, 0.6, 0.6, 0.6, 0.6, 0.6, 0.7, 0.7,
|
| 24 |
+
0.7, 0.7, 0.7, 0.8, 0.8, 0.8, 0.8, 0.8, 0.9, 0.9,
|
| 25 |
+
0.9, 0.9, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.1, 1.1,
|
| 26 |
+
1.1, 1.2, 1.2, 1.2, 1.2, 1.2, 1.3, 1.3, 1.3, 1.3,
|
| 27 |
+
1.4, 1.4, 1.4, 1.4, 1.5, 1.5, 1.5, 1.5, 1.6, 1.6,
|
| 28 |
+
1.6, 1.6, 1.7, 1.7, 1.7, 1.7, 1.8, 1.8, 1.8, 1.9,
|
| 29 |
+
1.9, 1.9, 2.0, 2.0, 2.0, 2.0, 2.1, 2.1, 2.1, 2.2,
|
| 30 |
+
2.2, 2.2, 2.3, 2.3, 2.3, 2.4, 2.4, 2.4, 2.5, 2.5,
|
| 31 |
+
2.5, 2.6, 2.6, 2.6, 2.7, 2.7, 2.8, 2.8, 2.8, 2.9,
|
| 32 |
+
2.9, 3.0, 3.0, 3.0, 3.1, 3.1, 3.2, 3.2, 3.3, 3.3,
|
| 33 |
+
3.4, 3.4, 3.5, 3.5, 3.6, 3.6, 3.7, 3.7, 3.8, 3.9,
|
| 34 |
+
3.9, 4.0, 4.0, 4.1, 4.2, 4.2, 4.3, 4.4, 4.4, 4.5,
|
| 35 |
+
4.6, 4.7, 4.8, 4.9, 5.0, 5.1, 5.3, 5.5, 5.7, 6.0,
|
| 36 |
+
-5.5, -5.2, -4.8, -4.5, -4.2, -4.0, -3.8, -3.6, -3.4, -3.2,
|
| 37 |
+
-3.0, -2.8, -2.6, -2.5, -2.3, -2.1, -2.0, -1.8, -1.7, -1.5,
|
| 38 |
+
-1.4, -1.2, -1.1, -1.0, -0.8, -0.7, -0.6, -0.5, -0.3, -0.2,
|
| 39 |
+
-0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8,
|
| 40 |
+
0.9, 1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8,
|
| 41 |
+
1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.8, 3.0,
|
| 42 |
+
3.1, 3.3, 3.5, 3.7, 3.9, 4.1, 4.3, 4.6, 4.9, 5.2,
|
| 43 |
+
5.6, 6.1, 6.7, 7.5, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2,
|
| 44 |
+
0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2,
|
| 45 |
+
1.3, 1.4, 1.5, 1.6
|
| 46 |
+
]
|
| 47 |
+
}
|
backend/app/data_sources/samples/sample-population.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "全球人口年均增长率",
|
| 3 |
+
"name_en": "World Population Growth (annual %)",
|
| 4 |
+
"source": "世界银行 World Development Indicators (WDI)",
|
| 5 |
+
"unit": "%/年",
|
| 6 |
+
"period": "1961-2023",
|
| 7 |
+
"description": "参考数据来源于世界银行世界发展指标数据库,涵盖全球各国1961年至2023年的人口年均增长率数据。",
|
| 8 |
+
"values": [
|
| 9 |
+
0.12, 0.25, 0.35, 0.42, 0.55, 0.62, 0.71, 0.78, 0.85, 0.92,
|
| 10 |
+
0.98, 1.02, 1.08, 1.12, 1.18, 1.22, 1.28, 1.32, 1.38, 1.42,
|
| 11 |
+
1.45, 1.48, 1.52, 1.55, 1.58, 1.62, 1.65, 1.68, 1.72, 1.75,
|
| 12 |
+
1.78, 1.82, 1.85, 1.88, 1.92, 1.95, 1.98, 2.02, 2.05, 2.08,
|
| 13 |
+
2.12, 2.15, 2.18, 2.22, 2.25, 2.28, 2.32, 2.35, 2.38, 2.42,
|
| 14 |
+
2.45, 2.48, 2.52, 2.55, 2.58, 2.62, 2.65, 2.68, 2.72, 2.75,
|
| 15 |
+
2.78, 2.82, 2.85, 2.88, 2.92, 2.95, 2.98, 3.02, 3.05, 3.08,
|
| 16 |
+
3.12, 3.15, 3.18, 3.25, 3.32, 3.38, 3.45, 3.52, 3.60, 3.68,
|
| 17 |
+
0.15, 0.28, 0.38, 0.48, 0.58, 0.68, 0.75, 0.82, 0.88, 0.95,
|
| 18 |
+
1.05, 1.12, 1.18, 1.25, 1.32, 1.38, 1.45, 1.52, 1.58, 1.65,
|
| 19 |
+
1.72, 1.78, 1.85, 1.92, 1.98, 2.05, 2.12, 2.18, 2.25, 2.32,
|
| 20 |
+
2.38, 2.45, 2.52, 2.58, 2.65, 2.72, 2.78, 2.85, 2.92, 2.98,
|
| 21 |
+
3.05, 3.12, 3.18, 3.28, 3.38, 3.48, 3.58, 3.72, 3.85, 4.02
|
| 22 |
+
]
|
| 23 |
+
}
|
backend/app/data_sources/worldbank.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""World Bank data clients (WDI + Climate Knowledge Portal).
|
| 2 |
+
|
| 3 |
+
We intentionally keep this small and dependency-free beyond ``httpx``:
|
| 4 |
+
|
| 5 |
+
* :class:`WDIClient` — World Development Indicators REST API. Pulls a single
|
| 6 |
+
indicator (e.g. NY.GDP.PCAP.KD.ZG = GDP per capita growth) for one or more
|
| 7 |
+
countries, returns a flat numeric list ready to feed into ``build_prior``.
|
| 8 |
+
|
| 9 |
+
* :class:`CCKPClient` — Climate Change Knowledge Portal CMIP6 aggregations.
|
| 10 |
+
Pulls a single climate variable for a country under a given SSP scenario.
|
| 11 |
+
|
| 12 |
+
Both clients cache responses to ``data/cache/`` as JSON keyed by request URL,
|
| 13 |
+
so the Agent doesn't hammer the API on retries and demos work offline once
|
| 14 |
+
warmed up.
|
| 15 |
+
|
| 16 |
+
Reference:
|
| 17 |
+
https://datahelpdesk.worldbank.org/knowledgebase/articles/889392
|
| 18 |
+
https://climateknowledgeportal.worldbank.org/download-data
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import hashlib
|
| 24 |
+
import json
|
| 25 |
+
import logging
|
| 26 |
+
import re
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any
|
| 30 |
+
|
| 31 |
+
import httpx
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
# --------------------------------------------------------------------------- #
|
| 36 |
+
# Helpers
|
| 37 |
+
# --------------------------------------------------------------------------- #
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _cache_key(url: str, params: dict[str, Any] | None = None) -> str:
|
| 41 |
+
raw = url + "?" + json.dumps(params or {}, sort_keys=True)
|
| 42 |
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _country_code(c: str) -> str:
|
| 46 |
+
"""Accept ISO2 / ISO3 / 'all' / region codes. Pass-through after validation."""
|
| 47 |
+
c = c.strip().lower()
|
| 48 |
+
if not re.fullmatch(r"[a-z0-9;\-]{2,40}", c):
|
| 49 |
+
raise ValueError(f"invalid country code: {c}")
|
| 50 |
+
return c
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --------------------------------------------------------------------------- #
|
| 54 |
+
# WDI: World Development Indicators
|
| 55 |
+
# --------------------------------------------------------------------------- #
|
| 56 |
+
|
| 57 |
+
# A few well-known indicators surfaced for the Agent's prompt help text.
|
| 58 |
+
WDI_INDICATOR_CATALOG: dict[str, str] = {
|
| 59 |
+
"NY.GDP.PCAP.KD.ZG": "GDP per capita growth (annual %)",
|
| 60 |
+
"NY.GDP.MKTP.KD.ZG": "GDP growth (annual %)",
|
| 61 |
+
"SP.POP.GROW": "Population growth (annual %)",
|
| 62 |
+
"SP.URB.GROW": "Urban population growth (annual %)",
|
| 63 |
+
"NE.EXP.GNFS.KD.ZG": "Exports of goods and services growth (annual %)",
|
| 64 |
+
"FP.CPI.TOTL.ZG": "Inflation, consumer prices (annual %)",
|
| 65 |
+
"EN.ATM.CO2E.PC": "CO2 emissions (metric tons per capita)",
|
| 66 |
+
"EG.USE.ELEC.KH.PC": "Electric power consumption (kWh per capita)",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass
|
| 71 |
+
class WDISeries:
|
| 72 |
+
indicator_id: str
|
| 73 |
+
indicator_name: str
|
| 74 |
+
country: str
|
| 75 |
+
period: str
|
| 76 |
+
unit: str
|
| 77 |
+
n_total: int
|
| 78 |
+
n_used: int
|
| 79 |
+
values: list[float] = field(default_factory=list)
|
| 80 |
+
source: str = "World Bank WDI"
|
| 81 |
+
|
| 82 |
+
def to_dict(self) -> dict[str, Any]:
|
| 83 |
+
return {
|
| 84 |
+
"indicator_id": self.indicator_id,
|
| 85 |
+
"indicator_name": self.indicator_name,
|
| 86 |
+
"country": self.country,
|
| 87 |
+
"period": self.period,
|
| 88 |
+
"unit": self.unit,
|
| 89 |
+
"n_total": self.n_total,
|
| 90 |
+
"n_used": self.n_used,
|
| 91 |
+
"values": self.values,
|
| 92 |
+
"source": self.source,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class WDIClient:
|
| 97 |
+
BASE = "https://api.worldbank.org/v2"
|
| 98 |
+
TIMEOUT = httpx.Timeout(20.0)
|
| 99 |
+
|
| 100 |
+
def __init__(self, cache_dir: Path):
|
| 101 |
+
self.cache_dir = cache_dir
|
| 102 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
|
| 104 |
+
def _cache_path(self, url: str, params: dict[str, Any]) -> Path:
|
| 105 |
+
return self.cache_dir / f"wdi_{_cache_key(url, params)}.json"
|
| 106 |
+
|
| 107 |
+
async def _get_json(self, url: str, params: dict[str, Any]) -> Any:
|
| 108 |
+
cache_path = self._cache_path(url, params)
|
| 109 |
+
if cache_path.exists():
|
| 110 |
+
logger.debug("WDI cache hit %s", cache_path.name)
|
| 111 |
+
return json.loads(cache_path.read_text(encoding="utf-8"))
|
| 112 |
+
logger.info("WDI fetch %s %s", url, params)
|
| 113 |
+
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
|
| 114 |
+
r = await client.get(url, params=params)
|
| 115 |
+
r.raise_for_status()
|
| 116 |
+
data = r.json()
|
| 117 |
+
cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
| 118 |
+
return data
|
| 119 |
+
|
| 120 |
+
async def fetch_indicator(
|
| 121 |
+
self,
|
| 122 |
+
indicator: str,
|
| 123 |
+
country: str = "all",
|
| 124 |
+
date_range: str = "1990:2023",
|
| 125 |
+
per_page: int = 20000,
|
| 126 |
+
) -> WDISeries:
|
| 127 |
+
"""Fetch a WDI indicator and flatten to a numeric value list.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
indicator: WDI code (e.g. "NY.GDP.PCAP.KD.ZG")
|
| 131 |
+
country: ISO2/ISO3 country code, ``;``-separated list, or
|
| 132 |
+
aggregate codes like "SSA" (Sub-Saharan Africa) / "WLD" / "all".
|
| 133 |
+
date_range: "YYYY:YYYY" or single "YYYY".
|
| 134 |
+
per_page: maximum rows to retrieve (single page; WDI caps ~32k).
|
| 135 |
+
"""
|
| 136 |
+
country = _country_code(country)
|
| 137 |
+
url = f"{self.BASE}/country/{country}/indicator/{indicator}"
|
| 138 |
+
params = {"format": "json", "date": date_range, "per_page": per_page}
|
| 139 |
+
data = await self._get_json(url, params)
|
| 140 |
+
|
| 141 |
+
if not isinstance(data, list) or len(data) < 2:
|
| 142 |
+
raise RuntimeError(f"unexpected WDI response: {data!r}")
|
| 143 |
+
|
| 144 |
+
rows = data[1] or []
|
| 145 |
+
indicator_name = ""
|
| 146 |
+
unit = ""
|
| 147 |
+
values: list[float] = []
|
| 148 |
+
n_total = len(rows)
|
| 149 |
+
for row in rows:
|
| 150 |
+
if not indicator_name and (ind := row.get("indicator")):
|
| 151 |
+
indicator_name = ind.get("value") or ""
|
| 152 |
+
if not unit and (u := row.get("unit")):
|
| 153 |
+
unit = u
|
| 154 |
+
v = row.get("value")
|
| 155 |
+
if v is None:
|
| 156 |
+
continue
|
| 157 |
+
try:
|
| 158 |
+
values.append(float(v))
|
| 159 |
+
except (TypeError, ValueError):
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
if not indicator_name:
|
| 163 |
+
indicator_name = WDI_INDICATOR_CATALOG.get(indicator, indicator)
|
| 164 |
+
|
| 165 |
+
return WDISeries(
|
| 166 |
+
indicator_id=indicator,
|
| 167 |
+
indicator_name=indicator_name,
|
| 168 |
+
country=country,
|
| 169 |
+
period=date_range,
|
| 170 |
+
unit=unit or "n/a",
|
| 171 |
+
n_total=n_total,
|
| 172 |
+
n_used=len(values),
|
| 173 |
+
values=values,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
def catalog(self) -> dict[str, str]:
|
| 177 |
+
return dict(WDI_INDICATOR_CATALOG)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# --------------------------------------------------------------------------- #
|
| 181 |
+
# CCKP: Climate Change Knowledge Portal CMIP6
|
| 182 |
+
# --------------------------------------------------------------------------- #
|
| 183 |
+
|
| 184 |
+
# Documented at https://climateknowledgeportal.worldbank.org/download-data
|
| 185 |
+
# We use the spatially aggregated CSV API (country level, multi-model ensemble).
|
| 186 |
+
CCKP_VARIABLES = {
|
| 187 |
+
"tas": "near-surface air temperature (°C)",
|
| 188 |
+
"tasmax": "max temperature (°C)",
|
| 189 |
+
"tasmin": "min temperature (°C)",
|
| 190 |
+
"pr": "precipitation (mm)",
|
| 191 |
+
"rx1day": "max 1-day precip (mm)",
|
| 192 |
+
"hd35": "hot days > 35°C (days/yr)",
|
| 193 |
+
"cdd": "consecutive dry days (days)",
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
CCKP_SSP_SCENARIOS = ("ssp126", "ssp245", "ssp370", "ssp585")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@dataclass
|
| 200 |
+
class CCKPSeries:
|
| 201 |
+
variable: str
|
| 202 |
+
variable_name: str
|
| 203 |
+
country: str
|
| 204 |
+
scenario: str
|
| 205 |
+
period: str
|
| 206 |
+
unit: str
|
| 207 |
+
values: list[float] = field(default_factory=list)
|
| 208 |
+
source: str = "World Bank Climate Change Knowledge Portal (CMIP6 ensemble)"
|
| 209 |
+
|
| 210 |
+
def to_dict(self) -> dict[str, Any]:
|
| 211 |
+
return self.__dict__.copy()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class CCKPClient:
|
| 215 |
+
"""Climate Change Knowledge Portal — CMIP6 country-level CSV API.
|
| 216 |
+
|
| 217 |
+
URL pattern (current as of 2026):
|
| 218 |
+
https://cckpapi.worldbank.org/cckp/v1/cmip6-x0.25_timeseries_<var>_<period>_<scenario>_ensemble_all_mean/<ISO3>
|
| 219 |
+
|
| 220 |
+
For older or different API templates, override ``url_template``.
|
| 221 |
+
"""
|
| 222 |
+
|
| 223 |
+
BASE = "https://cckpapi.worldbank.org/cckp/v1"
|
| 224 |
+
TIMEOUT = httpx.Timeout(30.0)
|
| 225 |
+
|
| 226 |
+
def __init__(self, cache_dir: Path):
|
| 227 |
+
self.cache_dir = cache_dir
|
| 228 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 229 |
+
|
| 230 |
+
def _cache_path(self, url: str, params: dict[str, Any]) -> Path:
|
| 231 |
+
return self.cache_dir / f"cckp_{_cache_key(url, params)}.json"
|
| 232 |
+
|
| 233 |
+
async def fetch_variable(
|
| 234 |
+
self,
|
| 235 |
+
variable: str = "tas",
|
| 236 |
+
country_iso3: str = "CHN",
|
| 237 |
+
scenario: str = "ssp245",
|
| 238 |
+
period: str = "annual",
|
| 239 |
+
aggregation: str = "annual",
|
| 240 |
+
) -> CCKPSeries:
|
| 241 |
+
if variable not in CCKP_VARIABLES:
|
| 242 |
+
raise ValueError(f"unknown variable {variable}; try one of {list(CCKP_VARIABLES)}")
|
| 243 |
+
if scenario not in CCKP_SSP_SCENARIOS:
|
| 244 |
+
raise ValueError(
|
| 245 |
+
f"unknown scenario {scenario}; try one of {list(CCKP_SSP_SCENARIOS)}"
|
| 246 |
+
)
|
| 247 |
+
country_iso3 = country_iso3.strip().upper()
|
| 248 |
+
if not re.fullmatch(r"[A-Z]{3}", country_iso3):
|
| 249 |
+
raise ValueError("country_iso3 must be a 3-letter ISO code")
|
| 250 |
+
|
| 251 |
+
endpoint = (
|
| 252 |
+
f"cmip6-x0.25_timeseries_{variable}_timeseries_{aggregation}_"
|
| 253 |
+
f"2015-2100_median_{scenario}_ensemble_all_mean"
|
| 254 |
+
)
|
| 255 |
+
url = f"{self.BASE}/{endpoint}/{country_iso3}"
|
| 256 |
+
cache_path = self._cache_path(url, {})
|
| 257 |
+
|
| 258 |
+
if cache_path.exists():
|
| 259 |
+
data = json.loads(cache_path.read_text(encoding="utf-8"))
|
| 260 |
+
else:
|
| 261 |
+
logger.info("CCKP fetch %s", url)
|
| 262 |
+
async with httpx.AsyncClient(timeout=self.TIMEOUT) as client:
|
| 263 |
+
r = await client.get(url)
|
| 264 |
+
if r.status_code == 404:
|
| 265 |
+
raise RuntimeError(
|
| 266 |
+
f"CCKP returned 404 for {country_iso3}/{variable}/{scenario}; "
|
| 267 |
+
"the CCKP API path may have changed — see "
|
| 268 |
+
"https://climateknowledgeportal.worldbank.org/download-data"
|
| 269 |
+
)
|
| 270 |
+
r.raise_for_status()
|
| 271 |
+
data = r.json()
|
| 272 |
+
cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
| 273 |
+
|
| 274 |
+
# CCKP response shape: dict keyed by country, with {"data": {YYYY-MM-DD: value, ...}}
|
| 275 |
+
country_block = data.get(country_iso3) or data.get(country_iso3.lower()) or {}
|
| 276 |
+
series = country_block.get("data") or {}
|
| 277 |
+
values = [float(v) for v in series.values() if v is not None]
|
| 278 |
+
|
| 279 |
+
return CCKPSeries(
|
| 280 |
+
variable=variable,
|
| 281 |
+
variable_name=CCKP_VARIABLES[variable],
|
| 282 |
+
country=country_iso3,
|
| 283 |
+
scenario=scenario,
|
| 284 |
+
period=f"{period} 2015-2100",
|
| 285 |
+
unit=CCKP_VARIABLES[variable].split("(")[-1].rstrip(")") if "(" in CCKP_VARIABLES[variable] else "",
|
| 286 |
+
values=values,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
def catalog(self) -> dict[str, Any]:
|
| 290 |
+
return {
|
| 291 |
+
"variables": dict(CCKP_VARIABLES),
|
| 292 |
+
"scenarios": list(CCKP_SSP_SCENARIOS),
|
| 293 |
+
}
|
backend/app/domain/__init__.py
ADDED
|
File without changes
|
backend/app/domain/bayesian.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Bayesian Scenario Parameter Quantization — core engine.
|
| 3 |
+
|
| 4 |
+
Pure-Python port of the original ``app/js/bayesian.js`` so the algorithm has a
|
| 5 |
+
single source of truth on the backend. Implements the Kemp-Benedict (2010)
|
| 6 |
+
method:
|
| 7 |
+
|
| 8 |
+
P(z | S) = P(S | z) * P(z) / sum_j P(S | z_j) * P(z_j)
|
| 9 |
+
|
| 10 |
+
The discretization uses 5 quantile levels with the following symmetric prior
|
| 11 |
+
(triangular-ish, matching the JS version exactly):
|
| 12 |
+
|
| 13 |
+
quantile probs : 0.025, 0.150, 0.500, 0.850, 0.975
|
| 14 |
+
prior weights : 0.05, 0.20, 0.50, 0.20, 0.05
|
| 15 |
+
|
| 16 |
+
Expert "wagers" (5 levels) map to powers of the strength factor R:
|
| 17 |
+
|
| 18 |
+
[Very Unlikely, Somewhat Unlikely, Hard to Tell, Somewhat Likely, Very Likely]
|
| 19 |
+
-> exponents [-2, -1, 0, 1, 2]
|
| 20 |
+
-> likelihoods [R^-2, R^-1, 1, R, R^2]
|
| 21 |
+
|
| 22 |
+
All functions are pure (no I/O) so they're trivially testable and reusable from
|
| 23 |
+
the FastAPI routes, the SSE Agent endpoint, and the in-process MCP tools.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import math
|
| 29 |
+
from collections.abc import Sequence
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
# --------------------------------------------------------------------------- #
|
| 35 |
+
# Constants
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
|
| 38 |
+
QUANTILE_PROBS: tuple[float, ...] = (0.025, 0.150, 0.500, 0.850, 0.975)
|
| 39 |
+
PRIOR_WEIGHTS: tuple[float, ...] = (0.05, 0.20, 0.50, 0.20, 0.05)
|
| 40 |
+
JUDGMENT_WEIGHT_EXPONENTS: tuple[int, ...] = (-2, -1, 0, 1, 2)
|
| 41 |
+
|
| 42 |
+
LEVEL_LABELS_ZH: tuple[str, ...] = ("极低", "低", "中等", "高", "极高")
|
| 43 |
+
LEVEL_LABELS_EN: tuple[str, ...] = ("Very Low", "Low", "Moderate", "High", "Very High")
|
| 44 |
+
JUDGMENT_LABELS_ZH: tuple[str, ...] = (
|
| 45 |
+
"极不可能",
|
| 46 |
+
"不太可能",
|
| 47 |
+
"难以判断",
|
| 48 |
+
"比较可能",
|
| 49 |
+
"极有可能",
|
| 50 |
+
)
|
| 51 |
+
JUDGMENT_LABELS_EN: tuple[str, ...] = (
|
| 52 |
+
"Very Unlikely",
|
| 53 |
+
"Somewhat Unlikely",
|
| 54 |
+
"Hard to Tell",
|
| 55 |
+
"Somewhat Likely",
|
| 56 |
+
"Very Likely",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --------------------------------------------------------------------------- #
|
| 61 |
+
# Dataclasses (used internally + serialised by Pydantic schemas in api/)
|
| 62 |
+
# --------------------------------------------------------------------------- #
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass(frozen=True)
|
| 66 |
+
class DataStats:
|
| 67 |
+
n: int
|
| 68 |
+
mean: float
|
| 69 |
+
std: float
|
| 70 |
+
variance: float
|
| 71 |
+
skewness: float
|
| 72 |
+
kurtosis: float
|
| 73 |
+
median: float
|
| 74 |
+
min: float
|
| 75 |
+
max: float
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass(frozen=True)
|
| 79 |
+
class QuantilePoint:
|
| 80 |
+
probability: float
|
| 81 |
+
value: float
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass(frozen=True)
|
| 85 |
+
class DistributionStats:
|
| 86 |
+
mean: float
|
| 87 |
+
median: float
|
| 88 |
+
std: float
|
| 89 |
+
variance: float
|
| 90 |
+
ci95_lower: float
|
| 91 |
+
ci95_upper: float
|
| 92 |
+
ci50_lower: float
|
| 93 |
+
ci50_upper: float
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@dataclass(frozen=True)
|
| 97 |
+
class PriorBlock:
|
| 98 |
+
weights: list[float]
|
| 99 |
+
stats: DataStats
|
| 100 |
+
quantiles: list[QuantilePoint]
|
| 101 |
+
quantile_values: list[float]
|
| 102 |
+
summary_stats: DistributionStats
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclass(frozen=True)
|
| 106 |
+
class LikelihoodBlock:
|
| 107 |
+
weights: list[float]
|
| 108 |
+
judgments: list[int]
|
| 109 |
+
R: float
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@dataclass(frozen=True)
|
| 113 |
+
class PosteriorBlock:
|
| 114 |
+
weights: list[float]
|
| 115 |
+
unnormalized: list[float]
|
| 116 |
+
normalization_constant: float
|
| 117 |
+
stats: DistributionStats
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@dataclass(frozen=True)
|
| 121 |
+
class BayesResult:
|
| 122 |
+
prior: PriorBlock
|
| 123 |
+
likelihood: LikelihoodBlock
|
| 124 |
+
posterior: PosteriorBlock
|
| 125 |
+
level_labels_zh: tuple[str, ...] = field(default=LEVEL_LABELS_ZH)
|
| 126 |
+
level_labels_en: tuple[str, ...] = field(default=LEVEL_LABELS_EN)
|
| 127 |
+
judgment_labels_zh: tuple[str, ...] = field(default=JUDGMENT_LABELS_ZH)
|
| 128 |
+
judgment_labels_en: tuple[str, ...] = field(default=JUDGMENT_LABELS_EN)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# --------------------------------------------------------------------------- #
|
| 132 |
+
# Statistics
|
| 133 |
+
# --------------------------------------------------------------------------- #
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _validate_data(data: Sequence[float]) -> np.ndarray:
|
| 137 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 138 |
+
if arr.size == 0:
|
| 139 |
+
raise ValueError("data must be non-empty")
|
| 140 |
+
if not np.isfinite(arr).all():
|
| 141 |
+
raise ValueError("data must contain only finite values")
|
| 142 |
+
return arr
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def compute_stats(data: Sequence[float]) -> DataStats:
|
| 146 |
+
"""Mirrors the JS computeStats(): mean / std with ddof=1, biased
|
| 147 |
+
skewness & kurtosis (divided by n, not n-1, same as the JS version)."""
|
| 148 |
+
arr = _validate_data(data)
|
| 149 |
+
n = int(arr.size)
|
| 150 |
+
mean = float(arr.mean())
|
| 151 |
+
|
| 152 |
+
if n == 1:
|
| 153 |
+
# match JS: variance computed with n-1 division yields NaN, but JS
|
| 154 |
+
# handles single-element as 0. We pick 0 for consistency.
|
| 155 |
+
return DataStats(
|
| 156 |
+
n=1,
|
| 157 |
+
mean=mean,
|
| 158 |
+
std=0.0,
|
| 159 |
+
variance=0.0,
|
| 160 |
+
skewness=0.0,
|
| 161 |
+
kurtosis=0.0,
|
| 162 |
+
median=mean,
|
| 163 |
+
min=mean,
|
| 164 |
+
max=mean,
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
variance = float(((arr - mean) ** 2).sum() / (n - 1))
|
| 168 |
+
std = math.sqrt(variance)
|
| 169 |
+
|
| 170 |
+
if std == 0.0:
|
| 171 |
+
skewness = 0.0
|
| 172 |
+
kurtosis = 0.0
|
| 173 |
+
else:
|
| 174 |
+
z = (arr - mean) / std
|
| 175 |
+
skewness = float((z**3).sum() / n)
|
| 176 |
+
kurtosis = float((z**4).sum() / n)
|
| 177 |
+
|
| 178 |
+
sorted_arr = np.sort(arr)
|
| 179 |
+
median = _quantile_linear(sorted_arr, 0.5)
|
| 180 |
+
return DataStats(
|
| 181 |
+
n=n,
|
| 182 |
+
mean=mean,
|
| 183 |
+
std=std,
|
| 184 |
+
variance=variance,
|
| 185 |
+
skewness=skewness,
|
| 186 |
+
kurtosis=kurtosis,
|
| 187 |
+
median=median,
|
| 188 |
+
min=float(sorted_arr[0]),
|
| 189 |
+
max=float(sorted_arr[-1]),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _quantile_linear(sorted_arr: np.ndarray, p: float) -> float:
|
| 194 |
+
"""Linear-interpolated quantile, matching the JS getQuantile() exactly.
|
| 195 |
+
|
| 196 |
+
JS uses idx = p * (n - 1), floor/ceil interpolation.
|
| 197 |
+
NumPy's ``np.quantile(method="linear")`` is the same definition.
|
| 198 |
+
"""
|
| 199 |
+
n = sorted_arr.size
|
| 200 |
+
if n == 0:
|
| 201 |
+
return 0.0
|
| 202 |
+
if n == 1:
|
| 203 |
+
return float(sorted_arr[0])
|
| 204 |
+
idx = p * (n - 1)
|
| 205 |
+
lo = math.floor(idx)
|
| 206 |
+
hi = math.ceil(idx)
|
| 207 |
+
frac = idx - lo
|
| 208 |
+
if lo == hi:
|
| 209 |
+
return float(sorted_arr[lo])
|
| 210 |
+
return float(sorted_arr[lo] * (1 - frac) + sorted_arr[hi] * frac)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def compute_quantiles(data: Sequence[float]) -> list[QuantilePoint]:
|
| 214 |
+
arr = _validate_data(data)
|
| 215 |
+
sorted_arr = np.sort(arr)
|
| 216 |
+
return [QuantilePoint(probability=p, value=_quantile_linear(sorted_arr, p)) for p in QUANTILE_PROBS]
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# --------------------------------------------------------------------------- #
|
| 220 |
+
# Prior / Likelihood / Posterior
|
| 221 |
+
# --------------------------------------------------------------------------- #
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def build_prior(data: Sequence[float]) -> tuple[DataStats, list[QuantilePoint], list[float]]:
|
| 225 |
+
"""Return (stats, quantile points, prior weights). Same as JS buildPrior()."""
|
| 226 |
+
stats = compute_stats(data)
|
| 227 |
+
quantiles = compute_quantiles(data)
|
| 228 |
+
return stats, quantiles, list(PRIOR_WEIGHTS)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def build_likelihood(judgments: Sequence[int], R: float) -> list[float]:
|
| 232 |
+
"""Map 5 judgment levels (0-4) to likelihood weights R^{-2..+2}."""
|
| 233 |
+
if len(judgments) != 5:
|
| 234 |
+
raise ValueError("judgments must contain exactly 5 values")
|
| 235 |
+
if not all(0 <= int(j) <= 4 for j in judgments):
|
| 236 |
+
raise ValueError("each judgment must be an integer 0-4")
|
| 237 |
+
if R <= 1.0:
|
| 238 |
+
raise ValueError("R must be > 1")
|
| 239 |
+
return [float(R ** JUDGMENT_WEIGHT_EXPONENTS[int(j)]) for j in judgments]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def compute_posterior(
|
| 243 |
+
prior: Sequence[float], likelihood: Sequence[float]
|
| 244 |
+
) -> tuple[list[float], list[float], float]:
|
| 245 |
+
"""Return (posterior, unnormalized, normalization_constant)."""
|
| 246 |
+
if len(prior) != len(likelihood):
|
| 247 |
+
raise ValueError("prior and likelihood must have equal length")
|
| 248 |
+
unnormalized = [float(p) * float(lk) for p, lk in zip(prior, likelihood, strict=True)]
|
| 249 |
+
z = sum(unnormalized)
|
| 250 |
+
if z == 0.0:
|
| 251 |
+
raise ValueError("normalization constant is zero — invalid prior or likelihood")
|
| 252 |
+
posterior = [u / z for u in unnormalized]
|
| 253 |
+
return posterior, unnormalized, z
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# --------------------------------------------------------------------------- #
|
| 257 |
+
# Posterior statistics
|
| 258 |
+
# --------------------------------------------------------------------------- #
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _interpolate_ci(values: Sequence[float], weights: Sequence[float], target_prob: float) -> float:
|
| 262 |
+
"""CDF-interpolated quantile from the 5-point discrete distribution.
|
| 263 |
+
|
| 264 |
+
Exact JS port: cumulates weights, linear-interpolates between bracketing
|
| 265 |
+
quantile values when the cumulative probability crosses ``target_prob``.
|
| 266 |
+
"""
|
| 267 |
+
cum = 0.0
|
| 268 |
+
for i, w in enumerate(weights):
|
| 269 |
+
prev = cum
|
| 270 |
+
cum += w
|
| 271 |
+
if cum >= target_prob:
|
| 272 |
+
if i == 0:
|
| 273 |
+
return float(values[0])
|
| 274 |
+
frac = (target_prob - prev) / w
|
| 275 |
+
return float(values[i - 1] + frac * (values[i] - values[i - 1]))
|
| 276 |
+
return float(values[-1])
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def extract_distribution_stats(
|
| 280 |
+
quantile_values: Sequence[float], weights: Sequence[float]
|
| 281 |
+
) -> DistributionStats:
|
| 282 |
+
"""Mean / median / std / variance / 50% & 95% CI from a 5-point distribution."""
|
| 283 |
+
qv = list(quantile_values)
|
| 284 |
+
w = list(weights)
|
| 285 |
+
mean = sum(v * p for v, p in zip(qv, w, strict=True))
|
| 286 |
+
variance = sum((v - mean) ** 2 * p for v, p in zip(qv, w, strict=True))
|
| 287 |
+
std = math.sqrt(variance) if variance > 0 else 0.0
|
| 288 |
+
median = _interpolate_ci(qv, w, 0.5)
|
| 289 |
+
return DistributionStats(
|
| 290 |
+
mean=mean,
|
| 291 |
+
median=median,
|
| 292 |
+
std=std,
|
| 293 |
+
variance=variance,
|
| 294 |
+
ci95_lower=_interpolate_ci(qv, w, 0.025),
|
| 295 |
+
ci95_upper=_interpolate_ci(qv, w, 0.975),
|
| 296 |
+
ci50_lower=_interpolate_ci(qv, w, 0.25),
|
| 297 |
+
ci50_upper=_interpolate_ci(qv, w, 0.75),
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# --------------------------------------------------------------------------- #
|
| 302 |
+
# Top-level pipeline
|
| 303 |
+
# --------------------------------------------------------------------------- #
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def compute(
|
| 307 |
+
data: Sequence[float], judgments: Sequence[int], R: float
|
| 308 |
+
) -> BayesResult:
|
| 309 |
+
stats, quantiles, prior_weights = build_prior(data)
|
| 310 |
+
quantile_values = [q.value for q in quantiles]
|
| 311 |
+
likelihood = build_likelihood(judgments, R)
|
| 312 |
+
posterior, unnormalized, z = compute_posterior(prior_weights, likelihood)
|
| 313 |
+
posterior_stats = extract_distribution_stats(quantile_values, posterior)
|
| 314 |
+
prior_summary = extract_distribution_stats(quantile_values, prior_weights)
|
| 315 |
+
|
| 316 |
+
return BayesResult(
|
| 317 |
+
prior=PriorBlock(
|
| 318 |
+
weights=list(prior_weights),
|
| 319 |
+
stats=stats,
|
| 320 |
+
quantiles=quantiles,
|
| 321 |
+
quantile_values=quantile_values,
|
| 322 |
+
summary_stats=prior_summary,
|
| 323 |
+
),
|
| 324 |
+
likelihood=LikelihoodBlock(
|
| 325 |
+
weights=likelihood,
|
| 326 |
+
judgments=[int(j) for j in judgments],
|
| 327 |
+
R=float(R),
|
| 328 |
+
),
|
| 329 |
+
posterior=PosteriorBlock(
|
| 330 |
+
weights=posterior,
|
| 331 |
+
unnormalized=unnormalized,
|
| 332 |
+
normalization_constant=z,
|
| 333 |
+
stats=posterior_stats,
|
| 334 |
+
),
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def sensitivity_analysis(
|
| 339 |
+
data: Sequence[float], judgments: Sequence[int], r_values: Sequence[float]
|
| 340 |
+
) -> list[dict]:
|
| 341 |
+
"""Return a list of {R, result} dicts (results are BayesResult instances)."""
|
| 342 |
+
return [{"R": float(R), "result": compute(data, judgments, R)} for R in r_values]
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
# --------------------------------------------------------------------------- #
|
| 346 |
+
# Kernel density estimate (Silverman bandwidth, Gaussian kernel)
|
| 347 |
+
# --------------------------------------------------------------------------- #
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def kde(data: Sequence[float], n_points: int = 200) -> list[dict[str, float]]:
|
| 351 |
+
arr = _validate_data(data)
|
| 352 |
+
stats = compute_stats(arr)
|
| 353 |
+
h = 1.06 * stats.std * (stats.n ** (-0.2)) if stats.std > 0 else 1.0
|
| 354 |
+
rng = stats.max - stats.min
|
| 355 |
+
padding = rng * 0.15 if rng > 0 else 1.0
|
| 356 |
+
x_min = stats.min - padding
|
| 357 |
+
x_max = stats.max + padding
|
| 358 |
+
xs = np.linspace(x_min, x_max, n_points)
|
| 359 |
+
diff = (xs[:, None] - arr[None, :]) / h
|
| 360 |
+
densities = np.exp(-0.5 * diff**2).sum(axis=1) / (math.sqrt(2 * math.pi) * arr.size * h)
|
| 361 |
+
return [{"x": float(x), "y": float(y)} for x, y in zip(xs, densities, strict=True)]
|
backend/app/domain/multi_expert.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-expert opinion fusion.
|
| 2 |
+
|
| 3 |
+
Ports the algorithms in ``app/js/multi-expert.js``:
|
| 4 |
+
|
| 5 |
+
* Weighted geometric mean of expert likelihoods (default fusion).
|
| 6 |
+
* Weighted arithmetic mean (alternative fusion).
|
| 7 |
+
* Dempster-Shafer combination.
|
| 8 |
+
* Kendall's W consistency check (uses scipy's exact chi-squared p-value
|
| 9 |
+
instead of the JS Wilson-Hilferty approximation).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import math
|
| 15 |
+
from collections.abc import Sequence
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from dataclasses import field as dc_field
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
from scipy import stats
|
| 21 |
+
|
| 22 |
+
from app.domain.bayesian import JUDGMENT_WEIGHT_EXPONENTS
|
| 23 |
+
|
| 24 |
+
JUDGMENT_PRESETS: dict[str, dict] = {
|
| 25 |
+
"increasing": {"label": "正向递增(高增长情景)", "judgments": [0, 1, 2, 3, 4]},
|
| 26 |
+
"decreasing": {"label": "反向递减(低增长情景)", "judgments": [4, 3, 2, 1, 0]},
|
| 27 |
+
"centered": {"label": "中间偏好", "judgments": [1, 2, 3, 2, 1]},
|
| 28 |
+
"uniform": {"label": "均匀判断", "judgments": [2, 2, 2, 2, 2]},
|
| 29 |
+
"extreme": {"label": "两极分化", "judgments": [4, 1, 0, 1, 4]},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# --------------------------------------------------------------------------- #
|
| 34 |
+
# Data structures
|
| 35 |
+
# --------------------------------------------------------------------------- #
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class Expert:
|
| 40 |
+
name: str
|
| 41 |
+
field: str = ""
|
| 42 |
+
weight: float = 1.0
|
| 43 |
+
judgments: list[int] = dc_field(default_factory=lambda: [0, 1, 2, 3, 4])
|
| 44 |
+
|
| 45 |
+
def __post_init__(self) -> None:
|
| 46 |
+
self.weight = float(np.clip(self.weight, 0.1, 5.0))
|
| 47 |
+
if len(self.judgments) != 5:
|
| 48 |
+
raise ValueError("each expert must have exactly 5 judgments")
|
| 49 |
+
for j in self.judgments:
|
| 50 |
+
if not (0 <= int(j) <= 4):
|
| 51 |
+
raise ValueError("judgments must be in [0, 4]")
|
| 52 |
+
self.judgments = [int(j) for j in self.judgments]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# --------------------------------------------------------------------------- #
|
| 56 |
+
# Fusion methods
|
| 57 |
+
# --------------------------------------------------------------------------- #
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _likelihood_matrix(experts: Sequence[Expert], R: float) -> np.ndarray:
|
| 61 |
+
"""Shape (n_experts, 5) of per-expert likelihoods R^exponent."""
|
| 62 |
+
return np.array(
|
| 63 |
+
[[R ** JUDGMENT_WEIGHT_EXPONENTS[j] for j in e.judgments] for e in experts],
|
| 64 |
+
dtype=np.float64,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def fuse_weighted_geometric_mean(experts: Sequence[Expert], R: float) -> list[float]:
|
| 69 |
+
"""L_fused[k] = product over experts of L_i[k] ** (w_i / sum(w))."""
|
| 70 |
+
if not experts:
|
| 71 |
+
raise ValueError("experts must be non-empty")
|
| 72 |
+
L = _likelihood_matrix(experts, R)
|
| 73 |
+
weights = np.array([e.weight for e in experts], dtype=np.float64)
|
| 74 |
+
weights = weights / weights.sum()
|
| 75 |
+
log_l = np.log(L) * weights[:, None]
|
| 76 |
+
fused = np.exp(log_l.sum(axis=0))
|
| 77 |
+
return [float(x) for x in fused]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def fuse_weighted_arithmetic_mean(experts: Sequence[Expert], R: float) -> list[float]:
|
| 81 |
+
"""L_fused[k] = sum_i (w_i / sum(w)) * L_i[k]."""
|
| 82 |
+
if not experts:
|
| 83 |
+
raise ValueError("experts must be non-empty")
|
| 84 |
+
L = _likelihood_matrix(experts, R)
|
| 85 |
+
weights = np.array([e.weight for e in experts], dtype=np.float64)
|
| 86 |
+
weights = weights / weights.sum()
|
| 87 |
+
return [float(x) for x in (L * weights[:, None]).sum(axis=0)]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _combine_two_bpa(m1: np.ndarray, m2: np.ndarray) -> np.ndarray:
|
| 91 |
+
combined = m1 * m2 # agreement on the same focal element
|
| 92 |
+
conflict = (m1[:, None] * m2[None, :]).sum() - combined.sum()
|
| 93 |
+
norm = 1.0 - conflict
|
| 94 |
+
if norm <= 1e-12:
|
| 95 |
+
return np.array(combined) # high conflict; return un-normalised
|
| 96 |
+
return combined / norm
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def fuse_dempster_shafer(experts: Sequence[Expert], R: float) -> list[float]:
|
| 100 |
+
"""Iteratively apply Dempster's rule of combination across experts."""
|
| 101 |
+
if not experts:
|
| 102 |
+
raise ValueError("experts must be non-empty")
|
| 103 |
+
bpa_list: list[np.ndarray] = []
|
| 104 |
+
for e in experts:
|
| 105 |
+
raw = np.array(
|
| 106 |
+
[R ** JUDGMENT_WEIGHT_EXPONENTS[j] for j in e.judgments], dtype=np.float64
|
| 107 |
+
)
|
| 108 |
+
bpa_list.append(raw / raw.sum())
|
| 109 |
+
|
| 110 |
+
combined = bpa_list[0]
|
| 111 |
+
for m in bpa_list[1:]:
|
| 112 |
+
combined = _combine_two_bpa(combined, m)
|
| 113 |
+
return [float(x) for x in combined]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# --------------------------------------------------------------------------- #
|
| 117 |
+
# Consistency: Kendall's W
|
| 118 |
+
# --------------------------------------------------------------------------- #
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _average_ranks(values: Sequence[float]) -> np.ndarray:
|
| 122 |
+
"""Standard "average rank" tie-breaking (matches scipy's rankdata)."""
|
| 123 |
+
return stats.rankdata(values, method="average")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass(frozen=True)
|
| 127 |
+
class KendallWResult:
|
| 128 |
+
W: float
|
| 129 |
+
chi_squared: float
|
| 130 |
+
df: int
|
| 131 |
+
p_value: float
|
| 132 |
+
interpretation: str
|
| 133 |
+
m_raters: int
|
| 134 |
+
n_items: int
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def kendall_w(experts: Sequence[Expert]) -> KendallWResult:
|
| 138 |
+
"""Kendall's coefficient of concordance over the 5 quantile levels."""
|
| 139 |
+
m = len(experts)
|
| 140 |
+
if m < 2:
|
| 141 |
+
raise ValueError("need at least 2 experts for Kendall's W")
|
| 142 |
+
n = 5
|
| 143 |
+
|
| 144 |
+
# Rank each expert's 5 judgments
|
| 145 |
+
rankings = np.array([_average_ranks(e.judgments) for e in experts])
|
| 146 |
+
column_sums = rankings.sum(axis=0)
|
| 147 |
+
mean_sum = column_sums.mean()
|
| 148 |
+
S = float(((column_sums - mean_sum) ** 2).sum())
|
| 149 |
+
W = (12 * S) / (m * m * (n**3 - n))
|
| 150 |
+
W = float(min(max(W, 0.0), 1.0))
|
| 151 |
+
|
| 152 |
+
chi_sq = m * (n - 1) * W
|
| 153 |
+
df = n - 1
|
| 154 |
+
# scipy chi-squared survival function gives an exact p-value
|
| 155 |
+
p = float(stats.chi2.sf(chi_sq, df))
|
| 156 |
+
|
| 157 |
+
if W >= 0.7:
|
| 158 |
+
interp = "强一致 (strong agreement)"
|
| 159 |
+
elif W >= 0.5:
|
| 160 |
+
interp = "中等一致 (moderate agreement)"
|
| 161 |
+
elif W >= 0.3:
|
| 162 |
+
interp = "弱一致 (weak agreement)"
|
| 163 |
+
else:
|
| 164 |
+
interp = "无显著一致 (no significant agreement)"
|
| 165 |
+
|
| 166 |
+
return KendallWResult(
|
| 167 |
+
W=W,
|
| 168 |
+
chi_squared=chi_sq,
|
| 169 |
+
df=df,
|
| 170 |
+
p_value=p,
|
| 171 |
+
interpretation=interp,
|
| 172 |
+
m_raters=m,
|
| 173 |
+
n_items=n,
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# --------------------------------------------------------------------------- #
|
| 178 |
+
# Convenience: full pipeline returning posterior using fused likelihood
|
| 179 |
+
# --------------------------------------------------------------------------- #
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def compute_with_fused_likelihood(
|
| 183 |
+
data: Sequence[float], fused_likelihood: Sequence[float]
|
| 184 |
+
) -> dict:
|
| 185 |
+
"""Run the standard Bayesian update with a pre-fused likelihood."""
|
| 186 |
+
from app.domain.bayesian import (
|
| 187 |
+
PRIOR_WEIGHTS,
|
| 188 |
+
compute_posterior,
|
| 189 |
+
compute_quantiles,
|
| 190 |
+
extract_distribution_stats,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
quantiles = compute_quantiles(data)
|
| 194 |
+
qvals = [q.value for q in quantiles]
|
| 195 |
+
posterior, unnormalized, z = compute_posterior(list(PRIOR_WEIGHTS), list(fused_likelihood))
|
| 196 |
+
stats = extract_distribution_stats(qvals, posterior)
|
| 197 |
+
return {
|
| 198 |
+
"quantile_values": qvals,
|
| 199 |
+
"prior_weights": list(PRIOR_WEIGHTS),
|
| 200 |
+
"fused_likelihood": list(fused_likelihood),
|
| 201 |
+
"posterior_weights": posterior,
|
| 202 |
+
"unnormalized": unnormalized,
|
| 203 |
+
"normalization_constant": z,
|
| 204 |
+
"posterior_stats": stats,
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
__all__ = [
|
| 209 |
+
"JUDGMENT_PRESETS",
|
| 210 |
+
"Expert",
|
| 211 |
+
"KendallWResult",
|
| 212 |
+
"compute_with_fused_likelihood",
|
| 213 |
+
"fuse_dempster_shafer",
|
| 214 |
+
"fuse_weighted_arithmetic_mean",
|
| 215 |
+
"fuse_weighted_geometric_mean",
|
| 216 |
+
"kendall_w",
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
# Silence the unused-import warning for math (only used implicitly via numpy)
|
| 221 |
+
_ = math
|
backend/app/domain/multi_param.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-parameter joint scenario analysis.
|
| 2 |
+
|
| 3 |
+
Ports ``app/js/multi-param.js`` — when a user wants to analyse multiple
|
| 4 |
+
parameters under the same scenario (e.g. GDP growth + inflation + unemployment
|
| 5 |
+
under "high growth scenario"), this module provides:
|
| 6 |
+
|
| 7 |
+
* Pearson / Spearman correlations
|
| 8 |
+
* KL / JS divergences between posterior distributions
|
| 9 |
+
* Wasserstein (earth-mover) distance
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from collections.abc import Sequence
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
from scipy import stats
|
| 19 |
+
|
| 20 |
+
from app.domain.bayesian import BayesResult, compute
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class Parameter:
|
| 25 |
+
name: str
|
| 26 |
+
unit: str = ""
|
| 27 |
+
data: list[float] = field(default_factory=list)
|
| 28 |
+
judgments: list[int] = field(default_factory=lambda: [0, 1, 2, 3, 4])
|
| 29 |
+
R: float = 10.0
|
| 30 |
+
enabled: bool = True
|
| 31 |
+
result: BayesResult | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def batch_compute(params: Sequence[Parameter]) -> list[Parameter]:
|
| 35 |
+
out: list[Parameter] = []
|
| 36 |
+
for p in params:
|
| 37 |
+
if not p.enabled or not p.data:
|
| 38 |
+
out.append(p)
|
| 39 |
+
continue
|
| 40 |
+
p.result = compute(p.data, p.judgments, p.R)
|
| 41 |
+
out.append(p)
|
| 42 |
+
return out
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# --------------------------------------------------------------------------- #
|
| 46 |
+
# Correlations
|
| 47 |
+
# --------------------------------------------------------------------------- #
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def pearson_correlation(x: Sequence[float], y: Sequence[float]) -> float:
|
| 51 |
+
a = np.asarray(list(x), dtype=np.float64)
|
| 52 |
+
b = np.asarray(list(y), dtype=np.float64)
|
| 53 |
+
n = min(a.size, b.size)
|
| 54 |
+
if n < 2:
|
| 55 |
+
return 0.0
|
| 56 |
+
r = float(np.corrcoef(a[:n], b[:n])[0, 1])
|
| 57 |
+
return 0.0 if np.isnan(r) else r
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def spearman_correlation(x: Sequence[float], y: Sequence[float]) -> float:
|
| 61 |
+
a = np.asarray(list(x), dtype=np.float64)
|
| 62 |
+
b = np.asarray(list(y), dtype=np.float64)
|
| 63 |
+
n = min(a.size, b.size)
|
| 64 |
+
if n < 2:
|
| 65 |
+
return 0.0
|
| 66 |
+
r, _ = stats.spearmanr(a[:n], b[:n])
|
| 67 |
+
return 0.0 if r is None or np.isnan(r) else float(r)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def correlation_matrix(params: Sequence[Parameter], method: str = "pearson") -> list[list[float]]:
|
| 71 |
+
n = len(params)
|
| 72 |
+
fn = pearson_correlation if method == "pearson" else spearman_correlation
|
| 73 |
+
matrix = [[1.0] * n for _ in range(n)]
|
| 74 |
+
for i in range(n):
|
| 75 |
+
for j in range(i + 1, n):
|
| 76 |
+
matrix[i][j] = matrix[j][i] = fn(params[i].data, params[j].data)
|
| 77 |
+
return matrix
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# --------------------------------------------------------------------------- #
|
| 81 |
+
# Distribution distances
|
| 82 |
+
# --------------------------------------------------------------------------- #
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def kl_divergence(p: Sequence[float], q: Sequence[float], epsilon: float = 1e-12) -> float:
|
| 86 |
+
pa = np.asarray(p, dtype=np.float64) + epsilon
|
| 87 |
+
qa = np.asarray(q, dtype=np.float64) + epsilon
|
| 88 |
+
return float((pa * np.log(pa / qa)).sum())
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def js_divergence(p: Sequence[float], q: Sequence[float]) -> float:
|
| 92 |
+
pa = np.asarray(p, dtype=np.float64)
|
| 93 |
+
qa = np.asarray(q, dtype=np.float64)
|
| 94 |
+
m = (pa + qa) / 2
|
| 95 |
+
return 0.5 * kl_divergence(pa, m) + 0.5 * kl_divergence(qa, m)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def wasserstein_distance(p: Sequence[float], q: Sequence[float], values: Sequence[float]) -> float:
|
| 99 |
+
"""Discrete W1 between two distributions over the same support ``values``."""
|
| 100 |
+
return float(
|
| 101 |
+
stats.wasserstein_distance(values, values, u_weights=p, v_weights=q)
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# --------------------------------------------------------------------------- #
|
| 106 |
+
# Scenario comparison
|
| 107 |
+
# --------------------------------------------------------------------------- #
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@dataclass
|
| 111 |
+
class ScenarioDelta:
|
| 112 |
+
delta_mean: float
|
| 113 |
+
delta_std: float
|
| 114 |
+
kl: float
|
| 115 |
+
js: float
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def compare_scenarios(r1: BayesResult, r2: BayesResult) -> ScenarioDelta:
|
| 119 |
+
return ScenarioDelta(
|
| 120 |
+
delta_mean=r2.posterior.stats.mean - r1.posterior.stats.mean,
|
| 121 |
+
delta_std=r2.posterior.stats.std - r1.posterior.stats.std,
|
| 122 |
+
kl=kl_divergence(r1.posterior.weights, r2.posterior.weights),
|
| 123 |
+
js=js_divergence(r1.posterior.weights, r2.posterior.weights),
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
__all__ = [
|
| 128 |
+
"Parameter",
|
| 129 |
+
"ScenarioDelta",
|
| 130 |
+
"batch_compute",
|
| 131 |
+
"compare_scenarios",
|
| 132 |
+
"correlation_matrix",
|
| 133 |
+
"js_divergence",
|
| 134 |
+
"kl_divergence",
|
| 135 |
+
"pearson_correlation",
|
| 136 |
+
"spearman_correlation",
|
| 137 |
+
"wasserstein_distance",
|
| 138 |
+
]
|
backend/app/domain/preprocessing.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data preprocessing utilities — ported from ``app/js/preprocessing.js``.
|
| 2 |
+
|
| 3 |
+
Uses scipy/numpy where possible for more accurate distributions / tests than
|
| 4 |
+
the JS originals (e.g. exact Jarque-Bera p-value, real KS test, Yeo-Johnson
|
| 5 |
+
in addition to Box-Cox so we can handle non-positive data).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from collections.abc import Sequence
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from scipy import stats
|
| 15 |
+
|
| 16 |
+
# --------------------------------------------------------------------------- #
|
| 17 |
+
# Outlier detection
|
| 18 |
+
# --------------------------------------------------------------------------- #
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class OutlierResult:
|
| 23 |
+
method: str
|
| 24 |
+
indices: list[int]
|
| 25 |
+
values: list[float]
|
| 26 |
+
lower_bound: float
|
| 27 |
+
upper_bound: float
|
| 28 |
+
count: int
|
| 29 |
+
percentage: float
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def detect_outliers_iqr(data: Sequence[float], k: float = 1.5) -> OutlierResult:
|
| 33 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 34 |
+
if arr.size == 0:
|
| 35 |
+
raise ValueError("data must be non-empty")
|
| 36 |
+
q1, q3 = np.quantile(arr, [0.25, 0.75])
|
| 37 |
+
iqr = q3 - q1
|
| 38 |
+
lower = q1 - k * iqr
|
| 39 |
+
upper = q3 + k * iqr
|
| 40 |
+
mask = (arr < lower) | (arr > upper)
|
| 41 |
+
idx = np.where(mask)[0].tolist()
|
| 42 |
+
return OutlierResult(
|
| 43 |
+
method="iqr",
|
| 44 |
+
indices=idx,
|
| 45 |
+
values=arr[mask].tolist(),
|
| 46 |
+
lower_bound=float(lower),
|
| 47 |
+
upper_bound=float(upper),
|
| 48 |
+
count=int(mask.sum()),
|
| 49 |
+
percentage=float(mask.mean() * 100),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def detect_outliers_zscore(data: Sequence[float], threshold: float = 3.0) -> OutlierResult:
|
| 54 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 55 |
+
if arr.size == 0:
|
| 56 |
+
raise ValueError("data must be non-empty")
|
| 57 |
+
mean = float(arr.mean())
|
| 58 |
+
std = float(arr.std(ddof=1)) if arr.size > 1 else 0.0
|
| 59 |
+
if std == 0:
|
| 60 |
+
return OutlierResult(
|
| 61 |
+
method="zscore",
|
| 62 |
+
indices=[],
|
| 63 |
+
values=[],
|
| 64 |
+
lower_bound=mean,
|
| 65 |
+
upper_bound=mean,
|
| 66 |
+
count=0,
|
| 67 |
+
percentage=0.0,
|
| 68 |
+
)
|
| 69 |
+
z = (arr - mean) / std
|
| 70 |
+
mask = np.abs(z) > threshold
|
| 71 |
+
idx = np.where(mask)[0].tolist()
|
| 72 |
+
return OutlierResult(
|
| 73 |
+
method="zscore",
|
| 74 |
+
indices=idx,
|
| 75 |
+
values=arr[mask].tolist(),
|
| 76 |
+
lower_bound=float(mean - threshold * std),
|
| 77 |
+
upper_bound=float(mean + threshold * std),
|
| 78 |
+
count=int(mask.sum()),
|
| 79 |
+
percentage=float(mask.mean() * 100),
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def winsorize(data: Sequence[float], lower_pct: float = 0.05, upper_pct: float = 0.95) -> list[float]:
|
| 84 |
+
"""Replace values below lower_pct / above upper_pct percentile."""
|
| 85 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 86 |
+
lo, hi = np.quantile(arr, [lower_pct, upper_pct])
|
| 87 |
+
return np.clip(arr, lo, hi).tolist()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# --------------------------------------------------------------------------- #
|
| 91 |
+
# Normality tests
|
| 92 |
+
# --------------------------------------------------------------------------- #
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@dataclass(frozen=True)
|
| 96 |
+
class NormalityResult:
|
| 97 |
+
test: str
|
| 98 |
+
statistic: float
|
| 99 |
+
p_value: float
|
| 100 |
+
is_normal: bool # True if cannot reject normality at alpha = 0.05
|
| 101 |
+
n: int
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def jarque_bera(data: Sequence[float]) -> NormalityResult:
|
| 105 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 106 |
+
if arr.size < 8:
|
| 107 |
+
raise ValueError("Jarque-Bera needs at least 8 samples")
|
| 108 |
+
result = stats.jarque_bera(arr)
|
| 109 |
+
return NormalityResult(
|
| 110 |
+
test="jarque-bera",
|
| 111 |
+
statistic=float(result.statistic),
|
| 112 |
+
p_value=float(result.pvalue),
|
| 113 |
+
is_normal=bool(result.pvalue > 0.05),
|
| 114 |
+
n=int(arr.size),
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def shapiro_wilk(data: Sequence[float]) -> NormalityResult:
|
| 119 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 120 |
+
if arr.size < 3:
|
| 121 |
+
raise ValueError("Shapiro-Wilk needs at least 3 samples")
|
| 122 |
+
if arr.size > 5000:
|
| 123 |
+
# Shapiro-Wilk is unreliable for very large N
|
| 124 |
+
arr = np.random.default_rng(42).choice(arr, size=5000, replace=False)
|
| 125 |
+
s, p = stats.shapiro(arr)
|
| 126 |
+
return NormalityResult(
|
| 127 |
+
test="shapiro-wilk",
|
| 128 |
+
statistic=float(s),
|
| 129 |
+
p_value=float(p),
|
| 130 |
+
is_normal=bool(p > 0.05),
|
| 131 |
+
n=int(arr.size),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def ks_normal(data: Sequence[float]) -> NormalityResult:
|
| 136 |
+
"""One-sample KS against fitted normal distribution."""
|
| 137 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 138 |
+
if arr.size < 4:
|
| 139 |
+
raise ValueError("KS test needs at least 4 samples")
|
| 140 |
+
mean = float(arr.mean())
|
| 141 |
+
std = float(arr.std(ddof=1))
|
| 142 |
+
if std == 0:
|
| 143 |
+
return NormalityResult("ks-normal", 0.0, 1.0, True, int(arr.size))
|
| 144 |
+
res = stats.kstest(arr, "norm", args=(mean, std))
|
| 145 |
+
return NormalityResult(
|
| 146 |
+
test="ks-normal",
|
| 147 |
+
statistic=float(res.statistic),
|
| 148 |
+
p_value=float(res.pvalue),
|
| 149 |
+
is_normal=bool(res.pvalue > 0.05),
|
| 150 |
+
n=int(arr.size),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# --------------------------------------------------------------------------- #
|
| 155 |
+
# Transformations
|
| 156 |
+
# --------------------------------------------------------------------------- #
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@dataclass(frozen=True)
|
| 160 |
+
class TransformResult:
|
| 161 |
+
transform: str
|
| 162 |
+
values: list[float]
|
| 163 |
+
lambda_: float | None = None
|
| 164 |
+
shifted_by: float | None = None
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def log_transform(data: Sequence[float], base: float = 10.0) -> TransformResult:
|
| 168 |
+
"""log_base(x + shift), where shift = 1 - min(x) if any non-positive values."""
|
| 169 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 170 |
+
shift = max(0.0, 1.0 - float(arr.min()))
|
| 171 |
+
shifted = arr + shift
|
| 172 |
+
if base == np.e:
|
| 173 |
+
out = np.log(shifted)
|
| 174 |
+
else:
|
| 175 |
+
out = np.log(shifted) / np.log(base)
|
| 176 |
+
return TransformResult(
|
| 177 |
+
transform=f"log_{base}",
|
| 178 |
+
values=out.tolist(),
|
| 179 |
+
shifted_by=shift if shift > 0 else None,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def boxcox_transform(data: Sequence[float]) -> TransformResult:
|
| 184 |
+
"""Box-Cox needs strictly positive data; falls back to Yeo-Johnson otherwise."""
|
| 185 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 186 |
+
if (arr > 0).all():
|
| 187 |
+
out, lam = stats.boxcox(arr)
|
| 188 |
+
return TransformResult("boxcox", out.tolist(), lambda_=float(lam))
|
| 189 |
+
# Yeo-Johnson handles zero/negative
|
| 190 |
+
out, lam = stats.yeojohnson(arr)
|
| 191 |
+
return TransformResult("yeo-johnson", out.tolist(), lambda_=float(lam))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# --------------------------------------------------------------------------- #
|
| 195 |
+
# Histogram binning (Freedman-Diaconis / Sturges / Scott)
|
| 196 |
+
# --------------------------------------------------------------------------- #
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def optimal_bin_count(data: Sequence[float], method: str = "fd") -> int:
|
| 200 |
+
arr = np.asarray(list(data), dtype=np.float64)
|
| 201 |
+
n = arr.size
|
| 202 |
+
if n < 2:
|
| 203 |
+
return 1
|
| 204 |
+
if method == "fd":
|
| 205 |
+
# Freedman-Diaconis
|
| 206 |
+
q75, q25 = np.quantile(arr, [0.75, 0.25])
|
| 207 |
+
iqr = q75 - q25
|
| 208 |
+
h = 2 * iqr / (n ** (1 / 3)) if iqr > 0 else 0
|
| 209 |
+
if h == 0:
|
| 210 |
+
return int(np.ceil(np.log2(n) + 1))
|
| 211 |
+
bins = int(np.ceil((arr.max() - arr.min()) / h))
|
| 212 |
+
elif method == "sturges":
|
| 213 |
+
bins = int(np.ceil(np.log2(n) + 1))
|
| 214 |
+
elif method == "scott":
|
| 215 |
+
std = float(arr.std(ddof=1))
|
| 216 |
+
h = 3.5 * std / (n ** (1 / 3))
|
| 217 |
+
bins = int(np.ceil((arr.max() - arr.min()) / h)) if h > 0 else 10
|
| 218 |
+
else:
|
| 219 |
+
raise ValueError(f"unknown method {method}")
|
| 220 |
+
return max(1, min(bins, 200))
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
__all__ = [
|
| 224 |
+
"NormalityResult",
|
| 225 |
+
"OutlierResult",
|
| 226 |
+
"TransformResult",
|
| 227 |
+
"boxcox_transform",
|
| 228 |
+
"detect_outliers_iqr",
|
| 229 |
+
"detect_outliers_zscore",
|
| 230 |
+
"jarque_bera",
|
| 231 |
+
"ks_normal",
|
| 232 |
+
"log_transform",
|
| 233 |
+
"optimal_bin_count",
|
| 234 |
+
"shapiro_wilk",
|
| 235 |
+
"winsorize",
|
| 236 |
+
]
|
backend/app/domain/report.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Markdown report generator.
|
| 2 |
+
|
| 3 |
+
Builds a structured analysis report from one BayesResult. Markdown only
|
| 4 |
+
(no PDF dependency for now) — most users either copy the markdown directly
|
| 5 |
+
or render it client-side. PDF export can be layered later via a small
|
| 6 |
+
WeasyPrint wrapper that takes the markdown output of this module.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections.abc import Sequence
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from textwrap import dedent
|
| 14 |
+
|
| 15 |
+
from app.domain.bayesian import (
|
| 16 |
+
JUDGMENT_LABELS_ZH,
|
| 17 |
+
BayesResult,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _fmt(v: float, decimals: int = 3) -> str:
|
| 22 |
+
if v is None:
|
| 23 |
+
return "—"
|
| 24 |
+
return f"{v:.{decimals}f}"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _pct(v: float, decimals: int = 1) -> str:
|
| 28 |
+
return f"{v * 100:.{decimals}f}%"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def render_markdown_report(
|
| 32 |
+
result: BayesResult,
|
| 33 |
+
*,
|
| 34 |
+
scenario_name: str = "未命名情景",
|
| 35 |
+
reference_case: str = "",
|
| 36 |
+
dataset_description: str = "",
|
| 37 |
+
judgment_rationale: str = "",
|
| 38 |
+
sensitivity_rows: Sequence[dict] | None = None,
|
| 39 |
+
) -> str:
|
| 40 |
+
"""Generate a structured Markdown report for one Bayesian analysis."""
|
| 41 |
+
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 42 |
+
labels = result.level_labels_zh
|
| 43 |
+
judgment_choices = [JUDGMENT_LABELS_ZH[j] for j in result.likelihood.judgments]
|
| 44 |
+
|
| 45 |
+
header = dedent(
|
| 46 |
+
f"""\
|
| 47 |
+
# 贝叶斯情景参数量化分析报告
|
| 48 |
+
|
| 49 |
+
> **情景**:{scenario_name}
|
| 50 |
+
>
|
| 51 |
+
> **生成时间**:{now}
|
| 52 |
+
>
|
| 53 |
+
> **参考数据**:{dataset_description or '未指定'}
|
| 54 |
+
>
|
| 55 |
+
> **判断强度** R = {result.likelihood.R}
|
| 56 |
+
"""
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
if reference_case:
|
| 60 |
+
header += f"\n**参考案例描述**:{reference_case}\n"
|
| 61 |
+
|
| 62 |
+
# Section 1 — descriptive statistics
|
| 63 |
+
s = result.prior.stats
|
| 64 |
+
sec_stats = dedent(
|
| 65 |
+
f"""
|
| 66 |
+
## 一、数据描述统计
|
| 67 |
+
|
| 68 |
+
| 指标 | 值 |
|
| 69 |
+
|------|----|
|
| 70 |
+
| 样本量 N | {s.n} |
|
| 71 |
+
| 均值 mean | {_fmt(s.mean)} |
|
| 72 |
+
| 标准差 std | {_fmt(s.std)} |
|
| 73 |
+
| 中位数 median | {_fmt(s.median)} |
|
| 74 |
+
| 偏度 skewness | {_fmt(s.skewness, 2)} |
|
| 75 |
+
| 峰度 kurtosis | {_fmt(s.kurtosis, 2)} |
|
| 76 |
+
| 最小值 min | {_fmt(s.min)} |
|
| 77 |
+
| 最大值 max | {_fmt(s.max)} |
|
| 78 |
+
"""
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Section 2 — prior
|
| 82 |
+
qrows = "\n".join(
|
| 83 |
+
f"| {labels[i]} | q = {result.prior.quantiles[i].probability} | "
|
| 84 |
+
f"{_fmt(result.prior.quantile_values[i])} | {_pct(result.prior.weights[i])} |"
|
| 85 |
+
for i in range(5)
|
| 86 |
+
)
|
| 87 |
+
sec_prior = dedent(
|
| 88 |
+
"""
|
| 89 |
+
## 二、先验分布(5 点离散)
|
| 90 |
+
|
| 91 |
+
| 水平 | 分位概率 | 分位值 | 先验权重 |
|
| 92 |
+
|------|---------|--------|---------|
|
| 93 |
+
"""
|
| 94 |
+
) + qrows + "\n"
|
| 95 |
+
|
| 96 |
+
# Section 3 — judgments and likelihood
|
| 97 |
+
jrows = "\n".join(
|
| 98 |
+
f"| {labels[i]} | {judgment_choices[i]} | "
|
| 99 |
+
f"{_fmt(result.likelihood.weights[i], 4)} |"
|
| 100 |
+
for i in range(5)
|
| 101 |
+
)
|
| 102 |
+
sec_judgments = dedent(
|
| 103 |
+
f"""
|
| 104 |
+
## 三、专家可能性判断
|
| 105 |
+
|
| 106 |
+
判断强度 R = {result.likelihood.R},对应「极有可能 : 极不可能」影响力比为
|
| 107 |
+
**{result.likelihood.R ** 4:.0f} : 1**。
|
| 108 |
+
|
| 109 |
+
| 参数水平 | 5 级赌注 | 似然权重 R^k |
|
| 110 |
+
|---------|---------|--------------|
|
| 111 |
+
"""
|
| 112 |
+
) + jrows + "\n"
|
| 113 |
+
if judgment_rationale:
|
| 114 |
+
sec_judgments += f"\n**判断理由**:{judgment_rationale}\n"
|
| 115 |
+
|
| 116 |
+
# Section 4 — posterior
|
| 117 |
+
p = result.posterior
|
| 118 |
+
ps = p.stats
|
| 119 |
+
post_rows = "\n".join(
|
| 120 |
+
f"| {labels[i]} | {_pct(result.prior.weights[i])} | {_pct(p.weights[i])} |"
|
| 121 |
+
for i in range(5)
|
| 122 |
+
)
|
| 123 |
+
shift = ps.mean - result.prior.summary_stats.mean
|
| 124 |
+
shrink_pct = (
|
| 125 |
+
(result.prior.summary_stats.std - ps.std) / result.prior.summary_stats.std * 100
|
| 126 |
+
if result.prior.summary_stats.std > 0
|
| 127 |
+
else 0.0
|
| 128 |
+
)
|
| 129 |
+
sec_posterior = dedent(
|
| 130 |
+
"""
|
| 131 |
+
## 四、后验分布与结论
|
| 132 |
+
|
| 133 |
+
| 参数水平 | 先验 P(z) | 后验 P(z\\|S) |
|
| 134 |
+
|---------|----------|--------------|
|
| 135 |
+
"""
|
| 136 |
+
) + post_rows + "\n" + dedent(
|
| 137 |
+
f"""
|
| 138 |
+
**后验汇总统计**:
|
| 139 |
+
|
| 140 |
+
| 指标 | 值 | 相对先验变化 |
|
| 141 |
+
|------|----|------------|
|
| 142 |
+
| 后验均值 | {_fmt(ps.mean)} | Δ = {shift:+.3f} |
|
| 143 |
+
| 后验中位数 | {_fmt(ps.median)} | — |
|
| 144 |
+
| 后验标准差 | {_fmt(ps.std)} | 收窄 {shrink_pct:.1f}% |
|
| 145 |
+
| 95% 置信区间 | [{_fmt(ps.ci95_lower)}, {_fmt(ps.ci95_upper)}] | — |
|
| 146 |
+
| 50% 置信区间 | [{_fmt(ps.ci50_lower)}, {_fmt(ps.ci50_upper)}] | — |
|
| 147 |
+
"""
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
# Section 5 — sensitivity (optional)
|
| 151 |
+
sec_sensitivity = ""
|
| 152 |
+
if sensitivity_rows:
|
| 153 |
+
srows = "\n".join(
|
| 154 |
+
f"| {row['R']} | {_fmt(row['posterior_mean'])} | "
|
| 155 |
+
f"{_fmt(row['posterior_std'])} | "
|
| 156 |
+
f"[{_fmt(row.get('ci95_lower', 0))}, {_fmt(row.get('ci95_upper', 0))}] |"
|
| 157 |
+
for row in sensitivity_rows
|
| 158 |
+
)
|
| 159 |
+
means = [r["posterior_mean"] for r in sensitivity_rows]
|
| 160 |
+
swing = (
|
| 161 |
+
(max(means) - min(means)) / abs(means[len(means) // 2]) * 100
|
| 162 |
+
if means[len(means) // 2] != 0
|
| 163 |
+
else 0.0
|
| 164 |
+
)
|
| 165 |
+
sec_sensitivity = dedent(
|
| 166 |
+
"""
|
| 167 |
+
## 五、稳健性检验(R 值敏感性)
|
| 168 |
+
|
| 169 |
+
| R 值 | 后验均值 | 后验标准差 | 95% CI |
|
| 170 |
+
|------|---------|-----------|--------|
|
| 171 |
+
"""
|
| 172 |
+
) + srows + "\n" + dedent(
|
| 173 |
+
f"""
|
| 174 |
+
|
| 175 |
+
后验均值在 R∈[{min(r['R'] for r in sensitivity_rows)},
|
| 176 |
+
{max(r['R'] for r in sensitivity_rows)}] 范围内的波动幅度约
|
| 177 |
+
**{swing:.1f}%**。{'结论稳健。' if swing < 20 else '结论对 R 较为敏感,建议谨慎解读。'}
|
| 178 |
+
"""
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
# Section 6 — interpretation
|
| 182 |
+
direction = (
|
| 183 |
+
"正向移动" if shift > 0.01 else "负向移动" if shift < -0.01 else "基本持平"
|
| 184 |
+
)
|
| 185 |
+
sec_conclusion = dedent(
|
| 186 |
+
f"""
|
| 187 |
+
## 六、关键发现
|
| 188 |
+
|
| 189 |
+
- 在「{scenario_name}」假设下,后验均值相对先验
|
| 190 |
+
**{direction}** Δ = {shift:+.3f}(先验均值 = {_fmt(result.prior.summary_stats.mean)},
|
| 191 |
+
后验均值 = {_fmt(ps.mean)})。
|
| 192 |
+
- 后验标准差从 {_fmt(result.prior.summary_stats.std)} 收窄到
|
| 193 |
+
{_fmt(ps.std)}({shrink_pct:.1f}%),表明专家判断带来的信息显著
|
| 194 |
+
降低了不确定性。
|
| 195 |
+
- 95% 置信区间收紧到 [{_fmt(ps.ci95_lower)}, {_fmt(ps.ci95_upper)}]。
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
*本报告由 BayesScenParams Agent 自动生成,方法基础:
|
| 200 |
+
Kemp-Benedict (2010)。*
|
| 201 |
+
"""
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
return (
|
| 205 |
+
header
|
| 206 |
+
+ sec_stats
|
| 207 |
+
+ sec_prior
|
| 208 |
+
+ sec_judgments
|
| 209 |
+
+ sec_posterior
|
| 210 |
+
+ sec_sensitivity
|
| 211 |
+
+ sec_conclusion
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
__all__ = ["render_markdown_report"]
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry-point for BayesScenParams Agent.
|
| 2 |
+
|
| 3 |
+
In production (e.g. Hugging Face Spaces single-container deployment), the
|
| 4 |
+
compiled frontend is copied into ``/app/frontend_dist`` and mounted at ``/``
|
| 5 |
+
so the same FastAPI process serves both the API and the SPA. Set
|
| 6 |
+
``FRONTEND_DIST`` env var to point at a different directory if needed.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
from contextlib import asynccontextmanager
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from fastapi import FastAPI
|
| 17 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
+
from fastapi.responses import FileResponse
|
| 19 |
+
from fastapi.staticfiles import StaticFiles
|
| 20 |
+
|
| 21 |
+
from app.api import bayesian as bayesian_api
|
| 22 |
+
from app.api import data as data_api
|
| 23 |
+
from app.api import health as health_api
|
| 24 |
+
from app.api import sessions as sessions_api
|
| 25 |
+
from app.core import db as core_db
|
| 26 |
+
from app.core.config import BACKEND_DIR, get_settings
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger("bayesscenparams")
|
| 29 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def lifespan(app: FastAPI):
|
| 34 |
+
settings = get_settings()
|
| 35 |
+
logger.info("Starting BayesScenParams backend")
|
| 36 |
+
logger.info(" model = %s", settings.anthropic_model)
|
| 37 |
+
logger.info(" anthropic key = %s", "set" if settings.has_anthropic_key else "MISSING")
|
| 38 |
+
logger.info(
|
| 39 |
+
" base URL = %s",
|
| 40 |
+
settings.anthropic_base_url or "https://api.anthropic.com (official)",
|
| 41 |
+
)
|
| 42 |
+
logger.info(" agent runtime = %s", settings.agent_runtime)
|
| 43 |
+
logger.info(" cache dir = %s", settings.data_cache_dir)
|
| 44 |
+
logger.info(" cors origins = %s", settings.cors_origin_list)
|
| 45 |
+
core_db.init_db()
|
| 46 |
+
# Lazy-import the agent module so the SDK isn't required when the user only
|
| 47 |
+
# wants to use the non-agent REST endpoints.
|
| 48 |
+
try:
|
| 49 |
+
from app.api import agent as agent_api
|
| 50 |
+
|
| 51 |
+
app.include_router(agent_api.router)
|
| 52 |
+
logger.info(" agent endpoint = mounted")
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.warning(" agent endpoint = NOT mounted (%s)", e)
|
| 55 |
+
yield
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def create_app() -> FastAPI:
|
| 59 |
+
settings = get_settings()
|
| 60 |
+
app = FastAPI(
|
| 61 |
+
title="BayesScenParams Agent",
|
| 62 |
+
version="0.1.0",
|
| 63 |
+
description=(
|
| 64 |
+
"Conversational autonomous research agent for Bayesian scenario "
|
| 65 |
+
"parameter quantization, powered by Claude Agent SDK."
|
| 66 |
+
),
|
| 67 |
+
lifespan=lifespan,
|
| 68 |
+
)
|
| 69 |
+
app.add_middleware(
|
| 70 |
+
CORSMiddleware,
|
| 71 |
+
allow_origins=settings.cors_origin_list,
|
| 72 |
+
allow_credentials=True,
|
| 73 |
+
allow_methods=["*"],
|
| 74 |
+
allow_headers=["*"],
|
| 75 |
+
)
|
| 76 |
+
app.include_router(health_api.router)
|
| 77 |
+
app.include_router(bayesian_api.router)
|
| 78 |
+
app.include_router(data_api.router)
|
| 79 |
+
app.include_router(sessions_api.router)
|
| 80 |
+
|
| 81 |
+
# Optionally mount the compiled frontend (single-container deployments).
|
| 82 |
+
frontend_dir = Path(os.environ.get("FRONTEND_DIST", BACKEND_DIR / "frontend_dist"))
|
| 83 |
+
if frontend_dir.is_dir() and (frontend_dir / "index.html").exists():
|
| 84 |
+
assets_dir = frontend_dir / "assets"
|
| 85 |
+
if assets_dir.is_dir():
|
| 86 |
+
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
| 87 |
+
|
| 88 |
+
# SPA fallback: serve index.html for any non-API path
|
| 89 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 90 |
+
async def spa_fallback(full_path: str):
|
| 91 |
+
# Static files inside frontend_dist (e.g. favicon)
|
| 92 |
+
candidate = frontend_dir / full_path
|
| 93 |
+
if candidate.is_file():
|
| 94 |
+
return FileResponse(candidate)
|
| 95 |
+
return FileResponse(frontend_dir / "index.html")
|
| 96 |
+
|
| 97 |
+
logger.info("Frontend mounted from %s", frontend_dir)
|
| 98 |
+
else:
|
| 99 |
+
logger.info("Frontend not mounted (no dist found at %s)", frontend_dir)
|
| 100 |
+
|
| 101 |
+
return app
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
app = create_app()
|
backend/pyproject.toml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68", "wheel"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "bayesscenparams-backend"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "FastAPI + Claude Agent SDK backend for BayesScenParams"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
requires-python = ">=3.11"
|
| 11 |
+
license = { text = "MIT" }
|
| 12 |
+
authors = [
|
| 13 |
+
{ name = "BayesScenParams" }
|
| 14 |
+
]
|
| 15 |
+
dependencies = [
|
| 16 |
+
# Web framework
|
| 17 |
+
"fastapi>=0.115.0",
|
| 18 |
+
"uvicorn[standard]>=0.32.0",
|
| 19 |
+
"python-multipart>=0.0.12",
|
| 20 |
+
"sse-starlette>=2.1.0",
|
| 21 |
+
# Settings / validation
|
| 22 |
+
"pydantic>=2.9.0",
|
| 23 |
+
"pydantic-settings>=2.5.0",
|
| 24 |
+
# HTTP client (for World Bank API etc.)
|
| 25 |
+
"httpx>=0.27.0",
|
| 26 |
+
# Numerical / stats
|
| 27 |
+
"numpy>=2.0.0",
|
| 28 |
+
"scipy>=1.14.0",
|
| 29 |
+
# Claude Agent SDK (drives the official Claude Code protocol)
|
| 30 |
+
"claude-agent-sdk>=0.1.0",
|
| 31 |
+
# Official Anthropic Python SDK (direct Messages API; works through
|
| 32 |
+
# community proxies like zhihuiapi / OneAPI / NewAPI as well)
|
| 33 |
+
"anthropic>=0.40.0",
|
| 34 |
+
# Persistence (SQLite for Phase 2; included now to avoid future re-installs)
|
| 35 |
+
"sqlalchemy>=2.0.0",
|
| 36 |
+
"aiosqlite>=0.20.0",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
[project.optional-dependencies]
|
| 40 |
+
dev = [
|
| 41 |
+
"pytest>=8.3.0",
|
| 42 |
+
"pytest-asyncio>=0.24.0",
|
| 43 |
+
"pytest-cov>=5.0.0",
|
| 44 |
+
"ruff>=0.7.0",
|
| 45 |
+
"mypy>=1.13.0",
|
| 46 |
+
"httpx>=0.27.0", # for TestClient
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
[tool.setuptools.packages.find]
|
| 50 |
+
where = ["."]
|
| 51 |
+
include = ["app*"]
|
| 52 |
+
|
| 53 |
+
[tool.ruff]
|
| 54 |
+
line-length = 100
|
| 55 |
+
target-version = "py311"
|
| 56 |
+
|
| 57 |
+
[tool.ruff.lint]
|
| 58 |
+
select = ["E", "F", "I", "B", "UP", "N", "RUF"]
|
| 59 |
+
ignore = [
|
| 60 |
+
"E501", # long lines (we run with line-length=100 already)
|
| 61 |
+
"N803", # arg name "R" — matches mathematical convention
|
| 62 |
+
"N806", # local var "R", "N", "S" — math notation
|
| 63 |
+
"RUF001", # ambiguous-unicode-character-string (Chinese punctuation in prompts)
|
| 64 |
+
"RUF002", # ambiguous-unicode-character-docstring
|
| 65 |
+
"B008", # FastAPI Depends/File use this pattern
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
[tool.ruff.lint.per-file-ignores]
|
| 69 |
+
"tests/*" = ["E741", "F841", "N802", "N806"]
|
| 70 |
+
|
| 71 |
+
[tool.pytest.ini_options]
|
| 72 |
+
testpaths = ["tests"]
|
| 73 |
+
asyncio_mode = "auto"
|
| 74 |
+
addopts = "-v --tb=short"
|
backend/tests/__init__.py
ADDED
|
File without changes
|
backend/tests/test_agent_sse.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke test for the /api/agent/chat SSE endpoint.
|
| 2 |
+
|
| 3 |
+
We don't hit the real Claude API in unit tests. Instead we verify the SSE
|
| 4 |
+
framing & graceful error when no key is configured.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.testclient import TestClient
|
| 11 |
+
|
| 12 |
+
from app.api import agent as agent_api
|
| 13 |
+
from app.core import config as cfg
|
| 14 |
+
from app.core.config import get_settings
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _app() -> FastAPI:
|
| 18 |
+
app = FastAPI()
|
| 19 |
+
app.include_router(agent_api.router)
|
| 20 |
+
return app
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_chat_streams_error_without_api_key(monkeypatch):
|
| 24 |
+
# Bypass project .env by pointing pydantic-settings at a non-existent file.
|
| 25 |
+
monkeypatch.setattr(
|
| 26 |
+
cfg.Settings,
|
| 27 |
+
"model_config",
|
| 28 |
+
{**cfg.Settings.model_config, "env_file": "/nonexistent.env"},
|
| 29 |
+
)
|
| 30 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "")
|
| 31 |
+
cfg.get_settings.cache_clear()
|
| 32 |
+
assert not get_settings().has_anthropic_key
|
| 33 |
+
|
| 34 |
+
client = TestClient(_app())
|
| 35 |
+
with client.stream("POST", "/api/agent/chat", json={"prompt": "hi"}) as r:
|
| 36 |
+
assert r.status_code == 200
|
| 37 |
+
assert r.headers["content-type"].startswith("text/event-stream")
|
| 38 |
+
body = b"".join(r.iter_bytes()).decode("utf-8")
|
| 39 |
+
|
| 40 |
+
# Should contain at least one error event and a done event
|
| 41 |
+
assert "event: error" in body
|
| 42 |
+
assert "ANTHROPIC_API_KEY" in body
|
| 43 |
+
assert "event: done" in body
|
| 44 |
+
cfg.get_settings.cache_clear()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_chat_validates_payload():
|
| 48 |
+
client = TestClient(_app())
|
| 49 |
+
r = client.post("/api/agent/chat", json={"prompt": ""})
|
| 50 |
+
assert r.status_code == 422
|
backend/tests/test_api.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke tests for the FastAPI app."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
from app.main import create_app
|
| 12 |
+
|
| 13 |
+
SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture(scope="module")
|
| 17 |
+
def client() -> TestClient:
|
| 18 |
+
return TestClient(create_app())
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_health(client: TestClient):
|
| 22 |
+
r = client.get("/api/health")
|
| 23 |
+
assert r.status_code == 200
|
| 24 |
+
body = r.json()
|
| 25 |
+
assert body["status"] == "ok"
|
| 26 |
+
assert body["service"] == "bayesscenparams-backend"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_list_samples(client: TestClient):
|
| 30 |
+
r = client.get("/api/data/samples")
|
| 31 |
+
assert r.status_code == 200
|
| 32 |
+
body = r.json()
|
| 33 |
+
ids = {s["id"] for s in body}
|
| 34 |
+
assert ids == {"gdp", "climate", "population"}
|
| 35 |
+
for s in body:
|
| 36 |
+
assert s["n"] > 0
|
| 37 |
+
assert s["icon"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_get_sample(client: TestClient):
|
| 41 |
+
r = client.get("/api/data/samples/gdp")
|
| 42 |
+
assert r.status_code == 200
|
| 43 |
+
body = r.json()
|
| 44 |
+
assert body["id"] == "gdp"
|
| 45 |
+
assert len(body["values"]) > 100
|
| 46 |
+
assert isinstance(body["values"][0], float)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_bayesian_compute(client: TestClient):
|
| 50 |
+
gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())
|
| 51 |
+
payload = {
|
| 52 |
+
"data": gdp["values"],
|
| 53 |
+
"judgments": [0, 1, 2, 3, 4],
|
| 54 |
+
"R": 10.0,
|
| 55 |
+
}
|
| 56 |
+
r = client.post("/api/bayesian/compute", json=payload)
|
| 57 |
+
assert r.status_code == 200
|
| 58 |
+
body = r.json()
|
| 59 |
+
assert "prior" in body and "likelihood" in body and "posterior" in body
|
| 60 |
+
assert len(body["posterior"]["weights"]) == 5
|
| 61 |
+
assert abs(sum(body["posterior"]["weights"]) - 1.0) < 1e-9
|
| 62 |
+
# likelihood for level 4 is R^2 = 100
|
| 63 |
+
assert body["likelihood"]["weights"][4] == pytest.approx(100.0)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_bayesian_compute_validates_R():
|
| 67 |
+
client = TestClient(create_app())
|
| 68 |
+
gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())
|
| 69 |
+
r = client.post(
|
| 70 |
+
"/api/bayesian/compute",
|
| 71 |
+
json={"data": gdp["values"], "judgments": [0, 1, 2, 3, 4], "R": 0.5},
|
| 72 |
+
)
|
| 73 |
+
assert r.status_code == 422
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_sensitivity(client: TestClient):
|
| 77 |
+
gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())
|
| 78 |
+
r = client.post(
|
| 79 |
+
"/api/bayesian/sensitivity",
|
| 80 |
+
json={"data": gdp["values"], "judgments": [0, 1, 2, 3, 4]},
|
| 81 |
+
)
|
| 82 |
+
assert r.status_code == 200
|
| 83 |
+
body = r.json()
|
| 84 |
+
assert len(body["points"]) == 5
|
| 85 |
+
# Each point's posterior sums to 1
|
| 86 |
+
for p in body["points"]:
|
| 87 |
+
s = sum(p["result"]["posterior"]["weights"])
|
| 88 |
+
assert abs(s - 1.0) < 1e-9
|
backend/tests/test_bayesian_parity.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Parity tests for the Python port of bayesian.js.
|
| 3 |
+
|
| 4 |
+
Strategy: load each sample dataset, run it through the original JS engine via
|
| 5 |
+
Node.js, and compare every output number with the Python port. Tolerance is
|
| 6 |
+
1e-9, well within IEEE 754 round-off across Node's V8 and CPython.
|
| 7 |
+
|
| 8 |
+
The Node side reads the same ``app/js/bayesian.js`` file the production frontend
|
| 9 |
+
uses. We do NOT vendor it — it stays the single source of the JS algorithm.
|
| 10 |
+
|
| 11 |
+
If Node.js isn't available the JS comparison is skipped and the test falls back
|
| 12 |
+
to known-good golden values cached in this file (so CI works without Node).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import math
|
| 19 |
+
import shutil
|
| 20 |
+
import subprocess
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
from app.domain.bayesian import compute, compute_stats, kde
|
| 26 |
+
|
| 27 |
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
| 28 |
+
LEGACY_JS = REPO_ROOT / "app" / "js" / "bayesian.js"
|
| 29 |
+
SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples"
|
| 30 |
+
|
| 31 |
+
SAMPLE_FILES = ["sample-gdp.json", "sample-climate.json", "sample-population.json"]
|
| 32 |
+
JUDGMENT_CASES: list[tuple[list[int], float]] = [
|
| 33 |
+
([0, 1, 2, 3, 4], 10.0), # default positive trend
|
| 34 |
+
([4, 3, 2, 1, 0], 10.0), # negative trend
|
| 35 |
+
([2, 2, 2, 2, 2], 10.0), # uniform / hard-to-tell
|
| 36 |
+
([0, 2, 4, 2, 0], 5.0), # middle-peaked
|
| 37 |
+
([1, 2, 3, 4, 4], 20.0), # strong upper
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _load_sample(name: str) -> list[float]:
|
| 42 |
+
return json.loads((SAMPLES_DIR / name).read_text(encoding="utf-8"))["values"]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _node_available() -> bool:
|
| 46 |
+
return shutil.which("node") is not None and LEGACY_JS.exists()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _run_js(data: list[float], judgments: list[int], R: float) -> dict:
|
| 50 |
+
"""Run the JS engine in a subprocess and return its compute() result."""
|
| 51 |
+
script = f"""
|
| 52 |
+
const path = require('path');
|
| 53 |
+
const BayesianEngine = require({json.dumps(str(LEGACY_JS))});
|
| 54 |
+
const data = {json.dumps(data)};
|
| 55 |
+
const judgments = {json.dumps(judgments)};
|
| 56 |
+
const R = {R};
|
| 57 |
+
const out = BayesianEngine.compute(data, judgments, R);
|
| 58 |
+
process.stdout.write(JSON.stringify(out));
|
| 59 |
+
"""
|
| 60 |
+
proc = subprocess.run(
|
| 61 |
+
["node", "-e", script], capture_output=True, text=True, timeout=30, check=False
|
| 62 |
+
)
|
| 63 |
+
if proc.returncode != 0:
|
| 64 |
+
raise RuntimeError(f"node failed: {proc.stderr}")
|
| 65 |
+
return json.loads(proc.stdout)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# --------------------------------------------------------------------------- #
|
| 69 |
+
# Plain unit tests (no Node required)
|
| 70 |
+
# --------------------------------------------------------------------------- #
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_compute_stats_basic():
|
| 74 |
+
s = compute_stats([1.0, 2.0, 3.0, 4.0, 5.0])
|
| 75 |
+
assert s.n == 5
|
| 76 |
+
assert s.mean == pytest.approx(3.0)
|
| 77 |
+
assert s.median == pytest.approx(3.0)
|
| 78 |
+
assert s.std == pytest.approx(math.sqrt(2.5)) # sample variance 2.5
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_uniform_judgments_keep_prior():
|
| 82 |
+
"""If every judgment is 'hard to tell' (level 2), likelihood is uniform
|
| 83 |
+
and the posterior must equal the prior exactly."""
|
| 84 |
+
data = _load_sample("sample-gdp.json")
|
| 85 |
+
r = compute(data, [2, 2, 2, 2, 2], R=10.0)
|
| 86 |
+
for prior_w, post_w in zip(r.prior.weights, r.posterior.weights, strict=True):
|
| 87 |
+
assert post_w == pytest.approx(prior_w, abs=1e-12)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_posterior_sums_to_one():
|
| 91 |
+
data = _load_sample("sample-gdp.json")
|
| 92 |
+
for judgments, R in JUDGMENT_CASES:
|
| 93 |
+
r = compute(data, judgments, R)
|
| 94 |
+
assert sum(r.posterior.weights) == pytest.approx(1.0, abs=1e-12)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_kde_integrates_close_to_one():
|
| 98 |
+
"""Riemann-sum the KDE — should integrate to roughly 1.0."""
|
| 99 |
+
data = _load_sample("sample-gdp.json")
|
| 100 |
+
pts = kde(data, n_points=500)
|
| 101 |
+
if len(pts) < 2:
|
| 102 |
+
pytest.skip("not enough KDE points")
|
| 103 |
+
dx = pts[1]["x"] - pts[0]["x"]
|
| 104 |
+
integral = sum(p["y"] for p in pts) * dx
|
| 105 |
+
# KDE pads ±15% of range; some mass leaks beyond the grid in heavy-tailed
|
| 106 |
+
# data, so we accept 0.90 - 1.01.
|
| 107 |
+
assert 0.90 < integral < 1.01, f"KDE integrates to {integral}"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# --------------------------------------------------------------------------- #
|
| 111 |
+
# Parity tests against the original JS implementation
|
| 112 |
+
# --------------------------------------------------------------------------- #
|
| 113 |
+
|
| 114 |
+
requires_node = pytest.mark.skipif(
|
| 115 |
+
not _node_available(),
|
| 116 |
+
reason="node.js or legacy app/js/bayesian.js not available",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@requires_node
|
| 121 |
+
@pytest.mark.parametrize("sample", SAMPLE_FILES)
|
| 122 |
+
@pytest.mark.parametrize("judgments,R", JUDGMENT_CASES)
|
| 123 |
+
def test_js_parity(sample: str, judgments: list[int], R: float):
|
| 124 |
+
data = _load_sample(sample)
|
| 125 |
+
js = _run_js(data, judgments, R)
|
| 126 |
+
py = compute(data, judgments, R)
|
| 127 |
+
|
| 128 |
+
# Prior summary stats
|
| 129 |
+
for a, b in [
|
| 130 |
+
(js["prior"]["stats"]["mean"], py.prior.stats.mean),
|
| 131 |
+
(js["prior"]["stats"]["std"], py.prior.stats.std),
|
| 132 |
+
(js["prior"]["stats"]["median"], py.prior.stats.median),
|
| 133 |
+
(js["prior"]["stats"]["skewness"], py.prior.stats.skewness),
|
| 134 |
+
(js["prior"]["stats"]["kurtosis"], py.prior.stats.kurtosis),
|
| 135 |
+
]:
|
| 136 |
+
assert a == pytest.approx(b, abs=1e-9)
|
| 137 |
+
|
| 138 |
+
# Quantile values
|
| 139 |
+
for jq, pq in zip(js["prior"]["quantileValues"], py.prior.quantile_values, strict=True):
|
| 140 |
+
assert jq == pytest.approx(pq, abs=1e-9)
|
| 141 |
+
|
| 142 |
+
# Likelihood
|
| 143 |
+
for jl, pl in zip(js["likelihood"]["weights"], py.likelihood.weights, strict=True):
|
| 144 |
+
assert jl == pytest.approx(pl, abs=1e-9)
|
| 145 |
+
|
| 146 |
+
# Posterior weights
|
| 147 |
+
for jw, pw in zip(js["posterior"]["weights"], py.posterior.weights, strict=True):
|
| 148 |
+
assert jw == pytest.approx(pw, abs=1e-9)
|
| 149 |
+
|
| 150 |
+
# Posterior summary
|
| 151 |
+
for a, b in [
|
| 152 |
+
(js["posterior"]["stats"]["mean"], py.posterior.stats.mean),
|
| 153 |
+
(js["posterior"]["stats"]["median"], py.posterior.stats.median),
|
| 154 |
+
(js["posterior"]["stats"]["std"], py.posterior.stats.std),
|
| 155 |
+
(js["posterior"]["stats"]["ci95"]["lower"], py.posterior.stats.ci95_lower),
|
| 156 |
+
(js["posterior"]["stats"]["ci95"]["upper"], py.posterior.stats.ci95_upper),
|
| 157 |
+
]:
|
| 158 |
+
assert a == pytest.approx(b, abs=1e-9)
|
backend/tests/test_mcp_tools.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smoke tests for the in-process MCP tools.
|
| 2 |
+
|
| 3 |
+
We call each tool's underlying handler directly (the ``tool`` decorator stores
|
| 4 |
+
it on the wrapped object) and verify the JSON contracts so the Agent has
|
| 5 |
+
something deterministic to work with.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import json
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from app.agent import tool_store
|
| 17 |
+
from app.agent.tools import (
|
| 18 |
+
build_prior_tool,
|
| 19 |
+
compute_posterior_tool,
|
| 20 |
+
compute_statistics_tool,
|
| 21 |
+
fetch_wdi_tool,
|
| 22 |
+
list_data_catalog_tool,
|
| 23 |
+
load_sample_dataset_tool,
|
| 24 |
+
sensitivity_analysis_tool,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _call(t, **kwargs) -> dict[str, Any]:
|
| 29 |
+
"""Invoke an MCP tool's handler synchronously and return the parsed payload."""
|
| 30 |
+
# SDK wraps tools as SdkMcpTool with `.handler` attr; fall back to direct call.
|
| 31 |
+
handler = getattr(t, "handler", t)
|
| 32 |
+
if asyncio.iscoroutinefunction(handler):
|
| 33 |
+
result = asyncio.run(handler(kwargs))
|
| 34 |
+
else: # pragma: no cover
|
| 35 |
+
result = handler(kwargs)
|
| 36 |
+
assert "content" in result
|
| 37 |
+
text = result["content"][0]["text"]
|
| 38 |
+
return json.loads(text)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@pytest.fixture(autouse=True)
|
| 42 |
+
def reset_store():
|
| 43 |
+
tool_store.clear()
|
| 44 |
+
tool_store.set_artifact_queue(None)
|
| 45 |
+
yield
|
| 46 |
+
tool_store.clear()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_list_data_catalog():
|
| 50 |
+
out = _call(list_data_catalog_tool)
|
| 51 |
+
assert "samples" in out
|
| 52 |
+
assert {s["id"] for s in out["samples"]} == {"gdp", "climate", "population"}
|
| 53 |
+
assert "NY.GDP.PCAP.KD.ZG" in out["wdi_indicators"]
|
| 54 |
+
assert "tas" in out["cckp_variables"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_load_sample_then_stats_then_prior_then_posterior_then_sensitivity():
|
| 58 |
+
# 1. load
|
| 59 |
+
s1 = _call(load_sample_dataset_tool, sample_id="gdp")
|
| 60 |
+
assert "dataset_handle" in s1
|
| 61 |
+
handle = s1["dataset_handle"]
|
| 62 |
+
assert s1["n"] > 100
|
| 63 |
+
|
| 64 |
+
# 2. stats
|
| 65 |
+
s2 = _call(compute_statistics_tool, dataset_handle=handle)
|
| 66 |
+
assert s2["n"] == s1["n"]
|
| 67 |
+
assert isinstance(s2["mean"], float)
|
| 68 |
+
assert isinstance(s2["std"], float)
|
| 69 |
+
|
| 70 |
+
# 3. build prior
|
| 71 |
+
s3 = _call(build_prior_tool, dataset_handle=handle)
|
| 72 |
+
assert "prior_handle" in s3
|
| 73 |
+
assert len(s3["quantile_values"]) == 5
|
| 74 |
+
assert s3["prior_weights"] == [0.05, 0.20, 0.50, 0.20, 0.05]
|
| 75 |
+
prior_handle = s3["prior_handle"]
|
| 76 |
+
|
| 77 |
+
# 4. compute posterior with a strong upward judgment
|
| 78 |
+
s4 = _call(
|
| 79 |
+
compute_posterior_tool,
|
| 80 |
+
prior_handle=prior_handle,
|
| 81 |
+
judgments=[0, 1, 2, 3, 4],
|
| 82 |
+
R=10.0,
|
| 83 |
+
judgment_rationale="testing",
|
| 84 |
+
)
|
| 85 |
+
assert "posterior_handle" in s4
|
| 86 |
+
assert len(s4["posterior_weights"]) == 5
|
| 87 |
+
assert abs(sum(s4["posterior_weights"]) - 1.0) < 1e-9
|
| 88 |
+
# Strong upward judgment should pull mean above prior mean
|
| 89 |
+
assert s4["posterior_mean"] > s4["prior_mean"]
|
| 90 |
+
|
| 91 |
+
# 5. sensitivity
|
| 92 |
+
s5 = _call(
|
| 93 |
+
sensitivity_analysis_tool,
|
| 94 |
+
prior_handle=prior_handle,
|
| 95 |
+
judgments=[0, 1, 2, 3, 4],
|
| 96 |
+
r_values=[2.0, 5.0, 10.0, 20.0],
|
| 97 |
+
)
|
| 98 |
+
assert len(s5["rows"]) == 4
|
| 99 |
+
means = [r["posterior_mean"] for r in s5["rows"]]
|
| 100 |
+
# Stronger R should give larger upward swing for this monotonic judgment
|
| 101 |
+
assert means[-1] > means[0]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_compute_posterior_validates_inputs():
|
| 105 |
+
# Bad handle
|
| 106 |
+
out = _call(
|
| 107 |
+
compute_posterior_tool,
|
| 108 |
+
prior_handle="does_not_exist",
|
| 109 |
+
judgments=[0, 1, 2, 3, 4],
|
| 110 |
+
R=10.0,
|
| 111 |
+
judgment_rationale="",
|
| 112 |
+
)
|
| 113 |
+
assert "error" in out
|
| 114 |
+
|
| 115 |
+
# Set up valid prior, then bad inputs
|
| 116 |
+
s1 = _call(load_sample_dataset_tool, sample_id="gdp")
|
| 117 |
+
s2 = _call(build_prior_tool, dataset_handle=s1["dataset_handle"])
|
| 118 |
+
ph = s2["prior_handle"]
|
| 119 |
+
|
| 120 |
+
out = _call(
|
| 121 |
+
compute_posterior_tool, prior_handle=ph, judgments=[0, 1, 2, 3], R=10.0, judgment_rationale=""
|
| 122 |
+
)
|
| 123 |
+
assert "error" in out
|
| 124 |
+
|
| 125 |
+
out = _call(
|
| 126 |
+
compute_posterior_tool,
|
| 127 |
+
prior_handle=ph,
|
| 128 |
+
judgments=[0, 1, 2, 3, 5], # 5 out of range
|
| 129 |
+
R=10.0,
|
| 130 |
+
judgment_rationale="",
|
| 131 |
+
)
|
| 132 |
+
assert "error" in out
|
| 133 |
+
|
| 134 |
+
out = _call(
|
| 135 |
+
compute_posterior_tool,
|
| 136 |
+
prior_handle=ph,
|
| 137 |
+
judgments=[0, 1, 2, 3, 4],
|
| 138 |
+
R=0.5,
|
| 139 |
+
judgment_rationale="",
|
| 140 |
+
)
|
| 141 |
+
assert "error" in out
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_load_sample_unknown_id():
|
| 145 |
+
out = _call(load_sample_dataset_tool, sample_id="nope")
|
| 146 |
+
assert "error" in out
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_fetch_wdi_uses_cache(monkeypatch, tmp_path):
|
| 150 |
+
"""The tool should work end-to-end when the WDI client's cache is warm."""
|
| 151 |
+
# Build a fake cache entry the WDIClient will pick up
|
| 152 |
+
from app.core import config as cfg
|
| 153 |
+
from app.data_sources.worldbank import _cache_key
|
| 154 |
+
|
| 155 |
+
monkeypatch.setattr(
|
| 156 |
+
cfg.Settings,
|
| 157 |
+
"model_config",
|
| 158 |
+
{**cfg.Settings.model_config, "env_file": "/nonexistent.env"},
|
| 159 |
+
)
|
| 160 |
+
cfg.get_settings.cache_clear()
|
| 161 |
+
monkeypatch.setenv("DATA_CACHE_DIR", str(tmp_path))
|
| 162 |
+
s = cfg.get_settings()
|
| 163 |
+
assert s.data_cache_dir == tmp_path
|
| 164 |
+
|
| 165 |
+
url = "https://api.worldbank.org/v2/country/ssa/indicator/NY.GDP.PCAP.KD.ZG"
|
| 166 |
+
params = {"format": "json", "date": "1990:2023", "per_page": 20000}
|
| 167 |
+
fake = [
|
| 168 |
+
{"pages": 1, "total": 2},
|
| 169 |
+
[
|
| 170 |
+
{"indicator": {"value": "GDP per capita growth (annual %)"}, "value": 1.5, "unit": "%"},
|
| 171 |
+
{"indicator": {"value": "GDP per capita growth (annual %)"}, "value": -2.0, "unit": "%"},
|
| 172 |
+
{"indicator": {"value": "GDP per capita growth (annual %)"}, "value": None, "unit": "%"},
|
| 173 |
+
],
|
| 174 |
+
]
|
| 175 |
+
cache_file = tmp_path / f"wdi_{_cache_key(url, params)}.json"
|
| 176 |
+
cache_file.write_text(json.dumps(fake), encoding="utf-8")
|
| 177 |
+
|
| 178 |
+
out = _call(
|
| 179 |
+
fetch_wdi_tool,
|
| 180 |
+
indicator="NY.GDP.PCAP.KD.ZG",
|
| 181 |
+
country="ssa",
|
| 182 |
+
date_range="1990:2023",
|
| 183 |
+
)
|
| 184 |
+
assert "dataset_handle" in out
|
| 185 |
+
assert out["n_used"] == 2
|
| 186 |
+
cfg.get_settings.cache_clear()
|
backend/tests/test_phase2_modules.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sanity tests for the Phase 2 domain modules.
|
| 2 |
+
|
| 3 |
+
These don't try to match the JS implementation byte-for-byte because we
|
| 4 |
+
deliberately swapped some approximations for scipy's exact versions
|
| 5 |
+
(Jarque-Bera p-value, KS test, chi-squared p-value, etc.). Instead they check
|
| 6 |
+
mathematical properties (monotonicity, symmetry, ranges) and a few golden
|
| 7 |
+
hand-calculated values.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pytest
|
| 17 |
+
|
| 18 |
+
from app.domain.multi_expert import (
|
| 19 |
+
JUDGMENT_PRESETS,
|
| 20 |
+
Expert,
|
| 21 |
+
fuse_dempster_shafer,
|
| 22 |
+
fuse_weighted_arithmetic_mean,
|
| 23 |
+
fuse_weighted_geometric_mean,
|
| 24 |
+
kendall_w,
|
| 25 |
+
)
|
| 26 |
+
from app.domain.multi_param import (
|
| 27 |
+
Parameter,
|
| 28 |
+
batch_compute,
|
| 29 |
+
compare_scenarios,
|
| 30 |
+
correlation_matrix,
|
| 31 |
+
js_divergence,
|
| 32 |
+
kl_divergence,
|
| 33 |
+
pearson_correlation,
|
| 34 |
+
spearman_correlation,
|
| 35 |
+
wasserstein_distance,
|
| 36 |
+
)
|
| 37 |
+
from app.domain.preprocessing import (
|
| 38 |
+
boxcox_transform,
|
| 39 |
+
detect_outliers_iqr,
|
| 40 |
+
detect_outliers_zscore,
|
| 41 |
+
jarque_bera,
|
| 42 |
+
ks_normal,
|
| 43 |
+
log_transform,
|
| 44 |
+
optimal_bin_count,
|
| 45 |
+
shapiro_wilk,
|
| 46 |
+
winsorize,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _gdp():
|
| 53 |
+
return json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())["values"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# --------------------------------------------------------------------------- #
|
| 57 |
+
# Multi-expert
|
| 58 |
+
# --------------------------------------------------------------------------- #
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_expert_validates_judgments():
|
| 62 |
+
with pytest.raises(ValueError):
|
| 63 |
+
Expert("A", judgments=[0, 1, 2, 3]) # only 4
|
| 64 |
+
with pytest.raises(ValueError):
|
| 65 |
+
Expert("B", judgments=[0, 1, 2, 3, 5]) # 5 out of range
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_geometric_fusion_equals_single_expert():
|
| 69 |
+
"""With one expert, geometric mean == that expert's likelihood."""
|
| 70 |
+
e = Expert("solo", weight=2.0, judgments=[0, 1, 2, 3, 4])
|
| 71 |
+
fused = fuse_weighted_geometric_mean([e], R=10)
|
| 72 |
+
expected = [10 ** k for k in (-2, -1, 0, 1, 2)]
|
| 73 |
+
for a, b in zip(fused, expected, strict=True):
|
| 74 |
+
assert a == pytest.approx(b, rel=1e-9)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_geometric_vs_arithmetic_two_experts_uniform_weights():
|
| 78 |
+
"""For identical experts, both fusion methods agree."""
|
| 79 |
+
e1 = Expert("A", judgments=[0, 1, 2, 3, 4])
|
| 80 |
+
e2 = Expert("B", judgments=[0, 1, 2, 3, 4])
|
| 81 |
+
g = fuse_weighted_geometric_mean([e1, e2], R=10)
|
| 82 |
+
a = fuse_weighted_arithmetic_mean([e1, e2], R=10)
|
| 83 |
+
for gi, ai in zip(g, a, strict=True):
|
| 84 |
+
assert gi == pytest.approx(ai, rel=1e-9)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_dempster_shafer_asymmetric_conflict():
|
| 88 |
+
"""Two non-symmetric experts: the one with extreme certainty should pull
|
| 89 |
+
the combined distribution toward its peak."""
|
| 90 |
+
e1 = Expert("A", judgments=[0, 1, 4, 4, 4]) # strong "high" + "very high"
|
| 91 |
+
e2 = Expert("B", judgments=[0, 1, 2, 3, 4]) # gradual increase
|
| 92 |
+
fused = fuse_dempster_shafer([e1, e2], R=5)
|
| 93 |
+
assert sum(fused) == pytest.approx(1.0, abs=1e-9)
|
| 94 |
+
# Combined mass should peak at "very high" (index 4)
|
| 95 |
+
assert fused.index(max(fused)) == 4
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_dempster_shafer_symmetric_opposite_yields_uniform():
|
| 99 |
+
"""Mathematically correct: symmetric opposing likelihoods give uniform DS."""
|
| 100 |
+
e1 = Expert("A", judgments=[4, 3, 2, 1, 0])
|
| 101 |
+
e2 = Expert("B", judgments=[0, 1, 2, 3, 4])
|
| 102 |
+
fused = fuse_dempster_shafer([e1, e2], R=5)
|
| 103 |
+
assert sum(fused) == pytest.approx(1.0, abs=1e-9)
|
| 104 |
+
for w in fused:
|
| 105 |
+
assert w == pytest.approx(0.2, abs=1e-6)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_kendall_w_perfect_agreement():
|
| 109 |
+
e1 = Expert("A", judgments=[0, 1, 2, 3, 4])
|
| 110 |
+
e2 = Expert("B", judgments=[0, 1, 2, 3, 4])
|
| 111 |
+
e3 = Expert("C", judgments=[0, 1, 2, 3, 4])
|
| 112 |
+
r = kendall_w([e1, e2, e3])
|
| 113 |
+
assert r.W == pytest.approx(1.0, abs=1e-9)
|
| 114 |
+
assert r.p_value < 0.05
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_kendall_w_perfect_disagreement():
|
| 118 |
+
# Three experts that disagree pairwise as much as possible
|
| 119 |
+
e1 = Expert("A", judgments=[0, 1, 2, 3, 4])
|
| 120 |
+
e2 = Expert("B", judgments=[4, 3, 2, 1, 0])
|
| 121 |
+
e3 = Expert("C", judgments=[2, 0, 4, 1, 3])
|
| 122 |
+
r = kendall_w([e1, e2, e3])
|
| 123 |
+
assert 0.0 <= r.W <= 1.0
|
| 124 |
+
# Likely no significant agreement
|
| 125 |
+
assert r.p_value > 0.05 or r.W < 0.5
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_presets_well_formed():
|
| 129 |
+
for _k, p in JUDGMENT_PRESETS.items():
|
| 130 |
+
assert isinstance(p["label"], str)
|
| 131 |
+
assert len(p["judgments"]) == 5
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# --------------------------------------------------------------------------- #
|
| 135 |
+
# Multi-parameter
|
| 136 |
+
# --------------------------------------------------------------------------- #
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_pearson_perfect_positive():
|
| 140 |
+
x = list(range(10))
|
| 141 |
+
y = [2 * v + 1 for v in x]
|
| 142 |
+
assert pearson_correlation(x, y) == pytest.approx(1.0, abs=1e-9)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_pearson_perfect_negative():
|
| 146 |
+
x = list(range(10))
|
| 147 |
+
y = [-3 * v for v in x]
|
| 148 |
+
assert pearson_correlation(x, y) == pytest.approx(-1.0, abs=1e-9)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_spearman_monotonic_nonlinear():
|
| 152 |
+
x = list(range(1, 11))
|
| 153 |
+
y = [v**2 for v in x]
|
| 154 |
+
assert spearman_correlation(x, y) == pytest.approx(1.0, abs=1e-9)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_correlation_matrix_symmetry():
|
| 158 |
+
p1 = Parameter("a", data=list(range(10)))
|
| 159 |
+
p2 = Parameter("b", data=[v * 0.5 for v in range(10)])
|
| 160 |
+
p3 = Parameter("c", data=[10 - v for v in range(10)])
|
| 161 |
+
m = correlation_matrix([p1, p2, p3])
|
| 162 |
+
for i in range(3):
|
| 163 |
+
assert m[i][i] == pytest.approx(1.0)
|
| 164 |
+
for j in range(3):
|
| 165 |
+
assert m[i][j] == pytest.approx(m[j][i])
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_batch_compute_runs():
|
| 169 |
+
gdp = _gdp()
|
| 170 |
+
p = Parameter("gdp", data=gdp, judgments=[0, 1, 2, 3, 4], R=10)
|
| 171 |
+
out = batch_compute([p])
|
| 172 |
+
assert out[0].result is not None
|
| 173 |
+
assert sum(out[0].result.posterior.weights) == pytest.approx(1.0, abs=1e-12)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_kl_zero_for_identical_distributions():
|
| 177 |
+
p = [0.1, 0.2, 0.4, 0.2, 0.1]
|
| 178 |
+
assert kl_divergence(p, p) == pytest.approx(0.0, abs=1e-9)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def test_js_symmetric():
|
| 182 |
+
a = [0.1, 0.2, 0.4, 0.2, 0.1]
|
| 183 |
+
b = [0.4, 0.3, 0.15, 0.1, 0.05]
|
| 184 |
+
assert js_divergence(a, b) == pytest.approx(js_divergence(b, a), abs=1e-12)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_wasserstein_known_value():
|
| 188 |
+
"""Two delta distributions one unit apart -> W1 = 1."""
|
| 189 |
+
values = [0.0, 1.0]
|
| 190 |
+
p = [1.0, 0.0]
|
| 191 |
+
q = [0.0, 1.0]
|
| 192 |
+
assert wasserstein_distance(p, q, values) == pytest.approx(1.0, abs=1e-9)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_compare_scenarios_runs():
|
| 196 |
+
from app.domain.bayesian import compute
|
| 197 |
+
|
| 198 |
+
gdp = _gdp()
|
| 199 |
+
r1 = compute(gdp, [0, 1, 2, 3, 4], 10.0)
|
| 200 |
+
r2 = compute(gdp, [4, 3, 2, 1, 0], 10.0)
|
| 201 |
+
d = compare_scenarios(r1, r2)
|
| 202 |
+
assert d.delta_mean < 0 # r2 (negative trend) should be lower
|
| 203 |
+
assert d.kl > 0
|
| 204 |
+
assert d.js > 0
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# --------------------------------------------------------------------------- #
|
| 208 |
+
# Preprocessing
|
| 209 |
+
# --------------------------------------------------------------------------- #
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_outliers_iqr_picks_extremes():
|
| 213 |
+
data = [0.0] * 100 + [100.0, -100.0]
|
| 214 |
+
r = detect_outliers_iqr(data)
|
| 215 |
+
assert 100.0 in r.values
|
| 216 |
+
assert -100.0 in r.values
|
| 217 |
+
assert r.count == 2
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def test_outliers_zscore_picks_extremes():
|
| 221 |
+
np.random.seed(0)
|
| 222 |
+
data = [*np.random.normal(0, 1, 1000).tolist(), 50.0, -50.0]
|
| 223 |
+
r = detect_outliers_zscore(data, threshold=3.0)
|
| 224 |
+
assert 50.0 in r.values
|
| 225 |
+
assert -50.0 in r.values
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_winsorize_clips_extremes():
|
| 229 |
+
data = list(range(100))
|
| 230 |
+
out = winsorize(data, 0.05, 0.95)
|
| 231 |
+
assert min(out) >= 4
|
| 232 |
+
assert max(out) <= 95
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_jarque_bera_detects_normal():
|
| 236 |
+
np.random.seed(0)
|
| 237 |
+
data = np.random.normal(0, 1, 2000)
|
| 238 |
+
r = jarque_bera(data)
|
| 239 |
+
assert r.is_normal
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def test_jarque_bera_detects_skewed():
|
| 243 |
+
np.random.seed(0)
|
| 244 |
+
data = np.random.exponential(1.0, 2000)
|
| 245 |
+
r = jarque_bera(data)
|
| 246 |
+
assert not r.is_normal
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def test_shapiro_wilk_basic():
|
| 250 |
+
np.random.seed(0)
|
| 251 |
+
r = shapiro_wilk(np.random.normal(0, 1, 200))
|
| 252 |
+
assert r.is_normal
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_ks_normal_basic():
|
| 256 |
+
np.random.seed(0)
|
| 257 |
+
r = ks_normal(np.random.normal(0, 1, 500))
|
| 258 |
+
assert r.is_normal
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def test_log_transform_handles_non_positive():
|
| 262 |
+
data = [-2.0, -1.0, 0.0, 1.0, 2.0]
|
| 263 |
+
r = log_transform(data)
|
| 264 |
+
assert r.shifted_by == pytest.approx(3.0)
|
| 265 |
+
assert len(r.values) == 5
|
| 266 |
+
assert all(v == v for v in r.values) # no NaN
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def test_boxcox_transform_falls_back_for_non_positive():
|
| 270 |
+
data = [-1.0, 0.0, 1.0, 2.0, 3.0]
|
| 271 |
+
r = boxcox_transform(data)
|
| 272 |
+
assert r.transform == "yeo-johnson"
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def test_optimal_bin_count():
|
| 276 |
+
np.random.seed(0)
|
| 277 |
+
data = np.random.normal(0, 1, 500).tolist()
|
| 278 |
+
bins_fd = optimal_bin_count(data, "fd")
|
| 279 |
+
bins_sturges = optimal_bin_count(data, "sturges")
|
| 280 |
+
bins_scott = optimal_bin_count(data, "scott")
|
| 281 |
+
for b in (bins_fd, bins_sturges, bins_scott):
|
| 282 |
+
assert 1 <= b <= 200
|
backend/tests/test_sessions_and_report.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for /api/sessions and /api/bayesian/report."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
from app.core import config as cfg
|
| 12 |
+
from app.core import db
|
| 13 |
+
from app.main import create_app
|
| 14 |
+
|
| 15 |
+
SAMPLES_DIR = Path(__file__).resolve().parents[1] / "app" / "data_sources" / "samples"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def client(tmp_path, monkeypatch) -> TestClient:
|
| 20 |
+
# Isolate DB in tmp; bypass project .env so monkeypatch can override.
|
| 21 |
+
monkeypatch.setattr(
|
| 22 |
+
cfg.Settings,
|
| 23 |
+
"model_config",
|
| 24 |
+
{**cfg.Settings.model_config, "env_file": "/nonexistent.env"},
|
| 25 |
+
)
|
| 26 |
+
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'test.db'}")
|
| 27 |
+
cfg.get_settings.cache_clear()
|
| 28 |
+
db.init_db()
|
| 29 |
+
return TestClient(create_app())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_session_lifecycle(client: TestClient):
|
| 33 |
+
r = client.post("/api/sessions", json={"label": "demo"})
|
| 34 |
+
assert r.status_code == 200
|
| 35 |
+
s = r.json()
|
| 36 |
+
assert s["id"]
|
| 37 |
+
assert s["label"] == "demo"
|
| 38 |
+
|
| 39 |
+
r = client.get("/api/sessions")
|
| 40 |
+
assert r.status_code == 200
|
| 41 |
+
assert any(x["id"] == s["id"] for x in r.json())
|
| 42 |
+
|
| 43 |
+
r = client.get(f"/api/sessions/{s['id']}/history")
|
| 44 |
+
assert r.status_code == 200
|
| 45 |
+
assert r.json() == []
|
| 46 |
+
|
| 47 |
+
r = client.delete(f"/api/sessions/{s['id']}")
|
| 48 |
+
assert r.status_code == 200
|
| 49 |
+
|
| 50 |
+
r = client.get(f"/api/sessions/{s['id']}/history")
|
| 51 |
+
assert r.status_code == 404
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_history_append_and_list(tmp_path, monkeypatch):
|
| 55 |
+
monkeypatch.setattr(
|
| 56 |
+
cfg.Settings,
|
| 57 |
+
"model_config",
|
| 58 |
+
{**cfg.Settings.model_config, "env_file": "/nonexistent.env"},
|
| 59 |
+
)
|
| 60 |
+
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{tmp_path / 'test.db'}")
|
| 61 |
+
cfg.get_settings.cache_clear()
|
| 62 |
+
db.init_db()
|
| 63 |
+
sid = db.create_session("x")
|
| 64 |
+
db.append_history(sid, "agent_turn", {"prompt": "hello"})
|
| 65 |
+
db.append_history(sid, "bayesian_compute", {"R": 10})
|
| 66 |
+
rows = db.list_history(sid)
|
| 67 |
+
assert len(rows) == 2
|
| 68 |
+
assert {r["kind"] for r in rows} == {"agent_turn", "bayesian_compute"}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_report_endpoint(client: TestClient):
|
| 72 |
+
gdp = json.loads((SAMPLES_DIR / "sample-gdp.json").read_text())
|
| 73 |
+
r = client.post(
|
| 74 |
+
"/api/bayesian/report",
|
| 75 |
+
json={
|
| 76 |
+
"data": gdp["values"],
|
| 77 |
+
"judgments": [0, 1, 2, 3, 4],
|
| 78 |
+
"R": 10.0,
|
| 79 |
+
"scenario_name": "高 GDP 增长情景",
|
| 80 |
+
"reference_case": "撒哈拉以南非洲 1975-2002",
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
+
assert r.status_code == 200
|
| 84 |
+
body = r.json()
|
| 85 |
+
md = body["markdown"]
|
| 86 |
+
assert "# 贝叶斯情景参数量化分析报告" in md
|
| 87 |
+
assert "高 GDP 增长情景" in md
|
| 88 |
+
assert "撒哈拉以南非洲 1975-2002" in md
|
| 89 |
+
assert "## 一、数据描述统计" in md
|
| 90 |
+
assert "## 二、先验分布" in md
|
| 91 |
+
assert "## 三、专家可能性判断" in md
|
| 92 |
+
assert "## 四、后验分布与结论" in md
|
| 93 |
+
assert "## 五、稳健性检验" in md
|
| 94 |
+
assert "## 六、关键发现" in md
|
| 95 |
+
# Result block is well-formed
|
| 96 |
+
assert "posterior" in body["result"]
|
| 97 |
+
assert abs(sum(body["result"]["posterior"]["weights"]) - 1.0) < 1e-9
|
backend/tests/test_worldbank.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the World Bank data clients.
|
| 2 |
+
|
| 3 |
+
We do **not** hit the live network in CI; tests instead pre-populate the cache
|
| 4 |
+
files so the clients deserialise canned responses. A real-network smoke test is
|
| 5 |
+
provided but gated behind ``RUN_NETWORK_TESTS=1``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from app.data_sources.worldbank import (
|
| 17 |
+
CCKP_SSP_SCENARIOS,
|
| 18 |
+
CCKP_VARIABLES,
|
| 19 |
+
WDI_INDICATOR_CATALOG,
|
| 20 |
+
CCKPClient,
|
| 21 |
+
WDIClient,
|
| 22 |
+
_cache_key,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture
|
| 27 |
+
def cache_dir(tmp_path: Path) -> Path:
|
| 28 |
+
return tmp_path / "cache"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_catalogs():
|
| 32 |
+
assert "NY.GDP.PCAP.KD.ZG" in WDI_INDICATOR_CATALOG
|
| 33 |
+
assert "tas" in CCKP_VARIABLES
|
| 34 |
+
assert "ssp245" in CCKP_SSP_SCENARIOS
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def test_wdi_uses_cache(cache_dir: Path):
|
| 38 |
+
client = WDIClient(cache_dir)
|
| 39 |
+
url = f"{client.BASE}/country/ssa/indicator/NY.GDP.PCAP.KD.ZG"
|
| 40 |
+
params = {"format": "json", "date": "1990:2023", "per_page": 20000}
|
| 41 |
+
payload = [
|
| 42 |
+
{"page": 1, "pages": 1, "per_page": "20000", "total": 3},
|
| 43 |
+
[
|
| 44 |
+
{
|
| 45 |
+
"indicator": {"id": "NY.GDP.PCAP.KD.ZG", "value": "GDP per capita growth (annual %)"},
|
| 46 |
+
"country": {"id": "ZG", "value": "Sub-Saharan Africa"},
|
| 47 |
+
"unit": "%",
|
| 48 |
+
"date": "2020",
|
| 49 |
+
"value": -1.234,
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"indicator": {"id": "NY.GDP.PCAP.KD.ZG", "value": "GDP per capita growth (annual %)"},
|
| 53 |
+
"country": {"id": "ZG", "value": "Sub-Saharan Africa"},
|
| 54 |
+
"unit": "%",
|
| 55 |
+
"date": "2019",
|
| 56 |
+
"value": 2.5,
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"indicator": {"id": "NY.GDP.PCAP.KD.ZG", "value": "GDP per capita growth (annual %)"},
|
| 60 |
+
"country": {"id": "ZG", "value": "Sub-Saharan Africa"},
|
| 61 |
+
"unit": "%",
|
| 62 |
+
"date": "2018",
|
| 63 |
+
"value": None,
|
| 64 |
+
},
|
| 65 |
+
],
|
| 66 |
+
]
|
| 67 |
+
cache_file = cache_dir / f"wdi_{_cache_key(url, params)}.json"
|
| 68 |
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
cache_file.write_text(json.dumps(payload), encoding="utf-8")
|
| 70 |
+
|
| 71 |
+
series = await client.fetch_indicator("NY.GDP.PCAP.KD.ZG", "ssa", "1990:2023")
|
| 72 |
+
assert series.indicator_id == "NY.GDP.PCAP.KD.ZG"
|
| 73 |
+
assert series.country == "ssa"
|
| 74 |
+
assert series.n_total == 3
|
| 75 |
+
assert series.n_used == 2 # one None filtered
|
| 76 |
+
assert series.values == [-1.234, 2.5]
|
| 77 |
+
assert "GDP" in series.indicator_name
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def test_cckp_uses_cache(cache_dir: Path):
|
| 81 |
+
client = CCKPClient(cache_dir)
|
| 82 |
+
endpoint = (
|
| 83 |
+
"cmip6-x0.25_timeseries_tas_timeseries_annual_2015-2100_median_ssp245_ensemble_all_mean"
|
| 84 |
+
)
|
| 85 |
+
url = f"{client.BASE}/{endpoint}/CHN"
|
| 86 |
+
payload = {
|
| 87 |
+
"CHN": {
|
| 88 |
+
"data": {
|
| 89 |
+
"2015-07-01": 9.1,
|
| 90 |
+
"2016-07-01": 9.2,
|
| 91 |
+
"2017-07-01": 9.3,
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
cache_file = cache_dir / f"cckp_{_cache_key(url, {})}.json"
|
| 96 |
+
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
cache_file.write_text(json.dumps(payload), encoding="utf-8")
|
| 98 |
+
|
| 99 |
+
s = await client.fetch_variable(variable="tas", country_iso3="CHN", scenario="ssp245")
|
| 100 |
+
assert s.country == "CHN"
|
| 101 |
+
assert s.scenario == "ssp245"
|
| 102 |
+
assert s.values == [9.1, 9.2, 9.3]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_cckp_rejects_bad_inputs(cache_dir: Path):
|
| 106 |
+
client = CCKPClient(cache_dir)
|
| 107 |
+
import asyncio
|
| 108 |
+
|
| 109 |
+
with pytest.raises(ValueError):
|
| 110 |
+
asyncio.run(client.fetch_variable(variable="nope", country_iso3="CHN"))
|
| 111 |
+
with pytest.raises(ValueError):
|
| 112 |
+
asyncio.run(client.fetch_variable(variable="tas", country_iso3="CN"))
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@pytest.mark.skipif(
|
| 116 |
+
os.environ.get("RUN_NETWORK_TESTS") != "1",
|
| 117 |
+
reason="set RUN_NETWORK_TESTS=1 to exercise live API",
|
| 118 |
+
)
|
| 119 |
+
async def test_wdi_live_smoke(tmp_path: Path):
|
| 120 |
+
client = WDIClient(tmp_path)
|
| 121 |
+
s = await client.fetch_indicator("NY.GDP.PCAP.KD.ZG", "ZG", "2015:2023")
|
| 122 |
+
assert s.n_used > 0
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
backend:
|
| 3 |
+
build:
|
| 4 |
+
context: ./backend
|
| 5 |
+
container_name: bayesscen-backend
|
| 6 |
+
restart: unless-stopped
|
| 7 |
+
environment:
|
| 8 |
+
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:?missing ANTHROPIC_API_KEY in .env}"
|
| 9 |
+
ANTHROPIC_MODEL: "${ANTHROPIC_MODEL:-claude-sonnet-4-5}"
|
| 10 |
+
ANTHROPIC_DEEP_MODEL: "${ANTHROPIC_DEEP_MODEL:-claude-opus-4-7}"
|
| 11 |
+
CORS_ORIGINS: "${CORS_ORIGINS:-http://localhost:8080}"
|
| 12 |
+
DATABASE_URL: "sqlite:////app/data/bayesscen.db"
|
| 13 |
+
DATA_CACHE_DIR: "/app/data/cache"
|
| 14 |
+
volumes:
|
| 15 |
+
- backend-data:/app/data
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=3)"]
|
| 18 |
+
interval: 30s
|
| 19 |
+
timeout: 5s
|
| 20 |
+
retries: 3
|
| 21 |
+
start_period: 10s
|
| 22 |
+
|
| 23 |
+
frontend:
|
| 24 |
+
build:
|
| 25 |
+
context: ./frontend
|
| 26 |
+
args:
|
| 27 |
+
# Empty = same-origin; nginx will proxy /api to backend
|
| 28 |
+
VITE_API_BASE_URL: ""
|
| 29 |
+
container_name: bayesscen-frontend
|
| 30 |
+
restart: unless-stopped
|
| 31 |
+
depends_on:
|
| 32 |
+
backend:
|
| 33 |
+
condition: service_healthy
|
| 34 |
+
ports:
|
| 35 |
+
- "${HTTP_PORT:-8080}:80"
|
| 36 |
+
|
| 37 |
+
volumes:
|
| 38 |
+
backend-data:
|