Jingrui77 commited on
Commit
f8a3ca2
·
1 Parent(s): 0e0c55c

deploy: sync BayesScenParams Agent (2026-05-17T16:01:00Z)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +20 -0
  2. .env.example +47 -0
  3. .gitignore +39 -0
  4. DEPLOY.md +151 -0
  5. Dockerfile +84 -0
  6. README.md +216 -6
  7. backend/.dockerignore +15 -0
  8. backend/Dockerfile +60 -0
  9. backend/README.md +62 -0
  10. backend/app/__init__.py +0 -0
  11. backend/app/agent/__init__.py +0 -0
  12. backend/app/agent/direct_orchestrator.py +278 -0
  13. backend/app/agent/orchestrator.py +233 -0
  14. backend/app/agent/prompts.py +98 -0
  15. backend/app/agent/tool_store.py +80 -0
  16. backend/app/agent/tools/__init__.py +30 -0
  17. backend/app/agent/tools/bayesian_tools.py +285 -0
  18. backend/app/agent/tools/data_tools.py +245 -0
  19. backend/app/api/__init__.py +0 -0
  20. backend/app/api/agent.py +74 -0
  21. backend/app/api/bayesian.py +84 -0
  22. backend/app/api/data.py +149 -0
  23. backend/app/api/health.py +24 -0
  24. backend/app/api/schemas.py +193 -0
  25. backend/app/api/sessions.py +61 -0
  26. backend/app/core/__init__.py +0 -0
  27. backend/app/core/config.py +119 -0
  28. backend/app/core/db.py +154 -0
  29. backend/app/data_sources/__init__.py +0 -0
  30. backend/app/data_sources/samples/sample-climate.json +32 -0
  31. backend/app/data_sources/samples/sample-gdp.json +47 -0
  32. backend/app/data_sources/samples/sample-population.json +23 -0
  33. backend/app/data_sources/worldbank.py +293 -0
  34. backend/app/domain/__init__.py +0 -0
  35. backend/app/domain/bayesian.py +361 -0
  36. backend/app/domain/multi_expert.py +221 -0
  37. backend/app/domain/multi_param.py +138 -0
  38. backend/app/domain/preprocessing.py +236 -0
  39. backend/app/domain/report.py +215 -0
  40. backend/app/main.py +104 -0
  41. backend/pyproject.toml +74 -0
  42. backend/tests/__init__.py +0 -0
  43. backend/tests/test_agent_sse.py +50 -0
  44. backend/tests/test_api.py +88 -0
  45. backend/tests/test_bayesian_parity.py +158 -0
  46. backend/tests/test_mcp_tools.py +186 -0
  47. backend/tests/test_phase2_modules.py +282 -0
  48. backend/tests/test_sessions_and_report.py +97 -0
  49. backend/tests/test_worldbank.py +122 -0
  50. 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: Bayesscenparams
3
- emoji: 👁
4
- colorFrom: green
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: