Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b913a3b83 | ||
|
|
93eb41a9f7 | ||
|
|
dd176a6ae6 | ||
|
|
f14ff6ec0f | ||
|
|
39854b9983 | ||
|
|
3b4347c87d | ||
|
|
d9efd98d26 | ||
|
|
b87cb310fd | ||
|
|
b15d097b9c | ||
|
|
8955c58d19 | ||
|
|
1cb51b1172 | ||
|
|
455b8360d0 | ||
|
|
e1984c7a35 |
@@ -46,6 +46,16 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||
- Keep filenames lowercase and hyphenated.
|
||||
- Apply the repository-specific rules file before writing.
|
||||
|
||||
#### Document Audience Routing (Planet)
|
||||
|
||||
In this repository, classify the action's performer before picking a target file:
|
||||
|
||||
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
|
||||
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
|
||||
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
|
||||
|
||||
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
||||
|
||||
### Step 3 — Write
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!VERSION
|
||||
!backend/
|
||||
!backend/**
|
||||
!aiprovider/
|
||||
!aiprovider/**
|
||||
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
aiprovider/.env
|
||||
aiprovider/.env.*
|
||||
!aiprovider/.env.example
|
||||
|
||||
64
.gitea/workflows/ci.yaml
Normal file
64
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.rclaw.top
|
||||
IMAGE_NAMESPACE: linkong/planet
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
- name: Sync Python dependencies
|
||||
run: ~/.local/bin/uv sync --group dev
|
||||
- name: Run backend smoke tests
|
||||
working-directory: backend
|
||||
run: PYTHONPATH=. "$GITHUB_WORKSPACE/.venv/bin/python" -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Bun
|
||||
run: curl -fsSL https://bun.sh/install | bash
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
~/.bun/bin/bun install --frozen-lockfile
|
||||
~/.bun/bin/bun run build
|
||||
|
||||
delivery:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- backend
|
||||
- frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Helm
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz
|
||||
tar -xzf /tmp/helm.tar.gz -C /tmp
|
||||
mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Docker build smoke
|
||||
run: |
|
||||
docker build -t "$REGISTRY/$IMAGE_NAMESPACE/frontend:${GITHUB_SHA}" ./frontend
|
||||
docker build -t "$REGISTRY/$IMAGE_NAMESPACE/backend:${GITHUB_SHA}" -f backend/Dockerfile .
|
||||
docker build -t "$REGISTRY/$IMAGE_NAMESPACE/aiprovider:${GITHUB_SHA}" -f aiprovider/Dockerfile .
|
||||
- name: Helm template smoke
|
||||
run: |
|
||||
helm lint deploy/helm/planet
|
||||
helm template planet-staging deploy/helm/planet \
|
||||
--namespace planet-staging \
|
||||
-f deploy/helm/planet/values.single-node.yaml \
|
||||
--set image.tag="${GITHUB_SHA}" >/tmp/planet-rendered.yaml
|
||||
67
.gitea/workflows/deploy-staging.yaml
Normal file
67
.gitea/workflows/deploy-staging.yaml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: deploy-staging
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.rclaw.top
|
||||
IMAGE_NAMESPACE: linkong/planet
|
||||
RELEASE_NAME: planet-staging
|
||||
NAMESPACE: planet-staging
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install deploy tools
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz
|
||||
tar -xzf /tmp/helm.tar.gz -C /tmp
|
||||
mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm"
|
||||
curl -fsSL https://dl.k8s.io/release/v1.30.5/bin/linux/amd64/kubectl -o /tmp/kubectl
|
||||
install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Configure kubeconfig
|
||||
run: |
|
||||
mkdir -p "$HOME/.kube"
|
||||
printf "%s" "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > "$HOME/.kube/config"
|
||||
chmod 600 "$HOME/.kube/config"
|
||||
- name: Deploy Helm release
|
||||
run: |
|
||||
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
||||
helm upgrade --install "$RELEASE_NAME" deploy/helm/planet \
|
||||
--namespace "$NAMESPACE" \
|
||||
-f deploy/helm/planet/values.single-node.yaml \
|
||||
--set global.imageRegistry="$REGISTRY" \
|
||||
--set global.imageNamespace="$IMAGE_NAMESPACE" \
|
||||
--set image.tag="${GITHUB_SHA}"
|
||||
- name: Wait for rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/planet-frontend -n "$NAMESPACE" --timeout=180s
|
||||
kubectl rollout status deployment/planet-backend -n "$NAMESPACE" --timeout=180s
|
||||
kubectl rollout status deployment/planet-aiprovider -n "$NAMESPACE" --timeout=180s
|
||||
- name: Smoke test services
|
||||
run: |
|
||||
kubectl run planet-smoke-${GITHUB_RUN_NUMBER} \
|
||||
--rm -i --restart=Never \
|
||||
--namespace "$NAMESPACE" \
|
||||
--image=curlimages/curl:8.11.1 \
|
||||
--command -- sh -c '
|
||||
set -eu
|
||||
curl -fsS http://planet-frontend:3000/ >/dev/null
|
||||
curl -fsS http://planet-frontend:3000/health >/dev/null
|
||||
curl -fsS http://planet-frontend:3000/api/health >/dev/null
|
||||
curl -fsS http://planet-backend:8000/health >/dev/null
|
||||
curl -fsS http://planet-aiprovider:8010/health >/dev/null
|
||||
'
|
||||
- name: Collect diagnostics on failure
|
||||
if: failure()
|
||||
run: |
|
||||
kubectl get all -n "$NAMESPACE" -o wide || true
|
||||
kubectl describe pods -n "$NAMESPACE" || true
|
||||
kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE_NAME" --all-containers --tail=200 || true
|
||||
58
.gitea/workflows/release.yaml
Normal file
58
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.rclaw.top
|
||||
IMAGE_NAMESPACE: linkong/planet
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Resolve image tags
|
||||
id: meta
|
||||
run: |
|
||||
echo "sha_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
||||
if printf "%s" "${GITHUB_REF}" | grep -q '^refs/tags/v'; then
|
||||
echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "release_tag=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Login to registry
|
||||
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
- name: Build and push images
|
||||
run: |
|
||||
for service in frontend backend aiprovider; do
|
||||
case "$service" in
|
||||
frontend)
|
||||
dockerfile="./frontend/Dockerfile"
|
||||
context="./frontend"
|
||||
;;
|
||||
backend)
|
||||
dockerfile="backend/Dockerfile"
|
||||
context="."
|
||||
;;
|
||||
aiprovider)
|
||||
dockerfile="aiprovider/Dockerfile"
|
||||
context="."
|
||||
;;
|
||||
esac
|
||||
|
||||
image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.sha_tag }}"
|
||||
docker build -t "$image" -f "$dockerfile" "$context"
|
||||
docker push "$image"
|
||||
|
||||
if [ -n "${{ steps.meta.outputs.release_tag }}" ]; then
|
||||
release_image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.release_tag }}"
|
||||
docker tag "$image" "$release_image"
|
||||
docker push "$release_image"
|
||||
fi
|
||||
done
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
config/earth-boundary-sources.local.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -150,3 +151,9 @@ temp/
|
||||
# Runtime Data
|
||||
# ----------------------
|
||||
data/ai/bgp-briefs/
|
||||
data/earth-boundary-sources/
|
||||
|
||||
# Generated Earth boundary tile artifacts. Keep source configs and builders in
|
||||
# Git; publish PMTiles/MVT artifacts through release/deploy storage instead of
|
||||
# committing thousands of generated tile files.
|
||||
frontend/public/earth/data/boundaries/
|
||||
|
||||
51
README.md
51
README.md
@@ -236,13 +236,15 @@ bun run build
|
||||
|
||||
推荐按下面顺序排查和配置。
|
||||
|
||||
端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
|
||||
|
||||
### 1. 在 WSL 中启动服务
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
|
||||
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`,AI Provider 通过 Docker 发布到 `0.0.0.0:8010`。启动前脚本会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。
|
||||
|
||||
### 2. 先确认 WSL 内部服务正常
|
||||
|
||||
@@ -251,14 +253,16 @@ bun run build
|
||||
```bash
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
ss -ltnp | grep -E ':3000|:8000'
|
||||
curl http://localhost:8010/health
|
||||
ss -ltnp | grep -E ':3000|:8000|:8010'
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- `3000` 返回前端 HTML
|
||||
- `8000/health` 返回健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
|
||||
- `8010/health` 返回 AI Provider 健康检查 JSON
|
||||
- `ss` 中能看到 `0.0.0.0:3000`、`0.0.0.0:8000` 和 `0.0.0.0:8010`,或 Docker 已发布 `8010`
|
||||
|
||||
如果这一步不通,先不要继续做 Windows 转发。
|
||||
|
||||
@@ -269,42 +273,31 @@ ss -ltnp | grep -E ':3000|:8000'
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8010/health
|
||||
```
|
||||
|
||||
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
||||
|
||||
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
|
||||
### 4. 如果需要让局域网设备访问,清理端口和防火墙
|
||||
|
||||
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
|
||||
`./planet.sh start --allow-lan` 不再启动额外的 Windows 端口转发进程。它直接让开发服务对 `3000` / `8000` / `8010` 开放,并在启动前尝试释放这些端口。端口被 Windows 侧 listener 或旧 `portproxy` 占用时,脚本会请求一次管理员 PowerShell 清理。
|
||||
|
||||
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
|
||||
如果以前手动配置过持久 `portproxy`,若自动请求被取消,可以手动清理,避免 `iphlpsvc` 继续占用端口:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||
```
|
||||
|
||||
再放行 Windows 防火墙:
|
||||
脚本会检测 Windows 防火墙是否已放行 `3000` / `8000` / `8010`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8010
|
||||
```
|
||||
|
||||
检查转发规则是否生效:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy show all
|
||||
```
|
||||
|
||||
预期能看到:
|
||||
|
||||
- `0.0.0.0:3000 -> 127.0.0.1:3000`
|
||||
- `0.0.0.0:8000 -> 127.0.0.1:8000`
|
||||
|
||||
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
@@ -319,6 +312,8 @@ ipconfig
|
||||
|
||||
- `http://<Windows局域网IP>:3000/earth`
|
||||
- `http://<Windows局域网IP>:3000/admin`
|
||||
- `http://<Windows局域网IP>:8000/health`
|
||||
- `http://<Windows局域网IP>:8010/health`
|
||||
|
||||
例如:
|
||||
|
||||
@@ -327,7 +322,7 @@ ipconfig
|
||||
### 6. 常见现象与判断
|
||||
|
||||
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
|
||||
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常是 Windows 防火墙、网络配置或旧 `portproxy` 残留
|
||||
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||
|
||||
### 7. 本项目一次性验证顺序
|
||||
@@ -336,10 +331,12 @@ ipconfig
|
||||
|
||||
1. WSL 中执行 `curl http://localhost:3000`
|
||||
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||
3. Windows 中执行 `curl http://localhost:3000`
|
||||
4. Windows 中执行 `curl http://localhost:8000/health`
|
||||
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
||||
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
||||
3. WSL 中执行 `curl http://localhost:8010/health`
|
||||
4. Windows 中执行 `curl http://localhost:3000`
|
||||
5. Windows 中执行 `curl http://localhost:8000/health`
|
||||
6. Windows 中执行 `curl http://localhost:8010/health`
|
||||
7. 按脚本提示完成 Windows 防火墙或端口清理 UAC 请求
|
||||
8. 用手机或其他电脑访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`
|
||||
|
||||
## 启动容错参数
|
||||
|
||||
@@ -363,7 +360,7 @@ DATABASE_RETRY_INTERVAL=10 \
|
||||
- `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` 秒
|
||||
- `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3`
|
||||
- `FRONTEND_MAX_RETRIES`: 控制前端 dev server 启动重试次数,默认 `3`
|
||||
- `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||
- `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `60` 次、`2` 秒
|
||||
- `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||
- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||
|
||||
|
||||
117
TODO.md
117
TODO.md
@@ -1,45 +1,76 @@
|
||||
# TODO
|
||||
|
||||
- [x] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点
|
||||
- [x] 开始做 BGP 异常和海缆/区域的关联展示
|
||||
- [x] 做 Earth 侧的 `BGP activity layer`,让低 incident 密度时地图仍然有持续可感知的观测存在感
|
||||
- [x] 给 Earth BGP 补三层状态表达:`平稳观测态 / 局部波动态 / 事件活跃态`
|
||||
- [x] 把“当前无活跃事件”改造成“观测网络仍在运行、当前未发现聚合级事件”的状态表达
|
||||
- [x] 做 collector / region 近 15 分钟 activity score 聚合接口或动态聚合逻辑
|
||||
- [x] 把 Earth 的 BGP incident 改成 `紧凑事件核 + 向外扩张环形 pulse`,替换当前大面积 glow
|
||||
- [x] 为 BGP incident 建立符号系统:按事件类型用不同 marker,而不是都用同一种亮点
|
||||
- [x] 把 incident 地理定位从 `collector-centric` 改成 `prefix-centric`,优先使用 `prefix_geography`,其次 `prefix_scope`,再次 ASN 区域,最后才回退到观测区域质心
|
||||
- [x] 新增 `prefix_geography` 数据层,不再把 `prefix_scope` 当成 prefix 地理归属本身
|
||||
- [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源
|
||||
- [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源
|
||||
- [x] 把 RIR delegated 设计成 prefix geography 的 fallback,而不是主来源
|
||||
- [ ] 为 `aiprovider` 建立 `provider -> api adapter -> compat policy` 的配置中心,优先落成 `json` 或 `yaml` 文件,运行时按 `provider/model` 读取兼容设置,而不是把专项兼容继续散落在 Python 分支里
|
||||
- [ ] 为市面上主流 AI 服务补专项兼容配置并固化到配置文件中,至少覆盖 `OpenAI / Anthropic / MiniMax / Ollama / Moonshot / DeepSeek / Qwen / GLM / Gemini / OpenRouter / vLLM / LM Studio / One API`
|
||||
- [ ] 在兼容配置中补齐可声明项:`api adapter`、`base_url pattern`、`auth header`、`thinking default`、`reasoning block mapping`、`stream path`、`tool-call capability`、`multimodal capability`、`provider-specific request patch`
|
||||
- [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层
|
||||
- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector
|
||||
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度
|
||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
||||
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
||||
- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板
|
||||
- [ ] 重写控制台 UI,逐步抛弃 Ant Design,建立自有组件体系,并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库
|
||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||
- [ ] AIS v3.1:修复船只聚合完整性,`/geo/vessels` 合并 raw observation 聚合结果与 legacy `vessel_position + vessel_static` 最新结果,确保 BarentsWatch-only 船只不会因为 AISStream 子集存在而消失,并增加 raw/legacy/final unique MMSI 诊断统计
|
||||
- [ ] AIS v3.2:把 AISStream 从收满 `max_messages` 后结束的批采集改成长连接 streaming service,持续写入 raw observations,通过内部 `/ws` 的 `vessels` channel 推送新船、位置和航向增量,Earth 前端按 MMSI upsert marker
|
||||
- [ ] AIS v3.3:修正 AISStream 采集页面状态语义,使用 connecting/streaming/reconnecting/stopped 与 indeterminate 状态,展示运行时长、消息数、unique MMSI、message rate、最近消息和错误,不再用一次性 REST 进度条表示长连接
|
||||
- [ ] AIS v3.4:修复船只身份字段和名称聚合,MMSI/IMO/callsign 按字符串显示且不带千分位符;查询并列出所有仍以 MMSI 号码或 `MMSI <number>` 作为船名的记录,标注来源、最近观测、message types 和缺失原因,并把这批 fallback-name 船只纳入名称聚合修复集合
|
||||
- [ ] Earth Live Sync:建立统一态势实时同步链路,新增 `earth_summary` WS channel,任意采集器成功后广播轻量 summary invalidation,前端收到后重新拉 `/api/v1/visualization/geo/summary` 并更新 HUD;同时为 BGP 增加 `bgp` WS channel,使 BGP incidents/anomalies/collectors 在不刷新页面时也能 upsert 图层;卫星采集完成后触发 summary 刷新,必要时按 TLE 版本重新 hydrate 卫星数据
|
||||
- [ ] AIS v4:开放船只多源聚合策略配置,支持 source priority、字段级规则、freshness 窗口和高级保护开关;保存时校验未知字段、非法模式和危险动态字段锁定,并在聚合接口返回命中的配置版本
|
||||
- [ ] AIS v5:实现船舶资料 enrichment 与冲突治理,按 `mmsi + imo + name + callsign` 异步补充船型细分、AIS 大类、旗国、尺寸、建造年份、运营方和图片缓存;详情面板展示缓存资料和字段来源,不在实时 AIS 请求链路现场抓第三方页面
|
||||
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
||||
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
||||
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
||||
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
||||
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
|
||||
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败
|
||||
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
||||
- [ ] 为国家级估算点设计更合理的落点策略:优先落在“该国主要算力/数据中心城市候选集”而不是几何质心,必要时同国多节点做稳定散列分配,避免大量节点堆在荒漠或海上
|
||||
- [ ] 为未知位置算力中心建立人工校验工作流:支持导出待核验清单、记录人工确认结果,并把人工确认反哺到位置注册表,逐步减少问号点比例
|
||||
This file is the active backlog only. Completed history belongs in `docs/CHANGELOG.md`; detailed designs belong in `docs/plans/`.
|
||||
|
||||
## Earth
|
||||
|
||||
- [x] High-precision country boundary tile framework: implement the static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache described in [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md).
|
||||
- [x] Add the `pmtiles-mvt` frontend tile provider contract, MVT decoder dependencies, static PMTiles Nginx handling, collector artifact registration, production readiness check, and user operation docs for Earth boundaries.
|
||||
- [x] Split Earth boundary ingestion into standard source collectors (`earth_admin0_boundaries`, `earth_coastline`, `earth_claim_lines`) plus the downstream `earth_boundary_tiles` PMTiles builder.
|
||||
- [ ] Replace debug GeoJSON boundary tiles with the real `earth-boundaries-china-pov-v1.pmtiles` production artifact after audited admin-0 / coastline / claim-line sources and the PMTiles toolchain are available.
|
||||
- [ ] Import authoritative China POV / coastline / claim-line source packages through the three standard Earth boundary source collectors, then rebuild a versioned PMTiles artifact so highest zoom `8-10` preserves trusted source geometry instead of seed data.
|
||||
- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, Kosovo, Gaza, and the official dashed maritime claim line before implementing final visual changes.
|
||||
- [ ] Earth high-resolution basemap tiles: implement the viewport-loaded imagery layer described in [Earth High Resolution Basemap Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-resolution-basemap-tiles-plan.md), using high-precision coastline as the alignment reference instead of replacing the globe with one huge texture.
|
||||
- [ ] Presentation controller ownership: replace the singleton card fallback in [presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) with a presentation/card token check before BGP/News migrate onto the shared controller, so connectors only attach to their owning card.
|
||||
- [ ] BGP frontend maintainability: split [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by responsibility into data loading, marker rendering, overlays, and animation once the current interaction behavior is stable.
|
||||
- [ ] Optional BGP marker experiment: evaluate HTML markers for BGP incident/collector points if WebGL marker density or fixed screen-size clickability becomes a real blocker.
|
||||
- [ ] Earth news cruise: connect Earth news to the generic cruise queue via a news adapter rather than coupling news-specific sequencing into `main.js`.
|
||||
|
||||
## Compute Centers And Location
|
||||
|
||||
- [ ] Unknown compute-center locations: continue reducing unresolved records through the shared location pipeline, with confidence, precision, reason, and verification date preserved in GeoJSON/details.
|
||||
- [ ] Compute-center registry: keep expanding the local canonical location registry with `canonical_name`, aliases, operator, country/region/city, coordinates, confidence, and source notes.
|
||||
- [ ] Compute-center enrichment: improve source-page parsing for Epoch AI and related collectors by extracting location clues from detail pages, embedded JSON, schema.org, OpenGraph, script variables, PDFs, and press releases.
|
||||
- [ ] Compute-center identity normalization: normalize operator / cluster / facility aliases such as `xAI / Colossus / Memphis`, `OpenAI / Stargate`, `CoreWeave`, `Lambda`, and `Crusoe`.
|
||||
- [ ] Compute-center manual review: add an export/review/import workflow for unresolved or estimated locations and feed confirmed results back into the registry.
|
||||
|
||||
## AIS / Vessels
|
||||
|
||||
- [ ] AIS aggregation strategy v4: expose source priority, field-level merge rules, freshness windows, and protected dynamic-field rules in configuration, with validation and strategy version returned by vessel APIs.
|
||||
- [ ] AIS vessel enrichment v5: add asynchronous vessel profile enrichment for ship type detail, AIS class, flag, dimensions, build year, operator, and cached media. Do not fetch third-party pages in the realtime AIS request path.
|
||||
- [ ] AIS identity cleanup: continue identifying vessels whose display name is only `MMSI <number>` and backfill names from AISStream static messages, BarentsWatch static fields, or enrichment cache.
|
||||
|
||||
## AI Provider And Agents
|
||||
|
||||
- [ ] AI provider compatibility center: move provider/model compatibility rules into a JSON/YAML config read by runtime, instead of continuing to scatter provider-specific branches through Python code.
|
||||
- [ ] Provider compatibility coverage: add explicit config for OpenAI, Anthropic, MiniMax, Ollama, Moonshot, DeepSeek, Qwen, GLM, Gemini, OpenRouter, vLLM, LM Studio, and One API.
|
||||
- [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches.
|
||||
- [ ] BGP geography fallback: evaluate `inetnum` / `inet6num` whois as a finer fallback layer after `prefix_geography`, `OpenGeoFeed`, and RIR delegated data.
|
||||
|
||||
## Platform
|
||||
|
||||
- [ ] Earth preferences scope: keep current device-local Earth preferences in `localStorage`; only design backend user preferences if account-level synchronization becomes a real product requirement.
|
||||
- [ ] System logs: finish a usable Planet log viewing flow that covers backend, frontend, AI Provider, and collector/task logs, with filtering and tailing.
|
||||
- [ ] Console UI modernization: gradually replace Ant Design with Planet-owned components and a consistent Tabler Icons based icon system.
|
||||
- [ ] Earth live sync: design a unified realtime invalidation path for summary/BGP/satellite updates if polling and current WebSocket channels become insufficient.
|
||||
|
||||
## Archive
|
||||
|
||||
Archived items stay here so old context is not lost. Completed items remain checked; obsolete, invalid, or superseded items stay unchecked and include the reason.
|
||||
|
||||
### Completed
|
||||
|
||||
- [x] Refined BGP observer and anomaly `hover/click` feel.
|
||||
- [x] Added BGP anomaly relationship display with cables / regions.
|
||||
- [x] Added the Earth BGP activity layer so the map still feels alive when incident density is low.
|
||||
- [x] Added BGP state expression for stable observation, local fluctuation, and active incident states.
|
||||
- [x] Reframed "no active incident" as "observation network is running; no aggregate incident detected".
|
||||
- [x] Added collector / region recent activity scoring.
|
||||
- [x] Replaced oversized BGP incident glow with compact incident core plus outward pulse rings.
|
||||
- [x] Added BGP incident symbol types instead of using one generic bright marker.
|
||||
- [x] Switched BGP incident geography from collector-centric to prefix-centric priority.
|
||||
- [x] Added `prefix_geography` as a separate data layer instead of treating `prefix_scope` as prefix geography.
|
||||
- [x] Added IPtoASN / IPtoCountry as the main prefix-centric geography source.
|
||||
- [x] Added OpenGeoFeed as a high-quality prefix geography override source.
|
||||
- [x] Made RIR delegated data a prefix geography fallback rather than the primary source.
|
||||
- [x] Added route leak and path instability / flap detectors after the activity layer work.
|
||||
|
||||
### Obsolete Or Superseded
|
||||
|
||||
- [ ] AIS v3.1 old `/geo/vessels` full-merge requirement. Superseded by `/api/v1/vessels/snapshot`, controlled legacy fallback, and diagnostics in the AIS aggregation plan.
|
||||
- [ ] AIS v3.2 old framing of AISStream as a batch collector that needed conversion. Superseded by the implemented long-lived AISStream collector and realtime stream UI.
|
||||
- [ ] AIS v3.3 old one-shot REST progress semantics for AISStream. Superseded by realtime stream status handling.
|
||||
- [ ] AIS v3.4 broad identity cleanup wording. Folded into the active AIS identity cleanup and v5 enrichment tasks.
|
||||
- [ ] Earth surface material overlay for boundary calibration. Superseded by the high-precision boundary tile plan; future work must use source-faithful boundary/coastline data rather than overlay calibration against the coarse base map.
|
||||
- [ ] Hardcoded Earth news source extraction as a standalone task. Superseded by the broader Earth news source configuration and collector plans.
|
||||
- [ ] Country-level compute-center fallback placement as a standalone task. Superseded by the shared location pipeline and registry/manual-review backlog.
|
||||
|
||||
@@ -32,6 +32,15 @@ AI_API_KEY=sk-cp-change-me
|
||||
AI_MAX_TOKENS=1200
|
||||
AI_ANTHROPIC_VERSION=2023-06-01
|
||||
|
||||
# Optional provider-specific keys used by Settings fallback before AI_API_KEY
|
||||
# MINIMAX_API_KEY=sk-cp-change-me
|
||||
# OPENAI_API_KEY=sk-change-me
|
||||
# ANTHROPIC_API_KEY=sk-ant-change-me
|
||||
# DEEPSEEK_API_KEY=sk-change-me
|
||||
# DASHSCOPE_API_KEY=sk-change-me
|
||||
# MOONSHOT_API_KEY=sk-change-me
|
||||
# OPENROUTER_API_KEY=sk-or-change-me
|
||||
|
||||
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
|
||||
# AI_PROVIDER=openai
|
||||
# AI_PROVIDER_API=openai-completions
|
||||
|
||||
@@ -27,4 +27,7 @@ COPY aiprovider /app/aiprovider
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010", "--reload"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8010/health >/dev/null || exit 1
|
||||
|
||||
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
|
||||
@@ -17,9 +17,6 @@ class Settings(BaseSettings):
|
||||
AI_HTTP_RETRY_ATTEMPTS: int = 2
|
||||
AI_MAX_TOKENS: int = 1200
|
||||
AI_ANTHROPIC_VERSION: str = "2023-06-01"
|
||||
AI_ANALYSIS_SYSTEM_PROMPT: str = (
|
||||
"你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。"
|
||||
)
|
||||
|
||||
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ class ProviderService:
|
||||
self.anthropic_version = str(
|
||||
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
|
||||
)
|
||||
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
|
||||
|
||||
def get_status(self) -> AIProviderStatusResponse:
|
||||
enabled = self.provider != "disabled"
|
||||
@@ -95,15 +94,20 @@ class ProviderService:
|
||||
prompt = self._build_prompt(payload)
|
||||
|
||||
if self.provider_api == "openai-completions":
|
||||
data = await self._request_openai_compatible(model, prompt)
|
||||
data = await self._request_openai_compatible(model, prompt, payload.system_prompt)
|
||||
content = self._extract_openai_content(data)
|
||||
content_blocks = self._extract_openai_blocks(data)
|
||||
elif self.provider_api == "anthropic-messages":
|
||||
data = await self._request_anthropic_messages(model, prompt, payload.thinking)
|
||||
data = await self._request_anthropic_messages(
|
||||
model,
|
||||
prompt,
|
||||
payload.thinking,
|
||||
payload.system_prompt,
|
||||
)
|
||||
content = self._extract_anthropic_content(data)
|
||||
content_blocks = self._extract_anthropic_blocks(data)
|
||||
elif self.provider_api == "ollama-generate":
|
||||
data = await self._request_ollama(model, prompt)
|
||||
data = await self._request_ollama(model, prompt, payload.system_prompt)
|
||||
content = self._extract_ollama_content(data)
|
||||
content_blocks = self._extract_ollama_blocks(data)
|
||||
else:
|
||||
@@ -139,19 +143,28 @@ class ProviderService:
|
||||
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
|
||||
if payload.context:
|
||||
sections.append(f"附加上下文:\n{payload.context}")
|
||||
sections.append(
|
||||
"请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。"
|
||||
)
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
def _resolve_system_prompt(self, system_prompt: str | None) -> str | None:
|
||||
resolved = str(system_prompt or "").strip()
|
||||
return resolved or None
|
||||
|
||||
async def _request_openai_compatible(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
messages = []
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
messages.append({"role": "system", "content": resolved_system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
request_body = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": self.max_tokens,
|
||||
}
|
||||
return await self._post(
|
||||
path="/chat/completions",
|
||||
@@ -167,10 +180,10 @@ class ProviderService:
|
||||
model: str,
|
||||
prompt: str,
|
||||
thinking: dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body = {
|
||||
"model": model,
|
||||
"system": self.system_prompt,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -185,6 +198,9 @@ class ProviderService:
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
resolved_thinking = self._resolve_anthropic_thinking(thinking)
|
||||
if resolved_thinking:
|
||||
request_body["thinking"] = resolved_thinking
|
||||
@@ -218,19 +234,28 @@ class ProviderService:
|
||||
model: str,
|
||||
prompt: str,
|
||||
thinking: dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request_anthropic_messages(model, prompt, thinking)
|
||||
return await self._request_anthropic_messages(model, prompt, thinking, system_prompt)
|
||||
|
||||
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
async def _request_ollama(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body = {
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"system": self.system_prompt,
|
||||
"prompt": prompt,
|
||||
"options": {
|
||||
"temperature": 0.2,
|
||||
"num_predict": self.max_tokens,
|
||||
},
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
return await self._post(
|
||||
path="/api/generate",
|
||||
headers={
|
||||
|
||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
||||
|
||||
class SituationalAnalysisRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
objective: str = Field(..., min_length=1, max_length=1000)
|
||||
objective: str = Field(..., min_length=1, max_length=20000)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
observations: list[str] = Field(default_factory=list)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
system_prompt: str | None = Field(default=None, max_length=8000)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV PYTHONPATH=/app/backend
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
@@ -25,4 +26,7 @@ COPY VERSION /app/VERSION
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8000/health >/dev/null || exit 1
|
||||
|
||||
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
2
backend/app/ai_tasks/__init__.py
Normal file
2
backend/app/ai_tasks/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""AI task prompt registry and runtime helpers."""
|
||||
|
||||
74
backend/app/ai_tasks/default_prompts.json
Normal file
74
backend/app/ai_tasks/default_prompts.json
Normal file
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"key": "earth.news.enrich",
|
||||
"label": "Earth 新闻汉化与定位",
|
||||
"group": "Earth 新闻",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title and summary based only on the supplied RSS headline, description, source, and date."
|
||||
},
|
||||
{
|
||||
"key": "alerts.brief",
|
||||
"label": "系统告警研判",
|
||||
"group": "告警研判",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
|
||||
"prompt": "基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "alerts.situational.brief",
|
||||
"label": "跨模块态势告警研判",
|
||||
"group": "告警研判",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
|
||||
"prompt": "综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "bgp.brief",
|
||||
"label": "BGP 态势简报",
|
||||
"group": "BGP",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "location.factcheck.normalize",
|
||||
"label": "位置事实核查结构化",
|
||||
"group": "位置解析",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Convert the supplied location factcheck text into exactly one strict JSON object. Extract only facts present in the text or original query."
|
||||
},
|
||||
{
|
||||
"key": "location.factcheck.resolve",
|
||||
"label": "位置事实核查兜底",
|
||||
"group": "位置解析",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return exactly one JSON object for the most likely physical location. Use only fact-checkable public knowledge; return null fields rather than guessing when evidence is weak."
|
||||
},
|
||||
{
|
||||
"key": "datasource.mapping",
|
||||
"label": "数据源映射生成",
|
||||
"group": "采集配置",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return only JSON for a deterministic mapping DSL. The JSON must contain source.items_path and fields. Do not include prose or code."
|
||||
},
|
||||
{
|
||||
"key": "credential.guide",
|
||||
"label": "采集器凭据教程",
|
||||
"group": "采集配置",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "生成一份中文采集器凭据配置教程。只能根据 context.search_evidence 中的来源生成教程;如果证据不足,明确说明需要以官方页面为准。"
|
||||
},
|
||||
{
|
||||
"key": "ai.connection_test",
|
||||
"label": "AI Provider 连接测试",
|
||||
"group": "运维测试",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Reply OK."
|
||||
}
|
||||
]
|
||||
182
backend/app/ai_tasks/prompts.py
Normal file
182
backend/app/ai_tasks/prompts.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
AI_PROMPTS_CATEGORY = "ai_prompts"
|
||||
DEFAULT_PROMPTS_PATH = Path(__file__).with_name("default_prompts.json")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AIPromptDefinition:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
version: str
|
||||
system_prompt: str
|
||||
prompt: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectiveAIPrompt:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
version: str
|
||||
default_system_prompt: str
|
||||
default_prompt: str
|
||||
system_prompt: str
|
||||
prompt: str
|
||||
is_custom: bool
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def list_prompt_definitions() -> tuple[AIPromptDefinition, ...]:
|
||||
raw_items = json.loads(DEFAULT_PROMPTS_PATH.read_text(encoding="utf-8"))
|
||||
return tuple(
|
||||
AIPromptDefinition(
|
||||
key=str(item["key"]),
|
||||
label=str(item["label"]),
|
||||
group=str(item["group"]),
|
||||
version=str(item["version"]),
|
||||
system_prompt=str(item.get("system_prompt") or ""),
|
||||
prompt=str(item.get("prompt") or ""),
|
||||
)
|
||||
for item in raw_items
|
||||
)
|
||||
|
||||
|
||||
def get_prompt_definition(task_key: str) -> AIPromptDefinition:
|
||||
for definition in list_prompt_definitions():
|
||||
if definition.key == task_key:
|
||||
return definition
|
||||
raise KeyError(task_key)
|
||||
|
||||
|
||||
async def _get_prompt_setting(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == AI_PROMPTS_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _normalize_overrides(payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
||||
raw = (payload or {}).get("overrides")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): dict(value)
|
||||
for key, value in raw.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
|
||||
|
||||
async def get_prompt_overrides(db: AsyncSession) -> dict[str, dict[str, Any]]:
|
||||
if not hasattr(db, "execute"):
|
||||
return {}
|
||||
setting = await _get_prompt_setting(db)
|
||||
return _normalize_overrides(setting.payload if setting else None)
|
||||
|
||||
|
||||
def _effective_prompt(
|
||||
definition: AIPromptDefinition,
|
||||
override: dict[str, Any] | None,
|
||||
) -> EffectiveAIPrompt:
|
||||
override = override or {}
|
||||
custom_system = override.get("system_prompt")
|
||||
custom_prompt = override.get("prompt")
|
||||
has_custom_system = isinstance(custom_system, str)
|
||||
has_custom_prompt = isinstance(custom_prompt, str)
|
||||
return EffectiveAIPrompt(
|
||||
key=definition.key,
|
||||
label=definition.label,
|
||||
group=definition.group,
|
||||
version=definition.version,
|
||||
default_system_prompt=definition.system_prompt,
|
||||
default_prompt=definition.prompt,
|
||||
system_prompt=custom_system if has_custom_system else definition.system_prompt,
|
||||
prompt=custom_prompt if has_custom_prompt else definition.prompt,
|
||||
is_custom=has_custom_system or has_custom_prompt,
|
||||
updated_at=str(override.get("updated_at") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
async def list_effective_prompts(db: AsyncSession) -> list[EffectiveAIPrompt]:
|
||||
overrides = await get_prompt_overrides(db)
|
||||
return [
|
||||
_effective_prompt(definition, overrides.get(definition.key))
|
||||
for definition in list_prompt_definitions()
|
||||
]
|
||||
|
||||
|
||||
async def get_effective_prompt(db: AsyncSession | None, task_key: str) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
if db is None:
|
||||
return _effective_prompt(definition, None)
|
||||
overrides = await get_prompt_overrides(db)
|
||||
return _effective_prompt(definition, overrides.get(task_key))
|
||||
|
||||
|
||||
async def save_prompt_override(
|
||||
db: AsyncSession,
|
||||
task_key: str,
|
||||
*,
|
||||
system_prompt: str,
|
||||
prompt: str,
|
||||
) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
setting = await _get_prompt_setting(db)
|
||||
payload = dict(setting.payload or {}) if setting else {}
|
||||
overrides = _normalize_overrides(payload)
|
||||
overrides[definition.key] = {
|
||||
"system_prompt": system_prompt,
|
||||
"prompt": prompt,
|
||||
"updated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
payload["overrides"] = overrides
|
||||
if setting is None:
|
||||
setting = SystemSetting(category=AI_PROMPTS_CATEGORY, payload=payload)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.payload = payload
|
||||
await db.commit()
|
||||
return _effective_prompt(definition, overrides[definition.key])
|
||||
|
||||
|
||||
async def reset_prompt_override(db: AsyncSession, task_key: str) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
setting = await _get_prompt_setting(db)
|
||||
if setting is None:
|
||||
return _effective_prompt(definition, None)
|
||||
payload = dict(setting.payload or {})
|
||||
overrides = _normalize_overrides(payload)
|
||||
overrides.pop(definition.key, None)
|
||||
payload["overrides"] = overrides
|
||||
setting.payload = payload
|
||||
await db.commit()
|
||||
return _effective_prompt(definition, None)
|
||||
|
||||
|
||||
def serialize_effective_prompt(prompt: EffectiveAIPrompt) -> dict[str, Any]:
|
||||
return {
|
||||
"key": prompt.key,
|
||||
"label": prompt.label,
|
||||
"group": prompt.group,
|
||||
"version": prompt.version,
|
||||
"default_system_prompt": prompt.default_system_prompt,
|
||||
"default_prompt": prompt.default_prompt,
|
||||
"system_prompt": prompt.system_prompt,
|
||||
"prompt": prompt.prompt,
|
||||
"is_custom": prompt.is_custom,
|
||||
"updated_at": prompt.updated_at,
|
||||
}
|
||||
@@ -5,16 +5,21 @@ from app.api.v1 import (
|
||||
users,
|
||||
datasource_config,
|
||||
datasources,
|
||||
docs,
|
||||
earth,
|
||||
tasks,
|
||||
dashboard,
|
||||
websocket,
|
||||
alerts,
|
||||
settings,
|
||||
collected_data,
|
||||
data_products,
|
||||
layers,
|
||||
visualization,
|
||||
vessel_aggregation,
|
||||
vessels,
|
||||
bgp,
|
||||
news,
|
||||
realtime_sources,
|
||||
system_control,
|
||||
tv,
|
||||
)
|
||||
@@ -29,17 +34,23 @@ api_router.include_router(
|
||||
)
|
||||
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
||||
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
|
||||
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
|
||||
api_router.include_router(earth.router, prefix="/earth", tags=["earth"])
|
||||
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(data_products.router, prefix="/data-products", tags=["data-products"])
|
||||
api_router.include_router(layers.router, prefix="/layers", tags=["layers"])
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(
|
||||
vessel_aggregation.router,
|
||||
prefix="/vessel-aggregation",
|
||||
tags=["vessel-aggregation"],
|
||||
)
|
||||
api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"])
|
||||
|
||||
@@ -1,26 +1,85 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
blacklist_token,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.schemas.user import (
|
||||
ForgotPasswordRequest,
|
||||
ResendCodeRequest,
|
||||
ResetPasswordRequest,
|
||||
UserRegister,
|
||||
UserResponse,
|
||||
VerifyEmailRequest,
|
||||
)
|
||||
from app.services import otp
|
||||
from app.services.email import (
|
||||
EmailError,
|
||||
EmailNotConfiguredError,
|
||||
send_verification_email,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _token_response(user: User) -> dict:
|
||||
access_token = create_access_token(data={"sub": user.id})
|
||||
refresh = create_refresh_token(data={"sub": user.id})
|
||||
expires_in = (
|
||||
settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": expires_in,
|
||||
"refresh_token": refresh,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"gatekeeper_groups": user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
||||
"FROM users WHERE email = :email"
|
||||
),
|
||||
{"email": email},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
user.email_verified = bool(row[7])
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
@@ -28,7 +87,8 @@ async def login(
|
||||
):
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
||||
"FROM users WHERE username = :username"
|
||||
),
|
||||
{"username": form_data.username},
|
||||
)
|
||||
@@ -46,6 +106,8 @@ async def login(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
user.email_verified = bool(row[7])
|
||||
|
||||
if not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
@@ -57,24 +119,13 @@ async def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is inactive",
|
||||
)
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "EMAIL_NOT_VERIFIED", "email": user.email},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.id})
|
||||
refresh_token = create_refresh_token(data={"sub": user.id})
|
||||
|
||||
expires_in = None
|
||||
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
|
||||
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": expires_in,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
},
|
||||
}
|
||||
return _token_response(user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@@ -95,6 +146,7 @@ async def refresh_token(
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,6 +163,181 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
"is_active": current_user.is_active,
|
||||
"email_verified": getattr(current_user, "email_verified", True),
|
||||
"created_at": current_user.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: str) -> None:
|
||||
try:
|
||||
await send_verification_email(db, to=email, code=code, purpose=purpose)
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except EmailError as exc:
|
||||
logger.warning_event(
|
||||
"SMTP send failed",
|
||||
event="auth.email.send_failed",
|
||||
context={"email": email, "purpose": purpose, "error": str(exc)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(
|
||||
text("SELECT id, email_verified FROM users WHERE username = :u OR email = :e"),
|
||||
{"u": payload.username, "e": payload.email},
|
||||
)
|
||||
row = existing.fetchone()
|
||||
if row is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "USER_ALREADY_EXISTS", "message": "Username or email already in use"},
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password_hash=get_password_hash(payload.password),
|
||||
role="viewer",
|
||||
is_active=True,
|
||||
email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
code = otp.issue_code(payload.email, "register")
|
||||
except otp.OtpResendRateLimited as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
||||
) from exc
|
||||
await _send_code_or_raise(db, payload.email, code, "register")
|
||||
return {"status": "pending_verification", "email": payload.email}
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=Token)
|
||||
async def verify_email(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "USER_NOT_FOUND"},
|
||||
)
|
||||
try:
|
||||
otp.verify_code(payload.email, "register", payload.code)
|
||||
except otp.OtpExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpAttemptsExceeded as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE users SET email_verified = TRUE WHERE id = :id"),
|
||||
{"id": user.id},
|
||||
)
|
||||
await db.commit()
|
||||
user.email_verified = True
|
||||
return _token_response(user)
|
||||
|
||||
|
||||
@router.post("/resend-code")
|
||||
async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
# Avoid email enumeration; pretend success.
|
||||
return {"status": "ok"}
|
||||
if payload.purpose == "register" and user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "ALREADY_VERIFIED"},
|
||||
)
|
||||
try:
|
||||
code = otp.issue_code(payload.email, payload.purpose)
|
||||
except otp.OtpResendRateLimited as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
||||
) from exc
|
||||
await _send_code_or_raise(db, payload.email, code, payload.purpose)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
# Don't leak whether an email is registered.
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
code = otp.issue_code(payload.email, "reset_password")
|
||||
except otp.OtpResendRateLimited:
|
||||
# Silently accept; the user can retry after the cooldown.
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
await send_verification_email(db, to=payload.email, code=code, purpose="reset_password")
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except EmailError as exc:
|
||||
logger.warning_event(
|
||||
"SMTP send failed",
|
||||
event="auth.email.send_failed",
|
||||
context={"email": payload.email, "purpose": "reset_password", "error": str(exc)},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "OTP_INVALID"},
|
||||
)
|
||||
try:
|
||||
otp.verify_code(payload.email, "reset_password", payload.code)
|
||||
except otp.OtpExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpAttemptsExceeded as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE users SET password_hash = :p, email_verified = TRUE WHERE id = :id"),
|
||||
{"p": get_password_hash(payload.new_password), "id": user.id},
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -5,13 +5,26 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.user import User
|
||||
from app.services.bgp_collector_locations import (
|
||||
build_bgp_collector_location_query,
|
||||
collect_bgp_collector_location_candidates,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.ai_client import get_ai_provider_client
|
||||
from app.api.v1.settings import get_web_search_client
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -264,6 +277,120 @@ async def get_bgp_collector_summary(
|
||||
}
|
||||
|
||||
|
||||
class CollectBGPCollectorLocationRequest(BaseModel):
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/collectors/{collector_id}/collect-location")
|
||||
async def collect_bgp_collector_location(
|
||||
collector_id: str,
|
||||
payload: CollectBGPCollectorLocationRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run the shared location pipeline for a BGP route collector.
|
||||
|
||||
Mirrors ``POST /api/v1/visualization/compute-centers/{source_id}/collect-location``.
|
||||
Returns ranked candidates from source coordinates and Nominatim queries
|
||||
built around the collector's stored context (IXP / city / country). Stored
|
||||
collector locations provide context only; they are not emitted as
|
||||
candidates.
|
||||
"""
|
||||
if not collector_id or not collector_id.strip():
|
||||
raise HTTPException(status_code=400, detail="collector_id is required")
|
||||
|
||||
legacy = get_bgp_collector_location_dict(collector_id) or {}
|
||||
site = payload.site or legacy.get("matched_location_name")
|
||||
city = payload.city or legacy.get("city")
|
||||
country = payload.country or legacy.get("country")
|
||||
operator = payload.operator or "RIPE NCC"
|
||||
|
||||
candidates, attempted_queries = collect_bgp_collector_location_candidates(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
llm_failure_reason = None
|
||||
if not candidates:
|
||||
query = build_bgp_collector_location_query(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
llm_result = None
|
||||
try:
|
||||
web_search_client = await get_web_search_client(db)
|
||||
search_result = await collect_location_search_evidence(
|
||||
web_search_client=web_search_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
)
|
||||
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||
if not search_result.evidence:
|
||||
llm_failure_reason = search_result.failure_reason
|
||||
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||
provider_client = await get_ai_provider_client(db)
|
||||
llm_result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
db=db,
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
|
||||
]
|
||||
if llm_result is not None:
|
||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||
candidates = llm_result.candidates
|
||||
llm_failure_reason = llm_result.failure_reason
|
||||
|
||||
context = {
|
||||
"collector": collector_id,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"operator": operator,
|
||||
}
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates or online geocoding result reached"
|
||||
" city-level precision for this collector."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"llm_failure_reason": llm_failure_reason,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": context,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overview/summary")
|
||||
async def get_bgp_overview_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
98
backend/app/api/v1/data_products.py
Normal file
98
backend/app/api/v1/data_products.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import get_visualization_geo_summary
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PRODUCT_DEFINITIONS: dict[str, dict] = {
|
||||
"vessels": {
|
||||
"name": "船只",
|
||||
"sources": ["aisstream_vessels", "barentswatch_vessels"],
|
||||
"primary_stat_key": "vessel_count",
|
||||
"stat_keys": ["vessel_count", "vessel_raw_unique_mmsi", "vessel_legacy_unique_mmsi"],
|
||||
},
|
||||
"cables": {
|
||||
"name": "海底光缆",
|
||||
"sources": [
|
||||
"arcgis_cables",
|
||||
"arcgis_landing_points",
|
||||
"arcgis_cable_landing_relation",
|
||||
"telegeography_cables",
|
||||
"telegeography_landing",
|
||||
"telegeography_systems",
|
||||
"fao_landing_points",
|
||||
],
|
||||
"primary_stat_key": "cable_count",
|
||||
"stat_keys": ["cable_count", "landing_point_count"],
|
||||
},
|
||||
"satellites": {
|
||||
"name": "卫星",
|
||||
"sources": ["celestrak_tle", "spacetrack_tle"],
|
||||
"primary_stat_key": "satellite_count",
|
||||
"stat_keys": ["satellite_count"],
|
||||
},
|
||||
"bgp": {
|
||||
"name": "BGP",
|
||||
"sources": [
|
||||
"ris_live_bgp",
|
||||
"bgpstream_bgp",
|
||||
"iptoasn_prefix_geo",
|
||||
"opengeofeed_prefix_geo",
|
||||
"nro_delegated_prefix_geo",
|
||||
],
|
||||
"primary_stat_key": "bgp_event_count",
|
||||
"stat_keys": ["bgp_event_count", "bgp_incident_count", "bgp_anomaly_count", "bgp_collector_count"],
|
||||
},
|
||||
"compute": {
|
||||
"name": "算力",
|
||||
"sources": ["top500", "epoch_ai_gpu"],
|
||||
"primary_stat_key": "compute_center_count",
|
||||
"stat_keys": ["compute_center_count", "supercomputer_count", "gpu_cluster_count"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_product_status(product_id: str, summary: dict) -> dict:
|
||||
definition = PRODUCT_DEFINITIONS[product_id]
|
||||
stats = summary.get("stats", {})
|
||||
product_stats = {key: stats.get(key, 0) for key in definition["stat_keys"]}
|
||||
total_count = int(product_stats.get(definition["primary_stat_key"]) or 0)
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"name": definition["name"],
|
||||
"sources": definition["sources"],
|
||||
"generated_at": summary.get("generated_at") or to_iso8601_utc(datetime.now(UTC)),
|
||||
"total_count": total_count,
|
||||
"stats": product_stats,
|
||||
"build_state": "ready",
|
||||
"stats_scope": "global",
|
||||
"stats_freshness": "cached_or_indexed",
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_data_products(db: AsyncSession = Depends(get_db)):
|
||||
summary = await get_visualization_geo_summary(db)
|
||||
return {
|
||||
"generated_at": summary.get("generated_at"),
|
||||
"data": [
|
||||
_build_product_status(product_id, summary)
|
||||
for product_id in PRODUCT_DEFINITIONS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{product_id}/status")
|
||||
async def get_data_product_status(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if product_id not in PRODUCT_DEFINITIONS:
|
||||
raise HTTPException(status_code=404, detail="Unknown data product")
|
||||
summary = await get_visualization_geo_summary(db)
|
||||
return _build_product_status(product_id, summary)
|
||||
@@ -22,6 +22,7 @@ from app.models.vessel import AISRawObservation, AISSourceHealth
|
||||
from app.core.security import get_current_user
|
||||
from app.core.cache import cache
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
||||
from app.services.datasource_mapping import (
|
||||
@@ -41,6 +42,8 @@ from app.services.custom_datasource_runtime import (
|
||||
stop_custom_stream,
|
||||
test_websocket_config,
|
||||
)
|
||||
|
||||
DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping"
|
||||
from app.services.datasource_connectivity import (
|
||||
get_builtin_connection_status,
|
||||
save_connectivity_success,
|
||||
@@ -50,6 +53,15 @@ from app.services.datasource_connectivity import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _default_builtin_config(name: str) -> dict[str, Any]:
|
||||
return {"timeout": 30, "retry": 3}
|
||||
|
||||
|
||||
def _default_builtin_source_type(name: str) -> str:
|
||||
if name == "aisstream_vessels":
|
||||
return "websocket"
|
||||
return "http"
|
||||
|
||||
|
||||
class DataSourceConfigCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
@@ -364,7 +376,7 @@ async def list_all_datasources(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all data sources: YAML defaults + DB overrides"""
|
||||
from app.core.data_sources import COLLECTOR_URL_KEYS, get_data_sources_config
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
|
||||
config = get_data_sources_config()
|
||||
|
||||
@@ -372,20 +384,22 @@ async def list_all_datasources(
|
||||
db_configs = {c.name: c for c in db_query.scalars().all()}
|
||||
|
||||
result = []
|
||||
for name, yaml_key in COLLECTOR_URL_KEYS.items():
|
||||
for name, metadata in DEFAULT_DATASOURCES.items():
|
||||
yaml_url = config.get_yaml_url(name)
|
||||
db_config = db_configs.get(name)
|
||||
default_config = _default_builtin_config(name)
|
||||
default_url = yaml_url
|
||||
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"default_url": yaml_url,
|
||||
"endpoint": db_config.endpoint if db_config else yaml_url,
|
||||
"default_url": default_url,
|
||||
"endpoint": db_config.endpoint if db_config else default_url,
|
||||
"is_overridden": db_config is not None and db_config.endpoint != yaml_url
|
||||
if yaml_url
|
||||
if default_url
|
||||
else db_config is not None,
|
||||
"is_active": db_config.is_active if db_config else True,
|
||||
"source_type": db_config.source_type if db_config else "http",
|
||||
"source_type": db_config.source_type if db_config else _default_builtin_source_type(name),
|
||||
"auth_type": db_config.auth_type if db_config else "none",
|
||||
"auth_configured": {
|
||||
"api_key": bool((db_config.auth_config or {}).get("api_key"))
|
||||
@@ -393,11 +407,11 @@ async def list_all_datasources(
|
||||
else False,
|
||||
},
|
||||
"headers": db_config.headers if db_config else {},
|
||||
"config": strip_connectivity_validation(db_config.config if db_config else {}),
|
||||
"config": strip_connectivity_validation(db_config.config if db_config else default_config),
|
||||
"config_id": db_config.id if db_config else None,
|
||||
"description": db_config.description
|
||||
if db_config
|
||||
else f"Data source from YAML: {yaml_key}",
|
||||
else f"内置采集器默认配置:{metadata.get('display_name') or metadata.get('name') or name}",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -757,14 +771,12 @@ async def propose_datasource_mapping(
|
||||
generated_by = "heuristic"
|
||||
if payload.use_ai:
|
||||
try:
|
||||
prompt = await get_effective_prompt(db, DATASOURCE_MAPPING_PROMPT_KEY)
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate datasource mapping for {schema.key}",
|
||||
objective=(
|
||||
"Return only JSON for a deterministic mapping DSL. "
|
||||
"The JSON must contain source.items_path and fields. "
|
||||
"Do not include prose or code."
|
||||
),
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"target_schema": schema.to_dict(),
|
||||
"sample_payload": redacted_sample,
|
||||
|
||||
@@ -3,7 +3,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select, text
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
@@ -17,6 +18,8 @@ from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.task import CollectionTask
|
||||
from app.models.user import User
|
||||
from app.models.vessel import AISRawObservation
|
||||
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA
|
||||
from app.services.scheduler import (
|
||||
cancel_running_collector_now,
|
||||
get_latest_task_id_for_datasource,
|
||||
@@ -27,6 +30,29 @@ from app.services.scheduler import (
|
||||
router = APIRouter()
|
||||
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
||||
|
||||
PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("vessels", ("vessel", "ais")),
|
||||
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
|
||||
("satellites", ("tle", "satellite", "spacetrack", "celestrak")),
|
||||
("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")),
|
||||
("compute", ("top500", "gpu", "supercomputer", "compute")),
|
||||
("ai", ("huggingface", "epoch_ai")),
|
||||
("media", ("news", "tv", "live_stream")),
|
||||
)
|
||||
|
||||
|
||||
class DatasourceBatchTriggerRequest(BaseModel):
|
||||
source_ids: list[int] = Field(default_factory=list)
|
||||
force: bool = False
|
||||
module: Optional[str] = None
|
||||
product: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
priority: Optional[str] = None
|
||||
run_status: Optional[str] = None
|
||||
collected: Optional[bool] = None
|
||||
credential_status: Optional[str] = None
|
||||
q: Optional[str] = None
|
||||
|
||||
|
||||
def format_frequency_label(minutes: int) -> str:
|
||||
if minutes % 1440 == 0:
|
||||
@@ -47,6 +73,20 @@ def datasource_metadata(source: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def datasource_product_key(datasource: DataSource) -> str:
|
||||
haystack = " ".join(
|
||||
[
|
||||
datasource.source or "",
|
||||
datasource.name or "",
|
||||
datasource.collector_class or "",
|
||||
]
|
||||
).lower()
|
||||
for product, keywords in PRODUCT_SOURCE_KEYWORDS:
|
||||
if any(keyword in haystack for keyword in keywords):
|
||||
return product
|
||||
return "other"
|
||||
|
||||
|
||||
def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
|
||||
if datasource.last_run_at is None:
|
||||
return True
|
||||
@@ -110,6 +150,68 @@ async def _load_latest_task_ids(
|
||||
return {datasource_id: task_id for datasource_id, task_id in result.all()}
|
||||
|
||||
|
||||
async def _load_latest_tasks(
|
||||
db: AsyncSession,
|
||||
datasource_ids: list[int],
|
||||
) -> dict[int, CollectionTask]:
|
||||
if not datasource_ids:
|
||||
return {}
|
||||
|
||||
ranked_tasks = (
|
||||
select(
|
||||
CollectionTask.id.label("task_id"),
|
||||
CollectionTask.datasource_id.label("datasource_id"),
|
||||
func.row_number().over(
|
||||
partition_by=CollectionTask.datasource_id,
|
||||
order_by=(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc()),
|
||||
).label("row_num"),
|
||||
)
|
||||
.where(CollectionTask.datasource_id.in_(datasource_ids))
|
||||
.subquery()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
.join(ranked_tasks, CollectionTask.id == ranked_tasks.c.task_id)
|
||||
.where(ranked_tasks.c.row_num == 1)
|
||||
)
|
||||
return {task.datasource_id: task for task in result.scalars().all()}
|
||||
|
||||
|
||||
async def _load_collected_record_counts(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
) -> dict[str, int]:
|
||||
if not sources:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(CollectedData.source, func.count(CollectedData.id))
|
||||
.where(CollectedData.source.in_(sources))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.group_by(CollectedData.source)
|
||||
)
|
||||
counts = {source: int(count or 0) for source, count in result.all()}
|
||||
|
||||
vessel_sources = [
|
||||
source
|
||||
for source in sources
|
||||
if datasource_metadata(source)["credential_provider"] in {"aisstream", "barentswatch"}
|
||||
or "vessel" in source
|
||||
or "ais" in source
|
||||
]
|
||||
if vessel_sources:
|
||||
raw_result = await db.execute(
|
||||
select(AISRawObservation.source, func.count(AISRawObservation.id))
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.where(AISRawObservation.source.in_(vessel_sources))
|
||||
.group_by(AISRawObservation.source)
|
||||
)
|
||||
for source, count in raw_result.all():
|
||||
counts[source] = max(counts.get(source, 0), int(count or 0))
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
async def _load_datasource_endpoint_overrides(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
@@ -133,7 +235,7 @@ async def _load_datasource_endpoint_overrides(
|
||||
async def _load_datasource_list_context(
|
||||
db: AsyncSession,
|
||||
datasources: list[DataSource],
|
||||
) -> tuple[dict[int, CollectionTask], dict[str, str]]:
|
||||
) -> tuple[dict[int, CollectionTask], dict[int, CollectionTask], dict[str, str]]:
|
||||
datasource_ids = [datasource.id for datasource in datasources]
|
||||
sources = [datasource.source for datasource in datasources]
|
||||
|
||||
@@ -157,8 +259,203 @@ async def _load_datasource_list_context(
|
||||
if stale_datasource_ids:
|
||||
running_tasks = await _load_latest_running_tasks(db, datasource_ids)
|
||||
|
||||
latest_tasks = await _load_latest_tasks(db, datasource_ids)
|
||||
endpoint_overrides = await _load_datasource_endpoint_overrides(db, sources)
|
||||
return running_tasks, endpoint_overrides
|
||||
return running_tasks, latest_tasks, endpoint_overrides
|
||||
|
||||
|
||||
def _apply_datasource_query_filters(
|
||||
query,
|
||||
*,
|
||||
module: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
priority: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
) -> object:
|
||||
if module:
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
if q:
|
||||
like_value = f"%{q.strip()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
DataSource.name.ilike(like_value),
|
||||
DataSource.source.ilike(like_value),
|
||||
DataSource.collector_class.ilike(like_value),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _filter_datasources_in_memory(
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
running_tasks: dict[int, CollectionTask],
|
||||
latest_tasks: dict[int, CollectionTask],
|
||||
record_counts: dict[str, int],
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
) -> list[DataSource]:
|
||||
filtered: list[DataSource] = []
|
||||
for datasource in datasources:
|
||||
record_count = record_counts.get(datasource.source, 0)
|
||||
latest_task = latest_tasks.get(datasource.id)
|
||||
effective_status = (
|
||||
"running"
|
||||
if datasource.id in running_tasks
|
||||
else latest_task.status
|
||||
if latest_task is not None
|
||||
else datasource.last_status
|
||||
)
|
||||
if product and datasource_product_key(datasource) != product:
|
||||
continue
|
||||
if collected is not None and (record_count > 0) != collected:
|
||||
continue
|
||||
if credential_status:
|
||||
metadata = datasource_metadata(datasource.source)
|
||||
if metadata["credential_status"] != credential_status:
|
||||
continue
|
||||
if run_status == "running" and datasource.id not in running_tasks:
|
||||
continue
|
||||
if run_status == "not_run" and effective_status is not None:
|
||||
continue
|
||||
if run_status not in {None, "running", "not_run", "collected", "uncollected"} and effective_status != run_status:
|
||||
continue
|
||||
if run_status == "collected" and record_count <= 0:
|
||||
continue
|
||||
if run_status == "uncollected" and record_count > 0:
|
||||
continue
|
||||
filtered.append(datasource)
|
||||
return filtered
|
||||
|
||||
|
||||
async def _trigger_datasource_batch(
|
||||
db: AsyncSession,
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
force: bool,
|
||||
) -> dict:
|
||||
if not datasources:
|
||||
return {
|
||||
"status": "noop",
|
||||
"message": "No matching data sources to trigger",
|
||||
"force": force,
|
||||
"triggered": [],
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
running_tasks = await _load_latest_running_tasks(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
|
||||
for datasource in datasources:
|
||||
if not datasource.is_active:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "disabled",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
if running_task is not None:
|
||||
if not force:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "already_running",
|
||||
"task_id": running_task.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
cancelled = await cancel_running_collector_now(datasource.source)
|
||||
if not cancelled:
|
||||
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "within_frequency_window",
|
||||
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
||||
"next_run_at": to_iso8601_utc(
|
||||
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
|
||||
|
||||
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
|
||||
@@ -355,28 +652,49 @@ async def list_datasources(
|
||||
module: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
priority: Optional[str] = None,
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if module:
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
query = _apply_datasource_query_filters(
|
||||
query,
|
||||
module=module,
|
||||
is_active=is_active,
|
||||
priority=priority,
|
||||
run_status=run_status,
|
||||
q=q,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
|
||||
collector_list = []
|
||||
config = get_data_sources_config()
|
||||
running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
|
||||
running_tasks, latest_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
latest_tasks=latest_tasks,
|
||||
record_counts=record_counts,
|
||||
product=product,
|
||||
run_status=run_status,
|
||||
collected=collected,
|
||||
credential_status=credential_status,
|
||||
)
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
latest_task = latest_tasks.get(datasource.id)
|
||||
display_task = running_task or latest_task
|
||||
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
|
||||
last_run_at = datasource.last_run_at
|
||||
last_status = datasource.last_status
|
||||
last_run_at = datasource.last_run_at or (latest_task.completed_at if latest_task else None)
|
||||
last_status = datasource.last_status or (latest_task.status if latest_task else None)
|
||||
collected_records = record_counts.get(datasource.source, 0)
|
||||
|
||||
collector_list.append(
|
||||
{
|
||||
@@ -384,6 +702,7 @@ async def list_datasources(
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
**datasource_metadata(datasource.source),
|
||||
"product": datasource_product_key(datasource),
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
"frequency": format_frequency_label(datasource.frequency_minutes),
|
||||
@@ -395,16 +714,19 @@ async def list_datasources(
|
||||
"last_run_at": to_iso8601_utc(last_run_at),
|
||||
"last_status": last_status,
|
||||
"is_running": running_task is not None,
|
||||
"task_id": running_task.id if running_task else None,
|
||||
"progress": running_task.progress if running_task else None,
|
||||
"phase": running_task.phase if running_task else None,
|
||||
"phase_progress": running_task.phase_progress if running_task else None,
|
||||
"phase_message": running_task.phase_message if running_task else None,
|
||||
"phase_current": running_task.phase_current if running_task else None,
|
||||
"phase_total": running_task.phase_total if running_task else None,
|
||||
"phase_unit": running_task.phase_unit if running_task else None,
|
||||
"records_processed": running_task.records_processed if running_task else None,
|
||||
"total_records": running_task.total_records if running_task else None,
|
||||
"task_id": display_task.id if display_task else None,
|
||||
"progress": display_task.progress if display_task else None,
|
||||
"phase": display_task.phase if display_task else None,
|
||||
"phase_progress": display_task.phase_progress if display_task else None,
|
||||
"phase_message": display_task.phase_message if display_task else None,
|
||||
"phase_current": display_task.phase_current if display_task else None,
|
||||
"phase_total": display_task.phase_total if display_task else None,
|
||||
"phase_unit": display_task.phase_unit if display_task else None,
|
||||
"records_processed": display_task.records_processed if display_task else None,
|
||||
"total_records": display_task.total_records if display_task else None,
|
||||
"error_message": display_task.error_message if display_task else None,
|
||||
"collected_records": collected_records,
|
||||
"has_collected_data": collected_records > 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -419,110 +741,47 @@ async def trigger_all_datasources(
|
||||
):
|
||||
result = await db.execute(
|
||||
select(DataSource)
|
||||
.where(DataSource.is_active == True)
|
||||
.where(DataSource.is_active.is_(True))
|
||||
.order_by(DataSource.module, DataSource.id)
|
||||
)
|
||||
datasources = result.scalars().all()
|
||||
return await _trigger_datasource_batch(db, datasources, force=force)
|
||||
|
||||
if not datasources:
|
||||
return {
|
||||
"status": "noop",
|
||||
"message": "No active data sources to trigger",
|
||||
"triggered": [],
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
running_tasks = await _load_latest_running_tasks(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
if running_task is not None:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "already_running",
|
||||
"task_id": running_task.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "within_frequency_window",
|
||||
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
||||
"next_run_at": to_iso8601_utc(
|
||||
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
}
|
||||
@router.post("/trigger-batch")
|
||||
async def trigger_datasource_batch(
|
||||
payload: DatasourceBatchTriggerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if payload.source_ids:
|
||||
query = query.where(DataSource.id.in_(payload.source_ids))
|
||||
else:
|
||||
query = _apply_datasource_query_filters(
|
||||
query,
|
||||
module=payload.module,
|
||||
is_active=payload.is_active,
|
||||
priority=payload.priority,
|
||||
run_status=payload.run_status,
|
||||
q=payload.q,
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
running_tasks, latest_tasks, _ = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
latest_tasks=latest_tasks,
|
||||
record_counts=record_counts,
|
||||
product=None if payload.source_ids else payload.product,
|
||||
run_status=None if payload.source_ids else payload.run_status,
|
||||
collected=None if payload.source_ids else payload.collected,
|
||||
credential_status=None if payload.source_ids else payload.credential_status,
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
return await _trigger_datasource_batch(db, datasources, force=payload.force)
|
||||
|
||||
|
||||
@router.get("/{source_id}")
|
||||
@@ -717,6 +976,14 @@ async def get_task_status(
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
else:
|
||||
task = await get_running_task(db, datasource.id)
|
||||
if task is None:
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == datasource.id)
|
||||
.order_by(CollectionTask.created_at.desc().nullslast(), CollectionTask.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return {
|
||||
@@ -745,4 +1012,5 @@ async def get_task_status(
|
||||
"records_processed": task.records_processed,
|
||||
"total_records": task.total_records,
|
||||
"status": task.status,
|
||||
"error_message": task.error_message,
|
||||
}
|
||||
|
||||
102
backend/app/api/v1/docs.py
Normal file
102
backend/app/api/v1/docs.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Authenticated documentation APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
from app.services.docs_gatekeeper import (
|
||||
DOCS_BY_SLUG,
|
||||
VALID_DOCS_LANGS,
|
||||
can_read_doc,
|
||||
catalog_for_user,
|
||||
doc_path_for,
|
||||
title_for,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None or payload.get("type") != "access" or payload.get("sub") is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(payload["sub"])},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None or not row[5]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def get_docs_catalog(current_user: User | None = Depends(get_optional_current_user)):
|
||||
return {
|
||||
"items": catalog_for_user(current_user),
|
||||
"authenticated": current_user is not None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{lang}/{slug}")
|
||||
async def get_doc_content(
|
||||
lang: str,
|
||||
slug: str,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
):
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
entry = DOCS_BY_SLUG.get(slug)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
path = doc_path_for(entry, lang)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
if not can_read_doc(entry, current_user):
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient Docs permissions")
|
||||
|
||||
return {
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
"markdown": path.read_text(encoding="utf-8"),
|
||||
}
|
||||
118
backend/app/api/v1/earth.py
Normal file
118
backend/app/api/v1/earth.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Earth asset management APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import decode_token, get_current_user, redis_client
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.services.earth_boundaries import (
|
||||
EarthBoundaryBuildError,
|
||||
get_boundary_build_status,
|
||||
get_boundary_status,
|
||||
save_boundary_config,
|
||||
start_boundary_build_job,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
class EarthBoundaryConfigPayload(BaseModel):
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/boundaries/status")
|
||||
async def get_earth_boundary_status():
|
||||
return get_boundary_status()
|
||||
|
||||
|
||||
async def _get_optional_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
token = credentials.credentials
|
||||
if redis_client.sismember("blacklisted_tokens", token):
|
||||
return None
|
||||
payload = decode_token(token)
|
||||
if payload is None or payload.get("type") != "access":
|
||||
return None
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None or not row[5]:
|
||||
return None
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
def _is_loopback_request(request: Request) -> bool:
|
||||
host = request.client.host if request.client else ""
|
||||
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
|
||||
|
||||
|
||||
def _require_local_or_user(request: Request, user: User | None) -> None:
|
||||
if user is not None or _is_loopback_request(request):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required outside localhost",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/boundaries/config")
|
||||
async def update_earth_boundary_config(
|
||||
payload: EarthBoundaryConfigPayload,
|
||||
_current_user: User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
return save_boundary_config(payload.config)
|
||||
except EarthBoundaryBuildError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": exc.code, "message": str(exc), "details": exc.details},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/boundaries/build")
|
||||
async def build_earth_boundary_assets(
|
||||
request: Request,
|
||||
current_user: User | None = Depends(_get_optional_current_user),
|
||||
):
|
||||
_require_local_or_user(request, current_user)
|
||||
try:
|
||||
return await start_boundary_build_job()
|
||||
except EarthBoundaryBuildError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"code": exc.code, "message": str(exc), "details": exc.details},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/boundaries/build/status")
|
||||
async def get_earth_boundary_build_status():
|
||||
return get_boundary_build_status()
|
||||
229
backend/app/api/v1/layers.py
Normal file
229
backend/app/api/v1/layers.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import (
|
||||
_parse_bbox,
|
||||
get_bgp_anomalies_geojson,
|
||||
get_bgp_collectors_geojson,
|
||||
get_bgp_incidents_geojson,
|
||||
get_cables_geojson,
|
||||
get_landing_points_geojson,
|
||||
get_satellites_geojson,
|
||||
)
|
||||
from app.api.v1.vessels import build_vessel_snapshot_response
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_LAYER_LIMIT = 1000
|
||||
MAX_LAYER_LIMIT = 5000
|
||||
LOW_ZOOM_FEATURE_LIMIT = 500
|
||||
|
||||
|
||||
def _clamp_limit(limit: int, zoom: int) -> tuple[int, bool]:
|
||||
clamped = min(max(limit, 1), MAX_LAYER_LIMIT)
|
||||
if zoom <= 3:
|
||||
return min(clamped, LOW_ZOOM_FEATURE_LIMIT), clamped != limit or clamped > LOW_ZOOM_FEATURE_LIMIT
|
||||
return clamped, clamped != limit
|
||||
|
||||
|
||||
def _coordinate_in_bbox(coord: Any, bbox: tuple[float, float, float, float]) -> bool:
|
||||
if not isinstance(coord, (list, tuple)) or len(coord) < 2:
|
||||
return False
|
||||
try:
|
||||
lon = float(coord[0])
|
||||
lat = float(coord[1])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
return lon_min <= lon <= lon_max and lat_min <= lat <= lat_max
|
||||
|
||||
|
||||
def _geometry_intersects_bbox(geometry: dict, bbox: tuple[float, float, float, float]) -> bool:
|
||||
coordinates = geometry.get("coordinates")
|
||||
geometry_type = geometry.get("type")
|
||||
if geometry_type == "Point":
|
||||
return _coordinate_in_bbox(coordinates, bbox)
|
||||
if geometry_type in {"LineString", "MultiPoint"}:
|
||||
return any(_coordinate_in_bbox(coord, bbox) for coord in coordinates or [])
|
||||
if geometry_type in {"Polygon", "MultiLineString"}:
|
||||
return any(
|
||||
_coordinate_in_bbox(coord, bbox)
|
||||
for line in coordinates or []
|
||||
for coord in line
|
||||
)
|
||||
if geometry_type == "MultiPolygon":
|
||||
return any(
|
||||
_coordinate_in_bbox(coord, bbox)
|
||||
for polygon in coordinates or []
|
||||
for line in polygon
|
||||
for coord in line
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _guard_geojson_layer(
|
||||
geojson: dict,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float],
|
||||
zoom: int,
|
||||
limit: int,
|
||||
) -> dict:
|
||||
bounded_limit, limit_clamped = _clamp_limit(limit, zoom)
|
||||
features = [
|
||||
feature
|
||||
for feature in geojson.get("features", [])
|
||||
if _geometry_intersects_bbox(feature.get("geometry") or {}, bbox)
|
||||
]
|
||||
visible_count = len(features)
|
||||
returned_features = features[:bounded_limit]
|
||||
return {
|
||||
**geojson,
|
||||
"features": returned_features,
|
||||
"visible_count": visible_count,
|
||||
"returned_count": len(returned_features),
|
||||
"diagnostics": {
|
||||
"bbox_limited": True,
|
||||
"limit": bounded_limit,
|
||||
"limit_clamped": limit_clamped,
|
||||
"truncated": visible_count > len(returned_features),
|
||||
"degraded": zoom <= 3 or visible_count > len(returned_features),
|
||||
"stats_scope": "viewport",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_layer_bbox(bbox: str) -> tuple[float, float, float, float]:
|
||||
parsed = _parse_bbox(bbox)
|
||||
if parsed is None:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get("/vessels/snapshot")
|
||||
async def get_vessel_layer_snapshot(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
vessel_type: Optional[str] = Query(None, alias="type"),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
parsed_bbox = _parse_layer_bbox(bbox)
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
type_filter=vessel_type,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cables")
|
||||
async def get_cable_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_cables_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/landing-points")
|
||||
async def get_landing_point_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_landing_points_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/satellites")
|
||||
async def get_satellite_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_satellites_geojson(limit=bounded_limit, db=db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/anomalies")
|
||||
async def get_bgp_anomaly_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_anomalies_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=bounded_limit,
|
||||
db=db,
|
||||
),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/incidents")
|
||||
async def get_bgp_incident_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_incidents_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=min(bounded_limit, 500),
|
||||
db=db,
|
||||
),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/collectors")
|
||||
async def get_bgp_collector_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_collectors_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.services.earth_news import get_earth_news_payload
|
||||
|
||||
router = APIRouter()
|
||||
@@ -9,5 +11,6 @@ router = APIRouter()
|
||||
async def get_earth_feed(
|
||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
||||
return await get_earth_news_payload(lat=lat, lon=lon, db=db)
|
||||
|
||||
280
backend/app/api/v1/realtime_sources.py
Normal file
280
backend/app/api/v1/realtime_sources.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Realtime datasource operations and runtime statistics."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.core.security import get_current_user
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.user import User
|
||||
from app.models.vessel import AISRawObservation, AISSourceHealth
|
||||
from app.services.custom_datasource_runtime import (
|
||||
get_custom_stream_status,
|
||||
start_custom_stream,
|
||||
stop_custom_stream,
|
||||
)
|
||||
from app.services.scheduler import (
|
||||
cancel_running_collector_now,
|
||||
is_collector_running,
|
||||
run_collector_now,
|
||||
)
|
||||
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA, update_ais_source_health
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BUILTIN_REALTIME_SOURCES = {"aisstream_vessels"}
|
||||
REALTIME_SOURCE_TYPES = {"websocket", "ws"}
|
||||
|
||||
|
||||
def _is_realtime_config(config: DataSourceConfig) -> bool:
|
||||
return str(config.source_type or "").lower() in REALTIME_SOURCE_TYPES
|
||||
|
||||
|
||||
def _safe_config_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _credential_configured(config: DataSourceConfig | None) -> bool:
|
||||
if config is not None:
|
||||
auth_config = _safe_config_dict(config.auth_config)
|
||||
config_payload = _safe_config_dict(config.config)
|
||||
if auth_config.get("api_key") or config_payload.get("api_key"):
|
||||
return True
|
||||
return bool(os.getenv("AISSTREAM_API_KEY"))
|
||||
|
||||
|
||||
async def _load_realtime_stats(db: AsyncSession, source: str) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
observed_24h = now - timedelta(hours=24)
|
||||
observed_1h = now - timedelta(hours=1)
|
||||
payload_mmsi = AISRawObservation.entity_key
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AISRawObservation.id).label("total_observations"),
|
||||
func.count(AISRawObservation.id)
|
||||
.filter(AISRawObservation.observed_at >= observed_24h)
|
||||
.label("observations_24h"),
|
||||
func.count(AISRawObservation.id)
|
||||
.filter(AISRawObservation.observed_at >= observed_1h)
|
||||
.label("observations_1h"),
|
||||
func.count(distinct(payload_mmsi)).label("unique_mmsi_total"),
|
||||
func.count(distinct(payload_mmsi))
|
||||
.filter(AISRawObservation.observed_at >= observed_24h)
|
||||
.label("unique_mmsi_24h"),
|
||||
func.max(AISRawObservation.observed_at).label("latest_observed_at"),
|
||||
func.max(AISRawObservation.collected_at).label("latest_collected_at"),
|
||||
)
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.where(AISRawObservation.source == source)
|
||||
)
|
||||
row = result.mappings().one()
|
||||
return {
|
||||
"total_observations": int(row["total_observations"] or 0),
|
||||
"observations_24h": int(row["observations_24h"] or 0),
|
||||
"observations_1h": int(row["observations_1h"] or 0),
|
||||
"unique_mmsi_total": int(row["unique_mmsi_total"] or 0),
|
||||
"unique_mmsi_24h": int(row["unique_mmsi_24h"] or 0),
|
||||
"latest_observed_at": to_iso8601_utc(row["latest_observed_at"]),
|
||||
"latest_collected_at": to_iso8601_utc(row["latest_collected_at"]),
|
||||
}
|
||||
|
||||
|
||||
def _runtime_status_for_builtin(source: str) -> dict[str, Any]:
|
||||
running = is_collector_running(source)
|
||||
return {
|
||||
"running": running,
|
||||
"done": False,
|
||||
"runtime": "collector",
|
||||
}
|
||||
|
||||
|
||||
def _runtime_status_for_custom(config_id: int) -> dict[str, Any]:
|
||||
status = get_custom_stream_status(config_id)
|
||||
return {
|
||||
"running": bool(status.get("running")),
|
||||
"done": bool(status.get("done")),
|
||||
"runtime": "custom_stream",
|
||||
}
|
||||
|
||||
|
||||
async def _serialize_builtin_aisstream(
|
||||
db: AsyncSession,
|
||||
datasource: DataSource,
|
||||
config: DataSourceConfig | None,
|
||||
) -> dict[str, Any]:
|
||||
health = await db.get(AISSourceHealth, datasource.source)
|
||||
config_payload = _safe_config_dict(config.config if config else {})
|
||||
endpoint = (
|
||||
(config.endpoint if config else None)
|
||||
or get_data_sources_config().get_yaml_url(datasource.source)
|
||||
)
|
||||
return {
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"display_name": "AISStream 实时船舶",
|
||||
"kind": "builtin",
|
||||
"source_type": "websocket",
|
||||
"endpoint": endpoint,
|
||||
"is_active": bool(datasource.is_active),
|
||||
"credential_configured": _credential_configured(config),
|
||||
"message_types": config_payload.get("message_types") or ["PositionReport", "ShipStaticData"],
|
||||
"bounding_boxes": config_payload.get("bounding_boxes") or [[[-90, -180], [90, 180]]],
|
||||
"config": config_payload,
|
||||
"runtime": _runtime_status_for_builtin(datasource.source),
|
||||
"health": health.to_dict() if health else None,
|
||||
"stats": await _load_realtime_stats(db, datasource.source),
|
||||
}
|
||||
|
||||
|
||||
async def _serialize_custom_stream(
|
||||
db: AsyncSession,
|
||||
config: DataSourceConfig,
|
||||
) -> dict[str, Any]:
|
||||
health = await db.get(AISSourceHealth, config.name)
|
||||
config_payload = _safe_config_dict(config.config)
|
||||
return {
|
||||
"source": config.name,
|
||||
"name": config.name,
|
||||
"display_name": config.description or config.name,
|
||||
"kind": "custom",
|
||||
"config_id": config.id,
|
||||
"source_type": config.source_type,
|
||||
"endpoint": config.endpoint,
|
||||
"is_active": bool(config.is_active),
|
||||
"credential_configured": config.auth_type == "none" or bool(_safe_config_dict(config.auth_config)),
|
||||
"message_types": config_payload.get("message_types") or [],
|
||||
"bounding_boxes": config_payload.get("bounding_boxes") or [],
|
||||
"config": config_payload,
|
||||
"runtime": _runtime_status_for_custom(config.id),
|
||||
"health": health.to_dict() if health else None,
|
||||
"stats": await _load_realtime_stats(db, config.name),
|
||||
}
|
||||
|
||||
|
||||
async def _load_builtin_aisstream(db: AsyncSession) -> tuple[DataSource | None, DataSourceConfig | None]:
|
||||
result = await db.execute(select(DataSource).where(DataSource.source == "aisstream_vessels"))
|
||||
datasource = result.scalar_one_or_none()
|
||||
config_result = await db.execute(
|
||||
select(DataSourceConfig)
|
||||
.where(DataSourceConfig.name == "aisstream_vessels")
|
||||
.where(DataSourceConfig.is_active.is_(True))
|
||||
.order_by(DataSourceConfig.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return datasource, config_result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _load_custom_realtime_config(db: AsyncSession, source: str) -> DataSourceConfig | None:
|
||||
result = await db.execute(
|
||||
select(DataSourceConfig)
|
||||
.where(DataSourceConfig.name == source)
|
||||
.order_by(DataSourceConfig.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
return config if config is not None and _is_realtime_config(config) else None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_realtime_sources(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
sources: list[dict[str, Any]] = []
|
||||
datasource, builtin_config = await _load_builtin_aisstream(db)
|
||||
if datasource is not None:
|
||||
sources.append(await _serialize_builtin_aisstream(db, datasource, builtin_config))
|
||||
|
||||
custom_result = await db.execute(
|
||||
select(DataSourceConfig)
|
||||
.where(func.lower(DataSourceConfig.source_type).in_(REALTIME_SOURCE_TYPES))
|
||||
.order_by(DataSourceConfig.name)
|
||||
)
|
||||
for config in custom_result.scalars().all():
|
||||
if config.name in BUILTIN_REALTIME_SOURCES:
|
||||
continue
|
||||
sources.append(await _serialize_custom_stream(db, config))
|
||||
|
||||
return {"total": len(sources), "data": sources}
|
||||
|
||||
|
||||
async def _ensure_builtin_startable(db: AsyncSession) -> DataSourceConfig | None:
|
||||
datasource, config = await _load_builtin_aisstream(db)
|
||||
if datasource is None:
|
||||
raise HTTPException(status_code=404, detail="Realtime source not found")
|
||||
if not datasource.is_active:
|
||||
raise HTTPException(status_code=400, detail="Realtime source is disabled")
|
||||
if not _credential_configured(config):
|
||||
raise HTTPException(status_code=400, detail="AISStream API key is not configured")
|
||||
return config
|
||||
|
||||
|
||||
@router.post("/{source}/start")
|
||||
async def start_realtime_source(
|
||||
source: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if source == "aisstream_vessels":
|
||||
await _ensure_builtin_startable(db)
|
||||
if is_collector_running(source):
|
||||
return {"status": "already_running", "source": source, "runtime": _runtime_status_for_builtin(source)}
|
||||
if not run_collector_now(source):
|
||||
raise HTTPException(status_code=409, detail="Realtime source could not be started")
|
||||
return {"status": "started", "source": source, "runtime": _runtime_status_for_builtin(source)}
|
||||
|
||||
config = await _load_custom_realtime_config(db, source)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Realtime source not found")
|
||||
if not config.is_active:
|
||||
raise HTTPException(status_code=400, detail="Realtime source is disabled")
|
||||
started = start_custom_stream(config.id)
|
||||
return {
|
||||
"status": "started" if started else "already_running",
|
||||
"source": source,
|
||||
"runtime": _runtime_status_for_custom(config.id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{source}/stop")
|
||||
async def stop_realtime_source(
|
||||
source: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if source == "aisstream_vessels":
|
||||
stopped = await cancel_running_collector_now(source)
|
||||
await update_ais_source_health(db, source=source, connection_state="disconnected", last_error=None)
|
||||
await db.commit()
|
||||
return {"status": "stopped" if stopped else "not_running", "source": source, "runtime": _runtime_status_for_builtin(source)}
|
||||
|
||||
config = await _load_custom_realtime_config(db, source)
|
||||
if config is None:
|
||||
raise HTTPException(status_code=404, detail="Realtime source not found")
|
||||
stopped = await stop_custom_stream(config.id)
|
||||
await update_ais_source_health(db, source=source, connection_state="disconnected", last_error=None)
|
||||
await db.commit()
|
||||
return {
|
||||
"status": "stopped" if stopped else "not_running",
|
||||
"source": source,
|
||||
"runtime": _runtime_status_for_custom(config.id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{source}/restart")
|
||||
async def restart_realtime_source(
|
||||
source: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await stop_realtime_source(source, current_user=current_user, db=db)
|
||||
return await start_realtime_source(source, current_user=current_user, db=db)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -7,10 +8,12 @@ from sqlalchemy import text
|
||||
from app.core.security import get_current_user, get_password_hash
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
|
||||
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
||||
user_role_value = (
|
||||
@@ -52,7 +55,7 @@ async def list_users(
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = text(
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
)
|
||||
count_query = text(f"SELECT COUNT(*) FROM users WHERE {where_sql}")
|
||||
|
||||
@@ -75,6 +78,7 @@ async def list_users(
|
||||
"is_active": u[4],
|
||||
"last_login_at": u[5],
|
||||
"created_at": u[6],
|
||||
"gatekeeper_groups": u[7] or [],
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -95,7 +99,7 @@ async def get_user(
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": user_id},
|
||||
)
|
||||
@@ -114,6 +118,7 @@ async def get_user(
|
||||
"is_active": user[4],
|
||||
"last_login_at": user[5],
|
||||
"created_at": user[6],
|
||||
"gatekeeper_groups": user[7] or [],
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +133,12 @@ async def create_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can create users",
|
||||
)
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
||||
@@ -142,13 +153,14 @@ async def create_user(
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
await db.execute(
|
||||
text("""INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, :is_active, NOW(), NOW())"""),
|
||||
text("""INSERT INTO users (username, email, password_hash, role, gatekeeper_groups, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, CAST(:gatekeeper_groups AS jsonb), :is_active, NOW(), NOW())"""),
|
||||
{
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"password_hash": hashed_password,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": json.dumps(user_data.gatekeeper_groups),
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
@@ -172,6 +184,7 @@ async def create_user(
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": user_data.gatekeeper_groups,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
@@ -194,6 +207,18 @@ async def update_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change user role",
|
||||
)
|
||||
if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change Gatekeeper groups",
|
||||
)
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE id = :id"),
|
||||
@@ -213,6 +238,9 @@ async def update_user(
|
||||
if user_data.role is not None:
|
||||
update_fields.append("role = :role")
|
||||
params["role"] = user_data.role
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
update_fields.append("gatekeeper_groups = CAST(:gatekeeper_groups AS jsonb)")
|
||||
params["gatekeeper_groups"] = json.dumps(user_data.gatekeeper_groups)
|
||||
if user_data.is_active is not None:
|
||||
update_fields.append("is_active = :is_active")
|
||||
params["is_active"] = user_data.is_active
|
||||
|
||||
39
backend/app/api/v1/vessels.py
Normal file
39
backend/app/api/v1/vessels.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Bounded vessel snapshot APIs for viewport-first consumers."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
|
||||
from app.db.session import get_db
|
||||
from app.services.vessel_ais_aggregation import MAX_SNAPSHOT_LIMIT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/snapshot")
|
||||
async def get_vessel_snapshot(
|
||||
bbox: Optional[str] = Query(None, description="Viewport bbox as lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20, description="Current map zoom level"),
|
||||
type: Optional[str] = Query(
|
||||
None,
|
||||
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
|
||||
),
|
||||
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not bbox:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
parsed_bbox = _parse_bbox(bbox)
|
||||
if parsed_bbox is None:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
type_filter=type,
|
||||
limit=limit,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
@@ -4,17 +4,20 @@ Unified API for all visualization data sources.
|
||||
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import math
|
||||
import re
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.countries import get_country_centroid
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
@@ -25,7 +28,22 @@ from app.models.collected_data import CollectedData
|
||||
from app.models.vessel import AISSourceHealth, VesselPosition, VesselStatic
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||
from app.services.compute_center_locations import (
|
||||
RENDERABLE_PRECISIONS,
|
||||
ResolutionDiagnostic,
|
||||
build_compute_center_location_query,
|
||||
collect_location_candidates,
|
||||
refresh_compute_center_location_cache,
|
||||
resolve_compute_center_location_full,
|
||||
upsert_compute_center_location,
|
||||
)
|
||||
from app.services.ai_client import get_ai_provider_client
|
||||
from app.api.v1.settings import get_web_search_client
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
from app.services.persistent_logs import record_system_log
|
||||
from app.services.vessel_ais_aggregation import (
|
||||
build_field_conflict_candidates,
|
||||
@@ -33,8 +51,10 @@ from app.services.vessel_ais_aggregation import (
|
||||
get_aggregated_vessel,
|
||||
get_aggregated_vessel_track,
|
||||
get_aggregated_vessels,
|
||||
get_aggregated_vessels_snapshot,
|
||||
get_vessel_conflict_records,
|
||||
get_vessel_raw_observations,
|
||||
MAX_SNAPSHOT_LIMIT,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
|
||||
@@ -43,7 +63,22 @@ logger = get_logger(__name__, service="api")
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
TERRAIN_TILE_CACHE_MAX_ITEMS = 512
|
||||
TERRAIN_TILE_BATCH_MAX_ITEMS = 128
|
||||
TERRAIN_TILE_BATCH_CONCURRENCY = 16
|
||||
_terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict()
|
||||
VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
|
||||
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True
|
||||
|
||||
|
||||
class TerrariumTileRequest(BaseModel):
|
||||
z: int = Field(ge=0, le=14)
|
||||
x: int = Field(ge=0)
|
||||
y: int = Field(ge=0)
|
||||
|
||||
|
||||
class TerrariumTileBatchRequest(BaseModel):
|
||||
tiles: List[TerrariumTileRequest] = Field(min_length=1, max_length=TERRAIN_TILE_BATCH_MAX_ITEMS)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
@@ -536,100 +571,6 @@ def _parse_float(value: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||
("el capitan", 37.6819, -121.7681),
|
||||
("livermore", 37.6819, -121.7681),
|
||||
("llnl", 37.6819, -121.7681),
|
||||
("lawrence livermore", 37.6819, -121.7681),
|
||||
("frontier", 35.9319, -84.3107),
|
||||
("oak ridge", 35.9319, -84.3107),
|
||||
("ornl", 35.9319, -84.3107),
|
||||
("aurora", 41.7130, -87.9820),
|
||||
("argonne", 41.7130, -87.9820),
|
||||
("anl", 41.7130, -87.9820),
|
||||
("fugaku", 34.6953, 135.1974),
|
||||
("kobe", 34.6953, 135.1974),
|
||||
("riken", 34.6953, 135.1974),
|
||||
("summit", 35.9319, -84.3107),
|
||||
("leonardo", 44.4949, 11.3426),
|
||||
("bologna", 44.4949, 11.3426),
|
||||
("alps", 46.0037, 8.9511),
|
||||
("lugano", 46.0037, 8.9511),
|
||||
("sunway taihulight", 31.4912, 120.3119),
|
||||
("wuxi", 31.4912, 120.3119),
|
||||
("tianhe-2", 23.1291, 113.2644),
|
||||
("tianhe-2a", 23.1291, 113.2644),
|
||||
("guangzhou", 23.1291, 113.2644),
|
||||
("colossus", 35.1495, -90.0490),
|
||||
("memphis", 35.1495, -90.0490),
|
||||
("xai", 35.1495, -90.0490),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hint_text(*parts: Any) -> str:
|
||||
return " ".join(
|
||||
str(part).strip().lower()
|
||||
for part in parts
|
||||
if part not in (None, "")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compute_center_coordinates(
|
||||
record: CollectedData,
|
||||
metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "precise",
|
||||
"geography_mode": "source_coordinates",
|
||||
"is_estimated": False,
|
||||
"estimated_reason": None,
|
||||
}
|
||||
|
||||
hint_text = _normalize_hint_text(
|
||||
record.name,
|
||||
get_record_field(record, "city"),
|
||||
get_record_field(record, "country"),
|
||||
metadata.get("site"),
|
||||
metadata.get("organization"),
|
||||
metadata.get("operator"),
|
||||
)
|
||||
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||
if needle in hint_text:
|
||||
return {
|
||||
"latitude": resolved_latitude,
|
||||
"longitude": resolved_longitude,
|
||||
"location_precision": "estimated_site",
|
||||
"geography_mode": "site_hint",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": f"Matched known site hint: {needle}",
|
||||
}
|
||||
|
||||
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||
if centroid:
|
||||
return {
|
||||
"latitude": centroid.get("latitude"),
|
||||
"longitude": centroid.get("longitude"),
|
||||
"location_precision": "estimated_country",
|
||||
"geography_mode": "country_centroid",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "Estimated from country centroid",
|
||||
}
|
||||
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "unknown",
|
||||
"geography_mode": "unknown",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "No resolvable location hints",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||
if capacity_value is None:
|
||||
return "unknown"
|
||||
@@ -654,22 +595,49 @@ def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str
|
||||
|
||||
|
||||
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||
features = []
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer.
|
||||
|
||||
Records that cannot be resolved to at least city-level precision are NOT
|
||||
silently dropped: they are returned in ``unresolved`` so the UI can offer
|
||||
the click-to-collect coordinate flow. The features list never contains
|
||||
``[0, 0]`` placeholders or country/region/unknown precision points.
|
||||
"""
|
||||
features: List[Dict[str, Any]] = []
|
||||
unresolved: List[Dict[str, Any]] = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||
latitude = coordinate_info.get("latitude")
|
||||
longitude = coordinate_info.get("longitude")
|
||||
result = resolve_compute_center_location_full(record, metadata)
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
|
||||
if not result.is_resolved:
|
||||
diagnostic = result.diagnostic or ResolutionDiagnostic(
|
||||
failure_reason="Unknown resolver failure",
|
||||
attempted_queries=(),
|
||||
record_id=getattr(record, "id", None),
|
||||
source=getattr(record, "source", None),
|
||||
source_id=getattr(record, "source_id", None),
|
||||
name=getattr(record, "name", None),
|
||||
)
|
||||
unresolved.append({
|
||||
**diagnostic.to_dict(),
|
||||
"site_type": site_type,
|
||||
})
|
||||
continue
|
||||
|
||||
location = result.location
|
||||
if location is None or not location.is_renderable:
|
||||
# Defensive: should not happen because is_resolved guards this.
|
||||
continue
|
||||
|
||||
location_props = location.to_geojson_properties()
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
|
||||
if site_type == "supercomputer":
|
||||
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||
capacity_unit = "GFlops"
|
||||
@@ -699,15 +667,16 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
"id": record.id,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [longitude or 0, latitude or 0],
|
||||
"coordinates": [longitude, latitude],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"site_type": site_type,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"country": get_record_field(record, "country") or location.country,
|
||||
"city": get_record_field(record, "city") or location.city,
|
||||
"region": location.region,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"operator": operator,
|
||||
@@ -723,17 +692,14 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
"source": record.source,
|
||||
"updated_at": updated_at,
|
||||
"status": "observed",
|
||||
"location_precision": coordinate_info.get("location_precision"),
|
||||
"geography_mode": coordinate_info.get("geography_mode"),
|
||||
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||
**location_props,
|
||||
"data_type": "compute_center",
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
return {"type": "FeatureCollection", "features": features, "unresolved": unresolved}
|
||||
|
||||
|
||||
VESSEL_TYPE_FILTERS = {
|
||||
@@ -976,6 +942,39 @@ def _merge_vessel_features(
|
||||
}
|
||||
|
||||
|
||||
async def _load_legacy_vessel_snapshot_features(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float] | None,
|
||||
limit: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
latest_positions = select(
|
||||
VesselPosition.mmsi.label("mmsi"),
|
||||
func.max(VesselPosition.received_at).label("received_at"),
|
||||
)
|
||||
if bbox is not None:
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
latest_positions = latest_positions.where(VesselPosition.lon >= lon_min)
|
||||
latest_positions = latest_positions.where(VesselPosition.lon <= lon_max)
|
||||
latest_positions = latest_positions.where(VesselPosition.lat >= lat_min)
|
||||
latest_positions = latest_positions.where(VesselPosition.lat <= lat_max)
|
||||
|
||||
latest_positions = latest_positions.group_by(VesselPosition.mmsi).subquery()
|
||||
result = await db.execute(
|
||||
select(VesselPosition, VesselStatic)
|
||||
.join(
|
||||
latest_positions,
|
||||
(VesselPosition.mmsi == latest_positions.c.mmsi)
|
||||
& (VesselPosition.received_at == latest_positions.c.received_at),
|
||||
)
|
||||
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
|
||||
.order_by(VesselPosition.received_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
legacy_geojson = convert_vessels_to_geojson(list(result.all()))
|
||||
return legacy_geojson.get("features", [])[:limit]
|
||||
|
||||
|
||||
def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
|
||||
by_type: dict[str, int] = {}
|
||||
underway = 0
|
||||
@@ -997,6 +996,52 @@ def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _safe_vessel_limit(value: int | None, *, default: int = 1000) -> int:
|
||||
if value is None or value <= 0:
|
||||
return default
|
||||
return min(value, MAX_SNAPSHOT_LIMIT)
|
||||
|
||||
|
||||
async def build_vessel_snapshot_response(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float] | None,
|
||||
zoom: int | None,
|
||||
type_filter: str | None,
|
||||
limit: int | None,
|
||||
since_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
requested_types = _requested_vessel_types(type_filter)
|
||||
safe_limit = _safe_vessel_limit(limit)
|
||||
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
|
||||
observed_since = datetime.now(UTC) - timedelta(minutes=safe_since_minutes)
|
||||
features, diagnostics = await _load_raw_vessel_snapshot_features(
|
||||
db,
|
||||
bbox=bbox,
|
||||
limit=safe_limit,
|
||||
observed_since=observed_since,
|
||||
)
|
||||
features = _filter_vessel_features(
|
||||
features,
|
||||
bbox=bbox,
|
||||
requested_types=requested_types,
|
||||
)[:safe_limit]
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"count": len(features),
|
||||
"stats": _build_vessel_stats(features),
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"filtered_count": len(features),
|
||||
"bbox_applied": bbox is not None,
|
||||
"zoom": zoom,
|
||||
"limit": safe_limit,
|
||||
"since_minutes": safe_since_minutes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def convert_bgp_anomalies_to_geojson(
|
||||
records: List[BGPAnomaly],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
@@ -1486,18 +1531,12 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
if not _is_valid_terrain_tile(z, x, y):
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
content, content_type, headers = await _fetch_terrain_tile(client, z, x, y)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
@@ -1509,22 +1548,140 @@ async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=content_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_terrain_tile(z: int, x: int, y: int) -> bool:
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
return False
|
||||
max_tile = 2 ** z
|
||||
return x < max_tile and y < max_tile
|
||||
|
||||
|
||||
def _get_cached_terrain_tile(z: int, x: int, y: int) -> tuple[bytes, str, dict[str, str]] | None:
|
||||
key = (z, x, y)
|
||||
cached = _terrain_tile_cache.get(key)
|
||||
if cached is None:
|
||||
return None
|
||||
_terrain_tile_cache.move_to_end(key)
|
||||
content, content_type, headers = cached
|
||||
return content, content_type, dict(headers)
|
||||
|
||||
|
||||
def _cache_terrain_tile(
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
headers: dict[str, str],
|
||||
) -> None:
|
||||
key = (z, x, y)
|
||||
_terrain_tile_cache[key] = (content, content_type, dict(headers))
|
||||
_terrain_tile_cache.move_to_end(key)
|
||||
while len(_terrain_tile_cache) > TERRAIN_TILE_CACHE_MAX_ITEMS:
|
||||
_terrain_tile_cache.popitem(last=False)
|
||||
|
||||
|
||||
async def _fetch_terrain_tile(
|
||||
client: httpx.AsyncClient,
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
) -> tuple[bytes, str, dict[str, str]]:
|
||||
cached = _get_cached_terrain_tile(z, x, y)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
content_type = upstream.headers.get("content-type", "image/png")
|
||||
content = upstream.content
|
||||
_cache_terrain_tile(z, x, y, content, content_type, headers)
|
||||
return content, content_type, dict(headers)
|
||||
|
||||
|
||||
@router.post("/terrain/terrarium/batch")
|
||||
async def get_terrarium_tile_batch(payload: TerrariumTileBatchRequest):
|
||||
"""Fetch Terrarium elevation tiles in batches so the browser avoids many tiny requests."""
|
||||
unique_tiles: list[TerrariumTileRequest] = []
|
||||
seen: set[tuple[int, int, int]] = set()
|
||||
for tile in payload.tiles:
|
||||
key = (tile.z, tile.x, tile.y)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not _is_valid_terrain_tile(tile.z, tile.x, tile.y):
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
unique_tiles.append(tile)
|
||||
|
||||
semaphore = asyncio.Semaphore(TERRAIN_TILE_BATCH_CONCURRENCY)
|
||||
results: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
async def fetch_one(tile: TerrariumTileRequest) -> None:
|
||||
async with semaphore:
|
||||
try:
|
||||
content, content_type, _headers = await _fetch_terrain_tile(
|
||||
client,
|
||||
tile.z,
|
||||
tile.x,
|
||||
tile.y,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"content_type": content_type,
|
||||
"data": base64.b64encode(content).decode("ascii"),
|
||||
},
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"status_code": exc.response.status_code,
|
||||
"message": f"upstream error: {exc.response.status_code}",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"status_code": 502,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.gather(*(fetch_one(tile) for tile in unique_tiles))
|
||||
|
||||
return {
|
||||
"tiles": results,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
@@ -1659,110 +1816,360 @@ async def get_compute_centers_geojson(
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"unresolved": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
"unresolved": 0,
|
||||
},
|
||||
}
|
||||
|
||||
await refresh_compute_center_location_cache(db)
|
||||
geojson = convert_compute_centers_to_geojson(records)
|
||||
features = geojson.get("features", [])
|
||||
unresolved = geojson.get("unresolved", [])
|
||||
# Belt-and-suspenders: ensure no Feature ever sneaks through without
|
||||
# city-or-better precision and finite, non-zero coordinates.
|
||||
sanitized_features: List[Dict[str, Any]] = []
|
||||
for feature in features:
|
||||
coords = feature.get("geometry", {}).get("coordinates") or []
|
||||
precision = feature.get("properties", {}).get("location_precision")
|
||||
if precision not in RENDERABLE_PRECISIONS:
|
||||
unresolved.append({
|
||||
"failure_reason": f"Rejected non-renderable precision '{precision}'",
|
||||
"record_id": feature.get("id"),
|
||||
"source_id": feature.get("properties", {}).get("source_id"),
|
||||
"name": feature.get("properties", {}).get("name"),
|
||||
})
|
||||
continue
|
||||
if (
|
||||
len(coords) != 2
|
||||
or coords[0] in (None, 0, 0.0)
|
||||
or coords[1] in (None, 0, 0.0)
|
||||
):
|
||||
unresolved.append({
|
||||
"failure_reason": "Rejected feature with [0,0] or invalid coordinates",
|
||||
"record_id": feature.get("id"),
|
||||
"source_id": feature.get("properties", {}).get("source_id"),
|
||||
"name": feature.get("properties", {}).get("name"),
|
||||
})
|
||||
continue
|
||||
sanitized_features.append(feature)
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(features),
|
||||
"type": "FeatureCollection",
|
||||
"features": sanitized_features,
|
||||
"unresolved": unresolved,
|
||||
"count": len(sanitized_features),
|
||||
"stats": {
|
||||
"total": len(features),
|
||||
"total": len(sanitized_features),
|
||||
"supercomputers": sum(
|
||||
1 for feature in features
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_clusters": sum(
|
||||
1 for feature in features
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
"unresolved": len(unresolved),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/vessels")
|
||||
async def get_vessels_geojson(
|
||||
bbox: Optional[str] = Query(
|
||||
None,
|
||||
description="Viewport bbox as lon_min,lat_min,lon_max,lat_max",
|
||||
),
|
||||
type: Optional[str] = Query(
|
||||
None,
|
||||
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
|
||||
),
|
||||
limit: Optional[int] = Query(
|
||||
None,
|
||||
ge=0,
|
||||
description="Maximum vessel features to return. Omit or pass 0 for no limit.",
|
||||
),
|
||||
class CollectComputeCenterLocationRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
organization: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
record_id: Optional[int] = Field(default=None, alias="id")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class SaveComputeCenterLocationRequest(BaseModel):
|
||||
source: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
latitude: float
|
||||
longitude: float
|
||||
precision: str = "city"
|
||||
confidence: Optional[float] = None
|
||||
location_source: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
source_note: Optional[str] = None
|
||||
raw_payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
needs_confirmation: bool = False
|
||||
verification_status: Optional[str] = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
@router.post("/compute-centers/{source_id}/collect-location")
|
||||
async def collect_compute_center_location(
|
||||
source_id: str,
|
||||
payload: CollectComputeCenterLocationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return latest vessel positions as GeoJSON points."""
|
||||
parsed_bbox = _parse_bbox(bbox)
|
||||
requested_types = _requested_vessel_types(type)
|
||||
merged_features, diagnostics = await _load_merged_vessel_features(db)
|
||||
features = _filter_vessel_features(
|
||||
merged_features,
|
||||
bbox=parsed_bbox,
|
||||
requested_types=requested_types,
|
||||
"""Run the full multi-query location collection pipeline for a record.
|
||||
|
||||
The endpoint accepts the source_id of a compute center plus contextual
|
||||
fields (name/operator/site/city/country/...) and returns ranked candidate
|
||||
locations from source coordinates, open organization lookups, and online
|
||||
geocoding combinations. The caller never has to type coordinates by hand:
|
||||
if any candidate is accepted it can be applied directly. If no candidate
|
||||
can reach city-level precision the response includes an explicit
|
||||
``failure_reason`` and the list of attempted queries.
|
||||
"""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
name = payload.name or (record.name if record else None)
|
||||
metadata = (record.extra_data or {}) if record else {}
|
||||
|
||||
operator = payload.operator or metadata.get("operator") or metadata.get("organization") or metadata.get("owner")
|
||||
site = payload.site or metadata.get("site")
|
||||
organization = payload.organization or metadata.get("organization")
|
||||
city = payload.city or get_record_field(record, "city") if record else payload.city
|
||||
country = payload.country or (get_record_field(record, "country") if record else None)
|
||||
source = payload.source or (record.source if record else None)
|
||||
record_id = payload.record_id or (record.id if record else None)
|
||||
|
||||
candidates, attempted_queries = collect_location_candidates(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
organization=organization,
|
||||
city=city,
|
||||
country=country,
|
||||
record_id=record_id,
|
||||
)
|
||||
if limit and limit > 0:
|
||||
features = features[:limit]
|
||||
llm_failure_reason = None
|
||||
if not candidates:
|
||||
query = build_compute_center_location_query(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
organization=organization,
|
||||
city=city,
|
||||
country=country,
|
||||
)
|
||||
llm_result = None
|
||||
try:
|
||||
web_search_client = await get_web_search_client(db)
|
||||
search_result = await collect_location_search_evidence(
|
||||
web_search_client=web_search_client,
|
||||
query=query,
|
||||
entity_type="compute_center",
|
||||
)
|
||||
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||
if not search_result.evidence:
|
||||
llm_failure_reason = search_result.failure_reason
|
||||
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||
provider_client = await get_ai_provider_client(db)
|
||||
llm_result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="compute_center",
|
||||
db=db,
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
|
||||
]
|
||||
if llm_result is not None:
|
||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||
candidates = llm_result.candidates
|
||||
llm_failure_reason = llm_result.failure_reason
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"record_id": record_id,
|
||||
"name": name,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates, organization lookup, or online geocoding"
|
||||
" result reached city-level precision."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"llm_failure_reason": llm_failure_reason,
|
||||
"context": {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"count": len(features),
|
||||
"stats": _build_vessel_stats(features),
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"filtered_count": len(features),
|
||||
"source_id": source_id,
|
||||
"record_id": record_id,
|
||||
"name": name,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_merged_vessel_features(db: AsyncSession) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
aggregated_vessels = await get_aggregated_vessels(db)
|
||||
raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels)
|
||||
@router.post("/compute-centers/{source_id}/location")
|
||||
async def save_compute_center_location(
|
||||
source_id: str,
|
||||
payload: SaveComputeCenterLocationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Persist the user-selected compute-center location candidate."""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
if payload.latitude in (0.0, None) or payload.longitude in (0.0, None):
|
||||
raise HTTPException(status_code=400, detail="latitude/longitude are required")
|
||||
if payload.precision not in RENDERABLE_PRECISIONS:
|
||||
raise HTTPException(status_code=400, detail="precision must be precise, site, or city")
|
||||
|
||||
latest_times = (
|
||||
select(
|
||||
VesselPosition.mmsi.label("mmsi"),
|
||||
func.max(VesselPosition.received_at).label("received_at"),
|
||||
)
|
||||
.group_by(VesselPosition.mmsi)
|
||||
.subquery()
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
metadata = (record.extra_data or {}) if record else {}
|
||||
record_source = payload.source or (record.source if record else None)
|
||||
if not record_source:
|
||||
raise HTTPException(status_code=400, detail="source is required for unknown compute center")
|
||||
|
||||
operator = (
|
||||
payload.operator
|
||||
or metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
)
|
||||
stmt = (
|
||||
select(VesselPosition, VesselStatic)
|
||||
.join(
|
||||
latest_times,
|
||||
(VesselPosition.mmsi == latest_times.c.mmsi)
|
||||
& (VesselPosition.received_at == latest_times.c.received_at),
|
||||
)
|
||||
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
|
||||
.order_by(VesselPosition.received_at.desc())
|
||||
site = payload.site or metadata.get("site") or metadata.get("organization")
|
||||
saved = await upsert_compute_center_location(
|
||||
db,
|
||||
source=record_source,
|
||||
source_id=source_id,
|
||||
name=payload.name or (record.name if record else None),
|
||||
operator=operator,
|
||||
site=site,
|
||||
city=payload.city or (get_record_field(record, "city") if record else None),
|
||||
country=payload.country or (get_record_field(record, "country") if record else None),
|
||||
latitude=payload.latitude,
|
||||
longitude=payload.longitude,
|
||||
precision=payload.precision,
|
||||
confidence=payload.confidence,
|
||||
location_source=payload.location_source or "manual_selection",
|
||||
source_url=payload.source_url,
|
||||
source_note=payload.source_note,
|
||||
raw_payload=payload.raw_payload,
|
||||
needs_confirmation=payload.needs_confirmation,
|
||||
verification_status=payload.verification_status
|
||||
or ("unverified" if payload.needs_confirmation else "verified"),
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.all())
|
||||
legacy_geojson = convert_vessels_to_geojson(rows)
|
||||
merged_features, diagnostics = _merge_vessel_features(
|
||||
raw_geojson.get("features", []),
|
||||
legacy_geojson.get("features", []),
|
||||
)
|
||||
return merged_features, {
|
||||
**diagnostics,
|
||||
"raw_feature_count": len(raw_geojson.get("features", [])),
|
||||
"legacy_feature_count": len(legacy_geojson.get("features", [])),
|
||||
return {
|
||||
"success": True,
|
||||
"source": saved.source,
|
||||
"source_id": saved.source_id,
|
||||
"location": saved.to_location_dict(),
|
||||
}
|
||||
|
||||
|
||||
async def _load_compute_center_record(db: AsyncSession, source_id: str) -> CollectedData | None:
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source_id == source_id)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.order_by(CollectedData.is_current.desc(), CollectedData.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def _load_raw_vessel_snapshot_features(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float] | None,
|
||||
limit: int,
|
||||
observed_since: datetime,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
if bbox is None:
|
||||
aggregated_vessels = await get_aggregated_vessels(
|
||||
db,
|
||||
limit=limit,
|
||||
observed_since=observed_since,
|
||||
)
|
||||
else:
|
||||
aggregated_vessels = await get_aggregated_vessels_snapshot(
|
||||
db,
|
||||
bbox=bbox,
|
||||
limit=limit,
|
||||
observed_since=observed_since,
|
||||
)
|
||||
raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels)
|
||||
raw_features = raw_geojson.get("features", [])
|
||||
features = raw_features
|
||||
legacy_features: list[dict[str, Any]] = []
|
||||
legacy_fallback_used = False
|
||||
if not raw_features and VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED:
|
||||
legacy_features = await _load_legacy_vessel_snapshot_features(
|
||||
db,
|
||||
bbox=bbox,
|
||||
limit=limit,
|
||||
)
|
||||
features, _merge_diagnostics = _merge_vessel_features(raw_features, legacy_features)
|
||||
legacy_fallback_used = bool(legacy_features)
|
||||
|
||||
return features, {
|
||||
"raw_feature_count": len(raw_features),
|
||||
"raw_unique_mmsi": len(
|
||||
{
|
||||
key
|
||||
for key in (_feature_mmsi_key(feature) for feature in raw_features)
|
||||
if key is not None
|
||||
}
|
||||
),
|
||||
"legacy_feature_count": len(legacy_features),
|
||||
"legacy_backfilled_mmsi": len(
|
||||
{
|
||||
key
|
||||
for key in (_feature_mmsi_key(feature) for feature in legacy_features)
|
||||
if key is not None
|
||||
}
|
||||
),
|
||||
"legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED,
|
||||
"legacy_fallback_used": legacy_fallback_used,
|
||||
"final_unique_mmsi": len(
|
||||
{
|
||||
key
|
||||
for key in (_feature_mmsi_key(feature) for feature in features)
|
||||
if key is not None
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
@router.get("/vessels/custom-supplements")
|
||||
async def get_vessel_custom_supplements(db: AsyncSession = Depends(get_db)):
|
||||
"""Group custom vessel_ais sources by their declared merge target for diagnostics."""
|
||||
@@ -2087,7 +2494,12 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
||||
select(func.count(func.distinct(VesselPosition.mmsi)))
|
||||
)
|
||||
legacy_unique_mmsi = int(legacy_unique_result.scalar() or 0)
|
||||
vessel_count = max(raw_unique_mmsi, legacy_unique_mmsi)
|
||||
legacy_fallback_active = (
|
||||
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED
|
||||
and raw_unique_mmsi == 0
|
||||
and legacy_unique_mmsi > 0
|
||||
)
|
||||
vessel_count = legacy_unique_mmsi if legacy_fallback_active else raw_unique_mmsi
|
||||
aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels")
|
||||
|
||||
return {
|
||||
@@ -2098,6 +2510,8 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
||||
"satellite_count": satellite_count,
|
||||
"compute_center_count": compute_center_count,
|
||||
"vessel_count": vessel_count,
|
||||
"vessel_count_source": "legacy_fallback" if legacy_fallback_active else "raw_recent",
|
||||
"vessel_legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED,
|
||||
"vessel_raw_unique_mmsi": raw_unique_mmsi,
|
||||
"vessel_raw_unique_window_hours": raw_unique_window_hours,
|
||||
"vessel_legacy_unique_mmsi": legacy_unique_mmsi,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""WebSocket API endpoints"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -59,7 +58,7 @@ async def websocket_endpoint(
|
||||
|
||||
is_anonymous = payload is None
|
||||
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
||||
supported_channels = ["vessels"] if is_anonymous else [
|
||||
supported_channels = ["vessels", "earth_news"] if is_anonymous else [
|
||||
"gpu_clusters",
|
||||
"submarine_cables",
|
||||
"ixp_nodes",
|
||||
@@ -67,6 +66,7 @@ async def websocket_endpoint(
|
||||
"dashboard",
|
||||
"datasource_tasks",
|
||||
"vessels",
|
||||
"earth_news",
|
||||
]
|
||||
await manager.connect(websocket, user_id)
|
||||
|
||||
@@ -95,14 +95,44 @@ async def websocket_endpoint(
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "subscribe":
|
||||
channels = data.get("data", {}).get("channels", [])
|
||||
payload_data = data.get("data", {})
|
||||
if not isinstance(payload_data, dict):
|
||||
payload_data = {}
|
||||
channels = payload_data.get("channels", [])
|
||||
if isinstance(channels, str):
|
||||
channels = [channels]
|
||||
elif not isinstance(channels, list):
|
||||
channels = []
|
||||
channel = payload_data.get("channel")
|
||||
if channel and channel not in channels:
|
||||
channels = [*channels, channel]
|
||||
if is_anonymous:
|
||||
channels = [channel for channel in channels if channel in supported_channels]
|
||||
vessel_subscription = None
|
||||
if "vessels" in channels and "bbox" in payload_data:
|
||||
try:
|
||||
vessel_subscription = manager.subscribe_vessels(websocket, payload_data)
|
||||
except ValueError as exc:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_error",
|
||||
"data": {"channel": "vessels", "detail": str(exc)},
|
||||
}
|
||||
)
|
||||
continue
|
||||
channels = [channel for channel in channels if channel != "vessels"]
|
||||
manager.subscribe(websocket, channels)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_confirmed",
|
||||
"data": {"action": "subscribe", "channels": channels},
|
||||
"data": {
|
||||
"action": "subscribe",
|
||||
"channels": [
|
||||
*channels,
|
||||
*(["vessels"] if vessel_subscription else []),
|
||||
],
|
||||
"vessels": vessel_subscription,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "unsubscribe":
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Any, Dict, Optional
|
||||
FIELD_ALIASES = {
|
||||
"country": ("country",),
|
||||
"city": ("city",),
|
||||
"latitude": ("latitude",),
|
||||
"longitude": ("longitude",),
|
||||
"latitude": ("latitude", "lat"),
|
||||
"longitude": ("longitude", "lon", "lng"),
|
||||
"value": ("value",),
|
||||
"unit": ("unit",),
|
||||
"cores": ("cores",),
|
||||
@@ -14,6 +14,28 @@ FIELD_ALIASES = {
|
||||
"power": ("power",),
|
||||
}
|
||||
|
||||
NESTED_FIELD_ALIASES = {
|
||||
"latitude": (
|
||||
("location", "latitude"),
|
||||
("location", "lat"),
|
||||
("geo", "latitude"),
|
||||
("geo", "lat"),
|
||||
("coordinates", "latitude"),
|
||||
("coordinates", "lat"),
|
||||
),
|
||||
"longitude": (
|
||||
("location", "longitude"),
|
||||
("location", "lon"),
|
||||
("location", "lng"),
|
||||
("geo", "longitude"),
|
||||
("geo", "lon"),
|
||||
("geo", "lng"),
|
||||
("coordinates", "longitude"),
|
||||
("coordinates", "lon"),
|
||||
("coordinates", "lng"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any:
|
||||
if isinstance(metadata, dict):
|
||||
@@ -21,9 +43,34 @@ def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback:
|
||||
value = metadata.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
for path in NESTED_FIELD_ALIASES.get(field, ()):
|
||||
current: Any = metadata
|
||||
for key in path:
|
||||
if not isinstance(current, dict):
|
||||
current = None
|
||||
break
|
||||
current = current.get(key)
|
||||
if current not in (None, ""):
|
||||
return current
|
||||
if field in {"latitude", "longitude"}:
|
||||
value = _get_coordinate_sequence_value(metadata, field)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def _get_coordinate_sequence_value(metadata: Dict[str, Any], field: str) -> Any:
|
||||
for key in ("coordinates", "coord", "coords"):
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
continue
|
||||
# GeoJSON uses [longitude, latitude]. Most raw collector tuples in this
|
||||
# codebase use explicit field names, so only sequence aliases are treated
|
||||
# as GeoJSON-shaped to avoid guessing.
|
||||
return value[1] if field == "latitude" else value[0]
|
||||
return None
|
||||
|
||||
|
||||
def build_dynamic_metadata(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
*,
|
||||
|
||||
@@ -257,6 +257,16 @@ DEFAULT_DATASOURCES = {
|
||||
"credential_provider": "aisstream",
|
||||
"credential_status": "supported",
|
||||
},
|
||||
"media_news_archive": {
|
||||
"id": 33,
|
||||
"name": "Media News Archive",
|
||||
"display_name": "媒体新闻归档",
|
||||
"module": "L4",
|
||||
"priority": "P2",
|
||||
"frequency_minutes": 720,
|
||||
"is_free": True,
|
||||
"requires_credentials": False,
|
||||
},
|
||||
}
|
||||
|
||||
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
||||
|
||||
@@ -105,7 +105,7 @@ async def get_current_user(
|
||||
)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
@@ -122,6 +122,7 @@ async def get_current_user(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@@ -144,7 +145,7 @@ async def get_current_user_refresh(
|
||||
)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
@@ -161,6 +162,7 @@ async def get_current_user_refresh(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
@@ -15,6 +15,8 @@ class DataBroadcaster:
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.tasks: Dict[str, asyncio.Task] = {}
|
||||
self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {}
|
||||
self._vessel_flush_interval = 1.0
|
||||
|
||||
async def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||
"""Get dashboard statistics"""
|
||||
@@ -68,6 +70,9 @@ class DataBroadcaster:
|
||||
|
||||
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
|
||||
"""Broadcast custom data to a specific channel"""
|
||||
if channel == "vessels":
|
||||
self.enqueue_vessel_update(data)
|
||||
return
|
||||
await manager.broadcast(
|
||||
{
|
||||
"type": "data_frame",
|
||||
@@ -78,6 +83,58 @@ class DataBroadcaster:
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
def enqueue_vessel_update(self, data: Dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list):
|
||||
return
|
||||
source = data.get("source")
|
||||
action = data.get("action") or "upsert"
|
||||
created = data.get("created")
|
||||
for vessel in vessels:
|
||||
if not isinstance(vessel, dict):
|
||||
continue
|
||||
mmsi = vessel.get("mmsi")
|
||||
if mmsi in (None, ""):
|
||||
continue
|
||||
self._pending_vessel_updates[str(mmsi)] = {
|
||||
**vessel,
|
||||
"_source": source,
|
||||
"_action": action,
|
||||
"_created": created,
|
||||
}
|
||||
|
||||
async def flush_vessel_updates(self):
|
||||
if not self._pending_vessel_updates:
|
||||
return
|
||||
pending = self._pending_vessel_updates
|
||||
self._pending_vessel_updates = {}
|
||||
vessels = []
|
||||
for item in pending.values():
|
||||
vessel = dict(item)
|
||||
source = vessel.pop("_source", None)
|
||||
action = vessel.pop("_action", "upsert")
|
||||
created = vessel.pop("_created", None)
|
||||
vessel["source"] = source
|
||||
vessel["action"] = action
|
||||
vessel["created"] = created
|
||||
vessels.append(vessel)
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": "mixed",
|
||||
"created": None,
|
||||
"vessels": vessels,
|
||||
}
|
||||
)
|
||||
|
||||
async def broadcast_vessels_periodically(self):
|
||||
while self.running:
|
||||
try:
|
||||
await self.flush_vessel_updates()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(self._vessel_flush_interval)
|
||||
|
||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||
"""Broadcast datasource task progress updates to connected clients."""
|
||||
await manager.broadcast(
|
||||
@@ -95,6 +152,7 @@ class DataBroadcaster:
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
|
||||
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
|
||||
|
||||
def stop(self):
|
||||
"""Stop all broadcasters"""
|
||||
@@ -102,6 +160,7 @@ class DataBroadcaster:
|
||||
for task in self.tasks.values():
|
||||
task.cancel()
|
||||
self.tasks.clear()
|
||||
self._pending_vessel_updates.clear()
|
||||
|
||||
|
||||
broadcaster = DataBroadcaster()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""WebSocket Connection Manager"""
|
||||
|
||||
from typing import Dict, Set, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Set, Optional
|
||||
from fastapi import WebSocket
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000
|
||||
MAX_VESSEL_WS_MESSAGE_ITEMS = 1000
|
||||
MAX_VESSEL_BBOX_AREA = 2500.0
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
@@ -14,6 +19,7 @@ class ConnectionManager:
|
||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
||||
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
||||
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: str):
|
||||
@@ -72,6 +78,50 @@ class ConnectionManager:
|
||||
channels = list(self.websocket_channels.get(websocket, set()))
|
||||
if channels:
|
||||
self.unsubscribe(websocket, channels)
|
||||
self.vessel_subscriptions.pop(websocket, None)
|
||||
|
||||
def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]:
|
||||
subscription = self._normalize_vessel_subscription(config)
|
||||
self.channel_subscriptions.setdefault("vessels", set()).add(websocket)
|
||||
self.websocket_channels.setdefault(websocket, set()).add("vessels")
|
||||
self.vessel_subscriptions[websocket] = subscription
|
||||
return subscription
|
||||
|
||||
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
bbox = config.get("bbox")
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
|
||||
try:
|
||||
lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("bbox values must be numbers") from exc
|
||||
if lat_min > lat_max:
|
||||
lat_min, lat_max = lat_max, lat_min
|
||||
if lon_min > lon_max:
|
||||
lon_min, lon_max = lon_max, lon_min
|
||||
if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
|
||||
raise ValueError("bbox longitude values must be between -180 and 180")
|
||||
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
|
||||
raise ValueError("bbox latitude values must be between -90 and 90")
|
||||
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
|
||||
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
|
||||
|
||||
zoom = int(config.get("zoom") or 1)
|
||||
if zoom < 1 or zoom > 20:
|
||||
raise ValueError("zoom must be between 1 and 20")
|
||||
limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT)
|
||||
vessel_types = {
|
||||
str(item).strip().lower()
|
||||
for item in str(config.get("type") or "").split(",")
|
||||
if str(item).strip()
|
||||
}
|
||||
return {
|
||||
"bbox": (lon_min, lat_min, lon_max, lat_max),
|
||||
"zoom": zoom,
|
||||
"limit": limit,
|
||||
"type": vessel_types,
|
||||
"last_sent_at": None,
|
||||
}
|
||||
|
||||
async def send_personal_message(self, message: dict, user_id: str):
|
||||
if user_id in self.active_connections:
|
||||
@@ -92,6 +142,58 @@ class ConnectionManager:
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
async def broadcast_vessels(self, data: dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list) or not vessels:
|
||||
return
|
||||
|
||||
for connection, subscription in list(self.vessel_subscriptions.items()):
|
||||
matched = [
|
||||
vessel
|
||||
for vessel in vessels
|
||||
if self._vessel_matches_subscription(vessel, subscription)
|
||||
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
|
||||
if not matched:
|
||||
continue
|
||||
subscription["last_sent_at"] = datetime.now(UTC)
|
||||
message = {
|
||||
"type": "data_frame",
|
||||
"channel": "vessels",
|
||||
"timestamp": subscription["last_sent_at"].isoformat(),
|
||||
"payload": {
|
||||
**data,
|
||||
"vessels": matched,
|
||||
"subscription": {
|
||||
"bbox": list(subscription["bbox"]),
|
||||
"zoom": subscription["zoom"],
|
||||
"limit": subscription["limit"],
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
def _vessel_matches_subscription(
|
||||
self,
|
||||
vessel: dict[str, Any],
|
||||
subscription: dict[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
lon = float(vessel.get("lon"))
|
||||
lat = float(vessel.get("lat"))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = subscription["bbox"]
|
||||
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
|
||||
return False
|
||||
requested_types = subscription.get("type") or set()
|
||||
if not requested_types:
|
||||
return True
|
||||
type_name = str(vessel.get("vessel_type_name") or "").lower()
|
||||
return any(requested_type in type_name for requested_type in requested_types)
|
||||
|
||||
async def close_all(self):
|
||||
for user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
@@ -99,6 +201,7 @@ class ConnectionManager:
|
||||
self.active_connections.clear()
|
||||
self.channel_subscriptions.clear()
|
||||
self.websocket_channels.clear()
|
||||
self.vessel_subscriptions.clear()
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"_comment": "Seed payload for the bgp_collector_locations DB table. Coordinates were migrated from the legacy RIPE_RIS_COLLECTOR_COORDS table and default to city-center; seeded rows are unverified and should be upgraded in the database with source evidence when known.",
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc00",
|
||||
"aliases": ["rrc00", "RIPE RIS rrc00", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc01",
|
||||
"aliases": ["rrc01", "RIPE RIS rrc01", "LINX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "LINX",
|
||||
"city": "London",
|
||||
"country": "United Kingdom",
|
||||
"latitude": 51.5072,
|
||||
"longitude": -0.1276,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc03",
|
||||
"aliases": ["rrc03", "RIPE RIS rrc03", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc04",
|
||||
"aliases": ["rrc04", "RIPE RIS rrc04", "CIXP", "CERN Internet Exchange Point"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "CIXP",
|
||||
"city": "Geneva",
|
||||
"country": "Switzerland",
|
||||
"latitude": 46.2044,
|
||||
"longitude": 6.1432,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc05",
|
||||
"aliases": ["rrc05", "RIPE RIS rrc05", "VIX", "Vienna Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "VIX",
|
||||
"city": "Vienna",
|
||||
"country": "Austria",
|
||||
"latitude": 48.2082,
|
||||
"longitude": 16.3738,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc06",
|
||||
"aliases": ["rrc06", "RIPE RIS rrc06", "JPIX", "Otemachi"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "JPIX",
|
||||
"city": "Otemachi",
|
||||
"country": "Japan",
|
||||
"latitude": 35.686,
|
||||
"longitude": 139.7671,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc07",
|
||||
"aliases": ["rrc07", "RIPE RIS rrc07", "Netnod", "Netnod Stockholm"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Netnod Stockholm",
|
||||
"city": "Stockholm",
|
||||
"country": "Sweden",
|
||||
"latitude": 59.3293,
|
||||
"longitude": 18.0686,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc10",
|
||||
"aliases": ["rrc10", "RIPE RIS rrc10", "MIX", "Milan Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "MIX",
|
||||
"city": "Milan",
|
||||
"country": "Italy",
|
||||
"latitude": 45.4642,
|
||||
"longitude": 9.19,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc11",
|
||||
"aliases": ["rrc11", "RIPE RIS rrc11", "NYIIX", "New York International Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "NYIIX",
|
||||
"city": "New York",
|
||||
"country": "United States",
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.006,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc12",
|
||||
"aliases": ["rrc12", "RIPE RIS rrc12", "DE-CIX", "DE-CIX Frankfurt"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "DE-CIX Frankfurt",
|
||||
"city": "Frankfurt",
|
||||
"country": "Germany",
|
||||
"latitude": 50.1109,
|
||||
"longitude": 8.6821,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc13",
|
||||
"aliases": ["rrc13", "RIPE RIS rrc13", "MSK-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "MSK-IX",
|
||||
"city": "Moscow",
|
||||
"country": "Russia",
|
||||
"latitude": 55.7558,
|
||||
"longitude": 37.6173,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc14",
|
||||
"aliases": ["rrc14", "RIPE RIS rrc14", "PAIX", "Palo Alto Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "PAIX",
|
||||
"city": "Palo Alto",
|
||||
"country": "United States",
|
||||
"latitude": 37.4419,
|
||||
"longitude": -122.143,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc15",
|
||||
"aliases": ["rrc15", "RIPE RIS rrc15", "PTT.br Sao Paulo", "PTTMetro Sao Paulo"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "PTT.br",
|
||||
"city": "Sao Paulo",
|
||||
"country": "Brazil",
|
||||
"latitude": -23.5558,
|
||||
"longitude": -46.6396,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc16",
|
||||
"aliases": ["rrc16", "RIPE RIS rrc16", "Equinix Miami", "NOTA Miami"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Equinix Miami",
|
||||
"city": "Miami",
|
||||
"country": "United States",
|
||||
"latitude": 25.7617,
|
||||
"longitude": -80.1918,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc18",
|
||||
"aliases": ["rrc18", "RIPE RIS rrc18", "CATNIX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "CATNIX",
|
||||
"city": "Barcelona",
|
||||
"country": "Spain",
|
||||
"latitude": 41.3874,
|
||||
"longitude": 2.1686,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc19",
|
||||
"aliases": ["rrc19", "RIPE RIS rrc19", "NAPAfrica", "JINX", "NAPAfrica Johannesburg"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "NAPAfrica Johannesburg",
|
||||
"city": "Johannesburg",
|
||||
"country": "South Africa",
|
||||
"latitude": -26.2041,
|
||||
"longitude": 28.0473,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc20",
|
||||
"aliases": ["rrc20", "RIPE RIS rrc20", "SwissIX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "SwissIX",
|
||||
"city": "Zurich",
|
||||
"country": "Switzerland",
|
||||
"latitude": 47.3769,
|
||||
"longitude": 8.5417,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc21",
|
||||
"aliases": ["rrc21", "RIPE RIS rrc21", "France-IX Paris"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "France-IX Paris",
|
||||
"city": "Paris",
|
||||
"country": "France",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc22",
|
||||
"aliases": ["rrc22", "RIPE RIS rrc22", "InterLAN Bucharest"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "InterLAN Bucharest",
|
||||
"city": "Bucharest",
|
||||
"country": "Romania",
|
||||
"latitude": 44.4268,
|
||||
"longitude": 26.1025,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc23",
|
||||
"aliases": ["rrc23", "RIPE RIS rrc23", "Equinix Singapore"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Equinix Singapore",
|
||||
"city": "Singapore",
|
||||
"country": "Singapore",
|
||||
"latitude": 1.3521,
|
||||
"longitude": 103.8198,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc24",
|
||||
"aliases": ["rrc24", "RIPE RIS rrc24", "LACNIC Montevideo"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "LACNIC Montevideo",
|
||||
"city": "Montevideo",
|
||||
"country": "Uruguay",
|
||||
"latitude": -34.9011,
|
||||
"longitude": -56.1645,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc25",
|
||||
"aliases": ["rrc25", "RIPE RIS rrc25", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc26",
|
||||
"aliases": ["rrc26", "RIPE RIS rrc26", "UAE-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "UAE-IX",
|
||||
"city": "Dubai",
|
||||
"country": "United Arab Emirates",
|
||||
"latitude": 25.2048,
|
||||
"longitude": 55.2708,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
}
|
||||
],
|
||||
"city_fallbacks": []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
@@ -72,25 +72,112 @@ async def seed_default_datasources(session: AsyncSession):
|
||||
await session.commit()
|
||||
|
||||
|
||||
LEGACY_EARTH_BOUNDARY_SOURCES = (
|
||||
"earth_admin0_boundaries",
|
||||
"earth_coastline",
|
||||
"earth_claim_lines",
|
||||
"earth_boundary_tiles",
|
||||
)
|
||||
LEGACY_EARTH_BOUNDARY_DATATYPES = (
|
||||
"earth_boundary_source",
|
||||
"earth_boundary_tiles",
|
||||
)
|
||||
LEGACY_EARTH_BOUNDARY_IDS = (29, 30, 31, 32)
|
||||
|
||||
|
||||
async def purge_legacy_earth_boundary_datasources(session: AsyncSession) -> None:
|
||||
source_names = tuple(LEGACY_EARTH_BOUNDARY_SOURCES)
|
||||
source_ids = tuple(LEGACY_EARTH_BOUNDARY_IDS)
|
||||
data_types = tuple(LEGACY_EARTH_BOUNDARY_DATATYPES)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM datasource_mapping_templates
|
||||
WHERE target_schema IN :data_types
|
||||
OR datasource_config_id IN (
|
||||
SELECT id FROM datasource_configs WHERE name IN :source_names
|
||||
)
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
|
||||
{"source_names": list(source_names), "data_types": list(data_types)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM datasource_configs WHERE name IN :source_names").bindparams(
|
||||
bindparam("source_names", expanding=True)
|
||||
),
|
||||
{"source_names": list(source_names)},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM collected_data
|
||||
WHERE source IN :source_names OR data_type IN :data_types
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
|
||||
{"source_names": list(source_names), "data_types": list(data_types)},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM data_snapshots
|
||||
WHERE source IN :source_names OR datasource_id IN :source_ids
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)),
|
||||
{"source_names": list(source_names), "source_ids": list(source_ids)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM collection_tasks WHERE datasource_id IN :source_ids").bindparams(
|
||||
bindparam("source_ids", expanding=True)
|
||||
),
|
||||
{"source_ids": list(source_ids)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM data_sources WHERE source IN :source_names OR id IN :source_ids").bindparams(
|
||||
bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)
|
||||
),
|
||||
{"source_names": list(source_names), "source_ids": list(source_ids)},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def ensure_default_admin_user(session: AsyncSession):
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = 'admin'")
|
||||
)
|
||||
if result.fetchone():
|
||||
return
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username="admin",
|
||||
email="admin@planet.local",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username=default_user["username"],
|
||||
email=default_user["email"],
|
||||
password_hash=get_password_hash(default_user["password"]),
|
||||
role=default_user["role"],
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -103,9 +190,11 @@ async def init_db():
|
||||
import app.models.datasource_config # noqa: F401
|
||||
import app.models.alert # noqa: F401
|
||||
import app.models.bgp_anomaly # noqa: F401
|
||||
import app.models.bgp_collector_location # noqa: F401
|
||||
import app.models.bgp_incident # noqa: F401
|
||||
import app.models.bgp_observation # noqa: F401
|
||||
import app.models.collected_data # noqa: F401
|
||||
import app.models.compute_center_location # noqa: F401
|
||||
import app.models.system_setting # noqa: F401
|
||||
import app.models.playground_session # noqa: F401
|
||||
import app.models.playground_message # noqa: F401
|
||||
@@ -113,6 +202,7 @@ async def init_db():
|
||||
import app.models.vessel # noqa: F401
|
||||
import app.models.vessel_enrichment # noqa: F401
|
||||
import app.models.datasource_mapping # noqa: F401
|
||||
import app.models.earth_news # noqa: F401
|
||||
|
||||
logger.warning_event(
|
||||
"Database pool settings active",
|
||||
@@ -128,6 +218,31 @@ async def init_db():
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
users_email_verified_existed = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'email_verified'
|
||||
"""
|
||||
)
|
||||
)
|
||||
).fetchone() is not None
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS pending_email VARCHAR(255)
|
||||
"""
|
||||
)
|
||||
)
|
||||
if not users_email_verified_existed:
|
||||
await conn.execute(
|
||||
text("UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -156,6 +271,18 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE earth_news_items
|
||||
ADD COLUMN IF NOT EXISTS content_language VARCHAR(32) NOT NULL DEFAULT 'en',
|
||||
ADD COLUMN IF NOT EXISTS localizations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS enrichment_status VARCHAR(80) NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS enrichment_error TEXT,
|
||||
ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -164,6 +291,22 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_news_enrichment_status
|
||||
ON earth_news_items (enrichment_status)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_news_enriched_at
|
||||
ON earth_news_items (enriched_at)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -188,6 +331,26 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_desc
|
||||
ON ais_raw_observations (target_schema, observed_at DESC)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_payload_lon_lat
|
||||
ON ais_raw_observations (
|
||||
((normalized_payload->>'lon')::double precision),
|
||||
((normalized_payload->>'lat')::double precision)
|
||||
)
|
||||
WHERE target_schema = 'vessel_ais'
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -208,5 +371,15 @@ async def init_db():
|
||||
)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from app.services.bgp_collector_locations import (
|
||||
seed_default_bgp_collector_locations,
|
||||
)
|
||||
from app.services.compute_center_locations import (
|
||||
seed_compute_center_locations_from_source_coords,
|
||||
)
|
||||
|
||||
await seed_default_bgp_collector_locations(session)
|
||||
await seed_compute_center_locations_from_source_coords(session)
|
||||
await seed_default_datasources(session)
|
||||
await purge_legacy_earth_boundary_datasources(session)
|
||||
await ensure_default_admin_user(session)
|
||||
|
||||
@@ -18,6 +18,10 @@ from app.services.scheduler import (
|
||||
stop_scheduler,
|
||||
sync_scheduler_with_datasources,
|
||||
)
|
||||
from app.services.earth_news_worker import (
|
||||
start_earth_news_target_worker,
|
||||
stop_earth_news_target_worker,
|
||||
)
|
||||
|
||||
|
||||
configure_logging()
|
||||
@@ -53,7 +57,9 @@ async def lifespan(app: FastAPI):
|
||||
start_scheduler()
|
||||
await sync_scheduler_with_datasources()
|
||||
broadcaster.start()
|
||||
start_earth_news_target_worker()
|
||||
yield
|
||||
await stop_earth_news_target_worker()
|
||||
broadcaster.stop()
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@ from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.playground_session import PlaygroundSession
|
||||
from app.models.playground_message import PlaygroundMessage
|
||||
from app.models.system_log import SystemLog, AuditLog
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic
|
||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -27,8 +30,10 @@ __all__ = [
|
||||
"AlertSeverity",
|
||||
"AlertStatus",
|
||||
"BGPAnomaly",
|
||||
"BGPCollectorLocation",
|
||||
"BGPIncident",
|
||||
"BGPObservation",
|
||||
"ComputeCenterLocationRecord",
|
||||
"SystemLog",
|
||||
"AuditLog",
|
||||
"PlaygroundSession",
|
||||
@@ -39,4 +44,5 @@ __all__ = [
|
||||
"AISConflictRecord",
|
||||
"AISSourceHealth",
|
||||
"DataSourceMappingTemplate",
|
||||
"EarthNewsItem",
|
||||
]
|
||||
|
||||
52
backend/app/models/bgp_collector_location.py
Normal file
52
backend/app/models/bgp_collector_location.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Stored BGP route-collector locations."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class BGPCollectorLocation(Base):
|
||||
"""Current known location for a BGP route collector."""
|
||||
|
||||
__tablename__ = "bgp_collector_locations"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
collector_id = Column(String(100), nullable=False, unique=True, index=True)
|
||||
operator = Column(String(255), nullable=True)
|
||||
site = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
country = Column(String(255), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
precision = Column(String(30), nullable=False, default="city")
|
||||
confidence = Column(Float, nullable=True)
|
||||
source = Column(String(80), nullable=False, default="legacy_seed", index=True)
|
||||
source_url = Column(String(500), nullable=True)
|
||||
source_note = Column(Text, nullable=True)
|
||||
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||
needs_confirmation = Column(Boolean, nullable=False, default=True, index=True)
|
||||
verification_status = Column(String(30), nullable=False, default="unverified", index=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_location_dict(self) -> dict:
|
||||
return {
|
||||
"city": self.city,
|
||||
"country": self.country,
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"precision": self.precision,
|
||||
"source": self.source,
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"matched_location_name": self.site or self.collector_id,
|
||||
"verified_at": to_iso8601_utc(self.verified_at),
|
||||
"confidence": self.confidence,
|
||||
"operator": self.operator,
|
||||
"site": self.site,
|
||||
"verification_status": self.verification_status,
|
||||
"source_note": self.source_note,
|
||||
"source_url": self.source_url,
|
||||
}
|
||||
60
backend/app/models/compute_center_location.py
Normal file
60
backend/app/models/compute_center_location.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Stored compute-center locations."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class ComputeCenterLocationRecord(Base):
|
||||
"""Current known location for a compute-center record."""
|
||||
|
||||
__tablename__ = "compute_center_locations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "source_id", name="uq_compute_center_location_source_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(100), nullable=False, index=True)
|
||||
source_id = Column(String(255), nullable=False, index=True)
|
||||
name = Column(String(500), nullable=True)
|
||||
operator = Column(String(255), nullable=True)
|
||||
site = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
country = Column(String(255), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
precision = Column(String(30), nullable=False, default="city")
|
||||
confidence = Column(Float, nullable=True)
|
||||
location_source = Column(String(80), nullable=False, default="stored_compute_center_location", index=True)
|
||||
source_url = Column(String(500), nullable=True)
|
||||
source_note = Column(Text, nullable=True)
|
||||
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||
needs_confirmation = Column(Boolean, nullable=False, default=False, index=True)
|
||||
verification_status = Column(String(30), nullable=False, default="verified", index=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_location_dict(self) -> dict:
|
||||
return {
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"operator": self.operator,
|
||||
"site": self.site,
|
||||
"city": self.city,
|
||||
"country": self.country,
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"precision": self.precision,
|
||||
"confidence": self.confidence,
|
||||
"location_source": self.location_source,
|
||||
"source_url": self.source_url,
|
||||
"source_note": self.source_note,
|
||||
"raw_payload": self.raw_payload or {},
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"verification_status": self.verification_status,
|
||||
"verified_at": to_iso8601_utc(self.verified_at),
|
||||
}
|
||||
40
backend/app/models/earth_news.py
Normal file
40
backend/app/models/earth_news.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Index, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class EarthNewsItem(Base):
|
||||
__tablename__ = "earth_news_items"
|
||||
|
||||
id = Column(String(160), primary_key=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
summary = Column(Text, nullable=False, default="")
|
||||
content_language = Column(String(32), nullable=False, default="en")
|
||||
localizations = Column(JSON, nullable=False, default=dict)
|
||||
url = Column(Text, nullable=False)
|
||||
source = Column(String(255), nullable=False, default="")
|
||||
feed_name = Column(String(255), nullable=False, default="")
|
||||
region = Column(String(80), nullable=False, index=True)
|
||||
homepage_url = Column(Text, nullable=False, default="")
|
||||
published_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
location_label = Column(String(255), nullable=False)
|
||||
location_source = Column(String(80), nullable=False, default="region_anchor")
|
||||
verified = Column(Boolean, nullable=False, default=False, index=True)
|
||||
location_meta = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
first_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
enrichment_status = Column(String(80), nullable=False, default="pending", index=True)
|
||||
enrichment_error = Column(Text, nullable=True)
|
||||
enriched_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_earth_news_region_published", "region", "published_at"),
|
||||
Index("idx_earth_news_region_seen", "region", "last_seen_at"),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
@@ -12,7 +12,10 @@ class User(Base):
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), default="viewer")
|
||||
gatekeeper_groups = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True)
|
||||
email_verified = Column(Boolean, default=False, nullable=False)
|
||||
pending_email = Column(String(255), nullable=True)
|
||||
last_login_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
|
||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
||||
|
||||
class SituationalAnalysisRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
objective: str = Field(..., min_length=1, max_length=1000)
|
||||
objective: str = Field(..., min_length=1, max_length=20000)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
observations: list[str] = Field(default_factory=list)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
system_prompt: str | None = Field(default=None, max_length=8000)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -12,17 +12,20 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
role: str = "viewer"
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
role: Optional[str] = None
|
||||
gatekeeper_groups: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
last_login_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
@@ -34,8 +37,36 @@ class UserInDB(UserBase):
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
email_verified: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class ResendCodeRequest(BaseModel):
|
||||
email: EmailStr
|
||||
purpose: str = Field(default="register", pattern="^(register|verify_email|reset_password)$")
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
7
backend/app/services/ai_tools/__init__.py
Normal file
7
backend/app/services/ai_tools/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Backend-owned AI tool services.
|
||||
|
||||
The services in this package are business tools used by Planet's backend
|
||||
orchestrators. They intentionally live outside ``aiprovider`` so model transport
|
||||
stays separate from evidence collection and domain policy.
|
||||
"""
|
||||
|
||||
48
backend/app/services/ai_tools/evidence_store.py
Normal file
48
backend/app/services/ai_tools/evidence_store.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Iterable
|
||||
|
||||
from app.services.ai_tools.schemas import FetchedEvidence, SearchEvidence
|
||||
|
||||
|
||||
def evidence_content_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_search_evidence(items: Iterable[SearchEvidence], *, limit: int = 5) -> list[dict]:
|
||||
normalized: list[dict] = []
|
||||
seen_urls: set[str] = set()
|
||||
for item in items:
|
||||
if not item.url or item.url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(item.url)
|
||||
normalized.append(
|
||||
{
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"snippet": item.snippet,
|
||||
"content": item.compact_text(),
|
||||
"score": item.score,
|
||||
"source_provider": item.source_provider,
|
||||
"retrieved_at": item.retrieved_at.isoformat(),
|
||||
"metadata": item.metadata,
|
||||
}
|
||||
)
|
||||
if len(normalized) >= limit:
|
||||
break
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_fetched_evidence(item: FetchedEvidence, *, text_limit: int = 1200) -> dict:
|
||||
text = " ".join(item.text.split())[:text_limit]
|
||||
return {
|
||||
"title": item.title,
|
||||
"url": item.final_url or item.url,
|
||||
"text": text,
|
||||
"content_hash": item.content_hash or evidence_content_hash(item.text),
|
||||
"extractor": item.extractor,
|
||||
"retrieved_at": item.retrieved_at.isoformat(),
|
||||
"metadata": item.metadata,
|
||||
}
|
||||
|
||||
63
backend/app/services/ai_tools/schemas.py
Normal file
63
backend/app/services/ai_tools/schemas.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchEvidence(BaseModel):
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
content: str = ""
|
||||
score: float | None = None
|
||||
source_provider: str = ""
|
||||
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def compact_text(self, limit: int = 700) -> str:
|
||||
text = " ".join((self.content or self.snippet or "").split())
|
||||
return text[:limit]
|
||||
|
||||
|
||||
class FetchedEvidence(BaseModel):
|
||||
url: str
|
||||
final_url: str = ""
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
content_hash: str = ""
|
||||
extractor: str = "basic_html"
|
||||
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebSearchProviderConfig(BaseModel):
|
||||
provider: str = "tavily"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
max_results: int = Field(default=5, ge=1, le=20)
|
||||
timeout_seconds: int = Field(default=20, ge=3, le=120)
|
||||
endpoint_path: str = ""
|
||||
search_depth: str = "basic"
|
||||
engine: str = "google"
|
||||
include_answer: bool = False
|
||||
include_raw_content: bool = False
|
||||
include_text: bool = False
|
||||
categories: str = "general"
|
||||
engines: list[str] = Field(default_factory=list)
|
||||
search_path: str = ""
|
||||
scrape_path: str = ""
|
||||
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
|
||||
|
||||
|
||||
class WebSearchConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
default_provider: str = "tavily"
|
||||
provider: str = "tavily"
|
||||
providers: dict[str, WebSearchProviderConfig] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def active_provider_config(self) -> WebSearchProviderConfig:
|
||||
return self.providers.get(self.default_provider) or self.providers.get(self.provider) or WebSearchProviderConfig(provider=self.default_provider or self.provider)
|
||||
|
||||
56
backend/app/services/ai_tools/web_fetch.py
Normal file
56
backend/app/services/ai_tools/web_fetch.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.services.ai_tools.schemas import FetchedEvidence
|
||||
|
||||
|
||||
class WebFetchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _extract_title_and_text(html: str) -> tuple[str, str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "svg"]):
|
||||
tag.decompose()
|
||||
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
||||
main = soup.find("main") or soup.find("article") or soup.body or soup
|
||||
text = main.get_text("\n", strip=True)
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
return title, "\n".join(lines)
|
||||
|
||||
|
||||
async def fetch_url_evidence(
|
||||
url: str,
|
||||
*,
|
||||
timeout_seconds: int = 20,
|
||||
max_bytes: int = 1_500_000,
|
||||
) -> FetchedEvidence:
|
||||
if not url:
|
||||
raise WebFetchError("url is required")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout_seconds,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "PlanetEvidenceFetcher/1.0"},
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
content = response.content[:max_bytes]
|
||||
except httpx.HTTPError as exc:
|
||||
raise WebFetchError(f"failed to fetch page: {exc}") from exc
|
||||
|
||||
title, text = _extract_title_and_text(content.decode(response.encoding or "utf-8", errors="ignore"))
|
||||
content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return FetchedEvidence(
|
||||
url=url,
|
||||
final_url=str(response.url),
|
||||
title=title,
|
||||
text=text,
|
||||
content_hash=content_hash,
|
||||
extractor="beautifulsoup_basic",
|
||||
)
|
||||
|
||||
391
backend/app/services/ai_tools/web_search.py
Normal file
391
backend/app/services/ai_tools/web_search.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig
|
||||
|
||||
|
||||
WEB_SEARCH_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"tavily": {
|
||||
"provider": "tavily",
|
||||
"label": "Tavily",
|
||||
"api_key_env": "TAVILY_API_KEY",
|
||||
"base_url": "https://api.tavily.com",
|
||||
"endpoint_path": "/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"search_depth": "basic",
|
||||
"include_answer": False,
|
||||
"include_raw_content": False,
|
||||
},
|
||||
"brave": {
|
||||
"provider": "brave",
|
||||
"label": "Brave Search API",
|
||||
"api_key_env": "BRAVE_SEARCH_API_KEY",
|
||||
"base_url": "https://api.search.brave.com",
|
||||
"endpoint_path": "/res/v1/web/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
"serpapi": {
|
||||
"provider": "serpapi",
|
||||
"label": "SerpAPI",
|
||||
"api_key_env": "SERPAPI_API_KEY",
|
||||
"base_url": "https://serpapi.com",
|
||||
"endpoint_path": "/search.json",
|
||||
"engine": "google",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
"exa": {
|
||||
"provider": "exa",
|
||||
"label": "Exa",
|
||||
"api_key_env": "EXA_API_KEY",
|
||||
"base_url": "https://api.exa.ai",
|
||||
"endpoint_path": "/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"include_text": False,
|
||||
},
|
||||
"firecrawl": {
|
||||
"provider": "firecrawl",
|
||||
"label": "Firecrawl Search / Scrape",
|
||||
"api_key_env": "FIRECRAWL_API_KEY",
|
||||
"base_url": "https://api.firecrawl.dev",
|
||||
"search_path": "/v2/search",
|
||||
"scrape_path": "/v2/scrape",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 30,
|
||||
"scrape_formats": ["markdown"],
|
||||
},
|
||||
"searxng": {
|
||||
"provider": "searxng",
|
||||
"label": "SearXNG",
|
||||
"api_key_env": "SEARXNG_API_KEY",
|
||||
"base_url": "http://localhost:8080",
|
||||
"endpoint_path": "/",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"categories": "general",
|
||||
"engines": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WebSearchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WebSearchConfigurationError(WebSearchError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_web_search_provider(provider: str | None) -> str:
|
||||
return (provider or "tavily").strip().lower() or "tavily"
|
||||
|
||||
|
||||
def get_web_search_provider_preset(provider: str) -> dict[str, Any]:
|
||||
provider_id = normalize_web_search_provider(provider)
|
||||
preset = WEB_SEARCH_PROVIDER_PRESETS.get(provider_id)
|
||||
if not preset:
|
||||
raise ValueError(f"Unsupported web search provider: {provider}")
|
||||
return deepcopy(preset)
|
||||
|
||||
|
||||
def list_web_search_provider_presets() -> list[dict[str, Any]]:
|
||||
return [get_web_search_provider_preset(provider) for provider in WEB_SEARCH_PROVIDER_PRESETS]
|
||||
|
||||
|
||||
def provider_defaults(provider: str) -> WebSearchProviderConfig:
|
||||
preset = get_web_search_provider_preset(provider)
|
||||
return WebSearchProviderConfig(**{
|
||||
key: value
|
||||
for key, value in preset.items()
|
||||
if key in WebSearchProviderConfig.model_fields
|
||||
})
|
||||
|
||||
|
||||
class WebSearchClient:
|
||||
def __init__(self, config: WebSearchConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
max_results: int | None = None,
|
||||
domains: list[str] | None = None,
|
||||
freshness_days: int | None = None,
|
||||
) -> list[SearchEvidence]:
|
||||
if not self.config.enabled:
|
||||
raise WebSearchConfigurationError("WebSearch is disabled.")
|
||||
provider_config = self.config.active_provider_config
|
||||
provider = normalize_web_search_provider(provider_config.provider)
|
||||
if provider != "searxng" and not provider_config.api_key:
|
||||
raise WebSearchConfigurationError(f"{provider} API key is not configured.")
|
||||
query = " ".join(str(query or "").split())
|
||||
if not query:
|
||||
raise WebSearchConfigurationError("search query is required.")
|
||||
limit = max_results or provider_config.max_results
|
||||
if provider == "tavily":
|
||||
return await self._search_tavily(provider_config, query, limit, domains, freshness_days)
|
||||
if provider == "brave":
|
||||
return await self._search_brave(provider_config, query, limit, domains)
|
||||
if provider == "serpapi":
|
||||
return await self._search_serpapi(provider_config, query, limit)
|
||||
if provider == "exa":
|
||||
return await self._search_exa(provider_config, query, limit, domains)
|
||||
if provider == "firecrawl":
|
||||
return await self._search_firecrawl(provider_config, query, limit)
|
||||
if provider == "searxng":
|
||||
return await self._search_searxng(provider_config, query, limit, domains)
|
||||
raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}")
|
||||
|
||||
async def test_connection(self) -> list[SearchEvidence]:
|
||||
return await self.search("Planet WebSearch connectivity test", max_results=1)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
provider_config: WebSearchProviderConfig,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=provider_config.timeout_seconds) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text or exc.response.reason_phrase
|
||||
raise WebSearchError(f"{provider_config.provider} request failed: {detail}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise WebSearchError(f"{provider_config.provider} request failed: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise WebSearchError(f"{provider_config.provider} returned invalid JSON") from exc
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def _search_tavily(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
freshness_days: int | None,
|
||||
) -> list[SearchEvidence]:
|
||||
body: dict[str, Any] = {
|
||||
"api_key": config.api_key,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
"search_depth": config.search_depth or "basic",
|
||||
"include_answer": config.include_answer,
|
||||
"include_raw_content": config.include_raw_content,
|
||||
}
|
||||
if domains:
|
||||
body["include_domains"] = domains
|
||||
if freshness_days:
|
||||
body["days"] = freshness_days
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||
provider_config=config,
|
||||
json=body,
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("content") or ""),
|
||||
content=str(item.get("raw_content") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="tavily",
|
||||
metadata={"query": data.get("query") or query},
|
||||
)
|
||||
for item in data.get("results") or []
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_brave(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
search_query = query
|
||||
if domains:
|
||||
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/res/v1/web/search"),
|
||||
provider_config=config,
|
||||
headers={"X-Subscription-Token": config.api_key},
|
||||
params={"q": search_query, "count": max_results},
|
||||
)
|
||||
results = (data.get("web") or {}).get("results") or []
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("description") or ""),
|
||||
source_provider="brave",
|
||||
metadata={"age": item.get("age")},
|
||||
)
|
||||
for item in results
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_serpapi(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
) -> list[SearchEvidence]:
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search.json"),
|
||||
provider_config=config,
|
||||
params={
|
||||
"api_key": config.api_key,
|
||||
"engine": config.engine or "google",
|
||||
"q": query,
|
||||
"num": max_results,
|
||||
},
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("link") or ""),
|
||||
snippet=str(item.get("snippet") or ""),
|
||||
source_provider="serpapi",
|
||||
metadata={"position": item.get("position")},
|
||||
)
|
||||
for item in data.get("organic_results") or []
|
||||
if isinstance(item, dict) and item.get("link")
|
||||
]
|
||||
|
||||
async def _search_exa(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
body: dict[str, Any] = {
|
||||
"query": query,
|
||||
"numResults": max_results,
|
||||
}
|
||||
if domains:
|
||||
body["includeDomains"] = domains
|
||||
if config.include_text:
|
||||
body["contents"] = {"text": True}
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||
provider_config=config,
|
||||
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||
json=body,
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("summary") or ""),
|
||||
content=str(item.get("text") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="exa",
|
||||
metadata={"id": item.get("id")},
|
||||
)
|
||||
for item in data.get("results") or []
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_firecrawl(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
) -> list[SearchEvidence]:
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.search_path or "/v2/search"),
|
||||
provider_config=config,
|
||||
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||
json={"query": query, "limit": max_results},
|
||||
)
|
||||
raw_results = data.get("data") or data.get("results") or []
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or item.get("sourceURL") or ""),
|
||||
snippet=str(item.get("description") or item.get("markdown") or ""),
|
||||
source_provider="firecrawl",
|
||||
metadata={"status": item.get("status")},
|
||||
)
|
||||
for item in raw_results
|
||||
if isinstance(item, dict) and (item.get("url") or item.get("sourceURL"))
|
||||
]
|
||||
|
||||
async def _search_searxng(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
search_query = query
|
||||
if domains:
|
||||
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||
params: dict[str, Any] = {
|
||||
"q": search_query,
|
||||
"format": "json",
|
||||
"categories": config.categories or "general",
|
||||
}
|
||||
if config.engines:
|
||||
params["engines"] = ",".join(config.engines)
|
||||
headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else None
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/"),
|
||||
provider_config=config,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
results = data.get("results") or []
|
||||
evidence = [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("content") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="searxng",
|
||||
metadata={"engine": item.get("engine")},
|
||||
)
|
||||
for item in results
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
return evidence[:max_results]
|
||||
|
||||
|
||||
def _join_url(base_url: str, path: str) -> str:
|
||||
return f"{(base_url or '').rstrip('/')}/{(path or '').lstrip('/')}"
|
||||
|
||||
|
||||
def _float_or_none(value: Any) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -8,6 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
|
||||
ALERT_BRIEF_PROMPT_KEY = "alerts.brief"
|
||||
|
||||
|
||||
def _format_counter(counter: Counter[str], empty_text: str = "无") -> str:
|
||||
@@ -84,11 +87,13 @@ async def build_alert_brief_request(
|
||||
"top_datasources": dict(datasource_counts.most_common(6)),
|
||||
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
|
||||
}
|
||||
prompt = await get_effective_prompt(db, ALERT_BRIEF_PROMPT_KEY)
|
||||
|
||||
return (
|
||||
SituationalAnalysisRequest(
|
||||
title="告警态势 AI 简报",
|
||||
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=facts,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
@@ -11,9 +11,12 @@ from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.bgp_enrichment import lookup_prefix_geography
|
||||
|
||||
BGP_BRIEF_PROMPT_KEY = "bgp.brief"
|
||||
|
||||
|
||||
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
||||
if not counter:
|
||||
@@ -243,10 +246,12 @@ async def build_bgp_brief_request(
|
||||
for prefix, item in list(prefix_geographies.items())[:8]
|
||||
},
|
||||
}
|
||||
prompt = await get_effective_prompt(db, BGP_BRIEF_PROMPT_KEY)
|
||||
|
||||
return SituationalAnalysisRequest(
|
||||
title="BGP 态势 AI 简报",
|
||||
objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=observations_lines,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
324
backend/app/services/bgp_collector_locations.py
Normal file
324
backend/app/services/bgp_collector_locations.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""BGP route-collector location resolver.
|
||||
|
||||
Collector positions are stored in the ``bgp_collector_locations`` database
|
||||
table. The old JSON registry is now only a seed payload used during database
|
||||
initialization, not a runtime resolver or candidate source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.services.location import (
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
coerce_str,
|
||||
normalize_text,
|
||||
)
|
||||
|
||||
SEED_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "data"
|
||||
/ "seeds"
|
||||
/ "ripe_ris_collector_locations_seed.json"
|
||||
)
|
||||
|
||||
# ── Geocoder (kept at module level for monkeypatching + cache_clear) ──
|
||||
|
||||
_geocode_online = build_default_nominatim_geocoder()
|
||||
|
||||
|
||||
# ── In-process compatibility cache ──────────────────────────────────
|
||||
|
||||
|
||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]:
|
||||
return record.to_location_dict()
|
||||
|
||||
|
||||
def set_bgp_collector_location_cache(
|
||||
locations: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Replace the legacy compatibility cache in-place."""
|
||||
RIPE_RIS_COLLECTOR_COORDS.clear()
|
||||
RIPE_RIS_COLLECTOR_COORDS.update(
|
||||
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||
)
|
||||
|
||||
|
||||
async def refresh_bgp_collector_location_cache(
|
||||
session: AsyncSession,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result = await session.execute(select(BGPCollectorLocation))
|
||||
records = result.scalars().all()
|
||||
cache = {
|
||||
record.collector_id: _collector_record_to_dict(record)
|
||||
for record in records
|
||||
if record.collector_id
|
||||
}
|
||||
set_bgp_collector_location_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def _load_seed_payload() -> dict[str, Any]:
|
||||
with SEED_PATH.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"operator": entry.get("operator") or "RIPE NCC",
|
||||
"site": entry.get("site"),
|
||||
"city": entry.get("city"),
|
||||
"country": entry.get("country"),
|
||||
"latitude": entry.get("latitude"),
|
||||
"longitude": entry.get("longitude"),
|
||||
"precision": entry.get("precision") or "city",
|
||||
"confidence": entry.get("confidence"),
|
||||
"source": "legacy_seed",
|
||||
"source_url": None,
|
||||
"source_note": entry.get("source_note")
|
||||
or "Seeded from legacy RIPE RIS collector coordinates",
|
||||
"raw_payload": entry,
|
||||
"needs_confirmation": True,
|
||||
"verification_status": "unverified",
|
||||
"verified_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def seed_default_bgp_collector_locations(session: AsyncSession) -> None:
|
||||
"""Seed default RIPE RIS collector locations without overwriting users."""
|
||||
payload = _load_seed_payload()
|
||||
for entry in payload.get("locations", []):
|
||||
aliases = entry.get("aliases") or []
|
||||
collector_ids = [
|
||||
coerce_str(alias)
|
||||
for alias in aliases
|
||||
if coerce_str(alias).startswith("rrc")
|
||||
]
|
||||
if not collector_ids:
|
||||
continue
|
||||
collector_id = collector_ids[0]
|
||||
existing = await session.scalar(
|
||||
select(BGPCollectorLocation).where(
|
||||
BGPCollectorLocation.collector_id == collector_id
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
session.add(
|
||||
BGPCollectorLocation(
|
||||
**_seed_entry_to_record_kwargs(entry, collector_id)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await refresh_bgp_collector_location_cache(session)
|
||||
|
||||
|
||||
def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]:
|
||||
"""Return the current cached collector location dict, or ``{}`` if unknown."""
|
||||
return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {}))
|
||||
|
||||
|
||||
def iter_known_collector_names() -> Iterator[str]:
|
||||
"""Yield every collector technical name (rrcXX) known in the cache."""
|
||||
return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys()))
|
||||
|
||||
|
||||
# ── Pipeline construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
class StoredCollectorLocationResolver:
|
||||
"""Resolve a collector through the DB-backed compatibility cache."""
|
||||
|
||||
name = "stored_collector_location"
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
collector = coerce_str(query.name)
|
||||
if not collector:
|
||||
for alias in query.aliases:
|
||||
collector = coerce_str(alias)
|
||||
if collector:
|
||||
break
|
||||
if not collector:
|
||||
return ResolverOutput()
|
||||
location = get_bgp_collector_location_dict(collector)
|
||||
if not location:
|
||||
return ResolverOutput()
|
||||
latitude = location.get("latitude")
|
||||
longitude = location.get("longitude")
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=float(latitude),
|
||||
longitude=float(longitude),
|
||||
display_name=location.get("matched_location_name") or collector,
|
||||
precision=location.get("precision") or "city",
|
||||
confidence=float(location.get("confidence") or 0.85),
|
||||
query=f"stored_collector_location::{collector}",
|
||||
source=location.get("source") or self.name,
|
||||
source_note=location.get("source_note"),
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=bool(location.get("needs_confirmation")),
|
||||
city=location.get("city"),
|
||||
region=None,
|
||||
country=location.get("country"),
|
||||
matched_location_name=(
|
||||
location.get("matched_location_name") or collector
|
||||
),
|
||||
location_verified_at=location.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bgp_collector_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Build the Nominatim query plan for a BGP collector."""
|
||||
extra = query.extra or {}
|
||||
site = str(extra.get("site") or "")
|
||||
operator = str(extra.get("operator") or "")
|
||||
city = query.city or ""
|
||||
country = query.country or ""
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
|
||||
def add(parts: list[tuple[str, str]]) -> None:
|
||||
non_empty = [(field, value) for field, value in parts if value]
|
||||
if not non_empty:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
fields: list[str] = []
|
||||
for field, value in non_empty:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(value)
|
||||
fields.append(field)
|
||||
if not cleaned:
|
||||
return
|
||||
composed = ", ".join(cleaned)
|
||||
if not any(composed == existing for existing, _ in plan):
|
||||
plan.append((composed, tuple(fields)))
|
||||
|
||||
add([("site", site), ("city", city), ("country", country)])
|
||||
add([("site", site), ("country", country)])
|
||||
add([("operator", operator), ("city", city), ("country", country)])
|
||||
add([("city", city), ("country", country)])
|
||||
return plan
|
||||
|
||||
|
||||
BGP_COLLECTOR_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
StoredCollectorLocationResolver(),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP collector to renderable coordinates from"
|
||||
" source coordinates or stored collector location."
|
||||
),
|
||||
)
|
||||
|
||||
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
NominatimResolver(
|
||||
query_plan_builder=_bgp_collector_query_plan,
|
||||
# Late-binding so tests can monkeypatch ``_geocode_online``.
|
||||
geocoder=lambda q: _geocode_online(q),
|
||||
),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP collector to renderable coordinates from"
|
||||
" source coordinates or online geocoding."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_bgp_collector_location(
|
||||
collector_name: str,
|
||||
*,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve a BGP collector to its best-known stored location."""
|
||||
stored = get_bgp_collector_location_dict(collector_name)
|
||||
name = coerce_str(collector_name) or None
|
||||
query = LocationQuery(
|
||||
name=name,
|
||||
aliases=tuple(filter(None, (collector_name,))),
|
||||
city=coerce_str(city or stored.get("city")) or None,
|
||||
country=coerce_str(country or stored.get("country")) or None,
|
||||
extra={
|
||||
"site": coerce_str(site or stored.get("site")),
|
||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||
},
|
||||
)
|
||||
return BGP_COLLECTOR_PIPELINE.resolve_best(query)
|
||||
|
||||
|
||||
def collect_bgp_collector_location_candidates(
|
||||
*,
|
||||
collector: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
query = build_bgp_collector_location_query(
|
||||
collector=collector,
|
||||
city=city,
|
||||
country=country,
|
||||
site=site,
|
||||
operator=operator,
|
||||
)
|
||||
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
|
||||
|
||||
|
||||
def build_bgp_collector_location_query(
|
||||
*,
|
||||
collector: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> LocationQuery:
|
||||
stored = get_bgp_collector_location_dict(collector or "")
|
||||
name = coerce_str(collector) or None
|
||||
return LocationQuery(
|
||||
name=name,
|
||||
aliases=tuple(filter(None, (collector,))),
|
||||
city=coerce_str(city or stored.get("city")) or None,
|
||||
country=coerce_str(country or stored.get("country")) or None,
|
||||
extra={
|
||||
"site": coerce_str(site or stored.get("site")),
|
||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||
"collector": coerce_str(collector),
|
||||
},
|
||||
)
|
||||
155
backend/app/services/bgp_event_locations.py
Normal file
155
backend/app/services/bgp_event_locations.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""BGP event location resolver.
|
||||
|
||||
A BGP event (announcement / withdrawal / RIB entry) is geographically tied to
|
||||
the route collector that observed it. This module defines the pipeline that
|
||||
turns an event payload into renderable coordinates.
|
||||
|
||||
Current resolver chain:
|
||||
|
||||
SourceCoordinates → event payload itself carries lat/lon (rare; some
|
||||
enriched feeds do).
|
||||
InheritFromCollector → look up the owning collector via
|
||||
:func:`resolve_bgp_collector_location`.
|
||||
|
||||
Future plug-ins (no consumer changes required, just append to the list):
|
||||
|
||||
ASNFacilityResolver — origin/peer ASN → peeringdb facility.
|
||||
PrefixGeoResolver — prefix → IP range geo lookup (iptoasn / opengeofeed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.bgp_collector_locations import (
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.location import (
|
||||
InheritFromAnotherEntityResolver,
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
ResolutionResult,
|
||||
SourceCoordinatesResolver,
|
||||
coerce_str,
|
||||
)
|
||||
|
||||
|
||||
def _inherit_from_owning_collector(
|
||||
query: LocationQuery,
|
||||
) -> LocationCandidate | None:
|
||||
"""Look up the event's owning collector by exact name in the DB-backed cache."""
|
||||
extra = query.extra or {}
|
||||
collector_name = coerce_str(extra.get("collector"))
|
||||
if not collector_name:
|
||||
return None
|
||||
legacy = get_bgp_collector_location_dict(collector_name)
|
||||
if not legacy:
|
||||
return None
|
||||
latitude = legacy.get("latitude")
|
||||
longitude = legacy.get("longitude")
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return None
|
||||
return LocationCandidate(
|
||||
latitude=float(latitude),
|
||||
longitude=float(longitude),
|
||||
display_name=legacy.get("matched_location_name") or collector_name,
|
||||
precision=legacy.get("precision") or "city",
|
||||
confidence=float(legacy.get("confidence") or 0.85),
|
||||
query=f"inherit_from_collector::{collector_name}",
|
||||
source="inherited_from_collector",
|
||||
source_note=(
|
||||
f"Inherited from owning collector {collector_name}"
|
||||
),
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=bool(legacy.get("needs_confirmation")),
|
||||
city=legacy.get("city"),
|
||||
region=None,
|
||||
country=legacy.get("country"),
|
||||
matched_location_name=legacy.get("matched_location_name"),
|
||||
location_verified_at=legacy.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
|
||||
|
||||
BGP_EVENT_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
InheritFromAnotherEntityResolver(
|
||||
source_lookup=_inherit_from_owning_collector,
|
||||
name="inherited_from_collector",
|
||||
),
|
||||
# Plug new resolvers (peeringdb / ASN facility / prefix-geo) here.
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP event coordinates: no source coords, owning"
|
||||
" collector unknown, and no fallback resolver matched."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_bgp_event_location(
|
||||
*,
|
||||
collector: str,
|
||||
source_latitude: float | None = None,
|
||||
source_longitude: float | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
peer_asn: int | None = None,
|
||||
origin_asn: int | None = None,
|
||||
prefix: str | None = None,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve a BGP event to its renderable coordinates.
|
||||
|
||||
The ``peer_asn`` / ``origin_asn`` / ``prefix`` arguments are accepted
|
||||
today so future resolvers (ASN→facility, prefix→geo) can consume them
|
||||
without callers needing to change.
|
||||
"""
|
||||
query = LocationQuery(
|
||||
name=collector or None,
|
||||
aliases=tuple(filter(None, (collector,))),
|
||||
source_latitude=source_latitude,
|
||||
source_longitude=source_longitude,
|
||||
extra={
|
||||
"collector": collector or "",
|
||||
"site": coerce_str(site),
|
||||
"operator": coerce_str(operator),
|
||||
"peer_asn": peer_asn,
|
||||
"origin_asn": origin_asn,
|
||||
"prefix": coerce_str(prefix),
|
||||
},
|
||||
)
|
||||
return BGP_EVENT_PIPELINE.resolve_best(query)
|
||||
|
||||
|
||||
def resolve_bgp_event_geo_dict(
|
||||
collector: str,
|
||||
*,
|
||||
source_latitude: float | None = None,
|
||||
source_longitude: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convenience wrapper returning the legacy ``collector_geo`` dict shape.
|
||||
|
||||
Preserves ``city``/``country``/``latitude``/``longitude`` keys (consumed
|
||||
by existing detectors / enrichment / DB serialization) and adds
|
||||
``precision``/``source``/``needs_confirmation`` for richer downstream use.
|
||||
"""
|
||||
result = resolve_bgp_event_location(
|
||||
collector=collector,
|
||||
source_latitude=source_latitude,
|
||||
source_longitude=source_longitude,
|
||||
)
|
||||
candidate = result.location
|
||||
if candidate is None:
|
||||
return {}
|
||||
return {
|
||||
"city": candidate.city,
|
||||
"country": candidate.country,
|
||||
"latitude": candidate.latitude,
|
||||
"longitude": candidate.longitude,
|
||||
"precision": candidate.precision,
|
||||
"source": candidate.source,
|
||||
"needs_confirmation": candidate.needs_confirmation,
|
||||
"matched_location_name": candidate.matched_location_name,
|
||||
"confidence": candidate.confidence,
|
||||
}
|
||||
@@ -36,6 +36,7 @@ from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
||||
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
||||
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
||||
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
||||
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
|
||||
from app.services.collectors.aisstream import AISStreamCollector
|
||||
from app.services.collectors.vessel_ais import VesselAISCollector
|
||||
|
||||
@@ -65,6 +66,7 @@ collector_registry.register(IPtoASNPrefixGeoCollector())
|
||||
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
||||
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
||||
collector_registry.register(NewsLiveStreamsCollector())
|
||||
collector_registry.register(MediaNewsArchiveCollector())
|
||||
collector_registry.register(VesselAISCollector())
|
||||
collector_registry.register(AISStreamCollector())
|
||||
|
||||
@@ -100,6 +102,7 @@ __all__ = [
|
||||
"OpenGeoFeedPrefixGeoCollector",
|
||||
"NRODelegatedPrefixGeoCollector",
|
||||
"NewsLiveStreamsCollector",
|
||||
"MediaNewsArchiveCollector",
|
||||
"VesselAISCollector",
|
||||
"AISStreamCollector",
|
||||
]
|
||||
|
||||
@@ -138,7 +138,7 @@ class AISStreamCollector(BaseCollector):
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError as exc:
|
||||
except ImportError:
|
||||
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
|
||||
|
||||
start_time = datetime.now(UTC)
|
||||
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_event_locations import resolve_bgp_event_geo_dict
|
||||
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
||||
from app.services.bgp_detectors import (
|
||||
detect_mass_withdrawal_anomalies,
|
||||
@@ -23,32 +28,17 @@ from app.services.bgp_detectors import (
|
||||
)
|
||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
||||
|
||||
|
||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {
|
||||
"rrc00": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc01": {"city": "London", "country": "United Kingdom", "latitude": 51.5072, "longitude": -0.1276},
|
||||
"rrc03": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc04": {"city": "Geneva", "country": "Switzerland", "latitude": 46.2044, "longitude": 6.1432},
|
||||
"rrc05": {"city": "Vienna", "country": "Austria", "latitude": 48.2082, "longitude": 16.3738},
|
||||
"rrc06": {"city": "Otemachi", "country": "Japan", "latitude": 35.686, "longitude": 139.7671},
|
||||
"rrc07": {"city": "Stockholm", "country": "Sweden", "latitude": 59.3293, "longitude": 18.0686},
|
||||
"rrc10": {"city": "Milan", "country": "Italy", "latitude": 45.4642, "longitude": 9.19},
|
||||
"rrc11": {"city": "New York", "country": "United States", "latitude": 40.7128, "longitude": -74.006},
|
||||
"rrc12": {"city": "Frankfurt", "country": "Germany", "latitude": 50.1109, "longitude": 8.6821},
|
||||
"rrc13": {"city": "Moscow", "country": "Russia", "latitude": 55.7558, "longitude": 37.6173},
|
||||
"rrc14": {"city": "Palo Alto", "country": "United States", "latitude": 37.4419, "longitude": -122.143},
|
||||
"rrc15": {"city": "Sao Paulo", "country": "Brazil", "latitude": -23.5558, "longitude": -46.6396},
|
||||
"rrc16": {"city": "Miami", "country": "United States", "latitude": 25.7617, "longitude": -80.1918},
|
||||
"rrc18": {"city": "Barcelona", "country": "Spain", "latitude": 41.3874, "longitude": 2.1686},
|
||||
"rrc19": {"city": "Johannesburg", "country": "South Africa", "latitude": -26.2041, "longitude": 28.0473},
|
||||
"rrc20": {"city": "Zurich", "country": "Switzerland", "latitude": 47.3769, "longitude": 8.5417},
|
||||
"rrc21": {"city": "Paris", "country": "France", "latitude": 48.8566, "longitude": 2.3522},
|
||||
"rrc22": {"city": "Bucharest", "country": "Romania", "latitude": 44.4268, "longitude": 26.1025},
|
||||
"rrc23": {"city": "Singapore", "country": "Singapore", "latitude": 1.3521, "longitude": 103.8198},
|
||||
"rrc24": {"city": "Montevideo", "country": "Uruguay", "latitude": -34.9011, "longitude": -56.1645},
|
||||
"rrc25": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc26": {"city": "Dubai", "country": "United Arab Emirates", "latitude": 25.2048, "longitude": 55.2708},
|
||||
}
|
||||
# Re-exported for backward compatibility with anything that imports
|
||||
# ``RIPE_RIS_COLLECTOR_COORDS`` from this module. New code should call
|
||||
# ``app.services.bgp_collector_locations.get_bgp_collector_location_dict()``
|
||||
# or ``resolve_bgp_collector_location()`` instead — those use the DB-backed
|
||||
# collector-location cache.
|
||||
__all__ = [
|
||||
"RIPE_RIS_COLLECTOR_COORDS",
|
||||
"normalize_bgp_event",
|
||||
"save_bgp_observations_for_batch",
|
||||
"create_bgp_anomalies_for_batch",
|
||||
]
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
@@ -131,7 +121,19 @@ def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, A
|
||||
)
|
||||
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
||||
# Routes through the BGP event pipeline: source coords (if any) →
|
||||
# collector inheritance. Returned dict keeps the legacy
|
||||
# {city, country, latitude, longitude} keys plus richer
|
||||
# {precision, source, needs_confirmation, matched_location_name, confidence}.
|
||||
collector_location = resolve_bgp_event_geo_dict(
|
||||
collector,
|
||||
source_latitude=payload.get("latitude"),
|
||||
source_longitude=payload.get("longitude"),
|
||||
)
|
||||
# Empty result (unknown collector & no source coords) — keep the
|
||||
# downstream-expected dict shape so detectors / serializers don't crash.
|
||||
if not collector_location:
|
||||
collector_location = get_bgp_collector_location_dict(collector)
|
||||
network_fields = extract_bgp_network_fields(prefix)
|
||||
metadata = {
|
||||
"project": project,
|
||||
|
||||
57
backend/app/services/collectors/media_news_archive.py
Normal file
57
backend/app/services/collectors/media_news_archive.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
from app.services.earth_news_store import list_all_earth_news_records
|
||||
|
||||
|
||||
class MediaNewsArchiveCollector(BaseCollector):
|
||||
name = "media_news_archive"
|
||||
priority = "P2"
|
||||
module = "L4"
|
||||
frequency_hours = 12
|
||||
data_type = "news_item"
|
||||
fail_on_empty = False
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
if not self._db_session:
|
||||
return []
|
||||
|
||||
records = await list_all_earth_news_records(self._db_session)
|
||||
items: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
location_meta = dict(record.location_meta or {})
|
||||
target = location_meta.get("target") if isinstance(location_meta.get("target"), dict) else {}
|
||||
country = target.get("country")
|
||||
city = target.get("city")
|
||||
items.append(
|
||||
{
|
||||
"id": record.id,
|
||||
"source_id": record.id,
|
||||
"name": record.title,
|
||||
"title": record.title,
|
||||
"description": record.summary,
|
||||
"country": country,
|
||||
"city": city,
|
||||
"latitude": record.latitude,
|
||||
"longitude": record.longitude,
|
||||
"reference_date": record.published_at,
|
||||
"metadata": {
|
||||
"url": record.url,
|
||||
"source": record.source,
|
||||
"feed_name": record.feed_name,
|
||||
"region": record.region,
|
||||
"homepage_url": record.homepage_url,
|
||||
"published_at": record.published_at.isoformat() if record.published_at else None,
|
||||
"location_label": record.location_label,
|
||||
"location_source": record.location_source,
|
||||
"verified": record.verified,
|
||||
"location_meta": location_meta,
|
||||
"first_seen_at": record.first_seen_at.isoformat() if record.first_seen_at else None,
|
||||
"last_seen_at": record.last_seen_at.isoformat() if record.last_seen_at else None,
|
||||
"resolved_at": record.resolved_at.isoformat() if record.resolved_at else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return items
|
||||
886
backend/app/services/compute_center_locations.py
Normal file
886
backend/app/services/compute_center_locations.py
Normal file
@@ -0,0 +1,886 @@
|
||||
"""Compute-center location resolver, built on the shared location pipeline.
|
||||
|
||||
This module is a thin domain wrapper that wires up
|
||||
:mod:`app.services.location` for compute centers:
|
||||
|
||||
SourceCoordinates
|
||||
|
||||
The online Nominatim step is intentionally reserved for the user-triggered
|
||||
``collect-location`` flow. The regular GeoJSON endpoint runs during Earth
|
||||
startup, so it must stay local and deterministic.
|
||||
|
||||
For the full design and the reason behind the abstraction (compute centers,
|
||||
BGP collectors, BGP events, and future entities all share one pipeline),
|
||||
see ``docs/plans/location-resolver-shared-pipeline-plan.md``.
|
||||
|
||||
The ``ComputeCenterLocation`` dataclass and the public function signatures are
|
||||
preserved verbatim so existing callers and tests do not need to change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
|
||||
from app.services.location import (
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
ROR_SEARCH_URL = "https://api.ror.org/v2/organizations"
|
||||
DEFAULT_ROR_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||
DEFAULT_ROR_TIMEOUT_SECONDS = 8.0
|
||||
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||
FORBIDDEN_PRECISIONS: tuple[str, ...] = (
|
||||
"country",
|
||||
"estimated_country",
|
||||
"country_major_compute_city",
|
||||
"region",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
# ── Public dataclasses ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComputeCenterLocation:
|
||||
latitude: float | None
|
||||
longitude: float | None
|
||||
location_precision: str
|
||||
geography_mode: str
|
||||
is_estimated: bool
|
||||
estimated_reason: str | None = None
|
||||
location_confidence: float | None = None
|
||||
location_source: str | None = None
|
||||
location_source_note: str | None = None
|
||||
location_verified_at: str | None = None
|
||||
matched_location_name: str | None = None
|
||||
needs_confirmation: bool = False
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
|
||||
@property
|
||||
def is_renderable(self) -> bool:
|
||||
if self.latitude in (None, 0.0) or self.longitude in (None, 0.0):
|
||||
return False
|
||||
return self.location_precision in RENDERABLE_PRECISIONS
|
||||
|
||||
def to_geojson_properties(self) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"location_precision": self.location_precision,
|
||||
"geography_mode": self.geography_mode,
|
||||
"is_estimated": self.is_estimated,
|
||||
"estimated_reason": self.estimated_reason,
|
||||
"location_confidence": self.location_confidence,
|
||||
"location_source": self.location_source,
|
||||
"location_source_note": self.location_source_note,
|
||||
"location_verified_at": self.location_verified_at,
|
||||
"matched_location_name": self.matched_location_name,
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionDiagnostic:
|
||||
failure_reason: str
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
record_id: int | None = None
|
||||
source: str | None = None
|
||||
source_id: str | None = None
|
||||
name: str | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
site: str | None = None
|
||||
operator: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"failure_reason": self.failure_reason,
|
||||
"attempted_queries": list(self.attempted_queries),
|
||||
"record_id": self.record_id,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"country": self.country,
|
||||
"city": self.city,
|
||||
"site": self.site,
|
||||
"operator": self.operator,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
location: ComputeCenterLocation | None
|
||||
diagnostic: ResolutionDiagnostic | None
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return bool(self.location and self.location.is_renderable)
|
||||
|
||||
|
||||
# ── Geocoder (kept at module level so tests can monkeypatch + cache_clear) ──
|
||||
|
||||
_geocode_online = build_default_nominatim_geocoder()
|
||||
|
||||
|
||||
# ── Stored location cache ───────────────────────────────────────────
|
||||
|
||||
|
||||
COMPUTE_CENTER_LOCATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _cache_key(source: str | None, source_id: str | None) -> str:
|
||||
return f"{coerce_str(source)}:{coerce_str(source_id)}"
|
||||
|
||||
|
||||
def set_compute_center_location_cache(
|
||||
locations: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
COMPUTE_CENTER_LOCATION_CACHE.clear()
|
||||
COMPUTE_CENTER_LOCATION_CACHE.update(
|
||||
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||
)
|
||||
|
||||
|
||||
async def refresh_compute_center_location_cache(
|
||||
session: AsyncSession,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result = await session.execute(select(ComputeCenterLocationRecord))
|
||||
records = result.scalars().all()
|
||||
cache = {}
|
||||
for record in records:
|
||||
if not hasattr(record, "to_location_dict"):
|
||||
continue
|
||||
if not record.source or not record.source_id:
|
||||
continue
|
||||
cache[_cache_key(record.source, record.source_id)] = record.to_location_dict()
|
||||
set_compute_center_location_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def get_compute_center_location_dict(
|
||||
source: str | None,
|
||||
source_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return dict(COMPUTE_CENTER_LOCATION_CACHE.get(_cache_key(source, source_id), {}))
|
||||
|
||||
|
||||
# ── Pipeline construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _lookup_ror_organization(query: str) -> dict[str, Any] | None:
|
||||
"""Lookup a research organization in ROR for user-triggered candidates."""
|
||||
if not query:
|
||||
return None
|
||||
response = httpx.get(
|
||||
ROR_SEARCH_URL,
|
||||
params={"query": query},
|
||||
headers={"User-Agent": DEFAULT_ROR_USER_AGENT},
|
||||
timeout=DEFAULT_ROR_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
first = items[0]
|
||||
if not isinstance(first, dict):
|
||||
return None
|
||||
organization = first.get("organization")
|
||||
if isinstance(organization, dict):
|
||||
return organization
|
||||
return first
|
||||
|
||||
|
||||
def _compute_center_ror_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
extra = query.extra or {}
|
||||
raw_parts: list[tuple[str, str]] = [
|
||||
("site", coerce_str(extra.get("site"))),
|
||||
("operator", coerce_str(extra.get("operator"))),
|
||||
("organization", coerce_str(extra.get("organization"))),
|
||||
]
|
||||
for field, value in tuple(raw_parts):
|
||||
if "/" not in value:
|
||||
continue
|
||||
raw_parts.extend(
|
||||
(field, part.strip())
|
||||
for part in value.split("/")
|
||||
if len(part.strip()) >= 3
|
||||
)
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
seen: set[str] = set()
|
||||
for field, value in raw_parts:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
plan.append((value, (field,)))
|
||||
return plan
|
||||
|
||||
|
||||
def _organization_label(organization: dict[str, Any], fallback: str) -> str:
|
||||
names = organization.get("names")
|
||||
if isinstance(names, list):
|
||||
for name in names:
|
||||
if not isinstance(name, dict):
|
||||
continue
|
||||
types = name.get("types")
|
||||
if isinstance(types, list) and "ror_display" in types:
|
||||
value = coerce_str(name.get("value"))
|
||||
if value:
|
||||
return value
|
||||
for name in names:
|
||||
if isinstance(name, dict):
|
||||
value = coerce_str(name.get("value"))
|
||||
if value:
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
class ROROrganizationResolver:
|
||||
"""Resolve source-provided organization/site text through the open ROR API."""
|
||||
|
||||
name = "ror_organization_registry"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query_plan_builder=_compute_center_ror_query_plan,
|
||||
lookup=lambda q: _lookup_ror_organization(q),
|
||||
confidence: float = 0.68,
|
||||
) -> None:
|
||||
self._query_plan_builder = query_plan_builder
|
||||
self._lookup = lookup
|
||||
self._confidence = confidence
|
||||
|
||||
def resolve(self, query: LocationQuery):
|
||||
from app.services.location import ResolverOutput
|
||||
from app.services.location.text import parse_float
|
||||
|
||||
attempted: list[str] = []
|
||||
candidates: list[LocationCandidate] = []
|
||||
context_country = normalize_text(normalize_country_text(query.country))
|
||||
|
||||
for ror_query, matched_fields in self._query_plan_builder(query):
|
||||
attempted.append(f"ror:{ror_query}")
|
||||
try:
|
||||
organization = self._lookup(ror_query)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(organization, dict):
|
||||
continue
|
||||
locations = organization.get("locations")
|
||||
if not isinstance(locations, list) or not locations:
|
||||
continue
|
||||
location = locations[0]
|
||||
if not isinstance(location, dict):
|
||||
continue
|
||||
details = location.get("geonames_details")
|
||||
if not isinstance(details, dict):
|
||||
continue
|
||||
latitude = parse_float(details.get("lat"))
|
||||
longitude = parse_float(details.get("lng"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
|
||||
country = normalize_country_text(details.get("country_name"))
|
||||
if context_country and normalize_text(country) != context_country:
|
||||
continue
|
||||
|
||||
city = coerce_str(details.get("name")) or None
|
||||
region = coerce_str(details.get("country_subdivision_name")) or None
|
||||
display_name = _organization_label(organization, ror_query)
|
||||
ror_id = coerce_str(organization.get("id"))
|
||||
geonames_id = location.get("geonames_id")
|
||||
source_note = (
|
||||
f"ROR organization match: {display_name}"
|
||||
+ (f" ({ror_id})" if ror_id else "")
|
||||
+ (f"; GeoNames {geonames_id}" if geonames_id else "")
|
||||
)
|
||||
candidates.append(
|
||||
LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=display_name,
|
||||
precision="city",
|
||||
confidence=self._confidence,
|
||||
query=ror_query,
|
||||
source=self.name,
|
||||
source_note=source_note,
|
||||
matched_fields=matched_fields,
|
||||
needs_confirmation=True,
|
||||
city=city,
|
||||
region=region,
|
||||
country=country or query.country,
|
||||
matched_location_name=display_name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
)
|
||||
|
||||
return ResolverOutput(
|
||||
candidates=tuple(candidates),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
|
||||
|
||||
class StoredComputeCenterLocationResolver:
|
||||
"""Resolve a compute center through the DB-backed current-location cache."""
|
||||
|
||||
name = "stored_compute_center_location"
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
extra = query.extra or {}
|
||||
stored = get_compute_center_location_dict(
|
||||
coerce_str(extra.get("source")),
|
||||
coerce_str(extra.get("source_id")),
|
||||
)
|
||||
if not stored:
|
||||
return ResolverOutput()
|
||||
latitude = parse_float(stored.get("latitude"))
|
||||
longitude = parse_float(stored.get("longitude"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=stored.get("name") or query.name or "Compute center",
|
||||
precision=stored.get("precision") or "city",
|
||||
confidence=float(stored.get("confidence") or 0.85),
|
||||
query=f"stored_compute_center_location::{stored.get('source')}:{stored.get('source_id')}",
|
||||
source=self.name,
|
||||
source_note=stored.get("source_note"),
|
||||
matched_fields=("source", "source_id"),
|
||||
needs_confirmation=bool(stored.get("needs_confirmation")),
|
||||
city=stored.get("city") or query.city,
|
||||
region=None,
|
||||
country=stored.get("country") or query.country,
|
||||
matched_location_name=stored.get("site") or stored.get("name") or query.name,
|
||||
location_verified_at=stored.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _short_system_name(name: Any) -> str:
|
||||
"""Strip vendor/system suffix from TOP500 names like ``"El Capitan - HPE Cray ..."``."""
|
||||
text = coerce_str(name)
|
||||
if not text:
|
||||
return ""
|
||||
head = text.split(" - ", 1)[0].strip()
|
||||
return head or text
|
||||
|
||||
|
||||
def _record_context(record: Any, metadata: dict[str, Any]) -> dict[str, str]:
|
||||
name = coerce_str(getattr(record, "name", None))
|
||||
return {
|
||||
"source": coerce_str(getattr(record, "source", None)),
|
||||
"source_id": coerce_str(getattr(record, "source_id", None)),
|
||||
"name": name,
|
||||
"name_short": _short_system_name(name),
|
||||
"city": coerce_str(get_record_field(record, "city")),
|
||||
"country": coerce_str(get_record_field(record, "country")),
|
||||
"site": coerce_str(metadata.get("site") or metadata.get("organization")),
|
||||
"operator": coerce_str(
|
||||
metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
),
|
||||
"organization": coerce_str(metadata.get("organization")),
|
||||
}
|
||||
|
||||
|
||||
def _context_to_query(
|
||||
context: dict[str, str],
|
||||
*,
|
||||
source_lat: float | None = None,
|
||||
source_lon: float | None = None,
|
||||
) -> LocationQuery:
|
||||
name = context.get("name") or None
|
||||
name_short = context.get("name_short") or ""
|
||||
aliases: tuple[str, ...] = ()
|
||||
if name_short and name_short != name:
|
||||
aliases = (name_short,)
|
||||
return LocationQuery(
|
||||
name=name,
|
||||
aliases=aliases,
|
||||
city=context.get("city") or None,
|
||||
country=context.get("country") or None,
|
||||
source_latitude=source_lat,
|
||||
source_longitude=source_lon,
|
||||
extra={
|
||||
"source": context.get("source") or "",
|
||||
"source_id": context.get("source_id") or "",
|
||||
"site": context.get("site") or "",
|
||||
"operator": context.get("operator") or "",
|
||||
"organization": context.get("organization") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _compute_center_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Build the Nominatim query plan for a compute-center query.
|
||||
|
||||
Mirrors the legacy ``_build_online_query_plan`` ordering exactly.
|
||||
"""
|
||||
name = query.name or ""
|
||||
name_short = (query.aliases[0] if query.aliases else "") or name
|
||||
extra = query.extra or {}
|
||||
site = str(extra.get("site") or "")
|
||||
operator = str(extra.get("operator") or "")
|
||||
city = query.city or ""
|
||||
country = query.country or ""
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
|
||||
def add(parts: list[tuple[str, str]]) -> None:
|
||||
non_empty = [(field, value) for field, value in parts if value]
|
||||
if not non_empty:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
fields: list[str] = []
|
||||
for field, value in non_empty:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(value)
|
||||
fields.append(field)
|
||||
if not cleaned:
|
||||
return
|
||||
composed = ", ".join(cleaned)
|
||||
if not any(composed == existing for existing, _ in plan):
|
||||
plan.append((composed, tuple(fields)))
|
||||
|
||||
add([("site", site), ("country", country)])
|
||||
add([("operator", operator), ("city", city), ("country", country)])
|
||||
add([("name", name_short), ("operator", operator), ("country", country)])
|
||||
add([("name", name_short), ("site", site)])
|
||||
add([("name", name_short), ("country", country)])
|
||||
add([("name", name_short), ("city", city), ("country", country)])
|
||||
add([("city", city), ("country", country)])
|
||||
if name and name != name_short:
|
||||
add([("name", name), ("country", country)])
|
||||
return plan
|
||||
|
||||
|
||||
COMPUTE_CENTER_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
StoredComputeCenterLocationResolver(),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
" or stored compute-center location."
|
||||
),
|
||||
)
|
||||
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
ROROrganizationResolver(),
|
||||
NominatimResolver(
|
||||
query_plan_builder=_compute_center_query_plan,
|
||||
# Late-binding so test monkeypatching of ``_geocode_online`` works.
|
||||
geocoder=lambda q: _geocode_online(q),
|
||||
),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
", ROR organization lookup, or online geocoding."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── Candidate → ComputeCenterLocation conversion ───────────────────
|
||||
|
||||
|
||||
_GEOGRAPHY_MODE_BY_SOURCE = {
|
||||
"source_coordinates": "source_coordinates",
|
||||
"stored_compute_center_location": "stored_compute_center_location",
|
||||
"ror_organization_registry": "ror_organization",
|
||||
"nominatim_online_geocode": "online_geocode",
|
||||
}
|
||||
|
||||
|
||||
def _candidate_to_location(
|
||||
candidate: LocationCandidate,
|
||||
*,
|
||||
context: dict[str, str],
|
||||
) -> ComputeCenterLocation:
|
||||
geography_mode = _GEOGRAPHY_MODE_BY_SOURCE.get(candidate.source, "online_geocode")
|
||||
is_estimated = candidate.needs_confirmation or candidate.source.startswith(
|
||||
"nominatim"
|
||||
)
|
||||
estimated_reason: str | None
|
||||
if candidate.source == "source_coordinates":
|
||||
estimated_reason = None
|
||||
elif candidate.source == "stored_compute_center_location":
|
||||
estimated_reason = candidate.source_note
|
||||
elif candidate.source == "ror_organization_registry":
|
||||
fields_summary = ", ".join(candidate.matched_fields) or "organization"
|
||||
estimated_reason = (
|
||||
f"Resolved by ROR organization lookup '{candidate.query}' "
|
||||
f"(matched fields: {fields_summary})"
|
||||
)
|
||||
elif candidate.source == "nominatim_online_geocode":
|
||||
fields_summary = ", ".join(candidate.matched_fields) or "name"
|
||||
estimated_reason = (
|
||||
f"Resolved by online geocoding query '{candidate.query}' "
|
||||
f"(matched fields: {fields_summary})"
|
||||
)
|
||||
else:
|
||||
estimated_reason = candidate.source_note
|
||||
|
||||
country = (
|
||||
candidate.country
|
||||
or normalize_country_text(context.get("country"))
|
||||
or context.get("country")
|
||||
or None
|
||||
)
|
||||
return ComputeCenterLocation(
|
||||
latitude=candidate.latitude,
|
||||
longitude=candidate.longitude,
|
||||
location_precision=candidate.precision,
|
||||
geography_mode=geography_mode,
|
||||
is_estimated=is_estimated,
|
||||
estimated_reason=estimated_reason,
|
||||
location_confidence=candidate.confidence,
|
||||
location_source=candidate.source,
|
||||
location_source_note=candidate.source_note,
|
||||
location_verified_at=candidate.location_verified_at,
|
||||
matched_location_name=candidate.matched_location_name
|
||||
or context.get("name")
|
||||
or None,
|
||||
needs_confirmation=candidate.needs_confirmation,
|
||||
city=candidate.city or context.get("city") or None,
|
||||
region=candidate.region,
|
||||
country=country,
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic_for(
|
||||
record: Any,
|
||||
context: dict[str, str],
|
||||
*,
|
||||
failure_reason: str,
|
||||
attempted_queries: tuple[str, ...] = (),
|
||||
) -> ResolutionDiagnostic:
|
||||
return ResolutionDiagnostic(
|
||||
failure_reason=failure_reason,
|
||||
attempted_queries=attempted_queries,
|
||||
record_id=getattr(record, "id", None),
|
||||
source=getattr(record, "source", None),
|
||||
source_id=getattr(record, "source_id", None),
|
||||
name=context.get("name") or getattr(record, "name", None),
|
||||
country=context.get("country") or None,
|
||||
city=context.get("city") or None,
|
||||
site=context.get("site") or None,
|
||||
operator=context.get("operator") or None,
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_compute_center_location(
|
||||
record: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ComputeCenterLocation:
|
||||
"""Backwards-compatible thin wrapper returning the renderable location only.
|
||||
|
||||
Records that cannot be resolved to city-level get a placeholder
|
||||
:class:`ComputeCenterLocation` with ``location_precision='unknown'``.
|
||||
Callers should generally prefer :func:`resolve_compute_center_location_full`.
|
||||
"""
|
||||
full = resolve_compute_center_location_full(record, metadata)
|
||||
return full.location or ComputeCenterLocation(
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
location_precision="unknown",
|
||||
geography_mode="unresolved",
|
||||
is_estimated=True,
|
||||
estimated_reason="No resolvable location hints",
|
||||
location_confidence=0.0,
|
||||
location_source="unknown",
|
||||
location_source_note=(
|
||||
"No source coordinates, ROR organization match, or online"
|
||||
" geocoding result."
|
||||
),
|
||||
matched_location_name=None,
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
|
||||
def resolve_compute_center_location_full(
|
||||
record: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
allow_online: bool = False,
|
||||
) -> ResolutionResult:
|
||||
metadata = metadata or {}
|
||||
context = _record_context(record, metadata)
|
||||
|
||||
from app.services.location.text import parse_float as _parse_float
|
||||
|
||||
source_lat = _parse_float(get_record_field(record, "latitude"))
|
||||
source_lon = _parse_float(get_record_field(record, "longitude"))
|
||||
if source_lat in (None, 0.0):
|
||||
source_lat = None
|
||||
if source_lon in (None, 0.0):
|
||||
source_lon = None
|
||||
|
||||
query = _context_to_query(
|
||||
context, source_lat=source_lat, source_lon=source_lon
|
||||
)
|
||||
pipeline = (
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE
|
||||
if allow_online
|
||||
else COMPUTE_CENTER_PIPELINE
|
||||
)
|
||||
pipeline_result = pipeline.resolve_best(query)
|
||||
|
||||
if pipeline_result.location and pipeline_result.location.precision in RENDERABLE_PRECISIONS:
|
||||
location = _candidate_to_location(pipeline_result.location, context=context)
|
||||
return ResolutionResult(location=location, diagnostic=None)
|
||||
|
||||
return ResolutionResult(
|
||||
location=None,
|
||||
diagnostic=_diagnostic_for(
|
||||
record,
|
||||
context,
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
", ROR organization lookup, or online geocoding."
|
||||
if allow_online
|
||||
else (
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
" or stored compute-center location."
|
||||
)
|
||||
),
|
||||
attempted_queries=pipeline_result.attempted_queries,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def collect_location_candidates(
|
||||
*,
|
||||
name: str | None = None,
|
||||
source: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
organization: str | None = None,
|
||||
record_id: int | None = None,
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
"""Run the full resolution chain and return ranked candidates with attempted queries.
|
||||
|
||||
The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept
|
||||
for backward compatibility with the API handler that calls this function.
|
||||
"""
|
||||
query = build_compute_center_location_query(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
organization=organization,
|
||||
)
|
||||
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
|
||||
|
||||
|
||||
def build_compute_center_location_query(
|
||||
*,
|
||||
name: str | None = None,
|
||||
source: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
organization: str | None = None,
|
||||
) -> LocationQuery:
|
||||
name_value = coerce_str(name)
|
||||
context: dict[str, str] = {
|
||||
"source": coerce_str(source),
|
||||
"source_id": coerce_str(source_id),
|
||||
"name": name_value,
|
||||
"name_short": _short_system_name(name_value),
|
||||
"city": coerce_str(city),
|
||||
"country": coerce_str(country),
|
||||
"site": coerce_str(site or organization),
|
||||
"operator": coerce_str(operator or organization),
|
||||
"organization": coerce_str(organization),
|
||||
}
|
||||
return _context_to_query(context)
|
||||
|
||||
|
||||
def _record_operator(metadata: dict[str, Any]) -> str | None:
|
||||
return coerce_str(
|
||||
metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
) or None
|
||||
|
||||
|
||||
async def seed_compute_center_locations_from_source_coords(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Seed stored compute-center locations only from real source coordinates."""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
changed = False
|
||||
|
||||
for record in records:
|
||||
source_value = coerce_str(getattr(record, "source", None))
|
||||
source_id = coerce_str(getattr(record, "source_id", None))
|
||||
if not source_value or not source_id:
|
||||
continue
|
||||
latitude = parse_float(get_record_field(record, "latitude"))
|
||||
longitude = parse_float(get_record_field(record, "longitude"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
existing = await session.scalar(
|
||||
select(ComputeCenterLocationRecord)
|
||||
.where(ComputeCenterLocationRecord.source == source_value)
|
||||
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
metadata = record.extra_data or {}
|
||||
session.add(
|
||||
ComputeCenterLocationRecord(
|
||||
source=source_value,
|
||||
source_id=source_id,
|
||||
name=getattr(record, "name", None),
|
||||
operator=_record_operator(metadata),
|
||||
site=coerce_str(metadata.get("site") or metadata.get("organization")) or None,
|
||||
city=coerce_str(get_record_field(record, "city")) or None,
|
||||
country=coerce_str(get_record_field(record, "country")) or None,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
location_source="source_coordinates",
|
||||
source_note="Seeded from source-provided compute-center coordinates",
|
||||
raw_payload={
|
||||
"record_id": getattr(record, "id", None),
|
||||
"source": source_value,
|
||||
"source_id": source_id,
|
||||
},
|
||||
needs_confirmation=False,
|
||||
verification_status="source_provided",
|
||||
verified_at=None,
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await session.commit()
|
||||
await refresh_compute_center_location_cache(session)
|
||||
|
||||
|
||||
async def upsert_compute_center_location(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str,
|
||||
name: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
precision: str = "city",
|
||||
confidence: float | None = None,
|
||||
location_source: str = "manual_selection",
|
||||
source_url: str | None = None,
|
||||
source_note: str | None = None,
|
||||
raw_payload: dict[str, Any] | None = None,
|
||||
needs_confirmation: bool = False,
|
||||
verification_status: str = "verified",
|
||||
) -> ComputeCenterLocationRecord:
|
||||
existing = await session.scalar(
|
||||
select(ComputeCenterLocationRecord)
|
||||
.where(ComputeCenterLocationRecord.source == source)
|
||||
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||
)
|
||||
verified_at = None if needs_confirmation else datetime.now(UTC)
|
||||
values = {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"precision": precision,
|
||||
"confidence": confidence,
|
||||
"location_source": location_source,
|
||||
"source_url": source_url,
|
||||
"source_note": source_note,
|
||||
"raw_payload": raw_payload or {},
|
||||
"needs_confirmation": needs_confirmation,
|
||||
"verification_status": verification_status,
|
||||
"verified_at": verified_at,
|
||||
}
|
||||
if existing:
|
||||
for key, value in values.items():
|
||||
setattr(existing, key, value)
|
||||
record = existing
|
||||
else:
|
||||
record = ComputeCenterLocationRecord(
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
**values,
|
||||
)
|
||||
session.add(record)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
await refresh_compute_center_location_cache(session)
|
||||
return record
|
||||
@@ -7,12 +7,16 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.services.ai_tools.evidence_store import normalize_search_evidence
|
||||
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||
|
||||
|
||||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||||
CREDENTIAL_GUIDE_PROMPT_KEY = "credential.guide"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -153,10 +157,26 @@ async def get_credential_guide(db, provider: str) -> dict[str, Any]:
|
||||
"markdown": custom.get("markdown") if custom else default.markdown,
|
||||
"prompt": default.prompt,
|
||||
"source": "ai" if custom else "default",
|
||||
"sources": custom.get("sources", []) if custom else [],
|
||||
"verification_status": (
|
||||
custom.get("verification_status", "verified_with_search_evidence")
|
||||
if custom
|
||||
else "default_unverified"
|
||||
),
|
||||
"verification_error": custom.get("verification_error") if custom else None,
|
||||
}
|
||||
|
||||
|
||||
async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]:
|
||||
async def save_credential_guide(
|
||||
db,
|
||||
provider: str,
|
||||
title: str,
|
||||
markdown: str,
|
||||
*,
|
||||
sources: list[dict[str, Any]] | None = None,
|
||||
verification_status: str = "verified_with_search_evidence",
|
||||
verification_error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||
if default is None:
|
||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||
@@ -165,6 +185,9 @@ async def save_credential_guide(db, provider: str, title: str, markdown: str) ->
|
||||
store[provider] = {
|
||||
"title": title or default.title,
|
||||
"markdown": markdown,
|
||||
"sources": sources or [],
|
||||
"verification_status": verification_status,
|
||||
"verification_error": verification_error,
|
||||
}
|
||||
if record is None:
|
||||
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
|
||||
@@ -192,28 +215,56 @@ async def generate_credential_guide(
|
||||
db,
|
||||
provider: str,
|
||||
ai_client: AIProviderClient,
|
||||
web_search_client: WebSearchClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||
if default is None:
|
||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||
|
||||
search_evidence: list[dict[str, Any]] = []
|
||||
search_error: str | None = None
|
||||
if web_search_client is not None:
|
||||
try:
|
||||
evidence = await web_search_client.search(
|
||||
_credential_guide_search_query(default),
|
||||
max_results=5,
|
||||
)
|
||||
search_evidence = normalize_search_evidence(evidence, limit=5)
|
||||
except WebSearchError as exc:
|
||||
search_error = str(exc)
|
||||
except Exception as exc:
|
||||
search_error = f"WebSearch unavailable: {exc}"
|
||||
|
||||
if not search_evidence:
|
||||
guide = await get_credential_guide(db, provider)
|
||||
guide["verification_status"] = "unverified_no_search_evidence"
|
||||
guide["verification_error"] = search_error
|
||||
guide["sources"] = []
|
||||
return guide
|
||||
|
||||
prompt = await get_effective_prompt(db, CREDENTIAL_GUIDE_PROMPT_KEY)
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate credential guide for {provider}",
|
||||
objective=default.prompt,
|
||||
objective=f"{default.prompt}\n{prompt.prompt}",
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"provider": provider,
|
||||
"current_default_guide": default.markdown,
|
||||
"product_context": "Planet collector credential settings",
|
||||
"search_evidence": search_evidence,
|
||||
},
|
||||
observations=[
|
||||
"Use concise Chinese markdown.",
|
||||
"Prefer stable concepts over brittle UI labels.",
|
||||
"Include verification and troubleshooting steps.",
|
||||
"Include a short sources section with the provided URLs.",
|
||||
],
|
||||
constraints=[
|
||||
"Do not ask the user for secrets.",
|
||||
"Do not include fabricated screenshots.",
|
||||
"Do not invent source URLs or product UI labels.",
|
||||
"Use only the provided search_evidence as factual support.",
|
||||
"Return markdown only.",
|
||||
],
|
||||
)
|
||||
@@ -221,4 +272,19 @@ async def generate_credential_guide(
|
||||
markdown = response.content.strip()
|
||||
if not markdown:
|
||||
markdown = default.markdown
|
||||
return await save_credential_guide(db, provider, default.title, markdown)
|
||||
return await save_credential_guide(
|
||||
db,
|
||||
provider,
|
||||
default.title,
|
||||
markdown,
|
||||
sources=search_evidence,
|
||||
verification_status="verified_with_search_evidence",
|
||||
)
|
||||
|
||||
|
||||
def _credential_guide_search_query(default: CredentialGuideDefault) -> str:
|
||||
if default.provider == "barentswatch":
|
||||
return "BarentsWatch developer tutorial AIS API OAuth client credentials"
|
||||
if default.provider == "aisstream":
|
||||
return "AISStream API key documentation websocket stream"
|
||||
return f"{default.provider} API credentials documentation"
|
||||
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.target_schema_registry import TargetSchema, get_target_schema
|
||||
@@ -254,6 +255,12 @@ def _best_field_match(field_name: str, candidates: list[str]) -> str | None:
|
||||
"lat": ("lat", "latitude", "y"),
|
||||
"lon": ("lon", "lng", "longitude", "x"),
|
||||
"mmsi": ("mmsi",),
|
||||
"geometry": ("geometry", "geom"),
|
||||
"properties": ("properties", "props"),
|
||||
"source_kind": ("source_kind", "kind", "type"),
|
||||
"feature_count": ("feature_count", "features_count", "count"),
|
||||
"artifact_path": ("artifact_path", "path", "file"),
|
||||
"sha256": ("sha256", "hash", "checksum"),
|
||||
"sog": ("sog", "speed", "speedOverGround"),
|
||||
"cog": ("cog", "course", "courseOverGround"),
|
||||
"received_at": ("received_at", "timestamp", "time", "updated_at"),
|
||||
|
||||
121
backend/app/services/docs_gatekeeper.py
Normal file
121
backend/app/services/docs_gatekeeper.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Server-side Docs metadata and Gatekeeper authorization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
DocsAccess = Literal["public", "docs_user", "docs_developer", "docs_admin"]
|
||||
DocsLang = Literal["zh", "en"]
|
||||
|
||||
VALID_DOCS_LANGS = {"zh", "en"}
|
||||
DOCS_README_FILENAME = "README.md"
|
||||
DEFAULT_DOCS_SLUG = "overview"
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
TECHNICAL_DOCS_ROOT = REPO_ROOT / "docs" / "technical"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocsMetadata:
|
||||
filename: str
|
||||
slug: str
|
||||
access: DocsAccess
|
||||
group: str
|
||||
order: int
|
||||
zh_title: str
|
||||
en_title: str
|
||||
|
||||
|
||||
DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
|
||||
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"),
|
||||
DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"),
|
||||
DocsMetadata("faq.md", "faq", "public", "Manual", 3, "常见问题", "FAQ"),
|
||||
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 4, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
|
||||
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
|
||||
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
|
||||
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),
|
||||
DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "Earth 卫星覆盖策略", "Earth Satellite Footprint Policy"),
|
||||
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Earth", 15, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "Earth 可交互图标接入", "Earth Interactable Usage"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "Earth 工具栏与浮层协同", "Earth Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||
DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"),
|
||||
DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"),
|
||||
DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"),
|
||||
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||
DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "Planet 运维手册", "Planet Ops Runbook"),
|
||||
DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"),
|
||||
DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"),
|
||||
)
|
||||
|
||||
DOCS_BY_SLUG = {entry.slug: entry for entry in DOCS_METADATA}
|
||||
|
||||
|
||||
def get_user_gatekeeper_groups(user: User | None) -> set[str]:
|
||||
if user is None:
|
||||
return set()
|
||||
|
||||
role = user.role.value if hasattr(user.role, "value") else str(user.role or "")
|
||||
if role == "super_admin":
|
||||
return {"docs_user", "docs_developer", "docs_admin"}
|
||||
if role == "admin":
|
||||
return {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
groups = set()
|
||||
raw_groups = user.gatekeeper_groups or []
|
||||
if isinstance(raw_groups, list):
|
||||
groups.update(str(group) for group in raw_groups)
|
||||
|
||||
if "docs_admin" in groups:
|
||||
groups.update({"docs_developer", "docs_user"})
|
||||
if "docs_developer" in groups:
|
||||
groups.add("docs_user")
|
||||
return groups
|
||||
|
||||
|
||||
def can_read_doc(entry: DocsMetadata, user: User | None) -> bool:
|
||||
if entry.access == "public":
|
||||
return True
|
||||
return entry.access in get_user_gatekeeper_groups(user)
|
||||
|
||||
|
||||
def doc_path_for(entry: DocsMetadata, lang: str) -> Path:
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise ValueError("Unsupported docs language")
|
||||
return TECHNICAL_DOCS_ROOT / lang / entry.filename
|
||||
|
||||
|
||||
def title_for(entry: DocsMetadata, lang: str) -> str:
|
||||
return entry.zh_title if lang == "zh" else entry.en_title
|
||||
|
||||
|
||||
def catalog_for_user(user: User | None) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for entry in DOCS_METADATA:
|
||||
if not can_read_doc(entry, user):
|
||||
continue
|
||||
for lang in sorted(VALID_DOCS_LANGS):
|
||||
if not doc_path_for(entry, lang).exists():
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
}
|
||||
)
|
||||
return sorted(items, key=lambda item: (item["lang"], item["order"], item["title"]))
|
||||
671
backend/app/services/earth_boundaries.py
Normal file
671
backend/app/services/earth_boundaries.py
Normal file
@@ -0,0 +1,671 @@
|
||||
"""Earth boundary static asset service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources"
|
||||
SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json"
|
||||
BUILD_RESULT_PATH = SOURCE_OUTPUT_DIR / "build-result.json"
|
||||
BUILD_JOB_PATH = SOURCE_OUTPUT_DIR / "build-job.json"
|
||||
BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1"
|
||||
BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json"
|
||||
PMTILES_ARTIFACT_PATH = (
|
||||
REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
)
|
||||
LEGACY_GEOJSON_PATH = REPO_ROOT / "frontend/public/earth/data/countries-admin0.min.geojson"
|
||||
POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
LOCAL_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.local.json"
|
||||
EXAMPLE_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.example.json"
|
||||
|
||||
BOUNDARY_SOURCE_KINDS = {
|
||||
"earth_admin0_boundaries": "admin0-boundaries",
|
||||
"earth_coastline": "coastline",
|
||||
"earth_claim_lines": "claim-lines",
|
||||
}
|
||||
|
||||
DEFAULT_PUBLIC_BOUNDARY_SOURCES = {
|
||||
"earth_admin0_boundaries": {
|
||||
"displayName": "Natural Earth Admin-0 Countries",
|
||||
"sourceKind": "admin0-boundaries",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_coastline": {
|
||||
"displayName": "Natural Earth Coastline",
|
||||
"sourceKind": "coastline",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_claim_lines": {
|
||||
"displayName": "Natural Earth Disputed Boundaries",
|
||||
"sourceKind": "claim-lines",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
}
|
||||
|
||||
BUILD_CONFIG = {
|
||||
"builder": "scripts/build_earth_boundary_pmtiles.py",
|
||||
"format": "pmtiles+mvt",
|
||||
"production_target": "pmtiles-mvt",
|
||||
}
|
||||
|
||||
|
||||
class EarthBoundaryBuildError(RuntimeError):
|
||||
def __init__(self, message: str, *, code: str = "build_failed", details: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.details = details
|
||||
|
||||
|
||||
_build_job_lock = asyncio.Lock()
|
||||
_build_task: asyncio.Task | None = None
|
||||
_build_job_state: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _public_job_state() -> dict[str, Any]:
|
||||
if _build_job_state:
|
||||
return dict(_build_job_state)
|
||||
return _read_json(BUILD_JOB_PATH)
|
||||
|
||||
|
||||
def get_boundary_build_status() -> dict[str, Any]:
|
||||
return {"job": _public_job_state()}
|
||||
|
||||
|
||||
def _set_job_state(**updates: Any) -> dict[str, Any]:
|
||||
global _build_job_state
|
||||
current = dict(_build_job_state)
|
||||
current.update(updates)
|
||||
current["updated_at"] = _utc_now_iso()
|
||||
_build_job_state = current
|
||||
_write_json(BUILD_JOB_PATH, current)
|
||||
return current
|
||||
|
||||
|
||||
def _append_job_log(message: str) -> None:
|
||||
logs = list(_build_job_state.get("logs") or [])
|
||||
logs.append({"time": _utc_now_iso(), "message": message})
|
||||
_set_job_state(logs=logs[-40:])
|
||||
|
||||
|
||||
def _update_job_progress(progress: float, phase: str, message: str, **extra: Any) -> None:
|
||||
bounded_progress = max(0, min(100, int(round(progress))))
|
||||
_set_job_state(
|
||||
status="running",
|
||||
progress=bounded_progress,
|
||||
phase=phase,
|
||||
message=message,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _stable_json_hash(payload: Any) -> str:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str:
|
||||
suffix = Path(endpoint.split("?", 1)[0]).suffix.lower()
|
||||
if suffix in {".json", ".geojson", ".zip", ".pbf"}:
|
||||
return suffix
|
||||
if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]:
|
||||
return ".geojson"
|
||||
if "json" in content_type:
|
||||
return ".json"
|
||||
return ".dat"
|
||||
|
||||
|
||||
def _json_feature_count(payload: Any) -> int:
|
||||
if isinstance(payload, dict) and isinstance(payload.get("features"), list):
|
||||
return len(payload["features"])
|
||||
if isinstance(payload, list):
|
||||
return len(payload)
|
||||
return 1 if payload else 0
|
||||
|
||||
|
||||
def _directory_stats(path: Path) -> dict[str, int]:
|
||||
if not path.exists():
|
||||
return {"file_count": 0, "size_bytes": 0}
|
||||
files = [item for item in path.rglob("*") if item.is_file()]
|
||||
return {"file_count": len(files), "size_bytes": sum(item.stat().st_size for item in files)}
|
||||
|
||||
|
||||
def _load_source_feature_collection(source: dict[str, Any]) -> dict[str, Any]:
|
||||
path = REPO_ROOT / source["path"]
|
||||
payload = _read_json(path)
|
||||
features = payload.get("features") if isinstance(payload, dict) else None
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features if isinstance(features, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _write_high_precision_geojson_manifest(
|
||||
sources: list[dict[str, Any]],
|
||||
build_input_hash: str,
|
||||
missing_tools: list[str],
|
||||
) -> dict[str, Any]:
|
||||
BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
|
||||
admin0_payload = _load_source_feature_collection(admin0)
|
||||
coastline_payload = _load_source_feature_collection(coastline)
|
||||
claim_payload = _load_source_feature_collection(claim_lines)
|
||||
for feature in coastline_payload["features"]:
|
||||
props = feature.setdefault("properties", {})
|
||||
if isinstance(props, dict):
|
||||
props["PLANET_LAYER"] = "coastline"
|
||||
|
||||
base_payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [*admin0_payload["features"], *coastline_payload["features"]],
|
||||
}
|
||||
base_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-base.geojson"
|
||||
hover_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-hover.geojson"
|
||||
claim_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-claims.geojson"
|
||||
_write_json(base_path, base_payload)
|
||||
_write_json(hover_path, admin0_payload)
|
||||
_write_json(claim_path, claim_payload)
|
||||
|
||||
manifest = {
|
||||
"version": "natural-earth-v1",
|
||||
"builtAt": _utc_now_iso(),
|
||||
"tileProvider": "geojson-high-precision",
|
||||
"format": "geojson-directory",
|
||||
"buildInputHash": build_input_hash,
|
||||
"base": base_path.name,
|
||||
"hoverIndex": hover_path.name,
|
||||
"claimLine": claim_path.name,
|
||||
"sourceFeatureCount": {
|
||||
"admin0": len(admin0_payload["features"]),
|
||||
"coastline": len(coastline_payload["features"]),
|
||||
"claimLines": len(claim_payload["features"]),
|
||||
},
|
||||
"pmtiles": None,
|
||||
"missingTools": missing_tools,
|
||||
}
|
||||
_write_json(BOUNDARY_MANIFEST_PATH, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def _relative(path: Path) -> str:
|
||||
return str(path.relative_to(REPO_ROOT))
|
||||
|
||||
|
||||
def load_boundary_config() -> tuple[dict[str, Any], str]:
|
||||
if LOCAL_CONFIG_PATH.exists():
|
||||
return _read_json(LOCAL_CONFIG_PATH), "local"
|
||||
return _read_json(EXAMPLE_CONFIG_PATH), "example"
|
||||
|
||||
|
||||
def save_boundary_config(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise EarthBoundaryBuildError("Earth boundary config must be a JSON object", code="invalid_config")
|
||||
_write_json(LOCAL_CONFIG_PATH, payload)
|
||||
return get_boundary_status()
|
||||
|
||||
|
||||
def _source_configs(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_sources = payload.get("collectorConfigs") or payload.get("sources") or {}
|
||||
return raw_sources if isinstance(raw_sources, dict) else {}
|
||||
|
||||
|
||||
def _is_placeholder_endpoint(endpoint: Any) -> bool:
|
||||
value = str(endpoint or "").strip()
|
||||
return not value or "example.com" in value
|
||||
|
||||
|
||||
def _source_configs_with_defaults(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_sources = _source_configs(payload)
|
||||
merged: dict[str, Any] = {}
|
||||
for source_key, default_config in DEFAULT_PUBLIC_BOUNDARY_SOURCES.items():
|
||||
configured = raw_sources.get(source_key)
|
||||
if not isinstance(configured, dict) or _is_placeholder_endpoint(configured.get("endpoint")):
|
||||
merged[source_key] = dict(default_config)
|
||||
else:
|
||||
merged[source_key] = {**default_config, **configured}
|
||||
for source_key, source_config in raw_sources.items():
|
||||
if source_key not in merged:
|
||||
merged[source_key] = source_config
|
||||
return merged
|
||||
|
||||
|
||||
def _build_input_hash(source_manifest: dict[str, Any]) -> str:
|
||||
return _stable_json_hash(
|
||||
{
|
||||
"source_manifest_schema": source_manifest.get("schema"),
|
||||
"sources": [
|
||||
{
|
||||
"id": source.get("id"),
|
||||
"sha256": source.get("sha256"),
|
||||
"kind": source.get("kind"),
|
||||
}
|
||||
for source in source_manifest.get("sources", [])
|
||||
],
|
||||
"pov_policy": source_manifest.get("povPolicy"),
|
||||
"build_config": BUILD_CONFIG,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool:
|
||||
return (
|
||||
bool(boundary_manifest)
|
||||
and boundary_manifest.get("buildInputHash") == build_input_hash
|
||||
and boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
||||
and PMTILES_ARTIFACT_PATH.exists()
|
||||
)
|
||||
|
||||
|
||||
def get_boundary_status() -> dict[str, Any]:
|
||||
config_payload, config_source = load_boundary_config()
|
||||
effective_source_configs = _source_configs_with_defaults(config_payload)
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
pmtiles_exists = PMTILES_ARTIFACT_PATH.exists()
|
||||
manifest_exists = BOUNDARY_MANIFEST_PATH.exists()
|
||||
high_precision_ready = (
|
||||
manifest_exists
|
||||
and (
|
||||
(
|
||||
boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
||||
and pmtiles_exists
|
||||
)
|
||||
or boundary_manifest.get("tileProvider") == "geojson-high-precision"
|
||||
)
|
||||
)
|
||||
legacy_exists = LEGACY_GEOJSON_PATH.exists()
|
||||
provider = (
|
||||
boundary_manifest.get("tileProvider")
|
||||
if high_precision_ready
|
||||
else "legacy-geojson" if legacy_exists else "missing"
|
||||
)
|
||||
return {
|
||||
"provider": provider,
|
||||
"high_precision_ready": high_precision_ready,
|
||||
"fallback_available": legacy_exists,
|
||||
"config_source": config_source,
|
||||
"config_path": _relative(LOCAL_CONFIG_PATH),
|
||||
"config_exists": LOCAL_CONFIG_PATH.exists(),
|
||||
"config": config_payload,
|
||||
"effective_default_sources": [
|
||||
source_key
|
||||
for source_key, source_config in effective_source_configs.items()
|
||||
if source_key in DEFAULT_PUBLIC_BOUNDARY_SOURCES
|
||||
and source_config.get("endpoint") == DEFAULT_PUBLIC_BOUNDARY_SOURCES[source_key]["endpoint"]
|
||||
],
|
||||
"manifest": {
|
||||
"path": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"exists": manifest_exists,
|
||||
"tileProvider": boundary_manifest.get("tileProvider"),
|
||||
"buildInputHash": boundary_manifest.get("buildInputHash"),
|
||||
"builtAt": boundary_manifest.get("builtAt"),
|
||||
},
|
||||
"pmtiles": {
|
||||
"path": _relative(PMTILES_ARTIFACT_PATH),
|
||||
"exists": pmtiles_exists,
|
||||
"size_bytes": PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0,
|
||||
},
|
||||
"legacy": {
|
||||
"path": _relative(LEGACY_GEOJSON_PATH),
|
||||
"exists": legacy_exists,
|
||||
"size_bytes": LEGACY_GEOJSON_PATH.stat().st_size if legacy_exists else 0,
|
||||
},
|
||||
"source_manifest": {
|
||||
"path": _relative(SOURCE_MANIFEST_PATH),
|
||||
"exists": SOURCE_MANIFEST_PATH.exists(),
|
||||
},
|
||||
"last_build": _read_json(BUILD_RESULT_PATH),
|
||||
"current_job": _public_job_state(),
|
||||
}
|
||||
|
||||
|
||||
async def _download_source(
|
||||
source_key: str,
|
||||
source_config: dict[str, Any],
|
||||
progress_callback: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
endpoint = str(source_config.get("endpoint") or "").strip()
|
||||
if _is_placeholder_endpoint(endpoint):
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} endpoint is not configured",
|
||||
code="source_not_configured",
|
||||
details={"source": source_key},
|
||||
)
|
||||
method = str(source_config.get("method") or "GET").upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} method must be GET or POST",
|
||||
code="invalid_config",
|
||||
details={"source": source_key, "method": method},
|
||||
)
|
||||
|
||||
if endpoint.startswith("file://") or Path(endpoint).expanduser().exists():
|
||||
payload = Path(endpoint.removeprefix("file://")).expanduser().read_bytes()
|
||||
content_type = "application/octet-stream"
|
||||
if progress_callback:
|
||||
progress_callback(1, len(payload), len(payload))
|
||||
else:
|
||||
timeout = float(source_config.get("timeout") or 120)
|
||||
headers = source_config.get("headers") if isinstance(source_config.get("headers"), dict) else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
async with client.stream(method, endpoint, headers=headers) as response:
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "")
|
||||
total = int(response.headers.get("content-length") or 0)
|
||||
chunks = []
|
||||
downloaded = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
(downloaded / total) if total else None,
|
||||
downloaded,
|
||||
total,
|
||||
)
|
||||
payload = b"".join(chunks)
|
||||
|
||||
extension = _artifact_extension(endpoint, content_type, payload)
|
||||
parsed: Any = None
|
||||
if extension in {".json", ".geojson"}:
|
||||
parsed = json.loads(payload.decode("utf-8"))
|
||||
feature_count = _json_feature_count(parsed)
|
||||
if feature_count <= 0:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} downloaded payload contains no features",
|
||||
code="empty_source",
|
||||
details={"source": source_key},
|
||||
)
|
||||
|
||||
sha256 = _sha256_bytes(payload)
|
||||
source_dir = SOURCE_OUTPUT_DIR / source_key
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_path = source_dir / f"{sha256}{extension}"
|
||||
artifact_path.write_bytes(payload)
|
||||
return {
|
||||
"id": source_key,
|
||||
"kind": source_config.get("sourceKind") or BOUNDARY_SOURCE_KINDS[source_key],
|
||||
"path": _relative(artifact_path),
|
||||
"sha256": sha256,
|
||||
"featureCount": feature_count,
|
||||
"license": source_config.get("license"),
|
||||
}
|
||||
|
||||
|
||||
async def _run_step(args: list[str]) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
*args,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await process.communicate()
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
payload: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "returncode": process.returncode}
|
||||
last_line = stdout.splitlines()[-1:] or []
|
||||
if last_line:
|
||||
try:
|
||||
payload["result"] = json.loads(last_line[0])
|
||||
except json.JSONDecodeError:
|
||||
payload["result"] = last_line[0]
|
||||
if process.returncode != 0:
|
||||
raise EarthBoundaryBuildError(
|
||||
stderr or stdout or f"command failed: {' '.join(args)}",
|
||||
code="build_command_failed",
|
||||
details=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def build_boundary_assets(progress_callback: Any = None) -> dict[str, Any]:
|
||||
config_payload, config_source = load_boundary_config()
|
||||
|
||||
source_configs = _source_configs_with_defaults(config_payload)
|
||||
missing = [source for source in BOUNDARY_SOURCE_KINDS if source not in source_configs]
|
||||
if missing:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"Missing Earth boundary source configs: {', '.join(missing)}",
|
||||
code="missing_sources",
|
||||
details={"missing": missing},
|
||||
)
|
||||
|
||||
sources = []
|
||||
source_keys = list(BOUNDARY_SOURCE_KINDS)
|
||||
for index, source_key in enumerate(source_keys):
|
||||
source_config = source_configs[source_key]
|
||||
if not isinstance(source_config, dict):
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} config must be an object",
|
||||
code="invalid_config",
|
||||
details={"source": source_key},
|
||||
)
|
||||
source_start = 8 + index * 18
|
||||
source_end = source_start + 18
|
||||
if progress_callback:
|
||||
progress_callback(source_start, "download", f"正在下载 {source_key}")
|
||||
|
||||
def report_download_progress(ratio: float | None, downloaded: int, total: int) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
if ratio is None:
|
||||
progress_callback(source_start + 8, "download", f"{source_key} 已下载 {downloaded} bytes")
|
||||
return
|
||||
progress_callback(
|
||||
source_start + (source_end - source_start) * ratio,
|
||||
"download",
|
||||
f"{source_key} 下载 {int(ratio * 100)}%",
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total,
|
||||
)
|
||||
|
||||
sources.append(await _download_source(source_key, source_config, report_download_progress))
|
||||
|
||||
source_manifest = {
|
||||
"schema": "planet-earth-boundary-sources/v2",
|
||||
"sources": sources,
|
||||
"povPolicy": _read_json(POV_POLICY_PATH),
|
||||
}
|
||||
if progress_callback:
|
||||
progress_callback(65, "manifest", "正在写入边界源 manifest")
|
||||
_write_json(SOURCE_MANIFEST_PATH, source_manifest)
|
||||
build_input_hash = _build_input_hash(source_manifest)
|
||||
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash)
|
||||
missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None]
|
||||
if missing_tools and not build_skipped:
|
||||
if progress_callback:
|
||||
progress_callback(82, "build", "缺少 PMTiles 工具,正在生成 GeoJSON 高清包")
|
||||
boundary_manifest = _write_high_precision_geojson_manifest(
|
||||
sources,
|
||||
build_input_hash,
|
||||
missing_tools,
|
||||
)
|
||||
result = {
|
||||
"status": "built_geojson_fallback",
|
||||
"code": "missing_tools",
|
||||
"missing_tools": missing_tools,
|
||||
"sources": sources,
|
||||
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"manifest": boundary_manifest,
|
||||
}
|
||||
_write_json(BUILD_RESULT_PATH, result)
|
||||
if progress_callback:
|
||||
progress_callback(96, "finalize", "GeoJSON 高清国界包已生成")
|
||||
return {**get_boundary_status(), "build": result}
|
||||
|
||||
if build_skipped:
|
||||
if progress_callback:
|
||||
progress_callback(96, "unchanged", "高精国界已是最新")
|
||||
build_result = {
|
||||
"status": "unchanged",
|
||||
"reason": "source manifest and build config hash unchanged",
|
||||
"buildInputHash": build_input_hash,
|
||||
}
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(72, "build", "正在构建 PMTiles/MVT")
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
build_result = await _run_step(
|
||||
[
|
||||
"scripts/build_earth_boundary_pmtiles.py",
|
||||
"--admin0-source",
|
||||
admin0["path"],
|
||||
"--coastline-source",
|
||||
coastline["path"],
|
||||
"--claims-source",
|
||||
claim_lines["path"],
|
||||
"--output",
|
||||
_relative(PMTILES_ARTIFACT_PATH),
|
||||
"--manifest",
|
||||
_relative(BOUNDARY_MANIFEST_PATH),
|
||||
"--build-input-hash",
|
||||
build_input_hash,
|
||||
"--pov-policy",
|
||||
_relative(POV_POLICY_PATH),
|
||||
]
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(95, "finalize", "正在校验构建产物")
|
||||
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR)
|
||||
result = {
|
||||
"status": "unchanged" if build_skipped else "built",
|
||||
"sources": sources,
|
||||
"source_manifest": _relative(SOURCE_MANIFEST_PATH),
|
||||
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"pmtiles_artifact": _relative(PMTILES_ARTIFACT_PATH),
|
||||
"pmtiles_exists": PMTILES_ARTIFACT_PATH.exists(),
|
||||
"boundary_stats": boundary_stats,
|
||||
"manifest": boundary_manifest,
|
||||
"build_result": build_result,
|
||||
}
|
||||
_write_json(BUILD_RESULT_PATH, result)
|
||||
return {**get_boundary_status(), "build": result}
|
||||
|
||||
|
||||
async def _run_boundary_build_job(job_id: str) -> None:
|
||||
def report(progress: float, phase: str, message: str, **extra: Any) -> None:
|
||||
if _build_job_state.get("id") != job_id:
|
||||
return
|
||||
_update_job_progress(progress, phase, message, **extra)
|
||||
|
||||
try:
|
||||
report(3, "prepare", "正在准备高精国界构建")
|
||||
result = await build_boundary_assets(report)
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="succeeded",
|
||||
progress=100,
|
||||
phase="complete",
|
||||
message="高精国界构建完成",
|
||||
finished_at=_utc_now_iso(),
|
||||
result={
|
||||
"provider": result.get("provider"),
|
||||
"high_precision_ready": result.get("high_precision_ready"),
|
||||
"pmtiles": result.get("pmtiles"),
|
||||
"manifest": result.get("manifest"),
|
||||
},
|
||||
)
|
||||
_append_job_log("高精国界构建完成")
|
||||
except EarthBoundaryBuildError as exc:
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="failed",
|
||||
progress=_build_job_state.get("progress", 0),
|
||||
phase="failed",
|
||||
message=str(exc),
|
||||
code=exc.code,
|
||||
details=exc.details,
|
||||
finished_at=_utc_now_iso(),
|
||||
)
|
||||
_append_job_log(str(exc))
|
||||
except Exception as exc: # pragma: no cover - defensive guard for background task
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="failed",
|
||||
progress=_build_job_state.get("progress", 0),
|
||||
phase="failed",
|
||||
message=str(exc),
|
||||
code="build_failed",
|
||||
finished_at=_utc_now_iso(),
|
||||
)
|
||||
_append_job_log(str(exc))
|
||||
|
||||
|
||||
async def start_boundary_build_job() -> dict[str, Any]:
|
||||
global _build_task
|
||||
async with _build_job_lock:
|
||||
if _build_task and not _build_task.done():
|
||||
return {"accepted": False, "job": _public_job_state()}
|
||||
job_id = uuid4().hex
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="queued",
|
||||
progress=0,
|
||||
phase="queued",
|
||||
message="高精国界构建已加入队列",
|
||||
logs=[],
|
||||
started_at=_utc_now_iso(),
|
||||
finished_at=None,
|
||||
code=None,
|
||||
details=None,
|
||||
)
|
||||
_append_job_log("高精国界构建已启动")
|
||||
_build_task = asyncio.create_task(_run_boundary_build_job(job_id))
|
||||
return {"accepted": True, "job": _public_job_state()}
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -13,6 +15,13 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country
|
||||
from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
|
||||
|
||||
|
||||
USER_AGENT = "PlanetEarthNewsBoard/1.0 (+https://planet.local)"
|
||||
@@ -20,6 +29,11 @@ REQUEST_TIMEOUT = 12.0
|
||||
MAX_ITEMS_PER_SOURCE = 6
|
||||
MAX_ITEMS_TOTAL = 12
|
||||
STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS
|
||||
MAX_TARGET_INFERENCE_CONCURRENCY = 3
|
||||
TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0
|
||||
DEFAULT_NEWS_LOCALE = "zh-CN"
|
||||
NEWS_ENRICH_PROMPT_KEY = "earth.news.enrich"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -49,6 +63,17 @@ class NewsFeedSource:
|
||||
priority: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsTargetLocation:
|
||||
latitude: float
|
||||
longitude: float
|
||||
label: str
|
||||
source: str
|
||||
confidence: float | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedNewsItem:
|
||||
id: str
|
||||
@@ -60,6 +85,18 @@ class ParsedNewsItem:
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
content_language: str = "en"
|
||||
localizations: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
enrichment_status: str = "pending"
|
||||
enrichment_error: str | None = None
|
||||
enriched_at: datetime | None = None
|
||||
target_location: NewsTargetLocation | None = None
|
||||
target_resolution_stage: str = "unresolved"
|
||||
target_ai_attempted: bool = False
|
||||
target_ai_status: str = "not_attempted"
|
||||
target_ai_error: str | None = None
|
||||
target_debug_note: str | None = None
|
||||
location_patch: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -236,6 +273,17 @@ NEWS_FEED_SOURCES: tuple[NewsFeedSource, ...] = (
|
||||
|
||||
|
||||
_REGION_CACHE: dict[str, CachedRegionFeed] = {}
|
||||
_news_target_geocode = build_default_nominatim_geocoder(user_agent=USER_AGENT)
|
||||
_CITY_HINTS: tuple[dict[str, str | None], ...] = (
|
||||
{"name": "Beijing", "country": "中国"},
|
||||
{"name": "Havana", "country": "古巴"},
|
||||
{"name": "Kyiv", "country": "乌克兰"},
|
||||
{"name": "Bangkok", "country": "泰国"},
|
||||
{"name": "Tehran", "country": "伊朗"},
|
||||
{"name": "Moscow", "country": "俄罗斯"},
|
||||
{"name": "Taipei", "country": "中国(台湾)"},
|
||||
{"name": "Hong Kong", "country": "中国(香港)"},
|
||||
)
|
||||
|
||||
|
||||
def determine_focus_region(lat: float | None, lon: float | None) -> str:
|
||||
@@ -258,6 +306,461 @@ def get_region_anchor(region: str) -> RegionAnchor:
|
||||
return REGION_ANCHORS.get(region, REGION_ANCHORS["global"])
|
||||
|
||||
|
||||
def _coerce_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
cleaned = re.sub(r"\s+", " ", value).strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _contains_location_alias(text: str, alias: str) -> bool:
|
||||
normalized_alias = _coerce_str(alias)
|
||||
if not normalized_alias:
|
||||
return False
|
||||
if re.search(r"[A-Za-z]", normalized_alias):
|
||||
pattern = r"(?<![A-Za-z])" + re.escape(normalized_alias) + r"(?![A-Za-z])"
|
||||
return re.search(pattern, text, flags=re.IGNORECASE) is not None
|
||||
return normalized_alias in text
|
||||
|
||||
|
||||
def _iter_searchable_country_variants(
|
||||
canonical: str,
|
||||
variants: list[str],
|
||||
) -> tuple[str, ...]:
|
||||
searchable: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for variant in (canonical, *variants):
|
||||
normalized = _coerce_str(variant)
|
||||
if not normalized:
|
||||
continue
|
||||
if re.fullmatch(r"[A-Z]{2,3}", normalized):
|
||||
continue
|
||||
if len(normalized) <= 2:
|
||||
continue
|
||||
key = normalized.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
searchable.append(normalized)
|
||||
return tuple(searchable)
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(parsed):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _first_json_object(text: str) -> dict[str, Any] | None:
|
||||
if not text:
|
||||
return None
|
||||
decoder = json.JSONDecoder()
|
||||
for index, char in enumerate(text):
|
||||
if char != "{":
|
||||
continue
|
||||
try:
|
||||
payload, _ = decoder.raw_decode(text[index:])
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_localizations(value: Any) -> dict[str, dict[str, str]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
normalized: dict[str, dict[str, str]] = {}
|
||||
for locale, payload in value.items():
|
||||
locale_key = _coerce_str(locale)
|
||||
if not locale_key or not isinstance(payload, dict):
|
||||
continue
|
||||
title = _coerce_str(payload.get("title"))
|
||||
summary = _coerce_str(payload.get("summary"))
|
||||
entry: dict[str, str] = {}
|
||||
if title:
|
||||
entry["title"] = title
|
||||
if summary:
|
||||
entry["summary"] = summary
|
||||
if entry:
|
||||
normalized[locale_key] = entry
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_locale_text(
|
||||
item: ParsedNewsItem,
|
||||
key: str,
|
||||
*,
|
||||
locale: str = DEFAULT_NEWS_LOCALE,
|
||||
) -> str:
|
||||
localized = item.localizations.get(locale)
|
||||
if isinstance(localized, dict):
|
||||
value = _coerce_str(localized.get(key))
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _has_default_localization(item: ParsedNewsItem) -> bool:
|
||||
localized = item.localizations.get(DEFAULT_NEWS_LOCALE)
|
||||
if not isinstance(localized, dict):
|
||||
return False
|
||||
return bool(_coerce_str(localized.get("title")) and _coerce_str(localized.get("summary")))
|
||||
|
||||
|
||||
def apply_enrichment_patch_to_item(
|
||||
item: ParsedNewsItem,
|
||||
patch: dict[str, Any],
|
||||
) -> ParsedNewsItem:
|
||||
item.location_patch = patch
|
||||
if "content_language" in patch:
|
||||
item.content_language = _coerce_str(patch.get("content_language")) or item.content_language
|
||||
if "localizations" in patch:
|
||||
item.localizations = _normalize_localizations(patch.get("localizations"))
|
||||
if "enrichment_status" in patch:
|
||||
item.enrichment_status = _coerce_str(patch.get("enrichment_status")) or item.enrichment_status
|
||||
if "enrichment_error" in patch:
|
||||
item.enrichment_error = _coerce_str(patch.get("enrichment_error"))
|
||||
if "enriched_at" in patch:
|
||||
item.enriched_at = _parse_datetime(_coerce_str(patch.get("enriched_at")))
|
||||
return item
|
||||
|
||||
|
||||
async def _geocode_target_location(query: str) -> dict[str, Any] | None:
|
||||
return await asyncio.to_thread(_news_target_geocode, query)
|
||||
|
||||
|
||||
async def _build_target_location_from_payload(
|
||||
payload: dict[str, Any],
|
||||
) -> NewsTargetLocation | None:
|
||||
country = normalize_country(payload.get("country"))
|
||||
city = _coerce_str(payload.get("city"))
|
||||
matched_location_name = _coerce_str(payload.get("matched_location_name"))
|
||||
confidence = _coerce_float(payload.get("confidence"))
|
||||
if confidence is not None:
|
||||
confidence = max(0.0, min(confidence, 1.0))
|
||||
|
||||
latitude = _coerce_float(payload.get("latitude"))
|
||||
longitude = _coerce_float(payload.get("longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
label = matched_location_name or ", ".join(part for part in (city, country) if part) or "关联位置"
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=label,
|
||||
source="ai_inferred_target",
|
||||
confidence=confidence,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
|
||||
geocode_queries: list[str] = []
|
||||
for value in (
|
||||
", ".join(part for part in (city, country) if part),
|
||||
matched_location_name,
|
||||
city,
|
||||
country,
|
||||
):
|
||||
normalized = _coerce_str(value)
|
||||
if normalized and normalized not in geocode_queries:
|
||||
geocode_queries.append(normalized)
|
||||
|
||||
for query in geocode_queries:
|
||||
try:
|
||||
result = await _geocode_target_location(query)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
latitude = _coerce_float(result.get("lat"))
|
||||
longitude = _coerce_float(result.get("lon"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
label = (
|
||||
_coerce_str(result.get("display_name"))
|
||||
or matched_location_name
|
||||
or ", ".join(part for part in (city, country) if part)
|
||||
or query
|
||||
)
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=label,
|
||||
source="ai_inferred_target",
|
||||
confidence=confidence,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
|
||||
centroid = get_country_centroid(country)
|
||||
if centroid:
|
||||
label = matched_location_name or city or country or "关联位置"
|
||||
return NewsTargetLocation(
|
||||
latitude=centroid["latitude"],
|
||||
longitude=centroid["longitude"],
|
||||
label=label,
|
||||
source="ai_inferred_target",
|
||||
confidence=confidence,
|
||||
country=country,
|
||||
city=city,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _extract_target_location_from_text(item: ParsedNewsItem) -> NewsTargetLocation | None:
|
||||
combined_text = " ".join(part for part in (item.title, item.summary) if part).strip()
|
||||
if not combined_text:
|
||||
return None
|
||||
|
||||
for hint in _CITY_HINTS:
|
||||
city_name = _coerce_str(hint.get("name"))
|
||||
if not city_name or not _contains_location_alias(combined_text, city_name):
|
||||
continue
|
||||
country = normalize_country(hint.get("country"))
|
||||
geocode_query = ", ".join(part for part in (city_name, country) if part)
|
||||
try:
|
||||
result = await _geocode_target_location(geocode_query)
|
||||
except Exception:
|
||||
result = None
|
||||
if isinstance(result, dict):
|
||||
latitude = _coerce_float(result.get("lat"))
|
||||
longitude = _coerce_float(result.get("lon"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return NewsTargetLocation(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
label=_coerce_str(result.get("display_name")) or geocode_query,
|
||||
source="headline_location_hint",
|
||||
confidence=0.78,
|
||||
country=country,
|
||||
city=city_name,
|
||||
)
|
||||
centroid = get_country_centroid(country)
|
||||
if centroid:
|
||||
return NewsTargetLocation(
|
||||
latitude=centroid["latitude"],
|
||||
longitude=centroid["longitude"],
|
||||
label=geocode_query,
|
||||
source="headline_location_hint",
|
||||
confidence=0.68,
|
||||
country=country,
|
||||
city=city_name,
|
||||
)
|
||||
|
||||
for canonical, variants in COUNTRY_VARIANTS_MAP.items():
|
||||
if not get_country_centroid(canonical):
|
||||
continue
|
||||
searchable_variants = _iter_searchable_country_variants(canonical, variants)
|
||||
if not any(_contains_location_alias(combined_text, variant) for variant in searchable_variants):
|
||||
continue
|
||||
centroid = get_country_centroid(canonical)
|
||||
if not centroid:
|
||||
continue
|
||||
return NewsTargetLocation(
|
||||
latitude=centroid["latitude"],
|
||||
longitude=centroid["longitude"],
|
||||
label=canonical,
|
||||
source="headline_country_hint",
|
||||
confidence=0.62,
|
||||
country=canonical,
|
||||
city=None,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _infer_news_target_location(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> NewsTargetLocation | None:
|
||||
target, _localizations = await _infer_news_enrichment(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
async def _infer_news_enrichment(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> tuple[NewsTargetLocation | None, dict[str, dict[str, str]]]:
|
||||
text_hint = await _extract_target_location_from_text(item)
|
||||
content_error: str | None = None
|
||||
if text_hint is not None and text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "skipped_text_hint"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"text hint matched {text_hint.label}"
|
||||
localizations: dict[str, dict[str, str]] = {}
|
||||
|
||||
if provider_client is None:
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "unavailable"
|
||||
item.target_ai_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
item.enrichment_status = "unavailable"
|
||||
item.enrichment_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
return text_hint, localizations
|
||||
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_ai_attempted = True
|
||||
item.target_ai_status = "attempted"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
item.enrichment_status = "attempted"
|
||||
item.enrichment_error = None
|
||||
|
||||
prompt = prompt or await get_effective_prompt(None, NEWS_ENRICH_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title="Enrich Earth news item with event location and zh-CN content",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"news_item": {
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"feed_region": item.feed_region,
|
||||
"url": item.url,
|
||||
"published_at": (
|
||||
item.published_at.isoformat().replace("+00:00", "Z")
|
||||
if item.published_at
|
||||
else None
|
||||
),
|
||||
},
|
||||
"required_json_schema": {
|
||||
"location": {
|
||||
"country": "string|null",
|
||||
"city": "string|null",
|
||||
"matched_location_name": "string|null",
|
||||
"latitude": "number|null",
|
||||
"longitude": "number|null",
|
||||
"confidence": "number from 0 to 1",
|
||||
"reasoning_summary": "short string",
|
||||
},
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"title": "faithful Simplified Chinese title",
|
||||
"summary": "1-2 sentence faithful Simplified Chinese summary",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
constraints=[
|
||||
"Return only strict JSON. Do not wrap it in markdown.",
|
||||
"For localizations, do not add facts that are absent from the RSS headline, description, source, or date.",
|
||||
"If the RSS description is thin, write a conservative summary that says only what is supported.",
|
||||
"Keep zh-CN summary concise, factual, and non-promotional.",
|
||||
"Prefer the event location, not the newsroom or publisher headquarters.",
|
||||
"When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.",
|
||||
"Use null for unknown fields instead of inventing details.",
|
||||
"Calibrate confidence conservatively: 0.75+ only when the city is strongly supported, 0.55-0.74 for country-level or likely city inference, below 0.55 when weak.",
|
||||
],
|
||||
)
|
||||
try:
|
||||
response = await provider_client.analyze(request)
|
||||
except Exception as exc:
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "provider_error"
|
||||
item.target_ai_error = str(exc)
|
||||
item.enrichment_status = "provider_error"
|
||||
item.enrichment_error = str(exc)
|
||||
return text_hint, localizations
|
||||
|
||||
payload = _first_json_object(response.content)
|
||||
if not isinstance(payload, dict):
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "parse_error"
|
||||
item.target_ai_error = "AI response did not contain a parseable JSON object."
|
||||
item.enrichment_status = "parse_error"
|
||||
item.enrichment_error = "AI response did not contain a parseable JSON object."
|
||||
return text_hint, localizations
|
||||
|
||||
localizations = _normalize_localizations(payload.get("localizations"))
|
||||
if not localizations:
|
||||
content_error = "AI returned no usable localizations."
|
||||
|
||||
location_payload = payload.get("location") if isinstance(payload.get("location"), dict) else payload
|
||||
if text_hint is not None and text_hint.city:
|
||||
target = text_hint
|
||||
else:
|
||||
target = await _build_target_location_from_payload(location_payload)
|
||||
if target is None:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "no_result"
|
||||
item.target_ai_error = "AI returned no usable target coordinates or geocodeable location."
|
||||
target = text_hint
|
||||
elif target.confidence is not None and target.confidence < 0.45:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "low_confidence"
|
||||
item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}"
|
||||
target = text_hint
|
||||
else:
|
||||
item.target_resolution_stage = target.source
|
||||
item.target_ai_status = "success"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"ai inferred {target.label}"
|
||||
|
||||
item.localizations = localizations
|
||||
if localizations and item.target_ai_status in {"success", "skipped_text_hint"}:
|
||||
item.enrichment_status = "success"
|
||||
item.enrichment_error = None
|
||||
elif localizations:
|
||||
item.enrichment_status = "content_only"
|
||||
item.enrichment_error = item.target_ai_error
|
||||
else:
|
||||
item.enrichment_status = "location_only" if target is not None else "no_result"
|
||||
item.enrichment_error = content_error or item.target_ai_error
|
||||
item.enriched_at = datetime.now(UTC) if localizations else None
|
||||
return target, localizations
|
||||
|
||||
|
||||
async def _enrich_items_with_target_locations(
|
||||
items: list[ParsedNewsItem],
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> list[ParsedNewsItem]:
|
||||
if not items:
|
||||
return items
|
||||
|
||||
semaphore = asyncio.Semaphore(MAX_TARGET_INFERENCE_CONCURRENCY)
|
||||
|
||||
async def enrich(item: ParsedNewsItem) -> ParsedNewsItem:
|
||||
async with semaphore:
|
||||
target = await _infer_news_target_location(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
item.target_location = target
|
||||
return item
|
||||
|
||||
return list(await asyncio.gather(*(enrich(item) for item in items)))
|
||||
|
||||
|
||||
def get_sources_for_region(region: str) -> list[NewsFeedSource]:
|
||||
return sorted(
|
||||
[source for source in NEWS_FEED_SOURCES if source.region in {"global", region}],
|
||||
@@ -385,23 +888,170 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
def _serialize_anchor(anchor: RegionAnchor) -> dict[str, Any]:
|
||||
return {
|
||||
"region": anchor.region,
|
||||
"label": anchor.label,
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | None:
|
||||
if target is None:
|
||||
return None
|
||||
return {
|
||||
"latitude": target.latitude,
|
||||
"longitude": target.longitude,
|
||||
"label": target.label,
|
||||
"source": target.source,
|
||||
"confidence": target.confidence,
|
||||
"country": target.country,
|
||||
"city": target.city,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_enriched_at(value: datetime | None) -> str | None:
|
||||
return value.isoformat().replace("+00:00", "Z") if value else None
|
||||
|
||||
|
||||
def _content_patch(item: ParsedNewsItem) -> dict[str, Any]:
|
||||
return {
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
}
|
||||
|
||||
|
||||
def build_anchor_location_patch(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
queued: bool = False,
|
||||
queue_available: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
anchor = get_region_anchor(item.feed_region)
|
||||
if queued:
|
||||
resolution_stage = "queued"
|
||||
ai_status = "queued"
|
||||
debug_note = "queued for async target location inference"
|
||||
else:
|
||||
resolution_stage = item.target_resolution_stage
|
||||
ai_status = item.target_ai_status
|
||||
debug_note = item.target_debug_note
|
||||
content_patch = _content_patch(item)
|
||||
if queued and content_patch["enrichment_status"] == "pending":
|
||||
content_patch["enrichment_status"] = "queued"
|
||||
return {
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
"location_label": anchor.label,
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {
|
||||
"resolution_stage": resolution_stage,
|
||||
"ai_attempted": item.target_ai_attempted,
|
||||
"ai_status": ai_status,
|
||||
"ai_error": item.target_ai_error,
|
||||
"debug_note": debug_note,
|
||||
"queue_available": queue_available,
|
||||
"target": None,
|
||||
"anchor": _serialize_anchor(anchor),
|
||||
},
|
||||
**content_patch,
|
||||
}
|
||||
|
||||
|
||||
def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation | None) -> dict[str, Any]:
|
||||
if target is None:
|
||||
return build_anchor_location_patch(item)
|
||||
anchor = get_region_anchor(item.feed_region)
|
||||
return {
|
||||
"latitude": target.latitude,
|
||||
"longitude": target.longitude,
|
||||
"location_label": target.label,
|
||||
"location_source": target.source,
|
||||
"verified": True,
|
||||
"location_meta": {
|
||||
"resolution_stage": item.target_resolution_stage,
|
||||
"ai_attempted": item.target_ai_attempted,
|
||||
"ai_status": item.target_ai_status,
|
||||
"ai_error": item.target_ai_error,
|
||||
"debug_note": item.target_debug_note,
|
||||
"target": _serialize_target(target),
|
||||
"anchor": _serialize_anchor(anchor),
|
||||
},
|
||||
**_content_patch(item),
|
||||
}
|
||||
|
||||
|
||||
def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"feed_region": item.feed_region,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
}
|
||||
|
||||
|
||||
def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
id=str(payload.get("id") or ""),
|
||||
title=str(payload.get("title") or ""),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
content_language=str(payload.get("content_language") or "en"),
|
||||
localizations=_normalize_localizations(payload.get("localizations")),
|
||||
enrichment_status=str(payload.get("enrichment_status") or "pending"),
|
||||
enrichment_error=_coerce_str(payload.get("enrichment_error")),
|
||||
enriched_at=_parse_datetime(_coerce_str(payload.get("enriched_at"))),
|
||||
url=str(payload.get("url") or ""),
|
||||
source=str(payload.get("source") or ""),
|
||||
feed_name=str(payload.get("feed_name") or ""),
|
||||
feed_region=str(payload.get("feed_region") or "global"),
|
||||
homepage_url=str(payload.get("homepage_url") or ""),
|
||||
published_at=_parse_datetime(_coerce_str(payload.get("published_at"))),
|
||||
)
|
||||
|
||||
|
||||
def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]:
|
||||
published_at = item.published_at
|
||||
location_patch = item.location_patch or build_target_location_patch(item, item.target_location)
|
||||
return {
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"display_title": _get_locale_text(item, "title"),
|
||||
"display_summary": _get_locale_text(item, "summary"),
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"display_region": get_region_anchor(item.feed_region).label,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
"location_label": anchor.label,
|
||||
"location_inferred": True,
|
||||
"latitude": location_patch["latitude"],
|
||||
"longitude": location_patch["longitude"],
|
||||
"location_label": location_patch["location_label"],
|
||||
"location_source": location_patch["location_source"],
|
||||
"verified": location_patch["verified"],
|
||||
"location_meta": location_patch["location_meta"],
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
@@ -426,6 +1076,7 @@ def _build_payload(
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"display_region": get_region_anchor(active_region).label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
@@ -472,6 +1123,68 @@ def _store_region_cache(region: str, *, items: list[ParsedNewsItem], sources: li
|
||||
)
|
||||
|
||||
|
||||
async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> list[ParsedNewsItem]:
|
||||
if not items:
|
||||
return items
|
||||
|
||||
from app.services.earth_news_queue import (
|
||||
enqueue_target_location_job,
|
||||
get_cached_target_location_patch,
|
||||
)
|
||||
|
||||
async def enqueue_item(item: ParsedNewsItem, *, force: bool = False) -> bool:
|
||||
return await enqueue_target_location_job(build_target_location_job_payload(item), force=force)
|
||||
|
||||
async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem:
|
||||
cached_patch = await get_cached_target_location_patch(item.id)
|
||||
if cached_patch:
|
||||
apply_enrichment_patch_to_item(item, cached_patch)
|
||||
if not _has_default_localization(item):
|
||||
queued = await enqueue_item(item, force=True)
|
||||
if queued and item.enrichment_status in {
|
||||
"pending",
|
||||
"unavailable",
|
||||
"provider_error",
|
||||
"parse_error",
|
||||
"no_result",
|
||||
"location_only",
|
||||
}:
|
||||
item.enrichment_status = "queued"
|
||||
return item
|
||||
|
||||
queued = await enqueue_item(item)
|
||||
item.location_patch = build_anchor_location_patch(
|
||||
item,
|
||||
queued=queued,
|
||||
queue_available=queued,
|
||||
)
|
||||
return item
|
||||
|
||||
return list(await asyncio.gather(*(apply_location(item) for item in items)))
|
||||
|
||||
|
||||
async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None:
|
||||
if not items:
|
||||
return
|
||||
|
||||
from app.services.earth_news_queue import enqueue_target_location_job
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
enqueue_target_location_job(
|
||||
build_target_location_job_payload(item),
|
||||
force=not _has_default_localization(item),
|
||||
)
|
||||
for item in items
|
||||
if (
|
||||
item.location_patch is None
|
||||
or item.location_patch.get("verified") is False
|
||||
or not _has_default_localization(item)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_source(
|
||||
client: httpx.AsyncClient,
|
||||
source: NewsFeedSource,
|
||||
@@ -484,11 +1197,10 @@ async def _fetch_source(
|
||||
return source, [], str(exc)
|
||||
|
||||
|
||||
async def get_earth_news_payload(lat: float | None = None, lon: float | None = None) -> dict[str, Any]:
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
async def _fetch_rss_items_for_sources(
|
||||
sources: list[NewsFeedSource],
|
||||
) -> tuple[list[ParsedNewsItem], list[str]]:
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
@@ -502,9 +1214,29 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
|
||||
errors.append(f"{source.name}: {error}")
|
||||
continue
|
||||
fetched_items.extend(items)
|
||||
return fetched_items, errors
|
||||
|
||||
|
||||
def _needs_rss_supplement(*, item_count: int, newest_at: datetime | None) -> bool:
|
||||
if item_count < MAX_ITEMS_TOTAL:
|
||||
return True
|
||||
if newest_at is None:
|
||||
return True
|
||||
age_seconds = (datetime.now(UTC) - newest_at).total_seconds()
|
||||
return age_seconds > RSS_SUPPLEMENT_MAX_AGE_SECONDS
|
||||
|
||||
|
||||
async def _get_earth_news_payload_from_rss_only(
|
||||
*,
|
||||
lat: float | None,
|
||||
lon: float | None,
|
||||
active_region: str,
|
||||
sources: list[NewsFeedSource],
|
||||
) -> dict[str, Any]:
|
||||
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
|
||||
ranked_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
if ranked_items:
|
||||
ranked_items = await _apply_cached_locations_and_enqueue(ranked_items)
|
||||
_store_region_cache(active_region, items=ranked_items, sources=sources)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
@@ -518,6 +1250,7 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
|
||||
|
||||
cached = _get_cached_region_feed(active_region)
|
||||
if cached:
|
||||
cached.items = await _apply_cached_locations_and_enqueue(cached.items)
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
@@ -538,3 +1271,55 @@ async def get_earth_news_payload(lat: float | None = None, lon: float | None = N
|
||||
errors=errors,
|
||||
stale=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_earth_news_payload(
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
*,
|
||||
provider_client: AIProviderClient | None = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del provider_client
|
||||
active_region = determine_focus_region(lat, lon)
|
||||
sources = get_sources_for_region(active_region)
|
||||
|
||||
if db is None:
|
||||
return await _get_earth_news_payload_from_rss_only(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
sources=sources,
|
||||
)
|
||||
|
||||
from app.services.earth_news_store import (
|
||||
get_earth_news_freshness,
|
||||
list_earth_news_items,
|
||||
upsert_earth_news_items,
|
||||
)
|
||||
|
||||
errors: list[str] = []
|
||||
item_count, newest_at = await get_earth_news_freshness(db, active_region=active_region)
|
||||
should_supplement = _needs_rss_supplement(item_count=item_count, newest_at=newest_at)
|
||||
if should_supplement:
|
||||
fetched_items, errors = await _fetch_rss_items_for_sources(sources)
|
||||
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
|
||||
await upsert_earth_news_items(db, ranked_fetched_items)
|
||||
|
||||
items = await list_earth_news_items(
|
||||
db,
|
||||
active_region=active_region,
|
||||
limit=MAX_ITEMS_TOTAL,
|
||||
)
|
||||
await _enqueue_unverified_locations(items)
|
||||
stale = bool(errors and items)
|
||||
|
||||
return _build_payload(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
active_region=active_region,
|
||||
items=items,
|
||||
sources=sources,
|
||||
errors=errors,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
234
backend/app/services/earth_news_queue.py
Normal file
234
backend/app/services/earth_news_queue.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Protocol
|
||||
|
||||
import redis.asyncio as redis
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="earth_news")
|
||||
|
||||
TARGET_LOCATION_STREAM = "earth_news:target_location:jobs"
|
||||
TARGET_LOCATION_GROUP = "earth_news_target_location"
|
||||
TARGET_LOCATION_DEAD_LETTER_STREAM = "earth_news:target_location:dead"
|
||||
TARGET_LOCATION_RESULT_TTL_SECONDS = 60 * 60 * 12
|
||||
TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS = 60 * 60 * 6
|
||||
TARGET_LOCATION_MAX_ATTEMPTS = 3
|
||||
|
||||
_redis_client: redis.Redis | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NewsTargetLocationMessage:
|
||||
message_id: str
|
||||
item_id: str
|
||||
payload: dict[str, Any]
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
class NewsTargetLocationQueue(Protocol):
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
...
|
||||
|
||||
async def consume_batch(
|
||||
self,
|
||||
*,
|
||||
consumer_name: str,
|
||||
count: int,
|
||||
block_ms: int,
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
...
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
...
|
||||
|
||||
async def retry_or_dead_letter(
|
||||
self,
|
||||
message: NewsTargetLocationMessage,
|
||||
*,
|
||||
error: str,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
|
||||
def _get_redis_client() -> redis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
return _redis_client
|
||||
|
||||
|
||||
def _result_key(item_id: str) -> str:
|
||||
return f"earth_news:target_location:result:{item_id}"
|
||||
|
||||
|
||||
def _queued_key(item_id: str) -> str:
|
||||
return f"earth_news:target_location:queued:{item_id}"
|
||||
|
||||
|
||||
class RedisStreamsNewsTargetLocationQueue:
|
||||
def __init__(self, client: redis.Redis | None = None) -> None:
|
||||
self.client = client or _get_redis_client()
|
||||
self._group_ready = False
|
||||
|
||||
async def _ensure_group(self) -> None:
|
||||
if self._group_ready:
|
||||
return
|
||||
try:
|
||||
await self.client.xgroup_create(
|
||||
TARGET_LOCATION_STREAM,
|
||||
TARGET_LOCATION_GROUP,
|
||||
id="0",
|
||||
mkstream=True,
|
||||
)
|
||||
except ResponseError as exc:
|
||||
if "BUSYGROUP" not in str(exc):
|
||||
raise
|
||||
self._group_ready = True
|
||||
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
await self._ensure_group()
|
||||
if force:
|
||||
await self.client.delete(_result_key(item_id), _queued_key(item_id))
|
||||
elif await self.client.exists(_result_key(item_id)):
|
||||
return False
|
||||
queued = await self.client.set(
|
||||
_queued_key(item_id),
|
||||
"1",
|
||||
nx=True,
|
||||
ex=TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS,
|
||||
)
|
||||
if not queued:
|
||||
return bool(await self.client.exists(_queued_key(item_id)))
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_STREAM,
|
||||
{
|
||||
"item_id": item_id,
|
||||
"attempts": "0",
|
||||
"payload": json.dumps(payload, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
async def consume_batch(
|
||||
self,
|
||||
*,
|
||||
consumer_name: str,
|
||||
count: int,
|
||||
block_ms: int,
|
||||
) -> list[NewsTargetLocationMessage]:
|
||||
await self._ensure_group()
|
||||
streams = await self.client.xreadgroup(
|
||||
TARGET_LOCATION_GROUP,
|
||||
consumer_name,
|
||||
{TARGET_LOCATION_STREAM: ">"},
|
||||
count=count,
|
||||
block=block_ms,
|
||||
)
|
||||
messages: list[NewsTargetLocationMessage] = []
|
||||
for _stream_name, stream_messages in streams:
|
||||
for message_id, fields in stream_messages:
|
||||
raw_payload = fields.get("payload")
|
||||
item_id = fields.get("item_id")
|
||||
if not raw_payload or not item_id:
|
||||
await self.ack(message_id)
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw_payload)
|
||||
except json.JSONDecodeError:
|
||||
await self.ack(message_id)
|
||||
continue
|
||||
attempts = int(fields.get("attempts") or 0)
|
||||
messages.append(
|
||||
NewsTargetLocationMessage(
|
||||
message_id=message_id,
|
||||
item_id=item_id,
|
||||
payload=payload,
|
||||
attempts=attempts,
|
||||
)
|
||||
)
|
||||
return messages
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
await self.client.xack(TARGET_LOCATION_STREAM, TARGET_LOCATION_GROUP, message_id)
|
||||
|
||||
async def retry_or_dead_letter(
|
||||
self,
|
||||
message: NewsTargetLocationMessage,
|
||||
*,
|
||||
error: str,
|
||||
) -> None:
|
||||
await self.ack(message.message_id)
|
||||
if message.attempts + 1 >= TARGET_LOCATION_MAX_ATTEMPTS:
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_DEAD_LETTER_STREAM,
|
||||
{
|
||||
"item_id": message.item_id,
|
||||
"attempts": str(message.attempts + 1),
|
||||
"error": error,
|
||||
"payload": json.dumps(message.payload, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
return
|
||||
await self.client.xadd(
|
||||
TARGET_LOCATION_STREAM,
|
||||
{
|
||||
"item_id": message.item_id,
|
||||
"attempts": str(message.attempts + 1),
|
||||
"payload": json.dumps(message.payload, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_news_target_location_queue() -> NewsTargetLocationQueue:
|
||||
return RedisStreamsNewsTargetLocationQueue()
|
||||
|
||||
|
||||
async def enqueue_target_location_job(payload: dict[str, Any], *, force: bool = False) -> bool:
|
||||
item_id = str(payload.get("id") or "")
|
||||
if not item_id:
|
||||
return False
|
||||
try:
|
||||
queue = get_news_target_location_queue()
|
||||
return await queue.enqueue(item_id=item_id, payload=payload, force=force)
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to enqueue Earth news target location job",
|
||||
event="earth_news.target_location.enqueue_failed",
|
||||
context={"item_id": item_id, "error": str(exc)},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def get_cached_target_location_patch(item_id: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
raw_value = await _get_redis_client().get(_result_key(item_id))
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to read Earth news target location cache",
|
||||
event="earth_news.target_location.cache_read_failed",
|
||||
context={"item_id": item_id, "error": str(exc)},
|
||||
)
|
||||
return None
|
||||
if not raw_value:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(raw_value)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def save_target_location_patch(item_id: str, patch: dict[str, Any]) -> None:
|
||||
client = _get_redis_client()
|
||||
await client.setex(
|
||||
_result_key(item_id),
|
||||
TARGET_LOCATION_RESULT_TTL_SECONDS,
|
||||
json.dumps(patch, ensure_ascii=False),
|
||||
)
|
||||
await client.delete(_queued_key(item_id))
|
||||
244
backend/app/services/earth_news_store.py
Normal file
244
backend/app/services/earth_news_store.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.services.earth_news import (
|
||||
ParsedNewsItem,
|
||||
apply_enrichment_patch_to_item,
|
||||
build_anchor_location_patch,
|
||||
)
|
||||
|
||||
|
||||
def _coerce_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": record.latitude,
|
||||
"longitude": record.longitude,
|
||||
"location_label": record.location_label,
|
||||
"location_source": record.location_source,
|
||||
"verified": record.verified,
|
||||
"location_meta": dict(record.location_meta or {}),
|
||||
}
|
||||
|
||||
|
||||
def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem:
|
||||
item = ParsedNewsItem(
|
||||
id=record.id,
|
||||
title=record.title,
|
||||
summary=record.summary or "",
|
||||
url=record.url,
|
||||
source=record.source or "",
|
||||
feed_name=record.feed_name or "",
|
||||
feed_region=record.region or "global",
|
||||
homepage_url=record.homepage_url or "",
|
||||
published_at=_coerce_datetime(record.published_at),
|
||||
content_language=record.content_language or "en",
|
||||
localizations=dict(record.localizations or {}),
|
||||
enrichment_status=record.enrichment_status or "pending",
|
||||
enrichment_error=record.enrichment_error,
|
||||
enriched_at=_coerce_datetime(record.enriched_at),
|
||||
)
|
||||
return apply_enrichment_patch_to_item(item, _location_patch_from_record(record))
|
||||
|
||||
|
||||
def _query_sort_key(active_region: str):
|
||||
return (
|
||||
EarthNewsItem.region != active_region,
|
||||
EarthNewsItem.published_at.is_(None),
|
||||
EarthNewsItem.published_at.desc().nullslast(),
|
||||
EarthNewsItem.feed_name.asc(),
|
||||
)
|
||||
|
||||
|
||||
async def list_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
active_region: str,
|
||||
limit: int,
|
||||
) -> list[ParsedNewsItem]:
|
||||
regions = {"global", active_region}
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem)
|
||||
.where(EarthNewsItem.region.in_(regions))
|
||||
.order_by(*_query_sort_key(active_region))
|
||||
.limit(limit)
|
||||
)
|
||||
return [record_to_parsed_news_item(record) for record in result.scalars().all()]
|
||||
|
||||
|
||||
async def get_earth_news_freshness(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
active_region: str,
|
||||
) -> tuple[int, datetime | None]:
|
||||
regions = {"global", active_region}
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(EarthNewsItem.id),
|
||||
func.max(func.coalesce(EarthNewsItem.published_at, EarthNewsItem.last_seen_at)),
|
||||
).where(EarthNewsItem.region.in_(regions))
|
||||
)
|
||||
count, newest = result.one()
|
||||
item_count = int(count or 0)
|
||||
if item_count == 0:
|
||||
return 0, None
|
||||
return item_count, _coerce_datetime(newest)
|
||||
|
||||
|
||||
async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem]) -> int:
|
||||
if not items:
|
||||
return 0
|
||||
now = datetime.now(UTC)
|
||||
existing_result = await db.execute(
|
||||
select(EarthNewsItem).where(EarthNewsItem.id.in_([item.id for item in items]))
|
||||
)
|
||||
existing = {record.id: record for record in existing_result.scalars().all()}
|
||||
changed = 0
|
||||
for item in items:
|
||||
record = existing.get(item.id)
|
||||
if record is None:
|
||||
patch = build_anchor_location_patch(item)
|
||||
record = EarthNewsItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
summary=item.summary,
|
||||
content_language=item.content_language,
|
||||
localizations=dict(item.localizations or {}),
|
||||
url=item.url,
|
||||
source=item.source,
|
||||
feed_name=item.feed_name,
|
||||
region=item.feed_region,
|
||||
homepage_url=item.homepage_url,
|
||||
published_at=item.published_at,
|
||||
latitude=patch["latitude"],
|
||||
longitude=patch["longitude"],
|
||||
location_label=patch["location_label"],
|
||||
location_source=patch["location_source"],
|
||||
verified=patch["verified"],
|
||||
location_meta=patch["location_meta"],
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
enrichment_status=item.enrichment_status,
|
||||
enrichment_error=item.enrichment_error,
|
||||
enriched_at=item.enriched_at,
|
||||
)
|
||||
db.add(record)
|
||||
changed += 1
|
||||
continue
|
||||
|
||||
record.title = item.title
|
||||
record.summary = item.summary
|
||||
record.url = item.url
|
||||
record.source = item.source
|
||||
record.feed_name = item.feed_name
|
||||
record.region = item.feed_region
|
||||
record.homepage_url = item.homepage_url
|
||||
record.published_at = item.published_at
|
||||
record.last_seen_at = now
|
||||
if item.localizations:
|
||||
record.content_language = item.content_language
|
||||
record.localizations = dict(item.localizations or {})
|
||||
record.enrichment_status = item.enrichment_status
|
||||
record.enrichment_error = item.enrichment_error
|
||||
record.enriched_at = item.enriched_at
|
||||
changed += 1
|
||||
await db.flush()
|
||||
return changed
|
||||
|
||||
|
||||
async def update_earth_news_item_location(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_id: str,
|
||||
patch: dict[str, Any],
|
||||
) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def update_earth_news_item_enrichment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_id: str,
|
||||
patch: dict[str, Any],
|
||||
) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if "latitude" in patch:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
if "content_language" in patch:
|
||||
record.content_language = str(patch.get("content_language") or "en")
|
||||
if "localizations" in patch:
|
||||
record.localizations = dict(patch.get("localizations") or {})
|
||||
if "enrichment_status" in patch:
|
||||
record.enrichment_status = str(patch.get("enrichment_status") or "pending")
|
||||
if "enrichment_error" in patch:
|
||||
record.enrichment_error = patch.get("enrichment_error")
|
||||
if patch.get("enriched_at"):
|
||||
try:
|
||||
parsed_enriched_at = datetime.fromisoformat(
|
||||
str(patch["enriched_at"]).replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
parsed_enriched_at = datetime.now(UTC)
|
||||
record.enriched_at = _coerce_datetime(parsed_enriched_at)
|
||||
elif patch.get("localizations"):
|
||||
record.enriched_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def list_unverified_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
active_region: str,
|
||||
limit: int,
|
||||
) -> list[ParsedNewsItem]:
|
||||
regions = {"global", active_region}
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem)
|
||||
.where(EarthNewsItem.region.in_(regions))
|
||||
.where(EarthNewsItem.verified.is_(False))
|
||||
.order_by(*_query_sort_key(active_region))
|
||||
.limit(limit)
|
||||
)
|
||||
return [record_to_parsed_news_item(record) for record in result.scalars().all()]
|
||||
|
||||
|
||||
async def list_all_earth_news_records(db: AsyncSession) -> list[EarthNewsItem]:
|
||||
result = await db.execute(
|
||||
select(EarthNewsItem).order_by(
|
||||
EarthNewsItem.published_at.desc().nullslast(),
|
||||
EarthNewsItem.last_seen_at.desc(),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
142
backend/app/services/earth_news_worker.py
Normal file
142
backend/app/services/earth_news_worker.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from socket import gethostname
|
||||
from typing import Any
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import async_session_factory
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.earth_news import (
|
||||
NEWS_ENRICH_PROMPT_KEY,
|
||||
_infer_news_enrichment,
|
||||
build_target_location_patch,
|
||||
parsed_news_item_from_job_payload,
|
||||
)
|
||||
from app.services.earth_news_queue import (
|
||||
NewsTargetLocationMessage,
|
||||
get_news_target_location_queue,
|
||||
save_target_location_patch,
|
||||
)
|
||||
from app.services.earth_news_store import update_earth_news_item_enrichment as update_earth_news_item_location
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="earth_news")
|
||||
|
||||
WORKER_BATCH_SIZE = 4
|
||||
WORKER_BLOCK_MS = 5000
|
||||
WORKER_BACKOFF_SECONDS = 5.0
|
||||
|
||||
_worker_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
async def _build_provider_client() -> AIProviderClient | None:
|
||||
try:
|
||||
from app.api.v1.settings import get_runtime_ai_provider_config
|
||||
|
||||
async with async_session_factory() as session:
|
||||
runtime_config = await get_runtime_ai_provider_config(session)
|
||||
return AIProviderClient(
|
||||
service_url=runtime_config["service_url"],
|
||||
service_token=runtime_config["service_token"],
|
||||
timeout=runtime_config["timeout_seconds"],
|
||||
retry_attempts=runtime_config["retry_attempts"],
|
||||
llm_config=runtime_config.get("llm_config") or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to build Earth news AI provider client",
|
||||
event="earth_news.target_location.provider_unavailable",
|
||||
context={"error": str(exc)},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def process_target_location_message(
|
||||
message: NewsTargetLocationMessage,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
) -> dict[str, Any]:
|
||||
item = parsed_news_item_from_job_payload(message.payload)
|
||||
async with async_session_factory() as session:
|
||||
prompt = await get_effective_prompt(session, NEWS_ENRICH_PROMPT_KEY)
|
||||
target, localizations = await _infer_news_enrichment(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
item.target_location = target
|
||||
item.localizations = localizations or item.localizations
|
||||
patch = build_target_location_patch(item, target)
|
||||
await save_target_location_patch(item.id, patch)
|
||||
async with async_session_factory() as session:
|
||||
await update_earth_news_item_location(session, item_id=item.id, patch=patch)
|
||||
await session.commit()
|
||||
await broadcaster.broadcast_custom(
|
||||
"earth_news",
|
||||
{
|
||||
"item_id": item.id,
|
||||
"patch": patch,
|
||||
},
|
||||
)
|
||||
return patch
|
||||
|
||||
|
||||
async def _run_target_location_worker() -> None:
|
||||
consumer_name = f"{gethostname()}:{id(asyncio.current_task())}"
|
||||
queue = get_news_target_location_queue()
|
||||
while True:
|
||||
try:
|
||||
messages = await queue.consume_batch(
|
||||
consumer_name=consumer_name,
|
||||
count=WORKER_BATCH_SIZE,
|
||||
block_ms=WORKER_BLOCK_MS,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Earth news target location worker queue read failed",
|
||||
event="earth_news.target_location.worker_read_failed",
|
||||
context={"error": str(exc)},
|
||||
)
|
||||
await asyncio.sleep(WORKER_BACKOFF_SECONDS)
|
||||
continue
|
||||
|
||||
if not messages:
|
||||
continue
|
||||
provider_client = await _build_provider_client()
|
||||
for message in messages:
|
||||
try:
|
||||
await process_target_location_message(message, provider_client=provider_client)
|
||||
await queue.ack(message.message_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Earth news target location worker job failed",
|
||||
event="earth_news.target_location.worker_job_failed",
|
||||
context={"item_id": message.item_id, "error": str(exc)},
|
||||
)
|
||||
with suppress(Exception):
|
||||
await queue.retry_or_dead_letter(message, error=str(exc))
|
||||
|
||||
|
||||
def start_earth_news_target_worker() -> None:
|
||||
global _worker_task
|
||||
if _worker_task is None or _worker_task.done():
|
||||
_worker_task = asyncio.create_task(_run_target_location_worker())
|
||||
|
||||
|
||||
async def stop_earth_news_target_worker() -> None:
|
||||
global _worker_task
|
||||
task = _worker_task
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
_worker_task = None
|
||||
123
backend/app/services/email.py
Normal file
123
backend/app/services/email.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""SMTP-backed email sender.
|
||||
|
||||
Generic primitive used by registration/verification today, reusable for alert
|
||||
digests and other notifications later. Configuration lives in the `smtp` row of
|
||||
`system_settings` and is loaded once per send (small surface, no caching layer
|
||||
yet to keep behavior obvious after settings changes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal, Optional
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
OtpPurpose = Literal["register", "verify_email", "reset_password"]
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
code: str = "EMAIL_ERROR"
|
||||
|
||||
|
||||
class EmailNotConfiguredError(EmailError):
|
||||
code = "EMAIL_PROVIDER_NOT_CONFIGURED"
|
||||
|
||||
|
||||
class EmailSendError(EmailError):
|
||||
code = "EMAIL_SEND_FAILED"
|
||||
|
||||
|
||||
async def _load_smtp_config(db: AsyncSession) -> dict:
|
||||
from app.api.v1.settings import get_setting_payload # local import avoids cycle
|
||||
|
||||
payload = await get_setting_payload(db, "smtp")
|
||||
if not payload.get("host") or not payload.get("from_address"):
|
||||
raise EmailNotConfiguredError("SMTP host/from_address not set")
|
||||
return payload
|
||||
|
||||
|
||||
async def send_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: Optional[str] = None,
|
||||
config: Optional[dict] = None,
|
||||
) -> None:
|
||||
cfg = config or await _load_smtp_config(db)
|
||||
|
||||
message = EmailMessage()
|
||||
from_name = (cfg.get("from_name") or "").strip()
|
||||
from_address = cfg["from_address"]
|
||||
message["From"] = f"{from_name} <{from_address}>" if from_name else from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(text_body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
use_tls = bool(cfg.get("use_tls", True))
|
||||
use_starttls = bool(cfg.get("use_starttls", False))
|
||||
port = int(cfg.get("port") or (465 if use_tls else 587))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=cfg["host"],
|
||||
port=port,
|
||||
username=cfg.get("username") or None,
|
||||
password=cfg.get("password") or None,
|
||||
use_tls=use_tls and not use_starttls,
|
||||
start_tls=use_starttls,
|
||||
timeout=int(cfg.get("timeout_seconds") or 20),
|
||||
)
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
except OSError as exc:
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
|
||||
|
||||
_SUBJECTS: dict[OtpPurpose, str] = {
|
||||
"register": "Confirm your Planet account",
|
||||
"verify_email": "Verify your Planet email",
|
||||
"reset_password": "Reset your Planet password",
|
||||
}
|
||||
|
||||
_HEADLINES: dict[OtpPurpose, str] = {
|
||||
"register": "Welcome to Planet — confirm your email to activate your account.",
|
||||
"verify_email": "Confirm your new email address to keep your Planet account active.",
|
||||
"reset_password": "Use this code to set a new password for your Planet account.",
|
||||
}
|
||||
|
||||
|
||||
async def send_verification_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
to: str,
|
||||
code: str,
|
||||
purpose: OtpPurpose,
|
||||
config: Optional[dict] = None,
|
||||
) -> None:
|
||||
subject = _SUBJECTS[purpose]
|
||||
headline = _HEADLINES[purpose]
|
||||
text_body = (
|
||||
f"{headline}\n\n"
|
||||
f"Your verification code: {code}\n"
|
||||
"This code expires in 10 minutes. If you did not request it, ignore this email.\n"
|
||||
)
|
||||
html_body = (
|
||||
f"<p>{headline}</p>"
|
||||
f"<p style=\"font-size:24px;letter-spacing:4px;font-family:monospace\"><b>{code}</b></p>"
|
||||
"<p>This code expires in 10 minutes. If you did not request it, ignore this email.</p>"
|
||||
)
|
||||
await send_email(
|
||||
db,
|
||||
to=to,
|
||||
subject=subject,
|
||||
text_body=text_body,
|
||||
html_body=html_body,
|
||||
config=config,
|
||||
)
|
||||
57
backend/app/services/location/__init__.py
Normal file
57
backend/app/services/location/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Shared location-resolution pipeline.
|
||||
|
||||
A reusable abstraction for "given a record, decide its lat/lon" — used by
|
||||
compute centers, BGP collectors, BGP events, and any future entity that needs
|
||||
location estimation.
|
||||
|
||||
Each domain wires its own :class:`LocationPipeline` from a sequence of
|
||||
:class:`LocationResolver` instances. Future algorithms (peeringdb, IXP tables,
|
||||
user-confirmed coordinates, …) plug in by implementing the protocol — no
|
||||
changes needed to consumers.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolutionDiagnostic,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
)
|
||||
from .pipeline import LocationPipeline, LocationResolver
|
||||
from .resolvers.inherit import InheritFromAnotherEntityResolver
|
||||
from .resolvers.nominatim import (
|
||||
NominatimResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
interpret_geocode_result,
|
||||
)
|
||||
from .resolvers.registry import RegistryResolver, default_score_alias_match
|
||||
from .resolvers.source_coordinates import SourceCoordinatesResolver
|
||||
from .text import (
|
||||
city_key,
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LocationCandidate",
|
||||
"LocationPipeline",
|
||||
"LocationQuery",
|
||||
"LocationResolver",
|
||||
"ResolutionDiagnostic",
|
||||
"ResolutionResult",
|
||||
"ResolverOutput",
|
||||
"InheritFromAnotherEntityResolver",
|
||||
"NominatimResolver",
|
||||
"RegistryResolver",
|
||||
"SourceCoordinatesResolver",
|
||||
"build_default_nominatim_geocoder",
|
||||
"city_key",
|
||||
"coerce_str",
|
||||
"default_score_alias_match",
|
||||
"interpret_geocode_result",
|
||||
"normalize_country_text",
|
||||
"normalize_text",
|
||||
"parse_float",
|
||||
]
|
||||
1069
backend/app/services/location/llm_fallback.py
Normal file
1069
backend/app/services/location/llm_fallback.py
Normal file
File diff suppressed because it is too large
Load Diff
128
backend/app/services/location/models.py
Normal file
128
backend/app/services/location/models.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Domain-neutral data structures for the location pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
# Renderable precision tiers, ordered from most precise to least.
|
||||
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationQuery:
|
||||
"""Domain-neutral input for the resolution pipeline.
|
||||
|
||||
``name`` and ``aliases`` are matched against registry alias indexes;
|
||||
``city`` / ``country`` / ``region`` provide geographic context for both
|
||||
registry lookups and Nominatim queries; ``source_latitude`` /
|
||||
``source_longitude`` short-circuit when the record already carries
|
||||
coordinates; ``extra`` carries domain-specific fields (operator, site,
|
||||
organization, asn, peer_ip, …) that resolvers can opt into.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
city: str | None = None
|
||||
country: str | None = None
|
||||
region: str | None = None
|
||||
source_latitude: float | None = None
|
||||
source_longitude: float | None = None
|
||||
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationCandidate:
|
||||
"""A resolved location candidate produced by a resolver."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
display_name: str
|
||||
precision: str # "precise" | "site" | "city" | (rejected: country/unknown)
|
||||
confidence: float
|
||||
query: str
|
||||
source: str
|
||||
source_note: str | None
|
||||
matched_fields: tuple[str, ...]
|
||||
needs_confirmation: bool
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
matched_location_name: str | None = None
|
||||
location_verified_at: str | None = None
|
||||
suggested_registry_entry: dict[str, Any] | None = None
|
||||
raw_payload: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"display_name": self.display_name,
|
||||
"precision": self.precision,
|
||||
"confidence": self.confidence,
|
||||
"query": self.query,
|
||||
"source": self.source,
|
||||
"source_note": self.source_note,
|
||||
"matched_fields": list(self.matched_fields),
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"city": self.city,
|
||||
"region": self.region,
|
||||
"country": self.country,
|
||||
"matched_location_name": self.matched_location_name,
|
||||
"location_verified_at": self.location_verified_at,
|
||||
"suggested_registry_entry": self.suggested_registry_entry,
|
||||
"raw_payload": self.raw_payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolverOutput:
|
||||
"""What a single resolver returns from one ``resolve()`` call."""
|
||||
|
||||
candidates: tuple[LocationCandidate, ...] = ()
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionDiagnostic:
|
||||
"""Why we could not resolve, plus what we tried."""
|
||||
|
||||
failure_reason: str
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
record_id: int | None = None
|
||||
source: str | None = None
|
||||
source_id: str | None = None
|
||||
name: str | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
site: str | None = None
|
||||
operator: str | None = None
|
||||
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"failure_reason": self.failure_reason,
|
||||
"attempted_queries": list(self.attempted_queries),
|
||||
"record_id": self.record_id,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"country": self.country,
|
||||
"city": self.city,
|
||||
"site": self.site,
|
||||
"operator": self.operator,
|
||||
**({"extra": dict(self.extra)} if self.extra else {}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
"""Pipeline output: best candidate (if any) + diagnostic on miss."""
|
||||
|
||||
location: LocationCandidate | None
|
||||
diagnostic: ResolutionDiagnostic | None
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return bool(self.location)
|
||||
126
backend/app/services/location/pipeline.py
Normal file
126
backend/app/services/location/pipeline.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Pipeline that runs a sequence of :class:`LocationResolver` instances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from .models import (
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolutionDiagnostic,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
)
|
||||
|
||||
|
||||
class LocationResolver(Protocol):
|
||||
"""Pluggable location resolution step.
|
||||
|
||||
Implementations: ``SourceCoordinatesResolver``, ``RegistryResolver``,
|
||||
``NominatimResolver``, ``InheritFromAnotherEntityResolver`` — see the
|
||||
``resolvers`` subpackage. New algorithms (peeringdb / IXP / user-confirmed
|
||||
coordinates) plug in by implementing this protocol; the pipeline does not
|
||||
care how candidates are produced.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||
|
||||
|
||||
def default_candidate_sort_key(
|
||||
candidate: LocationCandidate,
|
||||
) -> tuple[int, int, float]:
|
||||
precision_rank = {"precise": 0, "site": 1, "city": 2}.get(
|
||||
candidate.precision, 9
|
||||
)
|
||||
source_rank = {
|
||||
"source_coordinates": 0,
|
||||
"stored_compute_center_location": 1,
|
||||
"stored_collector_location": 1,
|
||||
"ror_organization_registry": 2,
|
||||
"inherited": 3,
|
||||
"nominatim_online_geocode": 4,
|
||||
"local_registry": 8,
|
||||
"local_registry_city": 9,
|
||||
}.get(candidate.source, 9)
|
||||
return (source_rank, precision_rank, -float(candidate.confidence or 0))
|
||||
|
||||
|
||||
class LocationPipeline:
|
||||
"""Orchestrate a sequence of resolvers.
|
||||
|
||||
``collect_candidates`` runs every resolver and returns *all* deduped
|
||||
candidates plus the queries each resolver attempted (useful for
|
||||
user-facing "why didn't this work?" diagnostics).
|
||||
|
||||
``resolve_best`` returns the top candidate per
|
||||
:func:`default_candidate_sort_key` (or a custom sort).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolvers: Sequence[LocationResolver],
|
||||
*,
|
||||
sort_key=default_candidate_sort_key,
|
||||
failure_reason: str = (
|
||||
"Could not resolve to renderable coordinates from any configured resolver."
|
||||
),
|
||||
) -> None:
|
||||
self._resolvers = list(resolvers)
|
||||
self._sort_key = sort_key
|
||||
self._failure_reason = failure_reason
|
||||
|
||||
@property
|
||||
def resolvers(self) -> tuple[LocationResolver, ...]:
|
||||
return tuple(self._resolvers)
|
||||
|
||||
def collect_candidates(
|
||||
self, query: LocationQuery
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
candidates: list[LocationCandidate] = []
|
||||
attempted: list[str] = []
|
||||
seen_keys: set[tuple[str, str, str]] = set()
|
||||
|
||||
for resolver in self._resolvers:
|
||||
output = resolver.resolve(query)
|
||||
for q in output.attempted_queries:
|
||||
if q and q not in attempted:
|
||||
attempted.append(q)
|
||||
for candidate in output.candidates:
|
||||
key = (
|
||||
candidate.source,
|
||||
f"{candidate.latitude:.4f}",
|
||||
f"{candidate.longitude:.4f}",
|
||||
)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
candidates.append(candidate)
|
||||
|
||||
candidates.sort(key=self._sort_key)
|
||||
return candidates, attempted
|
||||
|
||||
def resolve_best(self, query: LocationQuery) -> ResolutionResult:
|
||||
candidates, attempted = self.collect_candidates(query)
|
||||
if candidates:
|
||||
return ResolutionResult(
|
||||
location=candidates[0],
|
||||
diagnostic=None,
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
return ResolutionResult(
|
||||
location=None,
|
||||
diagnostic=ResolutionDiagnostic(
|
||||
failure_reason=self._failure_reason,
|
||||
attempted_queries=tuple(attempted),
|
||||
name=query.name,
|
||||
country=query.country,
|
||||
city=query.city,
|
||||
site=str(query.extra.get("site")) if query.extra.get("site") else None,
|
||||
operator=str(query.extra.get("operator"))
|
||||
if query.extra.get("operator")
|
||||
else None,
|
||||
),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
20
backend/app/services/location/resolvers/__init__.py
Normal file
20
backend/app/services/location/resolvers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Built-in resolver implementations."""
|
||||
|
||||
from .inherit import InheritFromAnotherEntityResolver
|
||||
from .nominatim import (
|
||||
NominatimResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
interpret_geocode_result,
|
||||
)
|
||||
from .registry import RegistryResolver, default_score_alias_match
|
||||
from .source_coordinates import SourceCoordinatesResolver
|
||||
|
||||
__all__ = [
|
||||
"InheritFromAnotherEntityResolver",
|
||||
"NominatimResolver",
|
||||
"RegistryResolver",
|
||||
"SourceCoordinatesResolver",
|
||||
"build_default_nominatim_geocoder",
|
||||
"default_score_alias_match",
|
||||
"interpret_geocode_result",
|
||||
]
|
||||
31
backend/app/services/location/resolvers/inherit.py
Normal file
31
backend/app/services/location/resolvers/inherit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Resolver that inherits a candidate from another entity's resolution.
|
||||
|
||||
Used by BGP events to pick up the location of their owning collector. The
|
||||
``source_lookup`` callable is the only domain coupling — it receives the
|
||||
incoming :class:`LocationQuery` and returns either an already-resolved
|
||||
:class:`LocationCandidate` (typically by querying another pipeline) or
|
||||
``None`` to signal "no parent location available".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
|
||||
|
||||
class InheritFromAnotherEntityResolver:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source_lookup: Callable[[LocationQuery], LocationCandidate | None],
|
||||
name: str = "inherited",
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._lookup = source_lookup
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
result = self._lookup(query)
|
||||
if result is None:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(candidates=(result,))
|
||||
292
backend/app/services/location/resolvers/nominatim.py
Normal file
292
backend/app/services/location/resolvers/nominatim.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Nominatim-backed online geocoder.
|
||||
|
||||
The actual HTTP call is encapsulated in :func:`build_default_nominatim_geocoder`
|
||||
which returns an ``lru_cache``-wrapped function. Domain modules typically:
|
||||
|
||||
1. Build a default geocoder via :func:`build_default_nominatim_geocoder`.
|
||||
2. Re-export it under a stable module-level name (e.g. ``_geocode_online``).
|
||||
3. Pass a *late-binding lambda* (``lambda q: _geocode_online(q)``) to
|
||||
:class:`NominatimResolver`.
|
||||
|
||||
This ensures tests that ``monkeypatch.setattr(module, "_geocode_online", ...)``
|
||||
can swap the geocoder behavior without touching pipeline construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
from ..text import (
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search"
|
||||
DEFAULT_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||
DEFAULT_MIN_INTERVAL_SECONDS = 1.1
|
||||
DEFAULT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
def build_default_nominatim_geocoder(
|
||||
*,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
cache_size: int = 512,
|
||||
) -> Callable[[str], dict[str, Any] | None]:
|
||||
"""Return a cached, rate-limited Nominatim geocoder."""
|
||||
|
||||
last_request_at = [0.0]
|
||||
|
||||
@lru_cache(maxsize=cache_size)
|
||||
def geocode(query: str) -> dict[str, Any] | None:
|
||||
if not query:
|
||||
return None
|
||||
elapsed = time.monotonic() - last_request_at[0]
|
||||
if elapsed < min_interval_seconds:
|
||||
time.sleep(min_interval_seconds - elapsed)
|
||||
last_request_at[0] = time.monotonic()
|
||||
response = httpx.get(
|
||||
NOMINATIM_SEARCH_URL,
|
||||
params={
|
||||
"q": query,
|
||||
"format": "jsonv2",
|
||||
"limit": 1,
|
||||
"addressdetails": 1,
|
||||
},
|
||||
headers={"User-Agent": user_agent},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return None
|
||||
result = payload[0]
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
return result
|
||||
|
||||
return geocode
|
||||
|
||||
|
||||
_DEFAULT_SITE_CATEGORIES = frozenset(
|
||||
{
|
||||
"amenity",
|
||||
"office",
|
||||
"building",
|
||||
"industrial",
|
||||
"research",
|
||||
"university",
|
||||
"education",
|
||||
"tourism",
|
||||
"shop",
|
||||
"man_made",
|
||||
"campus",
|
||||
"research_institute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def interpret_geocode_result(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
matched_fields: tuple[str, ...],
|
||||
context_country: str | None,
|
||||
site_categories: frozenset[str] = _DEFAULT_SITE_CATEGORIES,
|
||||
site_promoting_match_fields: frozenset[str] = frozenset(
|
||||
{"site", "operator", "name"}
|
||||
),
|
||||
) -> tuple[float, float, dict[str, Any], str] | None:
|
||||
"""Validate a Nominatim raw result. Returns (lat, lon, address, classification)."""
|
||||
latitude = parse_float(result.get("lat"))
|
||||
longitude = parse_float(result.get("lon"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return None
|
||||
|
||||
address = result.get("address") if isinstance(result.get("address"), dict) else {}
|
||||
if not isinstance(address, dict):
|
||||
address = {}
|
||||
|
||||
has_city_level = bool(
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or address.get("hamlet")
|
||||
or address.get("suburb")
|
||||
)
|
||||
osm_class = str(result.get("class") or "").lower()
|
||||
osm_type = str(result.get("type") or "").lower()
|
||||
is_site_like = osm_class in site_categories or osm_type in site_categories
|
||||
if not has_city_level and not is_site_like:
|
||||
return None
|
||||
|
||||
if context_country:
|
||||
normalized_context = normalize_text(normalize_country_text(context_country))
|
||||
normalized_result = normalize_text(
|
||||
normalize_country_text(address.get("country"))
|
||||
)
|
||||
if (
|
||||
normalized_context
|
||||
and normalized_result
|
||||
and normalized_context != normalized_result
|
||||
):
|
||||
return None
|
||||
|
||||
classification = (
|
||||
"site"
|
||||
if (
|
||||
is_site_like
|
||||
and has_city_level
|
||||
and any(field in site_promoting_match_fields for field in matched_fields)
|
||||
)
|
||||
else "city"
|
||||
)
|
||||
return float(latitude), float(longitude), address, classification
|
||||
|
||||
|
||||
def _candidate_from_geocode(
|
||||
*,
|
||||
query: LocationQuery,
|
||||
geocode_query: str,
|
||||
matched_fields: tuple[str, ...],
|
||||
raw_result: dict[str, Any],
|
||||
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None],
|
||||
source: str,
|
||||
site_confidence: float,
|
||||
city_confidence: float,
|
||||
) -> LocationCandidate | None:
|
||||
interpreted = interpret(
|
||||
raw_result,
|
||||
matched_fields=matched_fields,
|
||||
context_country=query.country,
|
||||
)
|
||||
if not interpreted:
|
||||
return None
|
||||
latitude, longitude, address, classification = interpreted
|
||||
city = (
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or query.city
|
||||
or None
|
||||
)
|
||||
region = address.get("state") or address.get("region")
|
||||
country = address.get("country") or query.country or None
|
||||
display_name = raw_result.get("display_name") or geocode_query
|
||||
confidence = city_confidence if classification == "city" else site_confidence
|
||||
|
||||
extra = query.extra or {}
|
||||
suggested_registry_entry = {
|
||||
"canonical_name": (
|
||||
(query.aliases[0] if query.aliases else None)
|
||||
or query.name
|
||||
or display_name
|
||||
),
|
||||
"aliases": list(
|
||||
{
|
||||
value
|
||||
for value in [
|
||||
query.name,
|
||||
*query.aliases,
|
||||
coerce_str(extra.get("operator")),
|
||||
coerce_str(extra.get("site")),
|
||||
]
|
||||
if value
|
||||
}
|
||||
),
|
||||
"operator": coerce_str(extra.get("operator")) or None,
|
||||
"site": coerce_str(extra.get("site"))
|
||||
or coerce_str(extra.get("organization"))
|
||||
or None,
|
||||
"country": country,
|
||||
"city": city,
|
||||
"region": region,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"precision": classification,
|
||||
"confidence": confidence,
|
||||
"source_note": (
|
||||
f"Resolved via Nominatim query '{geocode_query}' → {display_name}"
|
||||
),
|
||||
}
|
||||
return LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=display_name,
|
||||
precision=classification,
|
||||
confidence=confidence,
|
||||
query=geocode_query,
|
||||
source=source,
|
||||
source_note=f"Nominatim search result: {display_name}",
|
||||
matched_fields=matched_fields,
|
||||
needs_confirmation=True,
|
||||
city=city,
|
||||
region=region,
|
||||
country=country,
|
||||
matched_location_name=display_name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=suggested_registry_entry,
|
||||
)
|
||||
|
||||
|
||||
class NominatimResolver:
|
||||
"""Run a domain-specific query plan against Nominatim."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query_plan_builder: Callable[
|
||||
[LocationQuery], list[tuple[str, tuple[str, ...]]]
|
||||
],
|
||||
geocoder: Callable[[str], dict[str, Any] | None],
|
||||
name: str = "nominatim_online_geocode",
|
||||
site_confidence: float = 0.72,
|
||||
city_confidence: float = 0.62,
|
||||
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None] = (
|
||||
interpret_geocode_result
|
||||
),
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._query_plan_builder = query_plan_builder
|
||||
self._geocoder = geocoder
|
||||
self._site_confidence = site_confidence
|
||||
self._city_confidence = city_confidence
|
||||
self._interpret = interpret
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
plan = self._query_plan_builder(query)
|
||||
candidates: list[LocationCandidate] = []
|
||||
attempted: list[str] = []
|
||||
for geocode_query, matched_fields in plan:
|
||||
attempted.append(geocode_query)
|
||||
try:
|
||||
raw_result = self._geocoder(geocode_query)
|
||||
except Exception:
|
||||
continue
|
||||
if not raw_result:
|
||||
continue
|
||||
candidate = _candidate_from_geocode(
|
||||
query=query,
|
||||
geocode_query=geocode_query,
|
||||
matched_fields=matched_fields,
|
||||
raw_result=raw_result,
|
||||
interpret=self._interpret,
|
||||
source=self.name,
|
||||
site_confidence=self._site_confidence,
|
||||
city_confidence=self._city_confidence,
|
||||
)
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
return ResolverOutput(
|
||||
candidates=tuple(candidates),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
323
backend/app/services/location/resolvers/registry.py
Normal file
323
backend/app/services/location/resolvers/registry.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Resolver that matches a query against a local JSON registry.
|
||||
|
||||
Registry schema (a single JSON file):
|
||||
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "...",
|
||||
"aliases": ["...", "..."],
|
||||
"operator": "...",
|
||||
"site": "...",
|
||||
"city": "...",
|
||||
"country": "...",
|
||||
"region": "...",
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"precision": "precise" | "site" | "city",
|
||||
"confidence": 0.0,
|
||||
"verification_status": "verified",
|
||||
"source_note": "...",
|
||||
"verified_at": "YYYY-MM-DD"
|
||||
}
|
||||
],
|
||||
"city_fallbacks": [ {city, country, latitude, longitude, ...} ]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from ..models import (
|
||||
RENDERABLE_PRECISIONS,
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolverOutput,
|
||||
)
|
||||
from ..text import (
|
||||
city_key,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
# Field-priority weights when scoring "this query field text contains this
|
||||
# alias text". Tuned to match the legacy compute-center ordering — name beats
|
||||
# site beats operator beats city — which generalizes well to other domains.
|
||||
_DEFAULT_FIELD_PRIORITY = {
|
||||
"name": 8,
|
||||
"site": 6,
|
||||
"operator": 5,
|
||||
"city": 3,
|
||||
}
|
||||
|
||||
|
||||
def default_score_alias_match(
|
||||
alias_field: str, record_field: str, alias_text: str
|
||||
) -> int:
|
||||
score = max(0, len(alias_text))
|
||||
score += _DEFAULT_FIELD_PRIORITY.get(alias_field, 1)
|
||||
if alias_field == record_field:
|
||||
score += 4
|
||||
if alias_field == "name" and record_field in {"name", "name_short", "alias"}:
|
||||
score += 6
|
||||
if alias_field == "site" and record_field in {"site", "organization"}:
|
||||
score += 4
|
||||
if alias_field == "operator" and record_field in {"operator", "organization"}:
|
||||
score += 4
|
||||
return score
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _load_registry_file(path: str) -> dict[str, Any]:
|
||||
with Path(path).open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _build_alias_index(
|
||||
path: str,
|
||||
) -> tuple[tuple[dict[str, Any], tuple[tuple[str, str], ...]], ...]:
|
||||
index: list[tuple[dict[str, Any], tuple[tuple[str, str], ...]]] = []
|
||||
for entry in _load_registry_file(path).get("locations", []):
|
||||
aliases: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for alias in [entry.get("canonical_name"), *(entry.get("aliases") or [])]:
|
||||
normalized = normalize_text(alias)
|
||||
if normalized and normalized not in seen:
|
||||
aliases.append(("name", normalized))
|
||||
seen.add(normalized)
|
||||
for field_name in ("operator", "site", "city"):
|
||||
value = entry.get(field_name)
|
||||
normalized = normalize_text(value)
|
||||
if normalized and normalized not in seen:
|
||||
aliases.append((field_name, normalized))
|
||||
seen.add(normalized)
|
||||
index.append((entry, tuple(aliases)))
|
||||
return tuple(index)
|
||||
|
||||
|
||||
def _query_corpus(query: LocationQuery) -> dict[str, str]:
|
||||
"""Map a query into normalized strings keyed by source field."""
|
||||
fields: dict[str, str] = {
|
||||
"name": query.name or "",
|
||||
"city": query.city or "",
|
||||
"country": query.country or "",
|
||||
}
|
||||
for alias in query.aliases:
|
||||
if alias and alias != query.name:
|
||||
fields["name_short"] = alias
|
||||
break
|
||||
extra = query.extra or {}
|
||||
for key in ("site", "operator", "organization"):
|
||||
value = extra.get(key)
|
||||
if value:
|
||||
fields[key] = str(value)
|
||||
return {key: normalize_text(value) for key, value in fields.items() if value}
|
||||
|
||||
|
||||
def _country_compatible(entry: dict[str, Any], query: LocationQuery) -> bool:
|
||||
record_country = normalize_country_text(query.country)
|
||||
entry_country = normalize_country_text(entry.get("country"))
|
||||
if not record_country or not entry_country:
|
||||
return True
|
||||
return normalize_text(record_country) == normalize_text(entry_country)
|
||||
|
||||
|
||||
def _normalized_alias_matches(alias_normalized: str, record_text: str) -> bool:
|
||||
alias_tokens = alias_normalized.split()
|
||||
record_tokens = record_text.split()
|
||||
if not alias_tokens or not record_tokens:
|
||||
return False
|
||||
if len(alias_tokens) == 1:
|
||||
return alias_tokens[0] in record_tokens
|
||||
window_size = len(alias_tokens)
|
||||
return any(
|
||||
record_tokens[index : index + window_size] == alias_tokens
|
||||
for index in range(0, len(record_tokens) - window_size + 1)
|
||||
)
|
||||
|
||||
|
||||
def _entry_to_candidate(
|
||||
entry: dict[str, Any],
|
||||
*,
|
||||
matched_alias: str,
|
||||
matched_fields: Iterable[str],
|
||||
source: str,
|
||||
score_explainer: str,
|
||||
confidence_floor: float,
|
||||
) -> LocationCandidate:
|
||||
canonical_name = entry.get("canonical_name") or matched_alias
|
||||
# Registry entries are treated as candidates unless explicitly verified.
|
||||
# This prevents migrated hard-coded hints from appearing as factual
|
||||
# location evidence.
|
||||
is_verified = entry.get("verification_status") == "verified"
|
||||
precision = entry.get("precision") or "city"
|
||||
if precision not in RENDERABLE_PRECISIONS:
|
||||
precision = "city"
|
||||
fields_summary = ", ".join(sorted(set(matched_fields))) or "name"
|
||||
confidence_value = parse_float(entry.get("confidence"))
|
||||
confidence = (
|
||||
float(confidence_value)
|
||||
if confidence_value is not None
|
||||
else confidence_floor
|
||||
)
|
||||
return LocationCandidate(
|
||||
latitude=float(parse_float(entry.get("latitude")) or 0.0),
|
||||
longitude=float(parse_float(entry.get("longitude")) or 0.0),
|
||||
display_name=canonical_name,
|
||||
precision=precision,
|
||||
confidence=confidence,
|
||||
query=f"local_registry::{matched_alias or canonical_name}",
|
||||
source=source,
|
||||
source_note=entry.get("source_note")
|
||||
or f"{score_explainer}: matched {fields_summary}",
|
||||
matched_fields=tuple(sorted(set(matched_fields))) or ("name",),
|
||||
needs_confirmation=bool(entry.get("needs_confirmation")) or not is_verified,
|
||||
city=entry.get("city"),
|
||||
region=entry.get("region"),
|
||||
country=entry.get("country"),
|
||||
matched_location_name=canonical_name,
|
||||
location_verified_at=entry.get("verified_at") if is_verified else None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
|
||||
|
||||
class RegistryResolver:
|
||||
"""Match a query against a JSON registry (plus its city_fallbacks table)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry_path: Path | str,
|
||||
name: str = "local_registry",
|
||||
city_fallback_source: str = "local_registry_city",
|
||||
city_fallback_confidence_default: float = 0.65,
|
||||
confidence_default: float = 0.85,
|
||||
score_alias_match: Callable[[str, str, str], int] = default_score_alias_match,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._registry_path = str(Path(registry_path))
|
||||
self._city_fallback_source = city_fallback_source
|
||||
self._city_fallback_confidence_default = city_fallback_confidence_default
|
||||
self._confidence_default = confidence_default
|
||||
self._score = score_alias_match
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Drop the cached registry — useful when the JSON file is edited."""
|
||||
_load_registry_file.cache_clear()
|
||||
_build_alias_index.cache_clear()
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
candidates: list[LocationCandidate] = []
|
||||
candidates.extend(self._registry_candidates(query))
|
||||
city_candidate = self._city_fallback_candidate(query)
|
||||
if city_candidate is not None:
|
||||
candidates.append(city_candidate)
|
||||
return ResolverOutput(candidates=tuple(candidates))
|
||||
|
||||
# ── internals ──────────────────────────────────────────────
|
||||
|
||||
def _registry_candidates(
|
||||
self, query: LocationQuery
|
||||
) -> list[LocationCandidate]:
|
||||
corpus = _query_corpus(query)
|
||||
if not corpus:
|
||||
return []
|
||||
|
||||
# When the query carries a name (a record-specific identifier), require
|
||||
# at least one alias match against a name-class field — otherwise a
|
||||
# generic shared field like operator="RIPE NCC" would promote every
|
||||
# registry entry that lists that operator, regardless of whether the
|
||||
# name matches.
|
||||
query_has_name = bool(corpus.get("name") or corpus.get("name_short"))
|
||||
|
||||
results: list[LocationCandidate] = []
|
||||
for entry, aliases in _build_alias_index(self._registry_path):
|
||||
best_alias = ""
|
||||
best_score = 0
|
||||
matched_fields: list[str] = []
|
||||
matched_via_name_alias = False
|
||||
for alias_field, alias_normalized in aliases:
|
||||
for record_field, record_text in corpus.items():
|
||||
if not _normalized_alias_matches(alias_normalized, record_text):
|
||||
continue
|
||||
score = self._score(
|
||||
alias_field, record_field, alias_normalized
|
||||
)
|
||||
if score > best_score or (
|
||||
score == best_score
|
||||
and len(alias_normalized) > len(best_alias)
|
||||
):
|
||||
best_score = score
|
||||
best_alias = alias_normalized
|
||||
if record_field not in matched_fields:
|
||||
matched_fields.append(record_field)
|
||||
if alias_field == "name" and record_field in {"name", "name_short"}:
|
||||
matched_via_name_alias = True
|
||||
if not matched_fields or best_score <= 0:
|
||||
continue
|
||||
if query_has_name and not matched_via_name_alias:
|
||||
continue
|
||||
if not _country_compatible(entry, query):
|
||||
continue
|
||||
results.append(
|
||||
_entry_to_candidate(
|
||||
entry,
|
||||
matched_alias=best_alias,
|
||||
matched_fields=matched_fields,
|
||||
source=self.name,
|
||||
score_explainer="Registry alias match",
|
||||
confidence_floor=self._confidence_default,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def _city_fallback_candidate(
|
||||
self, query: LocationQuery
|
||||
) -> LocationCandidate | None:
|
||||
country = normalize_country_text(query.country)
|
||||
city = city_key(query.city)
|
||||
if not country or not city:
|
||||
return None
|
||||
|
||||
for fallback in _load_registry_file(self._registry_path).get(
|
||||
"city_fallbacks", []
|
||||
):
|
||||
fallback_country = normalize_country_text(fallback.get("country"))
|
||||
fallback_city = city_key(fallback.get("city"))
|
||||
if fallback_country != country or fallback_city != city:
|
||||
continue
|
||||
confidence_value = parse_float(fallback.get("confidence"))
|
||||
confidence = (
|
||||
float(confidence_value)
|
||||
if confidence_value is not None
|
||||
else self._city_fallback_confidence_default
|
||||
)
|
||||
return LocationCandidate(
|
||||
latitude=float(parse_float(fallback.get("latitude")) or 0.0),
|
||||
longitude=float(parse_float(fallback.get("longitude")) or 0.0),
|
||||
display_name=fallback.get("city") or "",
|
||||
precision="city",
|
||||
confidence=confidence,
|
||||
query=(
|
||||
f"city_fallback::{fallback.get('city')}, "
|
||||
f"{fallback.get('country')}"
|
||||
),
|
||||
source=self._city_fallback_source,
|
||||
source_note=fallback.get("source_note")
|
||||
or f"City fallback for {fallback.get('city')}, {fallback.get('country')}",
|
||||
matched_fields=("city", "country"),
|
||||
needs_confirmation=False,
|
||||
city=fallback.get("city"),
|
||||
region=fallback.get("region"),
|
||||
country=fallback.get("country"),
|
||||
matched_location_name=fallback.get("city"),
|
||||
location_verified_at=fallback.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Resolver that consumes lat/lon already present on the source record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
from ..text import normalize_country_text
|
||||
|
||||
|
||||
class SourceCoordinatesResolver:
|
||||
"""Pass-through for records that already carry valid coordinates."""
|
||||
|
||||
name = "source_coordinates"
|
||||
|
||||
def __init__(self, *, source: str = "source_coordinates") -> None:
|
||||
self._source = source
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
lat = query.source_latitude
|
||||
lon = query.source_longitude
|
||||
if lat in (None, 0.0) or lon in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
|
||||
country = normalize_country_text(query.country) or query.country
|
||||
candidate = LocationCandidate(
|
||||
latitude=float(lat),
|
||||
longitude=float(lon),
|
||||
display_name=query.name or "",
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
query="source_coordinates",
|
||||
source=self._source,
|
||||
source_note="Source record provided valid coordinates.",
|
||||
matched_fields=("source_coordinates",),
|
||||
needs_confirmation=False,
|
||||
city=query.city,
|
||||
region=query.region,
|
||||
country=country,
|
||||
matched_location_name=query.name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
return ResolverOutput(candidates=(candidate,))
|
||||
41
backend/app/services/location/text.py
Normal file
41
backend/app/services/location/text.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Text-normalization helpers shared by every resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.core.countries import normalize_country
|
||||
|
||||
|
||||
def parse_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_str(value: Any) -> str:
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_text(value: Any) -> str:
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
normalized = str(value).casefold()
|
||||
normalized = re.sub(r"[^a-z0-9一-鿿]+", " ", normalized)
|
||||
return re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
|
||||
def normalize_country_text(value: Any) -> str:
|
||||
normalized = normalize_country(value)
|
||||
return normalized or coerce_str(value)
|
||||
|
||||
|
||||
def city_key(city: Any) -> str:
|
||||
text = coerce_str(city).split(",", 1)[0]
|
||||
return normalize_text(text)
|
||||
100
backend/app/services/otp.py
Normal file
100
backend/app/services/otp.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""One-time verification codes backed by Redis.
|
||||
|
||||
Reusable primitive for register/verify-email/reset-password (and any future 2FA or
|
||||
phone-number verification). Codes are bcrypt-hashed before storage so a Redis dump
|
||||
does not leak active codes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
import bcrypt
|
||||
|
||||
from app.core.security import redis_client
|
||||
|
||||
OtpPurpose = Literal["register", "verify_email", "reset_password"]
|
||||
|
||||
CODE_TTL_SECONDS = 600 # 10 minutes
|
||||
RESEND_COOLDOWN_SECONDS = 60
|
||||
MAX_ATTEMPTS = 5
|
||||
CODE_LENGTH = 6
|
||||
|
||||
|
||||
class OtpError(Exception):
|
||||
code: str = "OTP_ERROR"
|
||||
|
||||
|
||||
class OtpResendRateLimited(OtpError):
|
||||
code = "OTP_RESEND_RATE_LIMITED"
|
||||
|
||||
def __init__(self, retry_after_seconds: int) -> None:
|
||||
super().__init__(f"Resend allowed in {retry_after_seconds}s")
|
||||
self.retry_after_seconds = retry_after_seconds
|
||||
|
||||
|
||||
class OtpInvalid(OtpError):
|
||||
code = "OTP_INVALID"
|
||||
|
||||
|
||||
class OtpExpired(OtpError):
|
||||
code = "OTP_EXPIRED"
|
||||
|
||||
|
||||
class OtpAttemptsExceeded(OtpError):
|
||||
code = "OTP_ATTEMPTS_EXCEEDED"
|
||||
|
||||
|
||||
def _code_key(email: str, purpose: OtpPurpose) -> str:
|
||||
return f"otp:{purpose}:{email.lower()}"
|
||||
|
||||
|
||||
def _rate_key(email: str, purpose: OtpPurpose) -> str:
|
||||
return f"otp_rate:{purpose}:{email.lower()}"
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
# secrets.randbelow gives uniform 0..10**CODE_LENGTH-1 without modulo bias
|
||||
return f"{secrets.randbelow(10 ** CODE_LENGTH):0{CODE_LENGTH}d}"
|
||||
|
||||
|
||||
def check_resend_allowed(email: str, purpose: OtpPurpose) -> None:
|
||||
ttl = redis_client.ttl(_rate_key(email, purpose))
|
||||
if ttl and ttl > 0:
|
||||
raise OtpResendRateLimited(ttl)
|
||||
|
||||
|
||||
def issue_code(email: str, purpose: OtpPurpose) -> str:
|
||||
"""Generate a new code, persist its hash, and start the resend cooldown.
|
||||
|
||||
Caller is responsible for delivering the returned plaintext (e.g. via email).
|
||||
Any pre-existing code for the same (purpose, email) is overwritten.
|
||||
"""
|
||||
check_resend_allowed(email, purpose)
|
||||
code = _generate_code()
|
||||
hashed = bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode()
|
||||
payload = json.dumps({"hash": hashed, "attempts": 0})
|
||||
redis_client.set(_code_key(email, purpose), payload, ex=CODE_TTL_SECONDS)
|
||||
redis_client.set(_rate_key(email, purpose), "1", ex=RESEND_COOLDOWN_SECONDS)
|
||||
return code
|
||||
|
||||
|
||||
def verify_code(email: str, purpose: OtpPurpose, code: str) -> None:
|
||||
"""Validate and consume a code. Raises subclasses of OtpError on failure."""
|
||||
key = _code_key(email, purpose)
|
||||
raw = redis_client.get(key)
|
||||
if raw is None:
|
||||
raise OtpExpired("Code expired or never issued")
|
||||
record = json.loads(raw)
|
||||
attempts = int(record.get("attempts", 0))
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
redis_client.delete(key)
|
||||
raise OtpAttemptsExceeded("Too many invalid attempts")
|
||||
if not bcrypt.checkpw(code.encode(), record["hash"].encode()):
|
||||
record["attempts"] = attempts + 1
|
||||
ttl = redis_client.ttl(key)
|
||||
redis_client.set(key, json.dumps(record), ex=max(ttl, 1))
|
||||
raise OtpInvalid("Incorrect code")
|
||||
redis_client.delete(key)
|
||||
@@ -33,6 +33,7 @@ from app.services.playground_session_store import upsert_playground_session
|
||||
STREAM_CHUNK_SIZE = 24
|
||||
STREAM_INTERVAL_SECONDS = 0.08
|
||||
THINKING_PREVIEW_SECONDS = 2.6
|
||||
ORPHANED_RUN_MESSAGE = "后台生成任务已中断,请点击上一条用户消息的重试按钮重新生成。"
|
||||
|
||||
|
||||
class _ActiveRun:
|
||||
@@ -179,6 +180,7 @@ async def _build_thread_response(
|
||||
session: PlaygroundSession,
|
||||
) -> PlaygroundThreadResponse:
|
||||
messages = await _list_visible_messages(db, session_id=session.id)
|
||||
messages = await _reconcile_orphaned_active_messages(db, messages)
|
||||
id_map = {item.id: item.public_id for item in messages}
|
||||
return PlaygroundThreadResponse(
|
||||
session=session_to_response(session),
|
||||
@@ -186,6 +188,30 @@ async def _build_thread_response(
|
||||
)
|
||||
|
||||
|
||||
async def _reconcile_orphaned_active_messages(
|
||||
db: AsyncSession,
|
||||
messages: list[PlaygroundMessage],
|
||||
) -> list[PlaygroundMessage]:
|
||||
changed = False
|
||||
for item in messages:
|
||||
if item.status not in {"pending", "thinking", "answering"}:
|
||||
continue
|
||||
if item.public_id in _ACTIVE_RUNS:
|
||||
continue
|
||||
item.status = "error"
|
||||
item.content = item.content or ORPHANED_RUN_MESSAGE
|
||||
orphan_meta = "错误: 后台任务已中断"
|
||||
if orphan_meta not in (item.meta or []):
|
||||
item.meta = [*(item.meta or []), orphan_meta]
|
||||
changed = True
|
||||
if changed:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
for item in messages:
|
||||
await db.refresh(item)
|
||||
return messages
|
||||
|
||||
|
||||
async def get_thread(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -550,6 +576,15 @@ def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_u
|
||||
return history[-8:]
|
||||
|
||||
|
||||
def _format_run_exception(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
return str(detail)
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
async def _run_assistant_message(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -680,13 +715,18 @@ async def _run_assistant_message(
|
||||
await db.commit()
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_message = _format_run_exception(exc)
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
|
||||
message = result.scalar_one_or_none()
|
||||
if message is not None:
|
||||
message.status = "error"
|
||||
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
|
||||
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
|
||||
message.content = message.content or f"分析失败:{error_message}"
|
||||
message.meta = [
|
||||
*(message.meta or []),
|
||||
f"Request ID: {request_id}",
|
||||
f"错误: {error_message}",
|
||||
]
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
finally:
|
||||
|
||||
@@ -175,19 +175,29 @@ async def run_collector_task(collector_name: str):
|
||||
)
|
||||
|
||||
try:
|
||||
collector._datasource_id = datasource.id
|
||||
datasource_id = datasource.id
|
||||
datasource_source = datasource.source
|
||||
collector._datasource_id = datasource_id
|
||||
logger.info_event(
|
||||
"Running collector",
|
||||
event="collector.run.started",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id},
|
||||
context={"collector_name": collector_name, "datasource_id": datasource_id},
|
||||
)
|
||||
task_result = await collector.run(db)
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
if datasource is None:
|
||||
logger.error_event(
|
||||
"Datasource disappeared after collector run",
|
||||
event="collector.run.datasource_missing_after_run",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource_id},
|
||||
)
|
||||
return
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = task_result.get("status")
|
||||
if datasource.last_status == "success":
|
||||
effective_candidate = await get_builtin_effective_candidate(db, datasource.source)
|
||||
effective_candidate = await get_builtin_effective_candidate(db, datasource_source)
|
||||
checksum, _credential_context = await build_builtin_connectivity_checksum(
|
||||
datasource.source,
|
||||
datasource_source,
|
||||
effective_candidate["endpoint"],
|
||||
effective_candidate["auth_type"],
|
||||
effective_candidate["headers"],
|
||||
@@ -196,7 +206,7 @@ async def run_collector_task(collector_name: str):
|
||||
)
|
||||
await save_connectivity_success(
|
||||
db,
|
||||
datasource.source,
|
||||
datasource_source,
|
||||
checksum,
|
||||
{"status_code": None},
|
||||
connected_by="collection",
|
||||
@@ -205,9 +215,11 @@ async def run_collector_task(collector_name: str):
|
||||
logger.info_event(
|
||||
"Collector completed",
|
||||
event="collector.run.completed",
|
||||
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
|
||||
context={"collector_name": collector_name, "datasource_id": datasource_id, "result": task_result},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
await db.rollback()
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "cancelled"
|
||||
await db.commit()
|
||||
@@ -218,6 +230,8 @@ async def run_collector_task(collector_name: str):
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
datasource.last_run_at = datetime.now(UTC)
|
||||
datasource.last_status = "failed"
|
||||
await db.commit()
|
||||
@@ -374,6 +388,11 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_collector_running(collector_name: str) -> bool:
|
||||
task = get_running_collector_task(collector_name)
|
||||
return bool(task is not None and not task.done())
|
||||
|
||||
|
||||
async def cancel_running_collector_now(collector_name: str) -> bool:
|
||||
task = get_running_collector_task(collector_name)
|
||||
if task is None or task.done():
|
||||
|
||||
@@ -10,8 +10,11 @@ from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record
|
||||
|
||||
SITUATIONAL_ALERT_BRIEF_PROMPT_KEY = "alerts.situational.brief"
|
||||
|
||||
|
||||
def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "无") -> str:
|
||||
if not pairs:
|
||||
@@ -158,10 +161,12 @@ async def build_situational_alert_brief_request(
|
||||
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,
|
||||
"latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None,
|
||||
}
|
||||
prompt = await get_effective_prompt(db, SITUATIONAL_ALERT_BRIEF_PROMPT_KEY)
|
||||
|
||||
request = SituationalAnalysisRequest(
|
||||
title="态势告警 AI 简报",
|
||||
objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=facts,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
|
||||
@@ -17,6 +18,10 @@ from app.services.vessel_types import normalize_vessel_type_name
|
||||
|
||||
VESSEL_AIS_SCHEMA = "vessel_ais"
|
||||
DEFAULT_AGGREGATION_WINDOW_HOURS = 24
|
||||
DEFAULT_SNAPSHOT_WINDOW_MINUTES = 60
|
||||
MAX_SNAPSHOT_LIMIT = 5000
|
||||
MAX_SNAPSHOT_CANDIDATE_MULTIPLIER = 20
|
||||
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS = 100_000
|
||||
BARENTSWATCH_DELIVERY_MODE = "polling"
|
||||
BARENTSWATCH_TRANSPORT = "http"
|
||||
AISSTREAM_DELIVERY_MODE = "realtime_stream"
|
||||
@@ -564,6 +569,46 @@ async def get_aggregated_vessels(
|
||||
return vessels
|
||||
|
||||
|
||||
async def get_aggregated_vessels_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float],
|
||||
limit: int = 1000,
|
||||
observed_since: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return a bounded viewport snapshot without loading the global AIS window."""
|
||||
|
||||
observed_since = observed_since or (
|
||||
datetime.now(UTC) - timedelta(minutes=DEFAULT_SNAPSHOT_WINDOW_MINUTES)
|
||||
)
|
||||
safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT)
|
||||
candidate_limit = min(
|
||||
max(safe_limit * MAX_SNAPSHOT_CANDIDATE_MULTIPLIER, safe_limit),
|
||||
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS,
|
||||
)
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
payload_lon = AISRawObservation.normalized_payload["lon"].as_string().cast(Float)
|
||||
payload_lat = AISRawObservation.normalized_payload["lat"].as_string().cast(Float)
|
||||
|
||||
stmt = (
|
||||
select(AISRawObservation)
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.where(AISRawObservation.observed_at >= observed_since)
|
||||
.where(payload_lon >= lon_min)
|
||||
.where(payload_lon <= lon_max)
|
||||
.where(payload_lat >= lat_min)
|
||||
.where(payload_lat <= lat_max)
|
||||
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
|
||||
.limit(candidate_limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
if not hasattr(result, "scalars"):
|
||||
return []
|
||||
vessels = await aggregate_vessel_observations(db, result.scalars().all())
|
||||
return vessels[:safe_limit]
|
||||
|
||||
|
||||
async def get_aggregated_vessel(db: AsyncSession, mmsi: int) -> dict[str, Any] | None:
|
||||
observations = await get_vessel_raw_observations(db, mmsi, limit=1000)
|
||||
vessels = await aggregate_vessel_observations(db, observations)
|
||||
|
||||
@@ -17,15 +17,16 @@ async def create_admin():
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
print(f"用户 linkong 已存在,更新密码...")
|
||||
existing_user.set_password("LK12345678")
|
||||
print("用户 linkong 已存在,更新密码...")
|
||||
existing_user.set_password("12345678")
|
||||
existing_user.role = "super_admin"
|
||||
existing_user.email = "linkong@planet.local"
|
||||
else:
|
||||
print("创建管理员用户...")
|
||||
user = User(
|
||||
username="linkong",
|
||||
email="linkong@example.com",
|
||||
password_hash=get_password_hash("LK12345678"),
|
||||
email="linkong@planet.local",
|
||||
password_hash=get_password_hash("12345678"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -6,29 +6,58 @@ import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import engine, async_session_factory
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_admin():
|
||||
from sqlalchemy import text
|
||||
|
||||
async with async_session_factory() as session:
|
||||
result = await session.execute(text("SELECT id FROM users WHERE username = 'admin'"))
|
||||
if result.fetchone():
|
||||
print("Admin user already exists")
|
||||
created = []
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username=default_user["username"],
|
||||
email=default_user["email"],
|
||||
password_hash=get_password_hash(default_user["password"]),
|
||||
role=default_user["role"],
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
created.append(default_user)
|
||||
|
||||
await session.commit()
|
||||
if not created:
|
||||
print("Default login users already exist")
|
||||
return
|
||||
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@planet.local",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
print("Admin user created: admin / admin123")
|
||||
for default_user in created:
|
||||
print(
|
||||
f"Default login user created: {default_user['username']} / {default_user['password']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,8 +12,20 @@ from sqlalchemy.orm import sessionmaker
|
||||
import bcrypt
|
||||
|
||||
|
||||
# Generate proper bcrypt hash
|
||||
ADMIN_PASSWORD_HASH = bcrypt.hashpw("admin123".encode(), bcrypt.gensalt()).decode()
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_admin():
|
||||
@@ -22,23 +34,42 @@ async def create_admin():
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = 'admin'")
|
||||
)
|
||||
if result.fetchone():
|
||||
print("Admin user already exists")
|
||||
created = []
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
password_hash = bcrypt.hashpw(
|
||||
default_user["password"].encode(), bcrypt.gensalt()
|
||||
).decode()
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password, :role, true, NOW(), NOW())
|
||||
"""),
|
||||
{
|
||||
"username": default_user["username"],
|
||||
"email": default_user["email"],
|
||||
"password": password_hash,
|
||||
"role": default_user["role"],
|
||||
},
|
||||
)
|
||||
created.append((default_user, password_hash))
|
||||
|
||||
await session.commit()
|
||||
if not created:
|
||||
print("Default login users already exist")
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES ('admin', 'admin@planet.local', :password, 'super_admin', true, NOW(), NOW())
|
||||
"""),
|
||||
{"password": ADMIN_PASSWORD_HASH},
|
||||
)
|
||||
await session.commit()
|
||||
print(f"Admin user created: admin / admin123")
|
||||
print(f"Hash: {ADMIN_PASSWORD_HASH}")
|
||||
for default_user, password_hash in created:
|
||||
print(
|
||||
f"Default login user created: {default_user['username']} / {default_user['password']}"
|
||||
)
|
||||
print(f"Hash: {password_hash}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,10 +2,45 @@
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bgp_collector_location_cache():
|
||||
"""Mirror app startup seeding for tests that call sync BGP helpers."""
|
||||
from app.services.bgp_collector_locations import (
|
||||
SEED_PATH,
|
||||
set_bgp_collector_location_cache,
|
||||
)
|
||||
|
||||
payload = json.loads(SEED_PATH.read_text(encoding="utf-8"))
|
||||
cache = {}
|
||||
for entry in payload.get("locations", []):
|
||||
collector_id = next(
|
||||
alias for alias in entry.get("aliases", []) if str(alias).startswith("rrc")
|
||||
)
|
||||
cache[collector_id] = {
|
||||
"city": entry.get("city"),
|
||||
"country": entry.get("country"),
|
||||
"latitude": entry.get("latitude"),
|
||||
"longitude": entry.get("longitude"),
|
||||
"precision": entry.get("precision") or "city",
|
||||
"source": "legacy_seed",
|
||||
"needs_confirmation": True,
|
||||
"matched_location_name": entry.get("site") or collector_id,
|
||||
"verified_at": None,
|
||||
"confidence": entry.get("confidence"),
|
||||
"operator": entry.get("operator"),
|
||||
"site": entry.get("site"),
|
||||
"verification_status": "unverified",
|
||||
"source_note": entry.get("source_note"),
|
||||
}
|
||||
set_bgp_collector_location_cache(cache)
|
||||
yield
|
||||
set_bgp_collector_location_cache({})
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
||||
@@ -18,6 +18,17 @@ from app.schemas.ai import (
|
||||
)
|
||||
|
||||
|
||||
class _FakeRedisClient:
|
||||
def sismember(self, *_args, **_kwargs):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_token_blacklist(monkeypatch):
|
||||
"""Keep API auth tests independent from an external Redis service."""
|
||||
monkeypatch.setattr("app.core.security.redis_client", _FakeRedisClient())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""Create authentication headers"""
|
||||
@@ -62,20 +73,43 @@ async def test_dashboard_stats_without_auth():
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_stats_with_auth(auth_headers):
|
||||
"""Test dashboard stats with authentication"""
|
||||
with patch("app.api.v1.dashboard.cache.get", return_value=None):
|
||||
with patch("app.api.v1.dashboard.cache.set", return_value=True):
|
||||
with patch("app.db.session.get_db") as mock_get_db:
|
||||
mock_session = AsyncMock()
|
||||
mock_result = AsyncMock()
|
||||
mock_result.scalar.return_value = 0
|
||||
mock_result.fetchall.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
class _StatsResult:
|
||||
def __init__(self, row):
|
||||
self._row = row
|
||||
|
||||
async def mock_db_context():
|
||||
yield mock_session
|
||||
def one(self):
|
||||
return self._row
|
||||
|
||||
mock_get_db.return_value = mock_db_context()
|
||||
class _FakeStatsSession:
|
||||
def __init__(self):
|
||||
self._rows = [
|
||||
type("DatasourceStats", (), {"custom_count": 0, "custom_active": 0})(),
|
||||
type("TaskStats", (), {"tasks_today": 0, "success_tasks": 0})(),
|
||||
type(
|
||||
"AlertStats",
|
||||
(),
|
||||
{"critical_alerts": 0, "warning_alerts": 0, "info_alerts": 0},
|
||||
)(),
|
||||
]
|
||||
|
||||
async def execute(self, _query):
|
||||
return _StatsResult(self._rows.pop(0))
|
||||
|
||||
def override_get_current_user():
|
||||
return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True)
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeStatsSession()
|
||||
|
||||
app.dependency_overrides.update(
|
||||
{
|
||||
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
|
||||
get_db: override_get_db,
|
||||
}
|
||||
)
|
||||
try:
|
||||
with patch("app.api.v1.dashboard.cache.get", return_value=None):
|
||||
with patch("app.api.v1.dashboard.cache.set", return_value=True):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
@@ -85,6 +119,8 @@ async def test_dashboard_stats_with_auth(auth_headers):
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total_datasources" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for BGP observability helpers."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -54,11 +55,34 @@ class _FakeResult:
|
||||
def scalars(self):
|
||||
return _FakeScalarResult(self._rows)
|
||||
|
||||
def all(self):
|
||||
if self._rows and all(isinstance(row, BGPObservation) for row in self._rows):
|
||||
return [
|
||||
(row.prefix, row.origin_asn, row.collector, row.collector_geo)
|
||||
for row in self._rows
|
||||
]
|
||||
return self._rows
|
||||
|
||||
def scalar(self):
|
||||
if not self._rows:
|
||||
return 0
|
||||
first = self._rows[0]
|
||||
if isinstance(first, (int, float, str)):
|
||||
return first
|
||||
if isinstance(first, tuple) and len(first) == 1:
|
||||
return first[0]
|
||||
return len(self._rows)
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
if not self._rows:
|
||||
return None
|
||||
first = self._rows[0]
|
||||
if isinstance(first, CollectedData):
|
||||
return {"extra_data": first.extra_data}
|
||||
return first
|
||||
|
||||
|
||||
class _FakeAsyncSession:
|
||||
@@ -988,7 +1012,7 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
data_type="cable",
|
||||
extra_data={"cable_id": 20},
|
||||
)
|
||||
db = _FakeAsyncSession([[landing], [relation], [cable]])
|
||||
db = _FakeAsyncSession([[landing, relation, cable]])
|
||||
|
||||
result = await infer_related_infrastructure(
|
||||
db,
|
||||
@@ -1012,27 +1036,37 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_bgp_collector_coverage_summarizes_observations():
|
||||
now = datetime.now(UTC)
|
||||
obs_one = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
aggregate = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
observation_count=2,
|
||||
prefix_count=2,
|
||||
origin_asn_count=2,
|
||||
peer_asn_count=2,
|
||||
recent_15m_observation_count=2,
|
||||
recent_24h_observation_count=2,
|
||||
recent_7d_observation_count=2,
|
||||
recent_15m_prefix_count=2,
|
||||
recent_24h_prefix_count=2,
|
||||
recent_7d_prefix_count=2,
|
||||
latest_observed_at=now + timedelta(minutes=5),
|
||||
)
|
||||
latest = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
latest_event_type="withdrawal",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
top_event = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
prefix="203.0.113.0/24",
|
||||
origin_asn=64496,
|
||||
peer_asn=3333,
|
||||
event_type="announcement",
|
||||
observed_at=now,
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
count=1,
|
||||
)
|
||||
obs_two = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
scope = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
prefix="198.51.100.0/24",
|
||||
origin_asn=64497,
|
||||
peer_asn=3334,
|
||||
event_type="withdrawal",
|
||||
observed_at=now + timedelta(minutes=5),
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
db = _FakeAsyncSession([[obs_one, obs_two]])
|
||||
db = _FakeAsyncSession([[aggregate], [latest], [top_event], [scope]])
|
||||
|
||||
coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
||||
|
||||
@@ -1363,18 +1397,39 @@ async def test_bgp_event_summary_api_returns_aggregates():
|
||||
@pytest.mark.asyncio
|
||||
async def test_bgp_collectors_api_returns_coverage():
|
||||
now = datetime.now(UTC)
|
||||
observation = BGPObservation(
|
||||
id=1,
|
||||
source="ris_live_bgp",
|
||||
aggregate = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
peer_asn=3333,
|
||||
prefix="203.0.113.0/24",
|
||||
event_type="announcement",
|
||||
origin_asn=64496,
|
||||
observed_at=now,
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
observation_count=1,
|
||||
prefix_count=1,
|
||||
origin_asn_count=1,
|
||||
peer_asn_count=1,
|
||||
recent_15m_observation_count=1,
|
||||
recent_24h_observation_count=1,
|
||||
recent_7d_observation_count=1,
|
||||
recent_15m_prefix_count=1,
|
||||
recent_24h_prefix_count=1,
|
||||
recent_7d_prefix_count=1,
|
||||
latest_observed_at=now,
|
||||
)
|
||||
latest = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
latest_event_type="announcement",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
top_event = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
event_type="announcement",
|
||||
count=1,
|
||||
)
|
||||
scope = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
db = _FakeAsyncSession(
|
||||
[[aggregate], [latest], [top_event], [scope], [aggregate], [latest], [top_event], [scope]]
|
||||
)
|
||||
db = _FakeAsyncSession([[observation], [observation]])
|
||||
client = await _bgp_test_client(db)
|
||||
|
||||
try:
|
||||
|
||||
222
backend/tests/test_bgp_collector_locations.py
Normal file
222
backend/tests/test_bgp_collector_locations.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Tests for the BGP collector + event location services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import bgp as bgp_api
|
||||
from app.services import bgp_collector_locations
|
||||
from app.services.location.llm_fallback import LocationLLMFallbackResult
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
collect_bgp_collector_location_candidates,
|
||||
iter_known_collector_names,
|
||||
resolve_bgp_collector_location,
|
||||
)
|
||||
from app.services.bgp_event_locations import (
|
||||
resolve_bgp_event_geo_dict,
|
||||
resolve_bgp_event_location,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_dict_view_preserves_backward_compatible_keys():
|
||||
rrc00 = RIPE_RIS_COLLECTOR_COORDS["rrc00"]
|
||||
assert rrc00["city"] == "Amsterdam"
|
||||
assert rrc00["country"] == "Netherlands"
|
||||
assert rrc00["latitude"] == pytest.approx(52.3676)
|
||||
assert rrc00["longitude"] == pytest.approx(4.9041)
|
||||
# New richer fields layered on top.
|
||||
assert rrc00["precision"] == "city"
|
||||
assert rrc00["source"] == "legacy_seed"
|
||||
assert rrc00["needs_confirmation"] is True
|
||||
|
||||
|
||||
def test_every_legacy_collector_present():
|
||||
expected = {
|
||||
"rrc00", "rrc01", "rrc03", "rrc04", "rrc05", "rrc06", "rrc07",
|
||||
"rrc10", "rrc11", "rrc12", "rrc13", "rrc14", "rrc15", "rrc16",
|
||||
"rrc18", "rrc19", "rrc20", "rrc21", "rrc22", "rrc23", "rrc24",
|
||||
"rrc25", "rrc26",
|
||||
}
|
||||
assert set(iter_known_collector_names()) == expected
|
||||
|
||||
|
||||
def test_resolve_bgp_collector_returns_stored_location():
|
||||
result = resolve_bgp_collector_location("rrc12")
|
||||
assert result.location is not None
|
||||
assert result.location.city == "Frankfurt"
|
||||
assert result.location.country == "Germany"
|
||||
assert result.location.precision == "city"
|
||||
assert result.location.source == "legacy_seed"
|
||||
assert result.location.needs_confirmation is True
|
||||
|
||||
|
||||
def test_resolve_unknown_bgp_collector_returns_diagnostic(monkeypatch):
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", lambda q: None)
|
||||
result = resolve_bgp_collector_location("rrc-doesnotexist")
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.failure_reason
|
||||
|
||||
|
||||
def test_collect_bgp_collector_candidates_uses_stored_context_without_registry(monkeypatch):
|
||||
bgp_collector_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
assert "CIXP" in query or "Geneva" in query
|
||||
return {
|
||||
"lat": "46.2044",
|
||||
"lon": "6.1432",
|
||||
"display_name": "Geneva, Switzerland",
|
||||
"address": {"city": "Geneva", "country": "Switzerland"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||
collector="rrc04",
|
||||
)
|
||||
assert attempted, "stored context should feed online query attempts"
|
||||
assert candidates, "online geocoding should produce at least one candidate"
|
||||
best = candidates[0]
|
||||
assert best.source == "nominatim_online_geocode"
|
||||
assert best.needs_confirmation is True
|
||||
assert all(candidate.source != "local_registry" for candidate in candidates)
|
||||
|
||||
|
||||
def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(monkeypatch):
|
||||
bgp_collector_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
if "Lyon" not in query and "France-IX" not in query and "FR-IX" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "45.764",
|
||||
"lon": "4.8357",
|
||||
"display_name": "Lyon, Auvergne-Rhône-Alpes, France",
|
||||
"address": {"city": "Lyon", "country": "France"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||
collector="rrc-mystery",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
assert attempted, "Nominatim plan should run"
|
||||
online = [c for c in candidates if c.source == "nominatim_online_geocode"]
|
||||
assert online, "online resolver must produce a candidate when registry misses"
|
||||
assert online[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
llm_candidate = bgp_collector_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon, France",
|
||||
precision="city",
|
||||
confidence=0.74,
|
||||
query="llm_factcheck:bgp_collector:rrc-mystery",
|
||||
source="llm_location_factcheck",
|
||||
source_note="LLM location factcheck fallback",
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"get_bgp_collector_location_dict",
|
||||
lambda _collector: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"collect_bgp_collector_location_candidates",
|
||||
lambda **_kwargs: ([], ["Lyon, France"]),
|
||||
)
|
||||
|
||||
from app.services.location.llm_fallback import LocationSearchEvidenceResult
|
||||
|
||||
async def _search_evidence(**_kwargs):
|
||||
return LocationSearchEvidenceResult(
|
||||
evidence=[
|
||||
{
|
||||
"title": "RRC source",
|
||||
"url": "https://example.test/rrc",
|
||||
"snippet": "rrc-mystery is in Lyon.",
|
||||
}
|
||||
],
|
||||
attempted_queries=["web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city"],
|
||||
)
|
||||
|
||||
async def _fallback(**_kwargs):
|
||||
return LocationLLMFallbackResult(
|
||||
candidates=[llm_candidate],
|
||||
attempted_queries=["llm_factcheck:bgp_collector:rrc-mystery"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "get_web_search_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "collect_location_search_evidence", _search_evidence)
|
||||
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||
|
||||
response = await bgp_api.collect_bgp_collector_location(
|
||||
"rrc-mystery",
|
||||
bgp_api.CollectBGPCollectorLocationRequest(city="Lyon", country="France"),
|
||||
current_user=object(),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "llm_location_factcheck"
|
||||
assert response["best_candidate"]["needs_confirmation"] is True
|
||||
assert response["attempted_queries"] == [
|
||||
"Lyon, France",
|
||||
"web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city",
|
||||
"llm_factcheck:bgp_collector:rrc-mystery",
|
||||
]
|
||||
|
||||
|
||||
# ── BGP event resolver ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_event_resolver_inherits_from_owning_collector():
|
||||
geo = resolve_bgp_event_geo_dict("rrc25")
|
||||
assert geo["city"] == "Amsterdam"
|
||||
assert geo["country"] == "Netherlands"
|
||||
assert geo["source"] == "inherited_from_collector"
|
||||
assert geo["precision"] == "city"
|
||||
|
||||
|
||||
def test_event_resolver_does_not_match_unrelated_collectors():
|
||||
"""Regression: passing operator=RIPE NCC must NOT make every collector match."""
|
||||
rrc12 = resolve_bgp_event_geo_dict("rrc12")
|
||||
rrc25 = resolve_bgp_event_geo_dict("rrc25")
|
||||
assert rrc12["city"] == "Frankfurt"
|
||||
assert rrc25["city"] == "Amsterdam"
|
||||
assert rrc12["latitude"] != rrc25["latitude"]
|
||||
|
||||
|
||||
def test_event_resolver_uses_source_coordinates_when_present():
|
||||
geo = resolve_bgp_event_geo_dict(
|
||||
"rrc12",
|
||||
source_latitude=12.34,
|
||||
source_longitude=56.78,
|
||||
)
|
||||
assert geo["latitude"] == pytest.approx(12.34)
|
||||
assert geo["longitude"] == pytest.approx(56.78)
|
||||
assert geo["precision"] == "precise"
|
||||
assert geo["source"] == "source_coordinates"
|
||||
|
||||
|
||||
def test_event_resolver_returns_empty_for_unknown_collector_without_source_coords():
|
||||
geo = resolve_bgp_event_geo_dict("rrc-doesnotexist")
|
||||
assert geo == {}
|
||||
|
||||
|
||||
def test_event_resolver_full_result_carries_diagnostic_on_miss():
|
||||
result = resolve_bgp_event_location(collector="rrc-doesnotexist")
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
86
backend/tests/test_datasources_batch.py
Normal file
86
backend/tests/test_datasources_batch.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import datasources as datasources_api
|
||||
from app.models.datasource import DataSource
|
||||
|
||||
|
||||
def make_datasource(
|
||||
datasource_id: int,
|
||||
source: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
module: str = "L4",
|
||||
is_active: bool = True,
|
||||
last_status: str | None = None,
|
||||
last_run_at: datetime | None = None,
|
||||
frequency_minutes: int = 60,
|
||||
) -> DataSource:
|
||||
return DataSource(
|
||||
id=datasource_id,
|
||||
name=name or source,
|
||||
source=source,
|
||||
module=module,
|
||||
priority="P1",
|
||||
frequency_minutes=frequency_minutes,
|
||||
collector_class=source,
|
||||
is_active=is_active,
|
||||
last_status=last_status,
|
||||
last_run_at=last_run_at,
|
||||
)
|
||||
|
||||
|
||||
def test_datasource_product_key_groups_domain_specific_sources():
|
||||
assert datasources_api.datasource_product_key(make_datasource(1, "aisstream_vessels")) == "vessels"
|
||||
assert datasources_api.datasource_product_key(make_datasource(2, "telegeography_cables")) == "cables"
|
||||
assert datasources_api.datasource_product_key(make_datasource(3, "celestrak_tle")) == "satellites"
|
||||
assert datasources_api.datasource_product_key(make_datasource(4, "ris_live_bgp")) == "bgp"
|
||||
|
||||
|
||||
def test_filter_datasources_by_product_status_and_collected_state():
|
||||
vessels = make_datasource(1, "aisstream_vessels", last_status="success")
|
||||
cables = make_datasource(2, "telegeography_cables", last_status="failed")
|
||||
filtered = datasources_api._filter_datasources_in_memory(
|
||||
[vessels, cables],
|
||||
running_tasks={},
|
||||
record_counts={"aisstream_vessels": 12, "telegeography_cables": 0},
|
||||
product="vessels",
|
||||
run_status="success",
|
||||
collected=True,
|
||||
)
|
||||
|
||||
assert filtered == [vessels]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
disabled = make_datasource(1, "aisstream_vessels", is_active=False)
|
||||
not_due = make_datasource(2, "telegeography_cables", last_run_at=now, frequency_minutes=120)
|
||||
due = make_datasource(3, "ris_live_bgp", last_run_at=now - timedelta(hours=2))
|
||||
triggered_sources: list[str] = []
|
||||
|
||||
async def fake_running_tasks(_db, _ids):
|
||||
return {}
|
||||
|
||||
async def fake_latest_task_ids(_db, _ids):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_running_tasks", fake_running_tasks)
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_task_ids", fake_latest_task_ids)
|
||||
monkeypatch.setattr(
|
||||
datasources_api,
|
||||
"run_collector_now",
|
||||
lambda source: triggered_sources.append(source) or True,
|
||||
)
|
||||
|
||||
result = await datasources_api._trigger_datasource_batch(
|
||||
object(),
|
||||
[disabled, not_due, due],
|
||||
force=False,
|
||||
)
|
||||
|
||||
assert [item["source"] for item in result["triggered"]] == ["ris_live_bgp"]
|
||||
assert {item["reason"] for item in result["skipped"]} == {"disabled", "within_frequency_window"}
|
||||
assert triggered_sources == ["ris_live_bgp"]
|
||||
116
backend/tests/test_docs_gatekeeper.py
Normal file
116
backend/tests/test_docs_gatekeeper.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Docs Gatekeeper API tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1 import docs as docs_api
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def make_user(role: str = "viewer", groups: list[str] | None = None) -> User:
|
||||
user = User(
|
||||
id=1,
|
||||
username="docs-user",
|
||||
email="docs@example.com",
|
||||
password_hash="x",
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
user.gatekeeper_groups = groups or []
|
||||
return user
|
||||
|
||||
|
||||
async def get_json(path: str, user: User | None = None):
|
||||
if user is not None:
|
||||
async def override_user():
|
||||
return user
|
||||
|
||||
app.dependency_overrides[docs_api.get_optional_current_user] = override_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_catalog_only_for_anonymous_user():
|
||||
response = await get_json("/api/v1/docs/catalog")
|
||||
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
assert {item["access"] for item in items} == {"public"}
|
||||
assert {item["slug"] for item in items if item["lang"] == "zh"} == {
|
||||
"overview",
|
||||
"quickstart",
|
||||
"manual",
|
||||
"faq",
|
||||
"location-pipeline-user",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_read_public_doc():
|
||||
response = await get_json("/api/v1/docs/zh/quickstart")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access"] == "public"
|
||||
assert "快速开始" in response.json()["markdown"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_protected_doc_requires_authentication():
|
||||
response = await get_json("/api/v1/docs/zh/backend-collectors")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_without_group_cannot_read_developer_doc():
|
||||
response = await get_json(
|
||||
"/api/v1/docs/zh/backend-collectors",
|
||||
make_user(role="viewer"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_developer_group_can_read_developer_but_not_admin_doc():
|
||||
user = make_user(role="viewer", groups=["docs_developer"])
|
||||
|
||||
developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user)
|
||||
admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user)
|
||||
|
||||
assert developer_response.status_code == 200
|
||||
assert developer_response.json()["access"] == "docs_developer"
|
||||
assert admin_response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_and_super_admin_can_read_admin_docs():
|
||||
admin_response = await get_json(
|
||||
"/api/v1/docs/zh/backend-system-service-control",
|
||||
make_user(role="admin"),
|
||||
)
|
||||
super_admin_response = await get_json(
|
||||
"/api/v1/docs/zh/backend-system-service-control",
|
||||
make_user(role="super_admin"),
|
||||
)
|
||||
|
||||
assert admin_response.status_code == 200
|
||||
assert super_admin_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_language_slug_and_path_traversal_do_not_read_files():
|
||||
bad_lang = await get_json("/api/v1/docs/fr/quickstart")
|
||||
bad_slug = await get_json("/api/v1/docs/zh/not-a-doc")
|
||||
traversal = await get_json("/api/v1/docs/zh/..%2Fmanual")
|
||||
|
||||
assert bad_lang.status_code == 404
|
||||
assert bad_slug.status_code == 404
|
||||
assert traversal.status_code == 404
|
||||
177
backend/tests/test_earth_boundaries.py
Normal file
177
backend/tests/test_earth_boundaries.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
||||
from app.services.collectors.registry import collector_registry
|
||||
from app.services import earth_boundaries
|
||||
|
||||
|
||||
def write_geojson(path, name="Test"):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": name},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def patch_paths(monkeypatch, tmp_path):
|
||||
repo = tmp_path
|
||||
source_dir = repo / "data/earth-boundary-sources"
|
||||
boundary_dir = repo / "frontend/public/earth/data/boundaries/v1"
|
||||
pmtiles = repo / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
legacy = repo / "frontend/public/earth/data/countries-admin0.min.geojson"
|
||||
config = repo / "config/earth-boundary-sources.local.json"
|
||||
example = repo / "config/earth-boundary-sources.example.json"
|
||||
policy = repo / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
for path in (source_dir, boundary_dir, pmtiles.parent, legacy.parent, config.parent):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
policy.write_text('{"productionTileFormat":"pmtiles+mvt"}\n', encoding="utf-8")
|
||||
example.write_text('{"collectorConfigs":{}}\n', encoding="utf-8")
|
||||
monkeypatch.setattr(earth_boundaries, "REPO_ROOT", repo)
|
||||
monkeypatch.setattr(earth_boundaries, "SOURCE_OUTPUT_DIR", source_dir)
|
||||
monkeypatch.setattr(earth_boundaries, "SOURCE_MANIFEST_PATH", source_dir / "manifest.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BUILD_RESULT_PATH", source_dir / "build-result.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BUILD_JOB_PATH", source_dir / "build-job.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BOUNDARY_OUTPUT_DIR", boundary_dir)
|
||||
monkeypatch.setattr(earth_boundaries, "BOUNDARY_MANIFEST_PATH", boundary_dir / "manifest.json")
|
||||
monkeypatch.setattr(earth_boundaries, "PMTILES_ARTIFACT_PATH", pmtiles)
|
||||
monkeypatch.setattr(earth_boundaries, "LEGACY_GEOJSON_PATH", legacy)
|
||||
monkeypatch.setattr(earth_boundaries, "LOCAL_CONFIG_PATH", config)
|
||||
monkeypatch.setattr(earth_boundaries, "EXAMPLE_CONFIG_PATH", example)
|
||||
monkeypatch.setattr(earth_boundaries, "POV_POLICY_PATH", policy)
|
||||
return {
|
||||
"repo": repo,
|
||||
"config": config,
|
||||
"legacy": legacy,
|
||||
"pmtiles": pmtiles,
|
||||
"manifest": boundary_dir / "manifest.json",
|
||||
}
|
||||
|
||||
|
||||
def test_boundary_status_uses_legacy_provider_when_pmtiles_missing(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
write_geojson(paths["legacy"])
|
||||
|
||||
status = earth_boundaries.get_boundary_status()
|
||||
|
||||
assert status["provider"] == "legacy-geojson"
|
||||
assert status["fallback_available"] is True
|
||||
assert status["high_precision_ready"] is False
|
||||
|
||||
|
||||
def test_boundary_status_prefers_high_precision_when_manifest_and_pmtiles_exist(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
write_geojson(paths["legacy"])
|
||||
paths["pmtiles"].write_bytes(b"pmtiles")
|
||||
paths["manifest"].write_text('{"tileProvider":"pmtiles-mvt"}\n', encoding="utf-8")
|
||||
|
||||
status = earth_boundaries.get_boundary_status()
|
||||
|
||||
assert status["provider"] == "pmtiles-mvt"
|
||||
assert status["high_precision_ready"] is True
|
||||
|
||||
|
||||
def test_save_boundary_config_writes_local_config(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
payload = {"collectorConfigs": {"earth_admin0_boundaries": {"endpoint": "file:///tmp/a.geojson"}}}
|
||||
|
||||
status = earth_boundaries.save_boundary_config(payload)
|
||||
|
||||
assert paths["config"].exists()
|
||||
assert status["config_source"] == "local"
|
||||
assert status["config"] == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_reports_missing_tools_after_source_artifacts(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
source_files = {}
|
||||
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
|
||||
source_path = paths["repo"] / f"{source}.geojson"
|
||||
write_geojson(source_path, name=source)
|
||||
source_files[source] = source_path
|
||||
paths["config"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collectorConfigs": {
|
||||
source: {
|
||||
"sourceKind": kind,
|
||||
"endpoint": str(source_files[source]),
|
||||
"method": "GET",
|
||||
}
|
||||
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
|
||||
progress_events = []
|
||||
|
||||
status = await earth_boundaries.build_boundary_assets(
|
||||
lambda progress, phase, message, **_extra: progress_events.append((progress, phase, message))
|
||||
)
|
||||
|
||||
assert status["provider"] == "geojson-high-precision"
|
||||
assert status["high_precision_ready"] is True
|
||||
assert (paths["repo"] / "data/earth-boundary-sources/manifest.json").exists()
|
||||
assert paths["manifest"].exists()
|
||||
assert any(phase == "download" for _progress, phase, _message in progress_events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_boundary_build_job_records_geojson_fallback_success(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(earth_boundaries, "_build_task", None)
|
||||
monkeypatch.setattr(earth_boundaries, "_build_job_state", {})
|
||||
source_files = {}
|
||||
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
|
||||
source_path = paths["repo"] / f"{source}.geojson"
|
||||
write_geojson(source_path, name=source)
|
||||
source_files[source] = source_path
|
||||
paths["config"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collectorConfigs": {
|
||||
source: {
|
||||
"sourceKind": kind,
|
||||
"endpoint": str(source_files[source]),
|
||||
"method": "GET",
|
||||
}
|
||||
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
|
||||
|
||||
response = await earth_boundaries.start_boundary_build_job()
|
||||
await earth_boundaries._build_task
|
||||
status = earth_boundaries.get_boundary_build_status()
|
||||
|
||||
assert response["accepted"] is True
|
||||
assert status["job"]["status"] == "succeeded"
|
||||
assert status["job"]["result"]["provider"] == "geojson-high-precision"
|
||||
|
||||
|
||||
def test_earth_boundary_collectors_are_not_registered_as_datasources():
|
||||
removed = set(earth_boundaries.BOUNDARY_SOURCE_KINDS) | {"earth_boundary_tiles"}
|
||||
|
||||
assert removed.isdisjoint(DEFAULT_DATASOURCES)
|
||||
for source in removed:
|
||||
assert collector_registry.get(source) is None
|
||||
@@ -1,6 +1,20 @@
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.earth_news import ParsedNewsItem, _serialize_item
|
||||
import pytest
|
||||
|
||||
from app.services.earth_news import (
|
||||
NewsFeedSource,
|
||||
NewsTargetLocation,
|
||||
ParsedNewsItem,
|
||||
_enrich_items_with_target_locations,
|
||||
_extract_target_location_from_text,
|
||||
_serialize_item,
|
||||
get_earth_news_payload,
|
||||
)
|
||||
from app.services.earth_news_queue import NewsTargetLocationMessage
|
||||
from app.services.earth_news_worker import process_target_location_message
|
||||
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
|
||||
|
||||
|
||||
def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
@@ -21,7 +35,10 @@ def test_serialize_item_includes_region_anchor_for_cruise():
|
||||
assert payload["latitude"] == 1.3521
|
||||
assert payload["longitude"] == 103.8198
|
||||
assert payload["location_label"] == "亚太"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["location_source"] == "region_anchor"
|
||||
assert payload["verified"] is False
|
||||
assert payload["location_meta"]["target"] is None
|
||||
assert payload["location_meta"]["anchor"]["region"] == "asia-pacific"
|
||||
assert payload["is_focus_match"] is True
|
||||
assert payload["published_at"] == "2026-04-23T02:30:00Z"
|
||||
|
||||
@@ -44,6 +61,687 @@ def test_serialize_item_falls_back_to_global_anchor():
|
||||
assert payload["latitude"] == 20.0
|
||||
assert payload["longitude"] == 0.0
|
||||
assert payload["location_label"] == "全球"
|
||||
assert payload["location_inferred"] is True
|
||||
assert payload["location_source"] == "region_anchor"
|
||||
assert payload["verified"] is False
|
||||
assert payload["location_meta"]["anchor"]["region"] == "global"
|
||||
assert payload["is_focus_match"] is False
|
||||
assert payload["published_at"] is None
|
||||
|
||||
|
||||
def test_serialize_item_includes_inferred_target_location():
|
||||
item = ParsedNewsItem(
|
||||
id="bbc-world:f55310fb667b",
|
||||
title="Watch: What happened on day one of Trump's China visit?",
|
||||
summary=(
|
||||
"China welcomed US President Donald Trump with cheering children "
|
||||
"and a troop parade."
|
||||
),
|
||||
url="https://example.com/china-visit",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
target_location=NewsTargetLocation(
|
||||
latitude=39.9042,
|
||||
longitude=116.4074,
|
||||
label="Beijing, China",
|
||||
source="ai_inferred_target",
|
||||
confidence=0.88,
|
||||
country="中国",
|
||||
city="Beijing",
|
||||
),
|
||||
)
|
||||
|
||||
payload = _serialize_item(item, active_region="global")
|
||||
|
||||
assert payload["latitude"] == 39.9042
|
||||
assert payload["longitude"] == 116.4074
|
||||
assert payload["location_label"] == "Beijing, China"
|
||||
assert payload["location_source"] == "ai_inferred_target"
|
||||
assert payload["verified"] is True
|
||||
assert payload["location_meta"]["target"]["confidence"] == 0.88
|
||||
assert payload["location_meta"]["target"]["country"] == "中国"
|
||||
assert payload["location_meta"]["target"]["city"] == "Beijing"
|
||||
assert payload["location_meta"]["resolution_stage"] == "unresolved"
|
||||
assert payload["location_meta"]["ai_attempted"] is False
|
||||
assert payload["location_meta"]["ai_status"] == "not_attempted"
|
||||
assert payload["location_meta"]["ai_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="bbc-world:f55310fb667b",
|
||||
title="Watch: What happened on day one of Trump's China visit?",
|
||||
summary="China welcomed US President Donald Trump before a long meeting with Xi Jinping.",
|
||||
url="https://example.com/china-visit",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "39.9042",
|
||||
"lon": "116.4074",
|
||||
"display_name": "Beijing, China",
|
||||
}
|
||||
|
||||
class FakeProviderClient:
|
||||
async def analyze(self, _request):
|
||||
class Response:
|
||||
content = (
|
||||
'{"country":"China","city":"Beijing","matched_location_name":"Beijing, China",'
|
||||
'"latitude":null,"longitude":null,"confidence":0.88}'
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FakeProviderClient(),
|
||||
)
|
||||
|
||||
assert len(enriched) == 1
|
||||
assert enriched[0].target_location is not None
|
||||
assert enriched[0].target_location.latitude == 39.9042
|
||||
assert enriched[0].target_location.longitude == 116.4074
|
||||
assert enriched[0].target_location.label == "Beijing, China"
|
||||
assert enriched[0].target_resolution_stage == "ai_inferred_target"
|
||||
assert enriched[0].target_ai_attempted is True
|
||||
assert enriched[0].target_ai_status == "success"
|
||||
assert enriched[0].target_ai_error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_adds_localizations(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="global-scan:localized",
|
||||
title="Global leaders meet to discuss energy security",
|
||||
summary="Officials said the talks focused on supply chains and grid resilience.",
|
||||
url="https://example.com/energy-security",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "50.1109",
|
||||
"lon": "8.6821",
|
||||
"display_name": "Frankfurt am Main, Germany",
|
||||
}
|
||||
|
||||
class FakeProviderClient:
|
||||
async def analyze(self, _request):
|
||||
class Response:
|
||||
content = (
|
||||
'{"location":{"country":"Germany","city":"Frankfurt",'
|
||||
'"matched_location_name":"Frankfurt, Germany",'
|
||||
'"latitude":null,"longitude":null,"confidence":0.77},'
|
||||
'"localizations":{"zh-CN":{"title":"全球领导人讨论能源安全",'
|
||||
'"summary":"官员表示,会谈聚焦供应链和电网韧性。"}}}'
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FakeProviderClient(),
|
||||
)
|
||||
payload = _serialize_item(enriched[0], active_region="global")
|
||||
|
||||
assert payload["title"] == "Global leaders meet to discuss energy security"
|
||||
assert payload["summary"] == "Officials said the talks focused on supply chains and grid resilience."
|
||||
assert payload["localizations"]["zh-CN"]["title"] == "全球领导人讨论能源安全"
|
||||
assert payload["display_title"] == "全球领导人讨论能源安全"
|
||||
assert payload["display_summary"] == "官员表示,会谈聚焦供应链和电网韧性。"
|
||||
assert payload["enrichment_status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_target_location_from_text_uses_country_hint(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="bbc-world:country-hint",
|
||||
title="Giant new dinosaur identified from fossils in Thailand",
|
||||
summary="The nagatitan is the largest dinosaur found in South-East Asia.",
|
||||
url="https://example.com/thailand-dinosaur",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 17, 28, 56, tzinfo=UTC),
|
||||
)
|
||||
|
||||
target = await _extract_target_location_from_text(item)
|
||||
|
||||
assert target is not None
|
||||
assert target.country == "泰国"
|
||||
assert target.latitude == 15.87
|
||||
assert target.longitude == 100.9925
|
||||
assert target.source == "headline_country_hint"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_records_ai_provider_error():
|
||||
item = ParsedNewsItem(
|
||||
id="global-scan:no-hint",
|
||||
title="The New Geopolitics of Power: Whoever Controls Electrons Wins the Decade",
|
||||
summary="A broad analysis of industrial policy and energy systems.",
|
||||
url="https://example.com/geopolitics-power",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 17, 3, 10, tzinfo=UTC),
|
||||
)
|
||||
|
||||
class FailingProviderClient:
|
||||
async def analyze(self, _request):
|
||||
raise RuntimeError("upstream ai timeout")
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FailingProviderClient(),
|
||||
)
|
||||
|
||||
assert len(enriched) == 1
|
||||
assert enriched[0].target_location is None
|
||||
assert enriched[0].target_resolution_stage == "unresolved"
|
||||
assert enriched[0].target_ai_attempted is True
|
||||
assert enriched[0].target_ai_status == "provider_error"
|
||||
assert enriched[0].target_ai_error == "upstream ai timeout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:timeout",
|
||||
title="Example story",
|
||||
summary="Example summary",
|
||||
url="https://example.com/story",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return None
|
||||
|
||||
enqueued_payloads = []
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued_payloads.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["id"] == "test-feed:timeout"
|
||||
assert payload["items"][0]["display_title"] == ""
|
||||
assert payload["items"][0]["display_summary"] == ""
|
||||
assert payload["items"][0]["latitude"] == 20.0
|
||||
assert payload["items"][0]["longitude"] == 0.0
|
||||
assert payload["items"][0]["location_source"] == "region_anchor"
|
||||
assert payload["items"][0]["verified"] is False
|
||||
assert payload["items"][0]["location_meta"]["ai_status"] == "queued"
|
||||
assert enqueued_payloads[0]["id"] == "test-feed:timeout"
|
||||
assert payload["errors"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_uses_fresh_database_items_without_rss(monkeypatch):
|
||||
db = object()
|
||||
item = ParsedNewsItem(
|
||||
id="db:fresh",
|
||||
title="Fresh database story",
|
||||
summary="Stored summary",
|
||||
url="https://example.com/fresh",
|
||||
source="Stored Source",
|
||||
feed_name="Stored Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
item.location_patch = {
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"location_label": "北京市, 中国",
|
||||
"location_source": "headline_location_hint",
|
||||
"verified": True,
|
||||
"location_meta": {"target": {"city": "Beijing"}, "anchor": {"region": "global"}},
|
||||
}
|
||||
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 12, datetime.now(UTC)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
assert limit == 12
|
||||
return [item]
|
||||
|
||||
async def fail_fetch(_sources):
|
||||
raise AssertionError("fresh database items should not fetch RSS")
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fail_fetch)
|
||||
|
||||
payload = await get_earth_news_payload(db=db)
|
||||
|
||||
assert payload["items"][0]["id"] == "db:fresh"
|
||||
assert payload["items"][0]["verified"] is True
|
||||
assert payload["items"][0]["latitude"] == 39.9057136
|
||||
assert payload["stale"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatch):
|
||||
db = object()
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:init",
|
||||
title="Initial RSS story",
|
||||
summary="Initial summary",
|
||||
url="https://example.com/init",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
item.location_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
}
|
||||
upserted = []
|
||||
enqueued = []
|
||||
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 0, None
|
||||
|
||||
async def fake_fetch_rss_items_for_sources(_sources):
|
||||
return [item], []
|
||||
|
||||
async def fake_upsert_earth_news_items(_db, items):
|
||||
upserted.extend(items)
|
||||
return len(items)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
return [item]
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
|
||||
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
|
||||
|
||||
payload = await get_earth_news_payload(db=db)
|
||||
|
||||
assert upserted[0].id == "test-feed:init"
|
||||
assert payload["items"][0]["id"] == "test-feed:init"
|
||||
assert payload["items"][0]["verified"] is False
|
||||
assert enqueued[0]["id"] == "test-feed:init"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_supplements_stale_database_items(monkeypatch):
|
||||
db = object()
|
||||
old_item = ParsedNewsItem(
|
||||
id="db:old",
|
||||
title="Old story",
|
||||
summary="Old summary",
|
||||
url="https://example.com/old",
|
||||
source="Stored Source",
|
||||
feed_name="Stored Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
old_item.location_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
}
|
||||
fetched = []
|
||||
|
||||
async def fake_get_earth_news_freshness(_db, *, active_region):
|
||||
return 12, datetime(2026, 5, 14, 3, 0, tzinfo=UTC)
|
||||
|
||||
async def fake_fetch_rss_items_for_sources(_sources):
|
||||
fetched.append(True)
|
||||
return [old_item], []
|
||||
|
||||
async def fake_upsert_earth_news_items(_db, items):
|
||||
return len(items)
|
||||
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
return [old_item]
|
||||
|
||||
async def fake_enqueue_target_location_job(_payload, **_kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_rss_items_for_sources", fake_fetch_rss_items_for_sources)
|
||||
monkeypatch.setattr("app.services.earth_news_store.upsert_earth_news_items", fake_upsert_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_store.list_earth_news_items", fake_list_earth_news_items)
|
||||
monkeypatch.setattr("app.services.earth_news_queue.enqueue_target_location_job", fake_enqueue_target_location_job)
|
||||
|
||||
payload = await get_earth_news_payload(db=db)
|
||||
|
||||
assert fetched == [True]
|
||||
assert payload["items"][0]["id"] == "db:old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_merges_cached_location_patch(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:cached",
|
||||
title="Cached story",
|
||||
summary="Cached summary",
|
||||
url="https://example.com/cached",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
cached_patch = {
|
||||
"latitude": 39.9057136,
|
||||
"longitude": 116.3912972,
|
||||
"location_label": "北京市, 中国",
|
||||
"location_source": "headline_location_hint",
|
||||
"verified": True,
|
||||
"location_meta": {
|
||||
"resolution_stage": "headline_location_hint",
|
||||
"ai_attempted": False,
|
||||
"ai_status": "skipped_text_hint",
|
||||
"ai_error": None,
|
||||
"debug_note": "text hint matched 北京市, 中国",
|
||||
"target": {"city": "Beijing"},
|
||||
"anchor": {"region": "global"},
|
||||
},
|
||||
}
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return cached_patch
|
||||
|
||||
enqueued = []
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert payload["items"][0]["latitude"] == 39.9057136
|
||||
assert payload["items"][0]["longitude"] == 116.3912972
|
||||
assert payload["items"][0]["verified"] is True
|
||||
assert payload["items"][0]["location_source"] == "headline_location_hint"
|
||||
assert enqueued[0]["id"] == "test-feed:cached"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_requeues_cached_failed_localization(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:failed-localization",
|
||||
title="Failed localization story",
|
||||
summary="English source summary.",
|
||||
url="https://example.com/failed-localization",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
cached_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
"content_language": "en",
|
||||
"localizations": {},
|
||||
"enrichment_status": "parse_error",
|
||||
"enrichment_error": "AI response did not contain a parseable JSON object.",
|
||||
"enriched_at": None,
|
||||
}
|
||||
enqueued = []
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return cached_patch
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert enqueued[0]["id"] == "test-feed:failed-localization"
|
||||
assert payload["items"][0]["display_title"] == ""
|
||||
assert payload["items"][0]["enrichment_status"] == "queued"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_processes_target_location_message_and_returns_patch(monkeypatch):
|
||||
message = NewsTargetLocationMessage(
|
||||
message_id="1-0",
|
||||
item_id="bbc-world:worker",
|
||||
payload={
|
||||
"id": "bbc-world:worker",
|
||||
"title": "Ukraine rescuers pull dead from rubble of Kyiv flats",
|
||||
"summary": "Massive Russian drone and missile attacks in Ukraine's capital.",
|
||||
"url": "https://example.com/kyiv",
|
||||
"source": "BBC World",
|
||||
"feed_name": "BBC World",
|
||||
"feed_region": "global",
|
||||
"homepage_url": "https://www.bbc.com/news/world",
|
||||
"published_at": "2026-05-14T13:16:32Z",
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "50.4500336",
|
||||
"lon": "30.5241361",
|
||||
"display_name": "Київ, Україна",
|
||||
}
|
||||
|
||||
saved = {}
|
||||
broadcasted = {}
|
||||
|
||||
async def fake_save_target_location_patch(item_id, patch):
|
||||
saved["item_id"] = item_id
|
||||
saved["patch"] = patch
|
||||
|
||||
async def fake_update_earth_news_item_location(_session, *, item_id, patch):
|
||||
saved["db_item_id"] = item_id
|
||||
saved["db_patch"] = patch
|
||||
return True
|
||||
|
||||
async def fake_broadcast_custom(channel, data):
|
||||
broadcasted["channel"] = channel
|
||||
broadcasted["data"] = data
|
||||
|
||||
class FakeSession:
|
||||
async def commit(self):
|
||||
saved["committed"] = True
|
||||
|
||||
class FakeSessionFactory:
|
||||
async def __aenter__(self):
|
||||
return FakeSession()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.save_target_location_patch",
|
||||
fake_save_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.update_earth_news_item_location",
|
||||
fake_update_earth_news_item_location,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.async_session_factory",
|
||||
lambda: FakeSessionFactory(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_worker.broadcaster.broadcast_custom",
|
||||
fake_broadcast_custom,
|
||||
)
|
||||
|
||||
patch = await process_target_location_message(message, provider_client=None)
|
||||
|
||||
assert patch["latitude"] == 50.4500336
|
||||
assert patch["longitude"] == 30.5241361
|
||||
assert patch["location_source"] == "headline_location_hint"
|
||||
assert patch["verified"] is True
|
||||
assert saved["item_id"] == "bbc-world:worker"
|
||||
assert saved["db_item_id"] == "bbc-world:worker"
|
||||
assert saved["committed"] is True
|
||||
assert broadcasted["channel"] == "earth_news"
|
||||
assert broadcasted["data"]["item_id"] == "bbc-world:worker"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_media_news_archive_collector_maps_news_items(monkeypatch):
|
||||
collector = MediaNewsArchiveCollector()
|
||||
collector._db_session = object()
|
||||
record = SimpleNamespace(
|
||||
id="bbc-world:archive",
|
||||
title="Archived news",
|
||||
summary="Archived summary",
|
||||
url="https://example.com/archive",
|
||||
source="BBC World",
|
||||
feed_name="BBC World",
|
||||
region="global",
|
||||
homepage_url="https://www.bbc.com/news/world",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
latitude=39.9057136,
|
||||
longitude=116.3912972,
|
||||
location_label="北京市, 中国",
|
||||
location_source="headline_location_hint",
|
||||
verified=True,
|
||||
location_meta={"target": {"country": "中国", "city": "Beijing"}},
|
||||
content_language="en",
|
||||
localizations={"zh-CN": {"title": "归档新闻", "summary": "归档概要"}},
|
||||
enrichment_status="success",
|
||||
enrichment_error=None,
|
||||
enriched_at=datetime(2026, 5, 15, 3, 6, tzinfo=UTC),
|
||||
first_seen_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC),
|
||||
last_seen_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
resolved_at=datetime(2026, 5, 15, 3, 5, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_list_all_earth_news_records(_db):
|
||||
return [record]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.media_news_archive.list_all_earth_news_records",
|
||||
fake_list_all_earth_news_records,
|
||||
)
|
||||
|
||||
items = await collector.fetch()
|
||||
|
||||
assert items[0]["source_id"] == "bbc-world:archive"
|
||||
assert collector.data_type == "news_item"
|
||||
assert items[0]["country"] == "中国"
|
||||
assert items[0]["city"] == "Beijing"
|
||||
assert items[0]["latitude"] == 39.9057136
|
||||
assert items[0]["metadata"]["verified"] is True
|
||||
assert "localizations" not in items[0]["metadata"]
|
||||
assert "enrichment_status" not in items[0]["metadata"]
|
||||
|
||||
70
backend/tests/test_layers.py
Normal file
70
backend/tests/test_layers.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import layers
|
||||
|
||||
|
||||
def test_layer_guard_requires_bbox():
|
||||
try:
|
||||
layers._parse_layer_bbox("")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("Expected missing bbox to fail")
|
||||
|
||||
|
||||
def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit():
|
||||
geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [121.0, 31.0]},
|
||||
"properties": {"id": "inside"},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [10.0, 10.0]},
|
||||
"properties": {"id": "outside"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = layers._guard_geojson_layer(
|
||||
geojson,
|
||||
bbox=(120.0, 30.0, 122.0, 32.0),
|
||||
zoom=2,
|
||||
limit=6000,
|
||||
)
|
||||
|
||||
assert result["returned_count"] == 1
|
||||
assert result["visible_count"] == 1
|
||||
assert result["features"][0]["properties"]["id"] == "inside"
|
||||
assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT
|
||||
assert result["diagnostics"]["limit_clamped"] is True
|
||||
assert result["diagnostics"]["degraded"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_layer_snapshot_passes_type_filter(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_build_vessel_snapshot_response(db, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"type": "FeatureCollection", "features": []}
|
||||
|
||||
monkeypatch.setattr(layers, "build_vessel_snapshot_response", fake_build_vessel_snapshot_response)
|
||||
|
||||
result = await layers.get_vessel_layer_snapshot(
|
||||
bbox="10,59,11,60",
|
||||
zoom=12,
|
||||
limit=1000,
|
||||
vessel_type="cargo",
|
||||
since_minutes=30,
|
||||
db=object(),
|
||||
)
|
||||
|
||||
assert result["features"] == []
|
||||
assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0)
|
||||
assert captured["type_filter"] == "cargo"
|
||||
assert "vessel_type" not in captured
|
||||
957
backend/tests/test_location_pipeline.py
Normal file
957
backend/tests/test_location_pipeline.py
Normal file
@@ -0,0 +1,957 @@
|
||||
"""Tests for the shared location resolution pipeline.
|
||||
|
||||
Validates the abstraction itself: the protocol contract, the orchestrator,
|
||||
each built-in resolver, and the pluggability promise (a custom resolver can
|
||||
be slotted in without touching consumers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.location import (
|
||||
InheritFromAnotherEntityResolver,
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
RegistryResolver,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
)
|
||||
from app.schemas.ai import SituationalAnalysisResponse
|
||||
import app.services.location.llm_fallback as llm_fallback
|
||||
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
|
||||
|
||||
|
||||
# ── Test fixtures ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_registry(tmp_path: Path) -> Path:
|
||||
payload = {
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "Test Site Alpha",
|
||||
"aliases": ["alpha", "alpha-one", "Acme HQ"],
|
||||
"operator": "Acme Networks",
|
||||
"site": "Acme HQ",
|
||||
"city": "Lyon",
|
||||
"country": "France",
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "site",
|
||||
"confidence": 0.92,
|
||||
"source_note": "Test fixture",
|
||||
"verified_at": "2026-05-08",
|
||||
},
|
||||
{
|
||||
"canonical_name": "Test Site Bravo",
|
||||
"aliases": ["bravo"],
|
||||
"operator": "Acme Networks",
|
||||
"site": "Bravo POP",
|
||||
"city": "Berlin",
|
||||
"country": "Germany",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
],
|
||||
"city_fallbacks": [
|
||||
{
|
||||
"city": "Bhutan-Capital",
|
||||
"country": "Bhutan",
|
||||
"latitude": 27.4728,
|
||||
"longitude": 89.639,
|
||||
"precision": "city",
|
||||
"confidence": 0.5,
|
||||
}
|
||||
],
|
||||
}
|
||||
path = tmp_path / "registry.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ── SourceCoordinatesResolver ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_passes_through_valid_coordinates():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
query = LocationQuery(
|
||||
name="Acme HQ",
|
||||
source_latitude=45.0,
|
||||
source_longitude=4.0,
|
||||
country="France",
|
||||
)
|
||||
output = resolver.resolve(query)
|
||||
assert len(output.candidates) == 1
|
||||
candidate = output.candidates[0]
|
||||
assert candidate.latitude == 45.0
|
||||
assert candidate.longitude == 4.0
|
||||
assert candidate.precision == "precise"
|
||||
assert candidate.source == "source_coordinates"
|
||||
assert candidate.needs_confirmation is False
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_skips_zero_coordinates():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="X", source_latitude=0.0, source_longitude=0.0)
|
||||
)
|
||||
assert output.candidates == ()
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_skips_when_missing():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
|
||||
|
||||
# ── RegistryResolver ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_registry_resolver_matches_alias(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="alpha", country="France")
|
||||
)
|
||||
candidates = list(output.candidates)
|
||||
assert candidates, "should match registry entry"
|
||||
assert any(c.matched_location_name == "Test Site Alpha" for c in candidates)
|
||||
alpha = next(c for c in candidates if c.matched_location_name == "Test Site Alpha")
|
||||
assert alpha.precision == "site"
|
||||
assert alpha.confidence == pytest.approx(0.92)
|
||||
assert alpha.needs_confirmation is True
|
||||
assert alpha.location_verified_at is None
|
||||
|
||||
|
||||
def test_registry_resolver_filters_country_mismatch(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
# alpha is in France; query says Spain → should reject
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="alpha", country="Spain")
|
||||
)
|
||||
assert all(
|
||||
c.matched_location_name != "Test Site Alpha" for c in output.candidates
|
||||
)
|
||||
|
||||
|
||||
def test_registry_resolver_emits_city_fallback_candidate(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(city="Bhutan-Capital", country="Bhutan")
|
||||
)
|
||||
candidates = list(output.candidates)
|
||||
assert candidates, "city fallback should fire"
|
||||
assert any(c.source == "local_registry_city" for c in candidates)
|
||||
|
||||
|
||||
# ── NominatimResolver ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nominatim_resolver_calls_geocoder_with_plan_queries():
|
||||
calls = []
|
||||
|
||||
def fake_geocoder(query: str):
|
||||
calls.append(query)
|
||||
return {
|
||||
"lat": "12.34",
|
||||
"lon": "56.78",
|
||||
"display_name": "Test City, Country",
|
||||
"address": {"city": "Test City", "country": "Country"},
|
||||
}
|
||||
|
||||
def plan(query: LocationQuery):
|
||||
return [
|
||||
("primary query", ("name",)),
|
||||
("secondary query", ("city",)),
|
||||
]
|
||||
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=plan,
|
||||
geocoder=fake_geocoder,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X", country="Country"))
|
||||
assert calls == ["primary query", "secondary query"]
|
||||
assert output.attempted_queries == ("primary query", "secondary query")
|
||||
assert len(output.candidates) == 2
|
||||
assert all(c.precision == "city" for c in output.candidates)
|
||||
assert all(c.needs_confirmation for c in output.candidates)
|
||||
|
||||
|
||||
def test_nominatim_resolver_skips_when_geocoder_returns_none():
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=lambda q: [("only", ("name",))],
|
||||
geocoder=lambda q: None,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
assert output.attempted_queries == ("only",)
|
||||
|
||||
|
||||
def test_nominatim_resolver_swallows_exceptions_per_query():
|
||||
def boom(query):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=lambda q: [("a", ()), ("b", ())],
|
||||
geocoder=boom,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
assert output.attempted_queries == ("a", "b")
|
||||
|
||||
|
||||
# ── InheritFromAnotherEntityResolver ────────────────────────────────
|
||||
|
||||
|
||||
def test_inherit_resolver_returns_provided_candidate():
|
||||
sentinel = LocationCandidate(
|
||||
latitude=10.0,
|
||||
longitude=20.0,
|
||||
display_name="Inherited",
|
||||
precision="city",
|
||||
confidence=0.7,
|
||||
query="inherit::test",
|
||||
source="inherited",
|
||||
source_note=None,
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
resolver = InheritFromAnotherEntityResolver(
|
||||
source_lookup=lambda q: sentinel
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == (sentinel,)
|
||||
|
||||
|
||||
def test_inherit_resolver_skips_when_lookup_returns_none():
|
||||
resolver = InheritFromAnotherEntityResolver(source_lookup=lambda q: None)
|
||||
assert resolver.resolve(LocationQuery(name="X")).candidates == ()
|
||||
|
||||
|
||||
# ── LocationPipeline orchestration ──────────────────────────────────
|
||||
|
||||
|
||||
def test_pipeline_aggregates_candidates_across_resolvers(tmp_registry):
|
||||
pipeline = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
RegistryResolver(registry_path=tmp_registry),
|
||||
NominatimResolver(
|
||||
query_plan_builder=lambda q: [("nominatim attempt", ("name",))],
|
||||
geocoder=lambda q: {
|
||||
"lat": "1.0",
|
||||
"lon": "2.0",
|
||||
"display_name": "Online City",
|
||||
"address": {"city": "Online City", "country": "France"},
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
pipeline.resolvers[1].reload()
|
||||
candidates, attempted = pipeline.collect_candidates(
|
||||
LocationQuery(
|
||||
name="alpha",
|
||||
country="France",
|
||||
source_latitude=44.0,
|
||||
source_longitude=5.0,
|
||||
)
|
||||
)
|
||||
sources = {c.source for c in candidates}
|
||||
assert "source_coordinates" in sources
|
||||
assert "local_registry" in sources
|
||||
assert "nominatim_online_geocode" in sources
|
||||
assert "nominatim attempt" in attempted
|
||||
|
||||
|
||||
def test_pipeline_dedupes_by_source_and_coordinates():
|
||||
same = LocationCandidate(
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
display_name="dup",
|
||||
precision="city",
|
||||
confidence=0.5,
|
||||
query="x",
|
||||
source="dup_source",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
class _DupResolver:
|
||||
name = "dup_source"
|
||||
|
||||
def resolve(self, query):
|
||||
return ResolverOutput(candidates=(same, same))
|
||||
|
||||
pipeline = LocationPipeline([_DupResolver()])
|
||||
candidates, _ = pipeline.collect_candidates(LocationQuery(name="X"))
|
||||
assert len(candidates) == 1
|
||||
|
||||
|
||||
def test_registry_short_aliases_do_not_match_inside_larger_tokens(tmp_path: Path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "Aurora",
|
||||
"aliases": ["Aurora", "ANL"],
|
||||
"site": "DOE/SC/Argonne National Laboratory",
|
||||
"country": "United States",
|
||||
"city": "Lemont",
|
||||
"latitude": 41.713,
|
||||
"longitude": -87.982,
|
||||
"precision": "site",
|
||||
},
|
||||
{
|
||||
"canonical_name": "Venado",
|
||||
"aliases": ["Venado"],
|
||||
"site": "DOE/NNSA/LANL",
|
||||
"country": "United States",
|
||||
"city": "Los Alamos",
|
||||
"latitude": 35.8443,
|
||||
"longitude": -106.2872,
|
||||
"precision": "site",
|
||||
},
|
||||
],
|
||||
"city_fallbacks": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
resolver = RegistryResolver(registry_path=registry_path)
|
||||
resolver.reload()
|
||||
|
||||
output = resolver.resolve(
|
||||
LocationQuery(
|
||||
name="Venado",
|
||||
country="United States",
|
||||
extra={"site": "DOE/NNSA/LANL"},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(output.candidates) == 1
|
||||
assert output.candidates[0].matched_location_name == "Venado"
|
||||
|
||||
|
||||
def test_pipeline_resolve_best_returns_highest_priority():
|
||||
online = LocationCandidate(
|
||||
latitude=10.0,
|
||||
longitude=20.0,
|
||||
display_name="online",
|
||||
precision="city",
|
||||
confidence=0.9,
|
||||
query="x",
|
||||
source="nominatim_online_geocode",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=True,
|
||||
)
|
||||
source = LocationCandidate(
|
||||
latitude=11.0,
|
||||
longitude=21.0,
|
||||
display_name="src",
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
query="x",
|
||||
source="source_coordinates",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
class _StubResolver:
|
||||
def __init__(self, c, name):
|
||||
self._c = c
|
||||
self.name = name
|
||||
|
||||
def resolve(self, query):
|
||||
return ResolverOutput(candidates=(self._c,))
|
||||
|
||||
pipeline = LocationPipeline(
|
||||
[
|
||||
_StubResolver(online, "online"),
|
||||
_StubResolver(source, "src"),
|
||||
]
|
||||
)
|
||||
result = pipeline.resolve_best(LocationQuery(name="X"))
|
||||
assert result.location is source, "source_coordinates should beat nominatim"
|
||||
|
||||
|
||||
def test_pipeline_returns_diagnostic_when_nothing_resolves():
|
||||
pipeline = LocationPipeline([SourceCoordinatesResolver()])
|
||||
result = pipeline.resolve_best(LocationQuery(name="X", country="Bhutan"))
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.country == "Bhutan"
|
||||
|
||||
|
||||
def test_pluggability_custom_resolver_works_without_changing_pipeline():
|
||||
"""Validates the abstraction promise: a new algorithm = a new class."""
|
||||
|
||||
class _PeeringDBStubResolver:
|
||||
name = "fake_peeringdb"
|
||||
|
||||
def resolve(self, query):
|
||||
asn = (query.extra or {}).get("asn")
|
||||
if asn != 174:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
display_name="Cogent HQ",
|
||||
precision="site",
|
||||
confidence=0.8,
|
||||
query=f"peeringdb::{asn}",
|
||||
source="peeringdb_stub",
|
||||
source_note="Stub for testing",
|
||||
matched_fields=("asn",),
|
||||
needs_confirmation=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
pipeline = LocationPipeline([_PeeringDBStubResolver()])
|
||||
candidates, _ = pipeline.collect_candidates(
|
||||
LocationQuery(name="X", extra={"asn": 174})
|
||||
)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].source == "peeringdb_stub"
|
||||
|
||||
|
||||
# ── LLM fallback helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeAIProviderClient:
|
||||
def __init__(self, content: str | list[str]):
|
||||
self.contents = content if isinstance(content, list) else [content]
|
||||
self.calls = 0
|
||||
|
||||
async def analyze(self, payload, request_id=None):
|
||||
self.calls += 1
|
||||
content = self.contents[min(self.calls - 1, len(self.contents) - 1)]
|
||||
return SituationalAnalysisResponse(
|
||||
provider="test",
|
||||
model="test-model",
|
||||
content=content,
|
||||
raw_response={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_returns_candidate_from_strict_json():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "city",
|
||||
"confidence": 0.74,
|
||||
"city": "Lyon",
|
||||
"region": "Auvergne-Rhone-Alpes",
|
||||
"country": "France",
|
||||
"matched_location_name": "Lyon, France",
|
||||
"evidence": ["operator and city point to Lyon"],
|
||||
"reasoning_summary": "Best supported city-level match.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(
|
||||
name="Mystery GPU Cluster",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
extra={"operator": "Mystery Operator"},
|
||||
),
|
||||
entity_type="compute_center",
|
||||
attempted_queries=("Mystery Operator, Lyon, France",),
|
||||
)
|
||||
|
||||
assert client.calls == 1
|
||||
assert result.failure_reason is None
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Mystery GPU Cluster"]
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.source == "llm_location_factcheck"
|
||||
assert candidate.needs_confirmation is True
|
||||
assert candidate.precision == "city"
|
||||
assert candidate.city == "Lyon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_common_precision_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"candidate": {
|
||||
"latitude": 43.2389,
|
||||
"longitude": 76.8897,
|
||||
"precision": "city-level",
|
||||
"confidence": "0.68",
|
||||
"city": "Almaty",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Almaty, Kazakhstan",
|
||||
"evidence": ["NITEC context points to Almaty"],
|
||||
"reasoning_summary": "City-level fallback.",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].precision == "city"
|
||||
assert result.candidates[0].confidence >= 0.55
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_lat_lng_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"lat": 51.1694,
|
||||
"lng": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].latitude == pytest.approx(51.1694)
|
||||
assert result.candidates[0].longitude == pytest.approx(71.4491)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_when_coordinates_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "51.1694",
|
||||
"lon": "71.4491",
|
||||
"display_name": "Astana, Kazakhstan",
|
||||
"address": {"city": "Astana", "country": "Kazakhstan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.latitude == pytest.approx(51.1694)
|
||||
assert candidate.longitude == pytest.approx(71.4491)
|
||||
assert "Nominatim city fallback" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_matched_location_without_city(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if "Falun" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Dalarna County, Sweden",
|
||||
"address": {"city": "Falun", "state": "Dalarna County", "country": "Sweden"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.64,
|
||||
"country": "Sweden",
|
||||
"matched_location_name": "Falun, Sweden",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Credible public source",
|
||||
"source_type": "news",
|
||||
"entity_match": True,
|
||||
"text": "DeepL Mercury supercomputer is located in Falun.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Falun"
|
||||
assert candidate.country == "瑞典"
|
||||
assert candidate.latitude == pytest.approx(60.6065)
|
||||
assert candidate.longitude == pytest.approx(15.6355)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_repairs_non_json_answer(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"TAIPEI-1 appears to be located in Taipei, Taiwan, based on NVIDIA context.",
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 appears to be located in Taipei.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "City-level location extracted from prose.",
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Taipei"
|
||||
assert result.candidates[0].source == "llm_location_factcheck"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.43,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 points to Taipei city-level placement.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Weak city-level evidence, but the entity name and geography align.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.confidence >= 0.55
|
||||
breakdown = candidate.suggested_registry_entry["llm_score_breakdown"]
|
||||
assert breakdown["weak_evidence_penalty"] <= 0.15
|
||||
assert breakdown["conflict_penalty"] == 0
|
||||
assert breakdown["name_location_hint"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unparseable(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if query != "Taipei, 中国(台湾)":
|
||||
return None
|
||||
return {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(["not a location answer", "still not json"])
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="中国(台湾)"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.latitude == pytest.approx(25.033)
|
||||
assert candidate.longitude == pytest.approx(121.5654)
|
||||
assert "Entity name city hint" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_extracts_city_from_non_json_when_repair_fails(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Sweden",
|
||||
"address": {"city": "Falun", "country": "Sweden"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"DeepL Mercury 超級電腦位於瑞典的 法倫 (Falun)。",
|
||||
"still not json",
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Falun"
|
||||
assert result.candidates[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_combines_model_score_with_evidence_score():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Kazakhstan National Supercomputing Center",
|
||||
"url": "https://example.test/alem-cloud",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is located in Astana.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Evidence supports city-level location but not exact facility coordinates.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Astana"
|
||||
assert candidate.confidence >= 0.55
|
||||
assert candidate.suggested_registry_entry["llm_model_confidence"] == pytest.approx(0.38)
|
||||
assert candidate.suggested_registry_entry["llm_combined_confidence"] == pytest.approx(
|
||||
candidate.confidence
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_low_combined_score():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": ["some page mentions Kazakhstan"],
|
||||
"reasoning_summary": "Weak and ambiguous city evidence.",
|
||||
"ambiguity": "weak city evidence",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "combined evidence score" in result.failure_reason
|
||||
assert "below minimum 0.55" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_explicit_conflicts():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 25.033,
|
||||
"longitude": 121.5654,
|
||||
"precision": "city",
|
||||
"confidence": 0.70,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Conflicting source",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"has_conflict": True,
|
||||
"text": "One source says Taipei, another contradicts it.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Conflicting evidence prevents confirmation.",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "conflict=" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"not json",
|
||||
json.dumps({"latitude": 0, "longitude": 0, "precision": "city", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "country", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "city", "confidence": 0.2}),
|
||||
],
|
||||
)
|
||||
async def test_llm_location_fallback_rejects_unsafe_outputs(content):
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(content),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert result.failure_reason
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Unsafe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_failure_explains_rejection_reason():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps({
|
||||
"latitude": 45,
|
||||
"longitude": 4,
|
||||
"precision": "region",
|
||||
"confidence": 0.9,
|
||||
})
|
||||
),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "precision" in result.failure_reason
|
||||
assert "region" in result.failure_reason
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user