Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60eefb19c9 | ||
|
|
83a10a6c34 | ||
|
|
cee1996809 | ||
|
|
58671e7bc3 | ||
|
|
a54fcdbeed | ||
|
|
1dd2921674 | ||
|
|
d30f7d08c5 | ||
|
|
5bdb55f3f1 | ||
|
|
fbecf30513 | ||
|
|
19d5ac0fee | ||
|
|
3265d22af5 | ||
|
|
899e3bce43 | ||
|
|
8c204717cd | ||
|
|
acbbfdf9e2 | ||
|
|
06aca980d0 | ||
|
|
f3f1ceb833 | ||
|
|
b18ffa0b0a | ||
| d15a9d488a | |||
|
|
eb4c4b7904 | ||
|
|
887fec972e | ||
|
|
5bf5c73ca0 | ||
| e65267fe21 | |||
| ae982e51cd | |||
|
|
65e6a96c0d | ||
|
|
37e92e7572 | ||
|
|
a37d4b6289 | ||
|
|
69789d7505 | ||
|
|
4f124121e7 | ||
|
|
085bdf9a80 | ||
|
|
fbca381512 | ||
|
|
5c65ee24d6 | ||
|
|
81970a1d05 | ||
|
|
9b913a3b83 | ||
|
|
93eb41a9f7 | ||
|
|
dd176a6ae6 | ||
|
|
f14ff6ec0f | ||
|
|
39854b9983 | ||
|
|
3b4347c87d | ||
|
|
d9efd98d26 | ||
|
|
b87cb310fd | ||
|
|
b15d097b9c | ||
|
|
8955c58d19 | ||
|
|
1cb51b1172 | ||
|
|
455b8360d0 |
@@ -1,139 +0,0 @@
|
|||||||
---
|
|
||||||
description: 审查当前工作区未提交代码中的垃圾代码,并在不影响逻辑的前提下自动清理
|
|
||||||
argument-hint: 可选:指定要检查的文件或目录(默认检查所有未提交修改)
|
|
||||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
|
||||||
---
|
|
||||||
|
|
||||||
# /cleanup — 垃圾代码审查与清理
|
|
||||||
|
|
||||||
分析当前工作区(git diff)中的未提交代码,找出并修复常见垃圾代码,**不得改变任何运行逻辑**。
|
|
||||||
|
|
||||||
## 检查范围
|
|
||||||
|
|
||||||
若 `$ARGUMENTS` 非空,则只检查指定文件/目录;否则检查所有未提交修改(`git diff HEAD`)。
|
|
||||||
|
|
||||||
## 节省上下文规则
|
|
||||||
|
|
||||||
优先用确定性的 CLI 检查缩小范围,不要一上来把完整文件或大 diff 读入上下文:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --name-only HEAD
|
|
||||||
git diff --unified=0 HEAD -- <path>
|
|
||||||
git diff --check
|
|
||||||
rg -n "TODO|FIXME|console\.log|debugger|print\(" <changed-paths>
|
|
||||||
```
|
|
||||||
|
|
||||||
只有 focused diff 不足以安全判断或修改时,才读取完整文件。
|
|
||||||
|
|
||||||
## 审查清单
|
|
||||||
|
|
||||||
按优先级检查以下问题(只报告在本次 diff 中**新增或修改**的代码里存在的问题):
|
|
||||||
|
|
||||||
### 1. 重复逻辑 (Duplicate Logic)
|
|
||||||
- 完全相同或高度相似的代码块在多处出现
|
|
||||||
- 同一函数/方法被多个地方各自实现,已有公共版本未被复用
|
|
||||||
- 相同的 DOM 查询、正则、模板字符串在同一文件重复
|
|
||||||
|
|
||||||
### 2. Magic Numbers / Magic Strings
|
|
||||||
- 裸数字直接参与计算(如偏移量、时间、尺寸、阈值),没有命名常量
|
|
||||||
- 硬编码字符串(如 id 名、状态值、URL 片段)散落在逻辑中
|
|
||||||
- 例外:`0`, `1`, `-1`, `100`, `""` 等语义明确的惯用值不算
|
|
||||||
|
|
||||||
### 3. 命名问题
|
|
||||||
- 含义不明的缩写变量(如 `or_`, `tmp2`, `x2`)
|
|
||||||
- 命名与实际用途不符
|
|
||||||
- 同一概念在不同地方用不同名字表达
|
|
||||||
|
|
||||||
### 4. 死代码 / 无效代码
|
|
||||||
- 注释掉的旧代码块(3行以上)
|
|
||||||
- 声明后从未使用的变量/参数/导入
|
|
||||||
- 永远不会执行的条件分支
|
|
||||||
|
|
||||||
### 5. 代码风格问题
|
|
||||||
- 尾部空白字符(trailing whitespace)
|
|
||||||
- 同一文件内风格不一致(如混用单双引号、缩进不统一)
|
|
||||||
- 空行使用不一致(连续多个空行等)
|
|
||||||
|
|
||||||
### 6. 其他常见问题
|
|
||||||
- 私有辅助函数应被 export 但没有,导致调用方重复实现
|
|
||||||
- 类型/接口重复定义
|
|
||||||
- 过于冗长的条件表达式可以简化(不改逻辑)
|
|
||||||
|
|
||||||
## 执行步骤
|
|
||||||
|
|
||||||
### Step 1 — 获取待检查文件列表
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 无参数时:获取所有未提交修改
|
|
||||||
git diff HEAD --name-only
|
|
||||||
|
|
||||||
# 有参数时:用 $ARGUMENTS 过滤
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2 — 逐文件阅读并分析
|
|
||||||
|
|
||||||
先从 focused diff 开始:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --unified=0 HEAD -- <file>
|
|
||||||
```
|
|
||||||
|
|
||||||
用 `rg`、`git diff --check`、编译器或 linter 输出确认确定性问题。只有需要上下文时才用 Read 读取完整文件。对照审查清单,记录每个问题:文件名、行号、问题类型、建议修复方式。
|
|
||||||
|
|
||||||
### Step 3 — 报告问题清单
|
|
||||||
|
|
||||||
在修改前,先以列表形式输出所有发现的问题:
|
|
||||||
|
|
||||||
```
|
|
||||||
发现 N 个问题:
|
|
||||||
|
|
||||||
[文件] js/foo.js
|
|
||||||
· L34, L78: 重复逻辑 — 两处都实现了相同的 DOM 查询,可提取到 getPanel()
|
|
||||||
· L91: Magic number — 硬编码 14 作为偏移量,应命名为 TOOLTIP_OFFSET
|
|
||||||
|
|
||||||
[文件] js/bar.js
|
|
||||||
· L12: 命名问题 — 变量 `or_` 语义不明,应命名为 outerR/outerG/outerB
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
如果没有发现问题,直接输出"未发现垃圾代码,当前代码质量良好。"并停止。
|
|
||||||
|
|
||||||
### Step 4 — 执行修复
|
|
||||||
|
|
||||||
对每个问题,使用 Edit 工具进行**最小化修改**:
|
|
||||||
|
|
||||||
- **重复逻辑**:提取为共享常量/函数,更新所有调用点
|
|
||||||
- **Magic number**:在文件顶部或逻辑附近声明 `const NAME = value`,替换所有引用
|
|
||||||
- **命名问题**:重命名变量,更新所有使用处
|
|
||||||
- **死代码**:直接删除
|
|
||||||
- **尾部空白/风格**:修正
|
|
||||||
- **未 export 的函数**:添加 `export`,在调用方改为导入(不重复实现)
|
|
||||||
|
|
||||||
**修复原则:**
|
|
||||||
- 只改在审查清单中发现的问题,不做额外优化
|
|
||||||
- 每次 Edit 只修改确实有问题的行,保持 diff 最小
|
|
||||||
- 改完后用 `grep` 验证旧的坏代码已消失
|
|
||||||
- 优先做精确补丁;只有仓库已有对应格式化流程时,才运行格式化工具
|
|
||||||
|
|
||||||
### Step 5 — 输出总结
|
|
||||||
|
|
||||||
```
|
|
||||||
清理完成:
|
|
||||||
|
|
||||||
修复了 N 个问题:
|
|
||||||
✓ earth.js — 提取重复 vertexShader 为 ATMOS_VERTEX_SHADER 常量
|
|
||||||
✓ main.js — 提取 TOOLTIP_CURSOR_OFFSET = 14(4处引用)
|
|
||||||
✓ controls.js — export updateLayerButtonState,移除 main.js 中的重复实现
|
|
||||||
...
|
|
||||||
|
|
||||||
未修改的问题(需人工确认):
|
|
||||||
! foo.js L45 — 注释代码块较长,建议手动确认是否可删除
|
|
||||||
```
|
|
||||||
|
|
||||||
## 约束
|
|
||||||
|
|
||||||
- **禁止**改变函数签名、接口定义、导出 API(除非问题正是私有函数应被 export)
|
|
||||||
- **禁止**添加新功能、新抽象、新参数
|
|
||||||
- **禁止**修改注释内容(只删除注释掉的死代码)
|
|
||||||
- **禁止**修改测试文件逻辑
|
|
||||||
- 如果一个 Magic number 的语义不完全确定,**跳过**,在总结中标记为"需人工确认"
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create or update repository documentation from current code changes
|
|
||||||
argument-hint: Optional: topic to document, or leave empty to infer from git diff
|
|
||||||
allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
|
|
||||||
---
|
|
||||||
|
|
||||||
# /docs — Documentation Workflow
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Create or update documentation that explains why a change exists, how it behaves, and what maintainers need to know. Keep this command generic. Repository-specific coverage rules live in the repository and must be loaded separately.
|
|
||||||
|
|
||||||
## Repository Rules
|
|
||||||
|
|
||||||
Before deciding scope, check whether the repository has a documentation rules file:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
test -f docs/documentation-coverage-rules.md && sed -n '1,240p' docs/documentation-coverage-rules.md
|
|
||||||
```
|
|
||||||
|
|
||||||
If it exists, apply it as the project-specific coverage checklist. If it does not exist, continue with the generic workflow below.
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
### Step 1 — Understand The Change
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff HEAD --stat
|
|
||||||
git diff HEAD --name-only
|
|
||||||
git log --oneline -10
|
|
||||||
rg --files docs
|
|
||||||
```
|
|
||||||
|
|
||||||
If `$ARGUMENTS` specifies a topic, focus on that topic. Otherwise infer the documentation topic from the changed files. Do not read the full repository diff by default; inspect focused files only:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff HEAD -- <path>
|
|
||||||
rg -n "class |def |function |export |router|@router|interface |type " <path>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2 — Decide Scope
|
|
||||||
|
|
||||||
- Prefer updating an existing relevant document over creating a duplicate.
|
|
||||||
- Use one document for one coherent topic.
|
|
||||||
- Split documents only when the change crosses meaningful domains.
|
|
||||||
- Keep filenames lowercase and hyphenated.
|
|
||||||
- Apply the repository-specific rules file before writing.
|
|
||||||
|
|
||||||
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
|
||||||
|
|
||||||
### Step 3 — Write
|
|
||||||
|
|
||||||
Explain:
|
|
||||||
|
|
||||||
- Background/problem: what was wrong or missing before.
|
|
||||||
- Core design decisions and rationale.
|
|
||||||
- Operational or user-facing impact.
|
|
||||||
- Relevant code paths, only when useful for future maintainers.
|
|
||||||
|
|
||||||
Style:
|
|
||||||
|
|
||||||
- Follow the repository’s existing language and heading conventions.
|
|
||||||
- Use fenced code blocks with language tags.
|
|
||||||
- Prefer tables for comparisons or parameter lists.
|
|
||||||
- Keep snippets concise and relevant.
|
|
||||||
|
|
||||||
### Step 4 — Verify
|
|
||||||
|
|
||||||
- Read the completed docs once for clarity and stale statements.
|
|
||||||
- Verify referenced paths exist with `test -e` or `rg --files`.
|
|
||||||
- Run applicable checks from `docs/documentation-coverage-rules.md`.
|
|
||||||
- Check Markdown links use readable user-facing titles unless repository rules allow otherwise.
|
|
||||||
|
|
||||||
### Step 5 — Report
|
|
||||||
|
|
||||||
Summarize changed docs and verification:
|
|
||||||
|
|
||||||
```md
|
|
||||||
Updated:
|
|
||||||
- path/to/doc.md — what changed
|
|
||||||
|
|
||||||
Verified:
|
|
||||||
- checks that passed
|
|
||||||
- checks that could not be run, if any
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hard Constraints
|
|
||||||
|
|
||||||
- Do not leave placeholder docs.
|
|
||||||
- Do not duplicate bilingual files byte-for-byte.
|
|
||||||
- Do not reference PR numbers, issue numbers, or the current conversation unless explicitly requested.
|
|
||||||
- Do not write changelog-style lists without the reasoning and tradeoffs behind the change.
|
|
||||||
- Keep docs maintainable and concise.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
---
|
|
||||||
description: 用 goal-driven 方法推动一个复杂任务持续执行,直到明确成功标准被满足
|
|
||||||
argument-hint: 建议填写任务目标;若同时给出成功标准更好
|
|
||||||
allowed-tools: ["Read", "Edit", "Bash", "Grep", "Glob"]
|
|
||||||
---
|
|
||||||
|
|
||||||
# /goal-driven — 目标驱动执行模式
|
|
||||||
|
|
||||||
使用 `lidangzzz/goal-driven` 的核心思想来推进复杂任务:先固定目标与成功标准,再持续执行和反复验收,直到标准真正满足。
|
|
||||||
|
|
||||||
适用场景:
|
|
||||||
|
|
||||||
- 长周期实现任务
|
|
||||||
- 高复杂度工程任务
|
|
||||||
- 可被明确验收的研究、实现、迁移、验证类工作
|
|
||||||
|
|
||||||
不适用场景:
|
|
||||||
|
|
||||||
- 纯脑暴
|
|
||||||
- 无法定义成功标准的模糊任务
|
|
||||||
- 很小的一次性修改
|
|
||||||
|
|
||||||
## 输入要求
|
|
||||||
|
|
||||||
若 `$ARGUMENTS` 只包含目标,没有成功标准,先补全一版可执行的成功标准再开始。
|
|
||||||
|
|
||||||
启动时先输出:
|
|
||||||
|
|
||||||
```md
|
|
||||||
Goal
|
|
||||||
- ...
|
|
||||||
|
|
||||||
Criteria for success
|
|
||||||
- ...
|
|
||||||
|
|
||||||
Plan
|
|
||||||
1. ...
|
|
||||||
2. ...
|
|
||||||
3. ...
|
|
||||||
|
|
||||||
Verification
|
|
||||||
- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 执行规则
|
|
||||||
|
|
||||||
1. 先把任务固化为两个核心块:
|
|
||||||
- `Goal`
|
|
||||||
- `Criteria for success`
|
|
||||||
|
|
||||||
2. 成功标准必须尽量客观,可验证,可落地。
|
|
||||||
优先写成:
|
|
||||||
- 需要交付什么
|
|
||||||
- 需要通过哪些测试或验证
|
|
||||||
- 如何判断结果真的完成
|
|
||||||
|
|
||||||
3. 进入持续执行循环:
|
|
||||||
- 完成一个阶段
|
|
||||||
- 检查当前结果是否满足成功标准
|
|
||||||
- 若未满足,明确剩余差距并继续推进
|
|
||||||
|
|
||||||
4. 任何“完成了”“差不多了”“已实现”之类的结论,都必须经过验证,不能直接接受。
|
|
||||||
|
|
||||||
5. 如果验证失败:
|
|
||||||
- 明确指出哪条成功标准没满足
|
|
||||||
- 继续工作,不要把阶段性进展误判为完成
|
|
||||||
|
|
||||||
6. 只有在以下情况之一才能停止:
|
|
||||||
- 成功标准已满足
|
|
||||||
- 用户明确要求停止
|
|
||||||
|
|
||||||
## 执行风格
|
|
||||||
|
|
||||||
- 重证据,轻口头判断
|
|
||||||
- 优先使用确定性工具证据:`rg`、`git diff --stat`、`git diff -- <path>`、测试、构建、lint、`curl`、数据库查询等能直接证明成功标准的方式
|
|
||||||
- 不把大段命令输出粘进回复;保留在工具调用里,回复只总结关键证据
|
|
||||||
- 重验收,轻自我感觉
|
|
||||||
- 优先用测试、日志、产物、对比结果来证明完成
|
|
||||||
- 对长期任务保持“未达标就继续”的节奏
|
|
||||||
|
|
||||||
## 简版模板
|
|
||||||
|
|
||||||
```md
|
|
||||||
Goal: [[[[[在此填写最终目标]]]]]
|
|
||||||
|
|
||||||
Criteria for success: [[[[[在此填写成功标准]]]]]
|
|
||||||
|
|
||||||
循环执行:
|
|
||||||
1. 推进任务
|
|
||||||
2. 检查是否满足成功标准
|
|
||||||
3. 若未满足,继续工作
|
|
||||||
4. 直到满足标准或用户明确停止
|
|
||||||
```
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
---
|
|
||||||
description: 发版工作流:根据变更类型决定版本号,更新所有版本文件和 changelog,运行验证,commit 并 push
|
|
||||||
argument-hint: 可选:feature | bugfix | 或直接描述本次发布内容
|
|
||||||
allowed-tools: ["Read", "Edit", "Bash", "Glob", "Grep"]
|
|
||||||
---
|
|
||||||
|
|
||||||
# /release — Planet 发版工作流
|
|
||||||
|
|
||||||
## 版本号规则
|
|
||||||
|
|
||||||
| 变更类型 | 版本跳动 | 适用场景 |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| `feature` | `+0.1.0` | 纯新功能,无 bugfix |
|
|
||||||
| `improvement` | `+0.0.1` | UI 调整、小功能增强、bugfix 混合,或以 UI/体验改进为主的迭代 |
|
|
||||||
| `bugfix` | `+0.0.1` | 纯 bug 修复,无新功能 |
|
|
||||||
| `docs` / `maintenance` / `refactor` | 默认不发版,除非用户明确要求 |
|
|
||||||
|
|
||||||
意图混合时以用户明确描述为准;bugfix + 小 feature 混合默认判定为 `improvement`(`+0.0.1`)。
|
|
||||||
|
|
||||||
## 必须同步更新的文件
|
|
||||||
|
|
||||||
使用 `git rev-parse --show-toplevel` 获取仓库根目录,以下路径均相对于根目录:
|
|
||||||
|
|
||||||
- `VERSION`
|
|
||||||
- `frontend/package.json`(`"version"` 字段)
|
|
||||||
- `pyproject.toml`(`version =` 字段)
|
|
||||||
- `uv.lock`(**不要手动编辑**,通过 `uv lock` 重新生成)
|
|
||||||
- `docs/CHANGELOG.md`
|
|
||||||
- `docs/version-history.md`
|
|
||||||
|
|
||||||
## 节省上下文规则
|
|
||||||
|
|
||||||
发版判断应以确定性 CLI 证据为主,优先使用紧凑命令和定点读取:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git status --short
|
|
||||||
git diff --stat HEAD
|
|
||||||
git diff --name-only HEAD
|
|
||||||
rg -n "version|^## |^Released:|当前开发版本|current" VERSION frontend/package.json pyproject.toml docs/CHANGELOG.md docs/version-history.md
|
|
||||||
```
|
|
||||||
|
|
||||||
除非需要判断某个代码变更是否属于本次发版,否则不要读取完整 diff。
|
|
||||||
|
|
||||||
## 执行步骤
|
|
||||||
|
|
||||||
### Step 1 — 环境检查
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git branch --show-current # 确认在 dev 分支
|
|
||||||
git status --short # 检查是否有无关的未暂存修改
|
|
||||||
cat VERSION # 读取当前版本
|
|
||||||
```
|
|
||||||
|
|
||||||
若当前**不在 `dev` 分支**,停下来告知用户,不要继续。
|
|
||||||
|
|
||||||
若存在无关的未暂存修改,列出并询问用户是否一并提交,或先 stash。
|
|
||||||
|
|
||||||
### Step 2 — 确定发版类型与新版本号
|
|
||||||
|
|
||||||
- 若 `$ARGUMENTS` 提供了明确类型(`feature` / `bugfix`),直接使用
|
|
||||||
- 否则根据 `git diff --stat HEAD`、`git diff --name-only HEAD`、必要的 focused diff 和 `git log` 推断
|
|
||||||
- 计算新版本号(例:`0.26.2` → bugfix → `0.26.3`)
|
|
||||||
- **先输出发版计划供用户确认**:
|
|
||||||
|
|
||||||
```
|
|
||||||
发版计划:
|
|
||||||
类型:bugfix
|
|
||||||
版本:0.26.2 → 0.26.3
|
|
||||||
分支:dev
|
|
||||||
将更新:VERSION, frontend/package.json, pyproject.toml, uv.lock, CHANGELOG.md, version-history.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3 — 更新版本号文件
|
|
||||||
|
|
||||||
按顺序更新(每步用 Edit 工具,精确替换,不要重写整个文件):
|
|
||||||
|
|
||||||
1. `VERSION` — 直接替换全部内容为新版本号
|
|
||||||
2. `frontend/package.json` — 替换 `"version": "x.x.x"` 行
|
|
||||||
3. `pyproject.toml` — 替换 `version = "x.x.x"` 行
|
|
||||||
4. 运行 `uv lock` 重新生成 `uv.lock`(在仓库根目录下执行)
|
|
||||||
|
|
||||||
### Step 4 — 更新 CHANGELOG.md
|
|
||||||
|
|
||||||
在文件顶部插入新条目,格式:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## [x.x.x] — YYYY-MM-DD
|
|
||||||
|
|
||||||
### ✨ Features / 🐛 Fixes / 🔧 Improvements
|
|
||||||
- ...(只列高信号条目,最多 5 条)
|
|
||||||
- ...
|
|
||||||
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
日期使用 `date +%Y-%m-%d` 获取今天的日期。
|
|
||||||
|
|
||||||
### Step 5 — 更新 docs/version-history.md
|
|
||||||
|
|
||||||
- 更新文件头部的"当前开发版本"字段
|
|
||||||
- 在时间线表格顶部插入新行:`| vx.x.x | YYYY-MM-DD | 一句话摘要 |`
|
|
||||||
|
|
||||||
### Step 6 — 验证
|
|
||||||
|
|
||||||
针对本次变更范围做最小验证:
|
|
||||||
|
|
||||||
- Python 文件有修改:先用 `git diff --name-only HEAD -- '*.py'` 列出,再运行 `python3 -m py_compile <changed_files>`
|
|
||||||
- Frontend 文件有修改:先用 `git diff --name-only HEAD -- frontend` 判断范围,再运行项目标准检查(若无则跳过并说明)
|
|
||||||
- 版本号一致性检查:用 grep 确认 VERSION、package.json、pyproject.toml 中的版本号完全一致
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat VERSION
|
|
||||||
rg -n "\"version\":|^version =|version = " frontend/package.json pyproject.toml uv.lock
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7 — 提交前预览
|
|
||||||
|
|
||||||
展示将要提交的文件列表:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git diff --stat HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
再次确认所有必须文件都在变更列表中,**不包含**非预期文件(如调试文件、.env 等)。
|
|
||||||
|
|
||||||
### Step 8 — Commit & Push(用户确认后)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add VERSION frontend/package.json pyproject.toml uv.lock docs/CHANGELOG.md docs/version-history.md
|
|
||||||
# 若有代码变更也一并 stage
|
|
||||||
git add <code_files>
|
|
||||||
|
|
||||||
git commit -m "release: bump version to x.x.x"
|
|
||||||
git tag vx.x.x
|
|
||||||
git push origin dev
|
|
||||||
git push origin vx.x.x
|
|
||||||
```
|
|
||||||
|
|
||||||
commit message 固定格式:`release: bump version to x.x.x`
|
|
||||||
|
|
||||||
### Step 9 — 完成确认
|
|
||||||
|
|
||||||
输出摘要:
|
|
||||||
|
|
||||||
```
|
|
||||||
✓ 版本号已更新:0.26.2 → 0.26.3
|
|
||||||
✓ CHANGELOG 已更新
|
|
||||||
✓ version-history 已更新
|
|
||||||
✓ uv.lock 已重新生成
|
|
||||||
✓ 验证通过
|
|
||||||
✓ commit: release: bump version to 0.26.3
|
|
||||||
✓ tag: v0.26.3
|
|
||||||
✓ 已 push 到 origin/dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- `uv.lock` 只能通过 `uv lock` 生成,绝不手动编辑
|
|
||||||
- 发版 commit 只包含版本文件 + 本次功能代码,不混入无关改动
|
|
||||||
- 若环境中 `uv` 不可用,说明原因并跳过 lockfile 更新,提醒用户手动运行
|
|
||||||
BIN
.codex/screenshots/earth-i18n-current-page.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
.codex/screenshots/earth-i18n-hd-texture-status.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
.codex/screenshots/i18n-admin-data-sidebar.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
.codex/screenshots/i18n-ai-tool-calls.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
.codex/screenshots/i18n-earth-brand-config.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
.codex/screenshots/i18n-earth-hud-brand.png
Normal file
|
After Width: | Height: | Size: 772 KiB |
BIN
.codex/screenshots/i18n-settings-notifications.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
.codex/screenshots/i18n-settings-system.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
.codex/screenshots/sidebar-left-align.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -52,6 +52,7 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
|
|||||||
- Keep code snippets short and directly relevant.
|
- Keep code snippets short and directly relevant.
|
||||||
- List related files only when they help future maintainers navigate.
|
- List related files only when they help future maintainers navigate.
|
||||||
- Use the repository’s existing language, heading style, and naming conventions.
|
- Use the repository’s existing language, heading style, and naming conventions.
|
||||||
|
- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change.
|
||||||
|
|
||||||
4. Verify:
|
4. Verify:
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
!pyproject.toml
|
!pyproject.toml
|
||||||
!uv.lock
|
!uv.lock
|
||||||
|
!VERSION
|
||||||
|
!backend/
|
||||||
|
!backend/**
|
||||||
!aiprovider/
|
!aiprovider/
|
||||||
!aiprovider/**
|
!aiprovider/**
|
||||||
|
|
||||||
|
backend/.env
|
||||||
|
backend/.env.*
|
||||||
aiprovider/.env
|
aiprovider/.env
|
||||||
aiprovider/.env.*
|
aiprovider/.env.*
|
||||||
!aiprovider/.env.example
|
!aiprovider/.env.example
|
||||||
|
|||||||
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
@@ -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
@@ -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
|
||||||
16
.gitignore
vendored
@@ -8,6 +8,7 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
config/earth-boundary-sources.local.json
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
*.crt
|
*.crt
|
||||||
@@ -24,11 +25,14 @@ __pycache__/
|
|||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
downloads/*
|
||||||
|
!downloads/usbipd-win/
|
||||||
|
downloads/usbipd-win/*
|
||||||
|
!downloads/usbipd-win/usbipd-win-5.3.0.msi
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
/lib/
|
||||||
lib64/
|
/lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
@@ -150,3 +154,9 @@ temp/
|
|||||||
# Runtime Data
|
# Runtime Data
|
||||||
# ----------------------
|
# ----------------------
|
||||||
data/ai/bgp-briefs/
|
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/
|
||||||
|
|||||||
180
AGENTS.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
**Planet agent harness. Defines behavior for coding agents working in this repository.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Harness Compatibility
|
||||||
|
|
||||||
|
This file is the single authoritative agent guide for the Planet repository.
|
||||||
|
The older lowercase `agents.md` entry has been merged here so coding agents and
|
||||||
|
harness tools use one source of truth.
|
||||||
|
|
||||||
|
### Source Of Truth
|
||||||
|
|
||||||
|
- `rules.md` is the mandatory repository rule source. Always load `core`,
|
||||||
|
`security`, and `workflow`; load only task-relevant modules after that.
|
||||||
|
- `AGENTS.md` defines the local agent operating mode and evidence gates.
|
||||||
|
- `project_context.md` is background, not a rule source. Prefer newer
|
||||||
|
implementation docs when it disagrees with current code.
|
||||||
|
- `.codex/skills/` is the active specialized workflow layer for cleanup, docs,
|
||||||
|
goal-driven work, and release.
|
||||||
|
- Do not duplicate long workflow text across harness files. Durable constraints
|
||||||
|
belong in `rules.md`; task procedures belong in skills or scripts.
|
||||||
|
|
||||||
|
Read these files before changing code:
|
||||||
|
|
||||||
|
1. `rules.md`
|
||||||
|
2. `AGENTS.md`
|
||||||
|
3. `project_context.md`
|
||||||
|
4. `README.md`
|
||||||
|
5. `docs/HARNESS.md`
|
||||||
|
6. `CODEMAP.md`
|
||||||
|
|
||||||
|
For documentation work, also read `docs/documentation-coverage-rules.md`.
|
||||||
|
|
||||||
|
### Start Safely
|
||||||
|
|
||||||
|
Before broad edits:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
scripts/harness/doctor.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Use focused context commands before reading large files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "<symbol-or-term>" <path>
|
||||||
|
git diff --stat HEAD
|
||||||
|
git diff --name-only HEAD
|
||||||
|
git diff --unified=0 HEAD -- <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve user changes already present in the worktree.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
Fast local harness validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/harness/quick-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Full local validation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/harness/validate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`validate.sh` includes quick checks, frontend Bun build, and frontend smoke
|
||||||
|
unless disabled by its documented environment flags. Docker image smoke builds
|
||||||
|
are intentionally opt-in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Harness scripts resolve `bun`, `uv`, and optional delivery tools from the
|
||||||
|
current non-interactive environment first. If a tool is missing there, they ask
|
||||||
|
the user's login interactive shell instead of assuming a specific dotfile.
|
||||||
|
|
||||||
|
### High-Risk Areas
|
||||||
|
|
||||||
|
- `planet.sh` owns local lifecycle, ports, WSL/LAN behavior, and destructive
|
||||||
|
`destroy` cleanup.
|
||||||
|
- Frontend package management is Bun-only. Do not use npm, pnpm, or yarn.
|
||||||
|
- Frontend changes must satisfy `scripts/harness/frontend-rules-check.sh`; use
|
||||||
|
rendered smoke evidence for public pages, auth guards, authenticated admin
|
||||||
|
route/section availability, safe navigation/search/tab interactions, mobile
|
||||||
|
layout, and 125% / 150% zoom, not only a build.
|
||||||
|
- Admin or Docs layout changes must load `rules.md` `uiux` and preserve the
|
||||||
|
one-screen (`一屏` / `首屏`) height chain: route roots use `height: 100%`,
|
||||||
|
intermediate wrappers keep `min-height: 0`, and only the intended child owns
|
||||||
|
scrolling.
|
||||||
|
- `aiprovider` is a protocol/provider adapter; keep business prompts and product
|
||||||
|
workflows in the backend.
|
||||||
|
- Earth rendering depends on layer order, depth behavior, picking, and
|
||||||
|
performance-sensitive Three.js code.
|
||||||
|
- Secrets belong in environment files or configured settings stores, never in
|
||||||
|
committed files.
|
||||||
|
- Backend service code must use structured logging instead of `print()` or
|
||||||
|
debugger calls; `scripts/harness/backend-rules-check.sh` enforces this.
|
||||||
|
|
||||||
|
### Conflict Policy
|
||||||
|
|
||||||
|
Existing project rules and workflows win. If new harness guidance conflicts with
|
||||||
|
`rules.md`, `AGENTS.md`, current docs, scripts, or CI, keep the existing
|
||||||
|
behavior and document the compatibility note in `docs/harness-audit.md` or
|
||||||
|
`docs/HARNESS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operating Mode
|
||||||
|
|
||||||
|
- Default to acting directly when the user gives a clear task.
|
||||||
|
- Ask before acting only when the missing decision is risky, cannot be
|
||||||
|
discovered from repository context, and no conservative assumption is safe.
|
||||||
|
- Read relevant files before editing.
|
||||||
|
- Prefer focused CLI evidence: `rg`, `git diff --stat`, `git diff --name-only`,
|
||||||
|
focused file reads, tests, builds, linters, and harness scripts.
|
||||||
|
- Keep changes scoped to the requested area. Do not mix cleanup, feature work,
|
||||||
|
release work, and documentation unless the task requires it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Evidence Gates
|
||||||
|
|
||||||
|
- Visual inputs are blocking evidence. If the user provides a screenshot, image,
|
||||||
|
mock, browser capture, or visual reference, obtain evidence from the artifact
|
||||||
|
before interpreting intent or editing code.
|
||||||
|
- Path resolution is part of the task. If the path cannot be opened, first try
|
||||||
|
reasonable local equivalents such as WSL/Windows path conversion,
|
||||||
|
workspace-relative lookup, absolute paths, and attached-file locations.
|
||||||
|
- Never guess from prompt text, filenames, previous context, logs, OCR, or
|
||||||
|
memory when a visual artifact was provided but cannot be accessed.
|
||||||
|
- OCR is acceptable evidence for text-only visual questions or non-multimodal
|
||||||
|
environments; state that OCR was used as the fallback. Layout, color, spacing,
|
||||||
|
pixel, and rendering issues need real visual inspection or a clear limitation
|
||||||
|
note.
|
||||||
|
- If a visual artifact still cannot be inspected, say so and pause that
|
||||||
|
visual-dependent part of the work.
|
||||||
|
- Claims of completion need evidence: a relevant test, build, lint, screenshot,
|
||||||
|
diff, direct file check, or harness result.
|
||||||
|
- For UI and rendering changes, verify the rendered result when local tooling
|
||||||
|
allows it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
- Match the user's language. Use Chinese for Chinese requests unless the user
|
||||||
|
asks otherwise.
|
||||||
|
- Keep updates short and specific: what is being inspected, edited, or verified.
|
||||||
|
- Final responses should summarize changed files and verification, with blockers
|
||||||
|
stated plainly.
|
||||||
|
- Use file references with line numbers when explaining code or review findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quality Bar
|
||||||
|
|
||||||
|
- Prefer existing project patterns over new abstractions.
|
||||||
|
- Remove stale branches, mocks, compatibility paths, and duplicated helpers once
|
||||||
|
a stable path exists.
|
||||||
|
- Centralize prompts, constants, defaults, and shared request/response handling.
|
||||||
|
- Do not add secrets, generated runtime output, or local environment files.
|
||||||
|
- Frontend commands use Bun only. Do not use `npm`, `pnpm`, or `yarn`.
|
||||||
|
- Run the smallest relevant verification for the changed scope and report
|
||||||
|
anything skipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prohibited
|
||||||
|
|
||||||
|
- Do not skip visual evidence handling when a visual artifact was provided.
|
||||||
|
- Do not preserve obsolete harness files just because they already exist.
|
||||||
|
- Do not invent behavior not present in code, docs, or verified external
|
||||||
|
sources.
|
||||||
|
- Do not rewrite unrelated files during cleanup.
|
||||||
|
- Do not mark a task complete without checking concrete success criteria.
|
||||||
109
CODEMAP.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Code Map
|
||||||
|
|
||||||
|
This map gives agents and maintainers a quick orientation without replacing the
|
||||||
|
deeper architecture docs. Current implementation docs under `docs/technical/`
|
||||||
|
are the source of detail for specific subsystems.
|
||||||
|
|
||||||
|
## Top-Level Areas
|
||||||
|
|
||||||
|
| Path | Role | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `backend/` | FastAPI backend, auth, APIs, data collectors, AI task orchestration, persistence | Tests live in `backend/tests/`; run backend tests from `backend/` with the root uv project. |
|
||||||
|
| `frontend/` | React admin console, Docs UI, Web Earth shell, Vite build | Use Bun only. Public Earth assets live under `frontend/public/earth/`. |
|
||||||
|
| `aiprovider/` | Model provider/protocol adapter service | Keep it free of product-specific prompts and workflows. |
|
||||||
|
| `motion_agent/` | Motion capture protocol service used by `planet.sh` | Often dry-runs when cameras are unavailable, especially in WSL. |
|
||||||
|
| `scripts/` | Utility scripts and harness wrappers | Harness commands live in `scripts/harness/`. |
|
||||||
|
| `docs/` | Plans, technical docs, changelog, harness docs | Public technical docs are explicitly registered by the frontend Docs catalog. |
|
||||||
|
| `deploy/helm/planet/` | Helm chart for staging/deployment smoke paths | CI runs helm lint/template when delivery checks are available. |
|
||||||
|
| `.gitea/workflows/` | CI, release image build, staging deploy workflows | This repository uses Gitea workflow files, not `.github/workflows/`. |
|
||||||
|
| `planet.sh` | Main local lifecycle script | Owns init/start/restart/stop/health/log/createuser/destroy. |
|
||||||
|
|
||||||
|
## Runtime Entry Points
|
||||||
|
|
||||||
|
| Runtime | Entry Point | Validation |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Local full stack | `./planet.sh start` | `./planet.sh health` |
|
||||||
|
| Backend API | `backend/app/main.py` | `cd backend && uv run --frozen --group dev --project .. python -m pytest -q` |
|
||||||
|
| Frontend app | `frontend/src/main.tsx` and `frontend/vite.config.mts` | `cd frontend && bun run build` |
|
||||||
|
| AI Provider | `aiprovider/main.py` | `curl http://localhost:8010/health` after startup |
|
||||||
|
| Motion Agent | `python -m motion_agent` via `planet.sh` | `./planet.sh health` or dry-run startup |
|
||||||
|
| Docs UI | `frontend/src/pages/Docs/` | Docs catalog metadata plus frontend build |
|
||||||
|
|
||||||
|
## Ownership Boundaries
|
||||||
|
|
||||||
|
- Backend owns business state, auth, evidence collection, prompt selection, AI
|
||||||
|
task orchestration, and database persistence.
|
||||||
|
- `aiprovider` owns provider identity, request adapter style, model gateway
|
||||||
|
retries, and health/status endpoints only.
|
||||||
|
- Frontend owns operator workflows, Docs presentation, Web Earth orchestration,
|
||||||
|
and client-side state that mirrors backend truth.
|
||||||
|
- Web Earth rendering changes must preserve documented layer order, altitude
|
||||||
|
offsets, picking behavior, legend semantics, and performance constraints.
|
||||||
|
- `planet.sh` owns local environment bootstrap and service lifecycle. Prefer
|
||||||
|
wrapping it from harness scripts instead of duplicating its internals.
|
||||||
|
- Harness scripts source `scripts/harness/lib.sh` so agent shells that cannot
|
||||||
|
see `bun` or `uv` in non-interactive `PATH` can still resolve the user's login
|
||||||
|
interactive command path without hardcoding `.zshrc`.
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/harness/doctor.sh
|
||||||
|
scripts/harness/security-check.sh
|
||||||
|
scripts/harness/backend-rules-check.sh
|
||||||
|
scripts/harness/frontend-rules-check.sh
|
||||||
|
scripts/harness/docs-consistency-check.sh
|
||||||
|
scripts/harness/quick-check.sh
|
||||||
|
scripts/harness/validate.sh
|
||||||
|
./planet.sh health
|
||||||
|
```
|
||||||
|
|
||||||
|
CI-equivalent local checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
uv run --frozen --group dev --project .. python -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q
|
||||||
|
|
||||||
|
cd frontend
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run build
|
||||||
|
PLANET_FRONTEND_SMOKE_URL=http://127.0.0.1:4173 bun ../scripts/harness/frontend-smoke.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend smoke covers public routes, unauthenticated admin guards,
|
||||||
|
login-error handling, the Earth iframe entry, and authenticated `super_admin`
|
||||||
|
admin route/section rendering with mocked API data. Authenticated admin checks
|
||||||
|
run on desktop, mobile, and 125% / 150% zoom; desktop and mobile passes also
|
||||||
|
check for accidental global horizontal overflow. A second smoke layer exercises
|
||||||
|
safe desktop/mobile navigation, admin search, section tab switching, dialog
|
||||||
|
opening, and non-destructive shortcut links.
|
||||||
|
|
||||||
|
Optional delivery smoke, when Docker and Helm are available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deeper Docs
|
||||||
|
|
||||||
|
| Topic | Start Here |
|
||||||
|
| --- | --- |
|
||||||
|
| Data products and flows | `docs/technical/zh/platform-data-flows.md` and `docs/technical/en/platform-data-flows.md` |
|
||||||
|
| Operations and local lifecycle | `docs/technical/zh/ops-runbook.md` and `docs/technical/en/ops-runbook.md` |
|
||||||
|
| `planet.sh` startup behavior | `docs/technical/zh/ops-planet-sh-startup.md` and `docs/technical/en/ops-planet-sh-startup.md` |
|
||||||
|
| AI Provider | `docs/technical/zh/agents-aiprovider.md` and `docs/technical/en/agents-aiprovider.md` |
|
||||||
|
| Admin frontend | `docs/technical/zh/frontend-admin-frontend-context.md` and `docs/technical/en/frontend-admin-frontend-context.md` |
|
||||||
|
| Earth frontend | `docs/technical/zh/earth-frontend-context.md` and `docs/technical/en/earth-frontend-context.md` |
|
||||||
|
| Earth render order | `docs/technical/zh/earth-render-layer-order.md` and `docs/technical/en/earth-render-layer-order.md` |
|
||||||
|
| Documentation rules | `docs/documentation-coverage-rules.md` |
|
||||||
|
| Harness workflow | `docs/HARNESS.md` |
|
||||||
|
|
||||||
|
## Known Sharp Edges
|
||||||
|
|
||||||
|
- `project_context.md` is static background for agents. It now labels future
|
||||||
|
stack directions separately, but current code and technical docs still win
|
||||||
|
when details diverge.
|
||||||
|
- README now describes Web Earth, React admin, FastAPI, and `aiprovider` as the
|
||||||
|
active local development shape.
|
||||||
|
- Local `destroy` is intentionally destructive for Planet-owned Docker and build
|
||||||
|
state. Never run it as a validation shortcut.
|
||||||
270
README.md
@@ -8,68 +8,54 @@
|
|||||||
|
|
||||||
## 系统架构
|
## 系统架构
|
||||||
|
|
||||||
|
当前仓库的核心形态是“Web Earth 可视化 + React 运维台 + FastAPI 数据与 AI 编排后端 + 独立模型适配层”。物理大屏与 UE 客户端仍是长期方向,但不再作为本地开发和当前发布的必需运行单元。
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ 物理大屏展示层 │
|
│ 浏览器展示与运维层 │
|
||||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
|
||||||
│ │ 偏振片3D大屏 (2m×3m, 4K, 120Hz, 眼镜式) │ │
|
│ │ Web Earth │ │ React 运维台 │ │
|
||||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
│ │ frontend/public/earth │ │ frontend/src │ │
|
||||||
│ │ │ 虚幻引擎 UE5 客户端 │ │ │
|
│ │ Three.js 地球 / HUD / 新闻 │ │ 数据源 / 告警 / AI 设置 │ │
|
||||||
│ │ │ ├── 3D地球渲染 (Cesium for UE) │ │ │
|
│ │ 国界精度 / 品牌内容配置 │ │ 提示词配置 / 用户与系统配置 │ │
|
||||||
│ │ │ ├── 算力点可视化 (GPU集群、智算中心) │ │ │
|
│ └──────────────────────────────┘ └──────────────────────────────┘ │
|
||||||
│ │ │ ├── 连接弧线 (光缆、路由、数据流向) │ │ │
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
│ │ │ ├── 粒子效果 (数据流动、告警提示) │ │ │
|
│ REST / WebSocket
|
||||||
│ │ │ └── 自动巡航相机 + 交互控制 │ │ │
|
▼
|
||||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
│ FastAPI 业务与编排后端 │
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
|
||||||
▲
|
│ │ 数据 API 与认证 │ │ Earth 新闻增强 │ │ 告警与态势简报 │ │
|
||||||
│ WebSocket (实时推送)
|
│ │ JWT / 权限 / 审计 │ │ 位置推断 / 本地化 │ │ BGP / 告警研判 │ │
|
||||||
│ 120Hz 心跳 / 数据帧同步
|
│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │
|
||||||
▼
|
│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
│ │ 系统运行配置 │ │ 默认提示词注册表 │ │ 未来 Agent Runtime│ │
|
||||||
│ 数据中台服务层 (FastAPI) │
|
│ │ system_settings │ │ 代码发布 + DB 覆盖 │ │ 工具/证据/工作流 │ │
|
||||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │
|
||||||
│ │ API Gateway (Redis 限流) │ │
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
│ SQLAlchemy / Redis Stream │ 纯净 LLM 调用
|
||||||
│ │ │
|
▼ ▼
|
||||||
│ ┌───────────────────┬──────────────────────────┬──────────────────┐ │
|
┌──────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
│ │ 数据采集服务 │ 核心业务服务 │ 运维管理服务 │ │
|
│ PostgreSQL / Redis │ │ aiprovider │
|
||||||
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │
|
│ 用户、配置、采集结果、新闻 │ │ provider + protocol adapter │
|
||||||
│ │ │ 调度中心 │ │ │ WebSocket 服务 │ │ │ 用户管理 │ │ │
|
│ Stream、缓存、运行状态 │ │ OpenAI / MiniMax / Ollama 等 │
|
||||||
│ │ │ (Celery) │ │ │ (FastAPI) │ │ │ (JWT Auth) │ │ │
|
└──────────────────────────────┘ └──────────────────────────────┘
|
||||||
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
|
▲
|
||||||
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │
|
│ 采集器 / 外部数据源
|
||||||
│ │ │ 采集器池 │ │ │ 数据查询 API │ │ │ 数据源配置 │ │ │
|
▼
|
||||||
│ │ │ (10+源) │ │ │ (REST) │ │ │ 监控告警 │ │ │
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
|
│ RSS 新闻、BGP 观测、公开数据源、后续 WebSearch/OCR/语音识别等工具 │
|
||||||
│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
│ │ │ 消息队列 │ │ │ 态势分析引擎 │ │ │ 系统配置 │ │ │
|
|
||||||
│ │ │ (Kafka) │ │ │ (计算/聚合) │ │ │ 日志审计 │ │ │
|
|
||||||
│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │
|
|
||||||
│ └───────────────────┴──────────────────────────┴──────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
▲
|
|
||||||
│ 内部 API 调用
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Web管理端 (React Admin) │
|
|
||||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 登录页 │ 仪表盘 │ 用户管理 │ 数据源配置 │ 任务监控 │ 系统配置 │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
▲
|
|
||||||
│ PostgreSQL / Redis
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 数据存储层 │
|
|
||||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
|
||||||
│ │ PostgreSQL │ │ TimescaleDB │ │ Redis │ │ MinIO │ │
|
|
||||||
│ │ (用户/配置) │ │ (时序数据) │ │ (缓存/会话) │ │ (文件存储) │ │
|
|
||||||
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
架构边界:
|
||||||
|
|
||||||
|
- `backend` 负责业务语义、证据收集、提示词选择、AI 任务编排、权限和数据落库。
|
||||||
|
- `aiprovider` 只负责把纯净模型请求适配到不同供应商或协议,不内置具体业务提示词。
|
||||||
|
- 默认提示词随代码发布并保存在 `backend/app/ai_tasks/default_prompts.json`,运维台可在数据库中保存覆盖值,重置时回到当前代码版本的默认提示词。
|
||||||
|
- Earth 新闻保留英文原文,中文展示结果存入 `localizations`,前端默认展示 `zh-CN` 的 `display_title`、`display_summary` 和中文地域/状态文案。
|
||||||
|
- Earth LLM 指令、语音识别、多角色态势研判属于后续 Agent Runtime 方向,计划见 [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)。
|
||||||
|
|
||||||
## 四大核心要素
|
## 四大核心要素
|
||||||
|
|
||||||
| 层级 | 要素 | 描述 |
|
| 层级 | 要素 | 描述 |
|
||||||
@@ -87,21 +73,21 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| FastAPI | 0.109+ | Web 框架 |
|
| FastAPI | 0.109+ | Web 框架 |
|
||||||
| SQLAlchemy | 2.0+ | ORM |
|
| SQLAlchemy | 2.0+ | ORM |
|
||||||
| Alembic | - | 数据库迁移 |
|
| uv | - | Python 依赖与命令运行 |
|
||||||
| Celery | 5.3+ | 任务队列 |
|
| Redis | 7.0+ | 缓存、Stream 与运行协调 |
|
||||||
| Redis | 7.0+ | 缓存/消息 |
|
|
||||||
| Kafka | 3.0+ | 事件流 |
|
|
||||||
| PyJWT | - | 认证 |
|
| PyJWT | - | 认证 |
|
||||||
|
| APScheduler / 后台任务 | - | 采集、增强与运行时任务 |
|
||||||
|
|
||||||
### 前端 (React Admin)
|
### 前端 (React Admin)
|
||||||
|
|
||||||
| 组件 | 用途 |
|
| 组件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| React 18 | UI 框架 |
|
| React 18 | UI 框架 |
|
||||||
| Ant Design Pro | 管理后台组件 |
|
| Tactile UI / Radix primitives / lucide-react | 管理后台组件、基础交互与图标 |
|
||||||
| Axios | HTTP 客户端 |
|
| Axios | HTTP 客户端 |
|
||||||
| Socket.io-client | WebSocket 客户端 |
|
| Socket.io-client | WebSocket 客户端 |
|
||||||
| ECharts | 统计图表 |
|
| ECharts | 统计图表 |
|
||||||
|
| Three.js | Earth 3D 地球渲染 |
|
||||||
| Bun | 前端包管理与脚本运行 |
|
| Bun | 前端包管理与脚本运行 |
|
||||||
|
|
||||||
前端工程统一使用 Bun:
|
前端工程统一使用 Bun:
|
||||||
@@ -110,22 +96,16 @@
|
|||||||
- 运行脚本使用 `bun run <script>`
|
- 运行脚本使用 `bun run <script>`
|
||||||
- 不使用 `npm`、`pnpm`、`yarn`
|
- 不使用 `npm`、`pnpm`、`yarn`
|
||||||
|
|
||||||
### 虚幻引擎客户端
|
### 大屏与 3D 展示方向
|
||||||
|
|
||||||
| 组件 | 版本 | 用途 |
|
当前发布优先使用浏览器 Web Earth。UE5 / Cesium for Unreal / Niagara 可作为后续物理大屏方向接入,但不是本地开发闭环的必需组件。
|
||||||
|------|------|------|
|
|
||||||
| Unreal Engine 5 | 5.3+ | 3D 渲染引擎 |
|
|
||||||
| Cesium for Unreal | 1.5+ | 地理可视化 |
|
|
||||||
| Niagara | - | 粒子系统 |
|
|
||||||
|
|
||||||
### 数据库
|
### 数据库
|
||||||
|
|
||||||
| 组件 | 用途 |
|
| 组件 | 用途 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| PostgreSQL 15+ | 关系数据 |
|
| PostgreSQL 15+ | 关系数据 |
|
||||||
| TimescaleDB | 时序数据扩展 |
|
| Redis 7+ | 缓存、Stream、运行状态 |
|
||||||
| Redis 7+ | 缓存/会话 |
|
|
||||||
| MinIO | S3 兼容存储 |
|
|
||||||
|
|
||||||
### 部署
|
### 部署
|
||||||
|
|
||||||
@@ -152,9 +132,9 @@
|
|||||||
| P0 | Epoch AI | 每小时 |
|
| P0 | Epoch AI | 每小时 |
|
||||||
| P0 | Hugging Face | 每 2 小时 |
|
| P0 | Hugging Face | 每 2 小时 |
|
||||||
| P0 | GitHub | 每 4 小时 |
|
| P0 | GitHub | 每 4 小时 |
|
||||||
| P0 每日 |
|
| P0 | 海底光缆 / IXP / 卫星等基础设施数据 | 每日或按源刷新 |
|
||||||
| P0 | PeeringDB | 每 2 小时 |
|
| P0 | PeeringDB | 每 2 小时 |
|
||||||
| P1 | Cloudflare Radar | | TeleGeography | 每小时 |
|
| P1 | Cloudflare Radar / TeleGeography | 每小时 |
|
||||||
| P1 | CAIDA BGPStream | 每 15 分钟 |
|
| P1 | CAIDA BGPStream | 每 15 分钟 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -166,20 +146,18 @@
|
|||||||
│ │ ├── core/ # 核心配置
|
│ │ ├── core/ # 核心配置
|
||||||
│ │ ├── models/ # 数据模型
|
│ │ ├── models/ # 数据模型
|
||||||
│ │ ├── schemas/ # Pydantic 模型
|
│ │ ├── schemas/ # Pydantic 模型
|
||||||
│ │ ├── services/ # 业务逻辑
|
│ │ ├── services/ # 业务逻辑与 AI 任务编排
|
||||||
│ │ └── tasks/ # Celery 任务
|
│ │ └── ai_tasks/ # 默认提示词与 AI 任务定义
|
||||||
│ └── tests/
|
│ └── tests/
|
||||||
|
├── aiprovider/ # 独立模型供应商适配层
|
||||||
├── frontend/ # React 管理后台
|
├── frontend/ # React 管理后台
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # 组件
|
│ │ ├── components/ # 组件
|
||||||
│ │ ├── pages/ # 页面
|
│ │ ├── pages/ # 页面
|
||||||
│ │ ├── services/ # API 服务
|
│ │ ├── services/ # API 服务
|
||||||
│ │ └── store/ # 状态管理
|
│ │ └── store/ # 状态管理
|
||||||
│ └── tests/
|
│ ├── public/earth/ # Web Earth 静态应用
|
||||||
├── unreal/ # UE5 大屏客户端
|
│ └── tests/ # 前端测试
|
||||||
│ ├── Content/
|
|
||||||
│ ├── Source/
|
|
||||||
│ └── Plugins/
|
|
||||||
├── data/ # 数据文件
|
├── data/ # 数据文件
|
||||||
├── docs/ # 文档
|
├── docs/ # 文档
|
||||||
├── scripts/ # 脚本
|
├── scripts/ # 脚本
|
||||||
@@ -190,11 +168,14 @@
|
|||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
|
入口需要先具备 `zsh`、`curl` 和可访问的软件源。Ubuntu / Ubuntu WSL 上,`init` 会自动检测并补装 Docker Engine、Compose v2 和 Buildx,启动 Docker 服务并配置当前用户的访问权限;需要系统权限时会提示输入 sudo 密码。其他系统请先准备可用的 Docker 环境。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 新机器首次初始化
|
# 新机器或空项目首次初始化
|
||||||
./scripts/bootstrap-dev.sh
|
./planet.sh init
|
||||||
# 会自动安装/检查 uv、bun,并同步 Python/前端依赖
|
# 会先准备 Docker / Compose / Buildx,再安装/检查 uv、bun 并同步 Python/前端依赖
|
||||||
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
|
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
|
||||||
|
# 会启动 PostgreSQL/Redis,并创建表、默认数据源和本地默认用户
|
||||||
|
|
||||||
# 启动前后端服务
|
# 启动前后端服务
|
||||||
./planet.sh start
|
./planet.sh start
|
||||||
@@ -210,6 +191,9 @@
|
|||||||
|
|
||||||
# 查看服务状态
|
# 查看服务状态
|
||||||
./planet.sh health
|
./planet.sh health
|
||||||
|
|
||||||
|
# 删除容器、卷、镜像和本地编译状态,执行前需要输入 Y 确认
|
||||||
|
./planet.sh destroy
|
||||||
```
|
```
|
||||||
|
|
||||||
前端命令约定:
|
前端命令约定:
|
||||||
@@ -236,13 +220,15 @@ bun run build
|
|||||||
|
|
||||||
推荐按下面顺序排查和配置。
|
推荐按下面顺序排查和配置。
|
||||||
|
|
||||||
|
端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
|
||||||
|
|
||||||
### 1. 在 WSL 中启动服务
|
### 1. 在 WSL 中启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./planet.sh start --allow-lan
|
./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 内部服务正常
|
### 2. 先确认 WSL 内部服务正常
|
||||||
|
|
||||||
@@ -251,14 +237,16 @@ bun run build
|
|||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000
|
curl http://localhost:3000
|
||||||
curl http://localhost:8000/health
|
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
|
- `3000` 返回前端 HTML
|
||||||
- `8000/health` 返回健康检查 JSON
|
- `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 转发。
|
如果这一步不通,先不要继续做 Windows 转发。
|
||||||
|
|
||||||
@@ -269,42 +257,31 @@ ss -ltnp | grep -E ':3000|:8000'
|
|||||||
```powershell
|
```powershell
|
||||||
curl http://localhost:3000
|
curl http://localhost:3000
|
||||||
curl http://localhost:8000/health
|
curl http://localhost:8000/health
|
||||||
|
curl http://localhost:8010/health
|
||||||
```
|
```
|
||||||
|
|
||||||
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
|
在常见的 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
|
```powershell
|
||||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
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 delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||||
|
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8010
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
再放行 Windows 防火墙:
|
脚本会检测 Windows 防火墙是否已放行 `3000` / `8000` / `8010`。如果缺少规则,会触发一次 Windows UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,也可以手动执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
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 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,并让其他设备访问
|
### 5. 查 Windows 局域网 IP,并让其他设备访问
|
||||||
|
|
||||||
在 Windows PowerShell 中执行:
|
在 Windows PowerShell 中执行:
|
||||||
@@ -319,6 +296,8 @@ ipconfig
|
|||||||
|
|
||||||
- `http://<Windows局域网IP>:3000/earth`
|
- `http://<Windows局域网IP>:3000/earth`
|
||||||
- `http://<Windows局域网IP>:3000/admin`
|
- `http://<Windows局域网IP>:3000/admin`
|
||||||
|
- `http://<Windows局域网IP>:8000/health`
|
||||||
|
- `http://<Windows局域网IP>:8010/health`
|
||||||
|
|
||||||
例如:
|
例如:
|
||||||
|
|
||||||
@@ -327,7 +306,7 @@ ipconfig
|
|||||||
### 6. 常见现象与判断
|
### 6. 常见现象与判断
|
||||||
|
|
||||||
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
|
- 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 不是提权管理员窗口
|
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
|
||||||
|
|
||||||
### 7. 本项目一次性验证顺序
|
### 7. 本项目一次性验证顺序
|
||||||
@@ -336,14 +315,16 @@ ipconfig
|
|||||||
|
|
||||||
1. WSL 中执行 `curl http://localhost:3000`
|
1. WSL 中执行 `curl http://localhost:3000`
|
||||||
2. WSL 中执行 `curl http://localhost:8000/health`
|
2. WSL 中执行 `curl http://localhost:8000/health`
|
||||||
3. Windows 中执行 `curl http://localhost:3000`
|
3. WSL 中执行 `curl http://localhost:8010/health`
|
||||||
4. Windows 中执行 `curl http://localhost:8000/health`
|
4. Windows 中执行 `curl http://localhost:3000`
|
||||||
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
|
5. Windows 中执行 `curl http://localhost:8000/health`
|
||||||
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
|
6. Windows 中执行 `curl http://localhost:8010/health`
|
||||||
|
7. 按脚本提示完成 Windows 防火墙或端口清理 UAC 请求
|
||||||
|
8. 用手机或其他电脑访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`
|
||||||
|
|
||||||
## 启动容错参数
|
## 启动容错参数
|
||||||
|
|
||||||
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。
|
`planet.sh` 为依赖安装、数据库、AI Provider 启动提供有限次重试。数据库先检查容器健康,再验证后端实际连接;`aiprovider` 直接以宿主机 `/health` 就绪为准。后端进程退出或应用初始化失败时立即停止等待,避免重复消耗健康检查预算。
|
||||||
|
|
||||||
可通过环境变量临时调整:
|
可通过环境变量临时调整:
|
||||||
|
|
||||||
@@ -363,18 +344,27 @@ DATABASE_RETRY_INTERVAL=10 \
|
|||||||
- `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` 秒
|
- `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` 秒
|
||||||
- `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3`
|
- `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3`
|
||||||
- `FRONTEND_MAX_RETRIES`: 控制前端 dev server 启动重试次数,默认 `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` 秒
|
- `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` 秒
|
- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||||
|
|
||||||
## AI 接口预留
|
## AI 与智能体接口
|
||||||
|
|
||||||
项目现在采用“两层”设计:
|
项目现在采用“三段式”边界:
|
||||||
|
|
||||||
- 主后端暴露稳定业务接口: `GET /api/v1/ai/provider/status`、`POST /api/v1/ai/situational-awareness/analyze`
|
- `backend`: 暴露业务接口,负责选择任务提示词、组织证据、调用工具、保存 AI 设置和结果。
|
||||||
- 独立 `aiprovider` 服务负责适配具体模型供应商
|
- `aiprovider`: 暴露模型网关接口,只负责 provider / protocol 适配,不写入 BGP、新闻、告警等业务提示词。
|
||||||
|
- 模型供应商: OpenAI 兼容、MiniMax、Anthropic、Ollama 或其他兼容网关。
|
||||||
|
|
||||||
这样前端和业务代码不直接依赖 OpenAI、本地模型网关或其他订阅服务,后续切换部署方式只需要调整环境变量。
|
这样前端和业务代码不直接依赖某个模型供应商,后续增加 Agent Runtime、Earth 一键 LLM 指令、语音识别或多角色态势研判时,也可以把业务工作流放在后端,而不是污染模型适配层。
|
||||||
|
|
||||||
|
当前已落地的 AI 配置能力:
|
||||||
|
|
||||||
|
- 运维台 AI 设置可维护 provider、模型、协议、超时、token 等运行配置。
|
||||||
|
- 运维台 AI 设置中的“提示词”页可选择不同功能入口,手动覆盖提示词,并一键重置到默认值。
|
||||||
|
- 默认提示词随代码发布,位于 [backend/app/ai_tasks/default_prompts.json](/home/ray/dev/linkong/planet/backend/app/ai_tasks/default_prompts.json)。
|
||||||
|
- 覆盖值保存在数据库运行配置中,升级代码后可继续保留现场配置,也可重置到新版本默认提示词。
|
||||||
|
- 态势摘要、告警研判、新闻本地化等入口应使用各自任务提示词;调用 `aiprovider` 时只传递当前任务所需的 `prompt` / `system_prompt`。
|
||||||
|
|
||||||
主后端建议配置:
|
主后端建议配置:
|
||||||
|
|
||||||
@@ -387,39 +377,33 @@ AI_PROVIDER_TIMEOUT_SECONDS=60
|
|||||||
`aiprovider` 服务建议配置:
|
`aiprovider` 服务建议配置:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
AI_PROVIDER=openai_compatible
|
AI_PROVIDER=minimax
|
||||||
AI_BASE_URL=https://api.openai.com/v1
|
AI_PROVIDER_API=anthropic-messages
|
||||||
|
AI_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
AI_API_KEY=your_api_key
|
AI_API_KEY=your_api_key
|
||||||
AI_MODEL=gpt-4o-mini
|
AI_MODEL=MiniMax-M2.7
|
||||||
AI_TIMEOUT_SECONDS=60
|
AI_TIMEOUT_SECONDS=60
|
||||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||||
```
|
```
|
||||||
|
|
||||||
OpenAI 兼容场景推荐使用:
|
推荐映射关系:
|
||||||
|
|
||||||
- `AI_PROVIDER=openai_compatible`
|
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai` + `AI_PROVIDER_API=openai-completions`
|
||||||
|
- `MiniMax`: `AI_PROVIDER=minimax` + `AI_PROVIDER_API=anthropic-messages`
|
||||||
|
- Claude 兼容网关: `AI_PROVIDER=anthropic` + `AI_PROVIDER_API=anthropic-messages`
|
||||||
|
- `Ollama`: `AI_PROVIDER=ollama` + `AI_PROVIDER_API=ollama-generate`
|
||||||
|
|
||||||
Claude 兼容场景推荐使用:
|
比如 MiniMax 可以这样配置:
|
||||||
|
|
||||||
- `AI_PROVIDER=anthropic`
|
|
||||||
- `AI_PROVIDER=anthropic_compatible`
|
|
||||||
- `AI_PROVIDER=claude_compatible`
|
|
||||||
|
|
||||||
Ollama 原生场景推荐使用:
|
|
||||||
|
|
||||||
- `AI_PROVIDER=ollama`
|
|
||||||
|
|
||||||
比如 MiniMax 或其他 Claude 兼容网关,可以这样配置:
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
AI_PROVIDER=claude_compatible
|
AI_PROVIDER=minimax
|
||||||
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
|
AI_PROVIDER_API=anthropic-messages
|
||||||
|
AI_BASE_URL=https://api.minimaxi.com/anthropic
|
||||||
AI_API_KEY=your_api_key
|
AI_API_KEY=your_api_key
|
||||||
AI_MODEL=your-claude-compatible-model
|
AI_MODEL=MiniMax-M2.7
|
||||||
AI_TIMEOUT_SECONDS=60
|
AI_TIMEOUT_SECONDS=60
|
||||||
AI_MAX_TOKENS=1200
|
AI_MAX_TOKENS=1200
|
||||||
AI_ANTHROPIC_VERSION=2023-06-01
|
AI_ANTHROPIC_VERSION=2023-06-01
|
||||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你要本地直接起模型适配层,项目里已经补了模板:
|
如果你要本地直接起模型适配层,项目里已经补了模板:
|
||||||
@@ -427,12 +411,6 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
|||||||
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
|
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
|
||||||
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
|
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
|
||||||
|
|
||||||
推荐映射关系:
|
|
||||||
|
|
||||||
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai_compatible`
|
|
||||||
- `MiniMax` / Claude 兼容网关: `AI_PROVIDER=claude_compatible`
|
|
||||||
- `Ollama`: `AI_PROVIDER=ollama`
|
|
||||||
|
|
||||||
运行与调用补充:
|
运行与调用补充:
|
||||||
|
|
||||||
- `./planet.sh start` 默认会启动 `aiprovider`
|
- `./planet.sh start` 默认会启动 `aiprovider`
|
||||||
@@ -442,11 +420,13 @@ AI_PROVIDER_SERVICE_TOKEN=change_me
|
|||||||
|
|
||||||
详细文档:
|
详细文档:
|
||||||
|
|
||||||
- [docs/technical/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/agents-aiprovider.md)
|
- [docs/technical/zh/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/zh/agents-aiprovider.md)
|
||||||
|
- [docs/technical/en/agents-aiprovider.md](/home/ray/dev/linkong/planet/docs/technical/en/agents-aiprovider.md)
|
||||||
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
|
||||||
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
- [docs/technical/frontend-layout-guidelines.md](/home/ray/dev/linkong/planet/docs/technical/frontend-layout-guidelines.md)
|
||||||
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
- [docs/plans/frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||||
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
|
- [docs/plans/agents-situational-awareness-foundation-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-situational-awareness-foundation-plan.md)
|
||||||
|
- [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)
|
||||||
|
|
||||||
## 前端页面布局规范
|
## 前端页面布局规范
|
||||||
|
|
||||||
|
|||||||
126
TODO.md
@@ -1,45 +1,85 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [x] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点
|
This file is the active backlog only. Completed history belongs in `docs/CHANGELOG.md`; detailed designs belong in `docs/plans/`.
|
||||||
- [x] 开始做 BGP 异常和海缆/区域的关联展示
|
|
||||||
- [x] 做 Earth 侧的 `BGP activity layer`,让低 incident 密度时地图仍然有持续可感知的观测存在感
|
## Earth
|
||||||
- [x] 给 Earth BGP 补三层状态表达:`平稳观测态 / 局部波动态 / 事件活跃态`
|
|
||||||
- [x] 把“当前无活跃事件”改造成“观测网络仍在运行、当前未发现聚合级事件”的状态表达
|
- [ ] Motion Agent v2 hardening: tune the implemented MediaPipe gesture recognizer across camera placements, exercise the UE command/control client, run reconnect and dual-camera soak tests, and continue the v3 calibrated 3D roadmap described in [Motion Agent v2 Control Protocol And 3D Calibration Roadmap](/home/ray/dev/linkong/planet/docs/plans/motion-agent-v2-control-protocol-plan.md).
|
||||||
- [x] 做 collector / region 近 15 分钟 activity score 聚合接口或动态聚合逻辑
|
- [ ] Earth AI command entry: merge natural-language and speech-triggered LLM commands into the existing Earth search panel as described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
|
||||||
- [x] 把 Earth 的 BGP incident 改成 `紧凑事件核 + 向外扩张环形 pulse`,替换当前大面积 glow
|
- [ ] Earth action executor: implement safe visualization actions for layer toggles, batch highlights, filters, focus, result panels, and clear-highlight behavior.
|
||||||
- [x] 为 BGP incident 建立符号系统:按事件类型用不同 marker,而不是都用同一种亮点
|
- [ ] Earth entity matching: support stable entity ids and batch matching for Beidou satellites, mainland China compute centers, BGP, news, vessels, and cables.
|
||||||
- [x] 把 incident 地理定位从 `collector-centric` 改成 `prefix-centric`,优先使用 `prefix_geography`,其次 `prefix_scope`,再次 ASN 区域,最后才回退到观测区域质心
|
- [ ] 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.
|
||||||
- [x] 新增 `prefix_geography` 数据层,不再把 `prefix_scope` 当成 prefix 地理归属本身
|
- [ ] 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.
|
||||||
- [x] 接入 `IPtoASN / IPtoCountry` 作为 prefix-centric geography 的主数据源
|
- [ ] 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.
|
||||||
- [x] 接入 `OpenGeoFeed` 作为 prefix geography 的高质量覆盖/override 数据源
|
- [ ] 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.
|
||||||
- [x] 把 RIR delegated 设计成 prefix geography 的 fallback,而不是主来源
|
- [ ] 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.
|
||||||
- [ ] 为 `aiprovider` 建立 `provider -> api adapter -> compat policy` 的配置中心,优先落成 `json` 或 `yaml` 文件,运行时按 `provider/model` 读取兼容设置,而不是把专项兼容继续散落在 Python 分支里
|
- [ ] 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.
|
||||||
- [ ] 为市面上主流 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`
|
## Compute Centers And Location
|
||||||
- [ ] 接入 `inetnum` / `inet6num` whois 作为比 RIR 更细粒度的后备层
|
|
||||||
- [x] 在 activity layer 之后继续补 `route leak` 和 `path instability / flap` detector
|
- [ ] Unknown compute-center locations: continue reducing unresolved records through the shared location pipeline, with confidence, precision, reason, and verification date preserved in GeoJSON/details.
|
||||||
- [ ] 对 [frontend/public/earth/js/bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) 做按职责拆分的小重构,拆成 data / markers / overlays / animation,降低后续维护复杂度
|
- [ ] Compute-center registry: keep expanding the local canonical location registry with `canonical_name`, aliases, operator, country/region/city, coordinates, confidence, and source notes.
|
||||||
- [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性
|
- [ ] 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.
|
||||||
- [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置
|
- [ ] Compute-center identity normalization: normalize operator / cluster / facility aliases such as `xAI / Colossus / Memphis`, `OpenAI / Stargate`, `CoreWeave`, `Lambda`, and `Crusoe`.
|
||||||
- [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略
|
- [ ] Compute-center manual review: add an export/review/import workflow for unresolved or estimated locations and feed confirmed results back into the registry.
|
||||||
- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板
|
|
||||||
- [ ] 重写控制台 UI,逐步抛弃 Ant Design,建立自有组件体系,并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库
|
## AIS / Vessels
|
||||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
|
||||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
- [ ] 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 v3.1:修复船只聚合完整性,`/geo/vessels` 合并 raw observation 聚合结果与 legacy `vessel_position + vessel_static` 最新结果,确保 BarentsWatch-only 船只不会因为 AISStream 子集存在而消失,并增加 raw/legacy/final unique MMSI 诊断统计
|
- [ ] 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 v3.2:把 AISStream 从收满 `max_messages` 后结束的批采集改成长连接 streaming service,持续写入 raw observations,通过内部 `/ws` 的 `vessels` channel 推送新船、位置和航向增量,Earth 前端按 MMSI upsert marker
|
- [ ] 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.
|
||||||
- [ ] 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 船只纳入名称聚合修复集合
|
## AI Provider And Agents
|
||||||
- [ ] 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 窗口和高级保护开关;保存时校验未知字段、非法模式和危险动态字段锁定,并在聚合接口返回命中的配置版本
|
- [ ] Unified integration config schema: implement the shared low-code schema engine for datasource, AI Provider, Web Search, and OCR configuration described in [Integration Config Schema System Plan](/home/ray/dev/linkong/planet/docs/plans/integration-config-schema-system-plan.md).
|
||||||
- [ ] AIS v5:实现船舶资料 enrichment 与冲突治理,按 `mmsi + imo + name + callsign` 异步补充船型细分、AIS 大类、旗国、尺寸、建造年份、运营方和图片缓存;详情面板展示缓存资料和字段来源,不在实时 AIS 请求链路现场抓第三方页面
|
- [ ] AI provider routing: finish the OpenClaw-style provider/model routing refactor described in [AI Provider OpenClaw-Style Routing Plan](/home/ray/dev/linkong/planet/docs/plans/ai-provider-openclaw-style-routing-plan.md), so model-specific transport rules live in provider metadata rather than runtime hardcoding.
|
||||||
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
- [ ] AI provider catalog: replace the temporary `model_provider_apis` bridge with structured `models_metadata`, discovery descriptors, and incremental model sync with stale marking.
|
||||||
- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机
|
- [ ] AI provider connectivity: keep the plug action as lightweight network/auth/model-directory validation only, and keep real generation tests inside Playground or explicit “trial run” actions.
|
||||||
- [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON
|
- [ ] Agent runtime foundation: add auditable agent runs, steps, evidence, proposals, and the Agent operations UI described in [Agent Runtime, Earth LLM Command, And Speech Entry Plan](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md).
|
||||||
- [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py`
|
- [ ] Agent tool protocol: add backend JSON tool-call fallback, optional provider-native tool compatibility, tool whitelist validation, and policy-gated proposal application.
|
||||||
- [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索
|
- [ ] Speech/ASR integration for agents: add provider-neutral transcription settings and API, defaulting to Whisper-compatible API providers while keeping text commands usable when ASR is unavailable.
|
||||||
- [ ] 为未知位置算力中心增加外部富化策略评估:可选接入公开知识源或搜索兜底,只抓“站点名/园区名/城市名”级别线索,不直接抓经纬度结论,并把结果作为候选证据而不是真值
|
- [ ] Earth voice wake: add device-local configurable wake-word preferences, microphone fallback states, and post-wake instruction upload for Earth commands.
|
||||||
- [ ] 为算力中心建立 `operator / cluster name / facility alias` 归一化层,先解决 `xAI / Colossus / Memphis`、`OpenAI / Stargate`、`CoreWeave`、`Lambda`、`Crusoe` 这类同一对象多种写法导致的地点匹配失败
|
- [ ] 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.
|
||||||
- [ ] 为估算位置增加更细的视觉和产品表达:除了问号角标,还要支持 tooltip/详情中的“估算依据”“精度级别”“最后核验时间”,并允许在设置中单独开关“仅看精确位置”
|
- [ ] 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.
|
||||||
|
|
||||||
|
## 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] Implemented the high-precision country boundary tile framework from [Earth High Precision Boundary Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-precision-boundary-tiles-plan.md): static vector tile builder, versioned seed output, frontend bbox tile loader, debounce, in-flight dedupe, and LRU cache.
|
||||||
|
- [x] Added 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.
|
||||||
|
- [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.
|
||||||
|
- [x] Console UI modernization. Admin is now the only console, legacy Ant Design / Admin Next code paths and dependencies have been removed, and current console UI uses Planet-owned components.
|
||||||
|
- [x] Earth news cruise adapter. News cruise now uses `news-cruise-adapter.js` and is wired from `main.js` instead of keeping news-specific sequencing directly in the main Earth loop.
|
||||||
|
- [x] Presentation controller ownership. `PresentationController` now guards async ownership through active request identity checks, and current callers pass per-request card targets so stale connector/card work cannot overwrite the active presentation.
|
||||||
|
- [x] Earth live sync. Database writes now flow through `earth_data_change_events`, `earth_db_change_listener`, layer adapters, cache invalidation, and the `earth_updates` WebSocket channel; the Earth frontend debounces updates and refreshes BGP, cables, compute centers, satellites, vessels, news, and interactables by layer.
|
||||||
|
- [x] System logs. Log sources now normalize into `LogEvent`, Admin supports snapshot filtering plus WebSocket tail/follow, task/detail views deep-link into prefiltered logs, and Admin runtime errors report through the `admin-client` log source.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- [ ] Earth preferences backend sync scope. Superseded by the current product decision to keep Earth preferences device-local in `localStorage` until account-level synchronization becomes a real requirement.
|
||||||
|
|||||||
231
agents.md
@@ -1,231 +0,0 @@
|
|||||||
# agents.md
|
|
||||||
|
|
||||||
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Identity
|
|
||||||
|
|
||||||
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
|
|
||||||
|
|
||||||
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
|
|
||||||
- Python FastAPI backend
|
|
||||||
- React Admin dashboard
|
|
||||||
- Unreal Engine 5 3D visualization
|
|
||||||
- Multi-source data collection
|
|
||||||
- Polarized 3D large display (4K, 120Hz)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Communication Style
|
|
||||||
|
|
||||||
### Tone
|
|
||||||
- **Professional but concise**
|
|
||||||
- Technical accuracy with clarity
|
|
||||||
- No unnecessary verbosity
|
|
||||||
- Use code comments sparingly (explain **why**, not **what**)
|
|
||||||
|
|
||||||
### When Responding
|
|
||||||
1. **Answer directly** - 1-3 sentences for simple questions
|
|
||||||
2. **Use code blocks** for all code snippets
|
|
||||||
3. **Include file:line_number** references when discussing code
|
|
||||||
4. **Never** start with "I am an AI assistant" or similar phrases
|
|
||||||
5. **Never** add unnecessary preambles/postambles
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
**Good:**
|
|
||||||
```
|
|
||||||
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bad:**
|
|
||||||
```
|
|
||||||
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Operational Mode
|
|
||||||
|
|
||||||
### Plan Mode (default for complex tasks)
|
|
||||||
- Analyze requirements
|
|
||||||
- Propose architecture
|
|
||||||
- Confirm with user before execution
|
|
||||||
- **DO NOT** write code until approved
|
|
||||||
|
|
||||||
### Build Mode (after user approval)
|
|
||||||
- Execute the approved plan
|
|
||||||
- Write code, run commands
|
|
||||||
- Verify results
|
|
||||||
- Report completion concisely
|
|
||||||
|
|
||||||
### Read-Only Mode
|
|
||||||
- Analyze code
|
|
||||||
- Explain functionality
|
|
||||||
- Answer questions
|
|
||||||
- **DO NOT** modify files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decision Framework
|
|
||||||
|
|
||||||
### When to Ask Before Acting
|
|
||||||
- Unclear requirements
|
|
||||||
- Multiple implementation approaches
|
|
||||||
- Architecture changes
|
|
||||||
- Dependency additions
|
|
||||||
- Anything that could break existing functionality
|
|
||||||
|
|
||||||
### When to Act Directly
|
|
||||||
- Clear, approved requirements
|
|
||||||
- Routine tasks (linting, formatting, running tests)
|
|
||||||
- Following established patterns
|
|
||||||
- Fixing obvious bugs
|
|
||||||
|
|
||||||
### When to Refuse
|
|
||||||
- Malicious code requests
|
|
||||||
- Security violations (secrets, credentials)
|
|
||||||
- Anything that violates `rules.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Working Principles
|
|
||||||
|
|
||||||
### 1. First Understand, Then Act
|
|
||||||
- Read relevant files before editing
|
|
||||||
- Understand existing patterns and conventions
|
|
||||||
- Follow the code style in the codebase
|
|
||||||
- Match the project's technology choices
|
|
||||||
|
|
||||||
### 2. Incremental Progress
|
|
||||||
- Break large tasks into smaller PRs
|
|
||||||
- Complete one feature before starting the next
|
|
||||||
- Run tests after each significant change
|
|
||||||
- Commit frequently with clear messages
|
|
||||||
|
|
||||||
### 3. Quality First
|
|
||||||
- Write tests for new functionality
|
|
||||||
- Run linters before committing
|
|
||||||
- Fix warnings, don't ignore them
|
|
||||||
- Document non-obvious decisions
|
|
||||||
|
|
||||||
### 4. Communication Clarity
|
|
||||||
- Use precise technical language
|
|
||||||
- Show relevant code, not explanations
|
|
||||||
- Report errors with context
|
|
||||||
- Confirm understanding of requirements
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Review Checklist
|
|
||||||
|
|
||||||
Before marking a task complete:
|
|
||||||
|
|
||||||
- [ ] Code follows `rules.md` style guidelines
|
|
||||||
- [ ] Type hints are correct and complete
|
|
||||||
- [ ] Error handling is proper (no silent failures)
|
|
||||||
- [ ] Tests pass locally
|
|
||||||
- [ ] Linting passes
|
|
||||||
- [ ] No TODO comments left behind
|
|
||||||
- [ ] Documentation updated if needed
|
|
||||||
- [ ] Commit message is clear
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Workflows
|
|
||||||
|
|
||||||
### Feature Development
|
|
||||||
```
|
|
||||||
1. Understand requirements
|
|
||||||
2. Check existing patterns in codebase
|
|
||||||
3. Design solution (brief mental model)
|
|
||||||
4. Write code following rules.md
|
|
||||||
5. Write/run tests
|
|
||||||
6. Lint and format
|
|
||||||
7. Commit with clear message
|
|
||||||
8. Report completion
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bug Fix
|
|
||||||
```
|
|
||||||
1. Reproduce the bug (write failing test)
|
|
||||||
2. Locate the source
|
|
||||||
3. Fix the issue
|
|
||||||
4. Verify test passes
|
|
||||||
5. Check for regressions
|
|
||||||
6. Commit fix
|
|
||||||
```
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
```
|
|
||||||
1. Understand current behavior
|
|
||||||
2. Design target state
|
|
||||||
3. Make incremental changes
|
|
||||||
4. Preserve tests
|
|
||||||
5. Verify functionality
|
|
||||||
6. Clean up dead code
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Special Considerations
|
|
||||||
|
|
||||||
### WebSocket Services
|
|
||||||
- Implement heartbeat mechanism (30-second intervals)
|
|
||||||
- Handle disconnection gracefully
|
|
||||||
- Include camera position in control frames
|
|
||||||
- Support both update and full sync modes
|
|
||||||
|
|
||||||
### Data Collectors
|
|
||||||
- Inherit from BaseCollector
|
|
||||||
- Implement fetch() and transform() methods
|
|
||||||
- Support incremental updates
|
|
||||||
- Handle API changes gracefully
|
|
||||||
|
|
||||||
### UE5 Integration
|
|
||||||
- Communicate via WebSocket
|
|
||||||
- Send data frames at configurable intervals (default 5 min)
|
|
||||||
- Support auto-cruise and manual modes
|
|
||||||
- Optimize for 4K@120Hz rendering
|
|
||||||
|
|
||||||
### Multi-User Security
|
|
||||||
- JWT tokens with 15-minute expiration
|
|
||||||
- Redis token blacklist for logout
|
|
||||||
- Role-based access control (RBAC)
|
|
||||||
- Audit logging for all actions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
### When Writing Code
|
|
||||||
```python
|
|
||||||
# File: backend/app/services/collectors/top500.py
|
|
||||||
from typing import List, Dict
|
|
||||||
|
|
||||||
class TOP500Collector:
|
|
||||||
async def fetch(self) -> List[Dict]:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### When Explaining
|
|
||||||
- Use concise paragraphs
|
|
||||||
- Include code references
|
|
||||||
- No conversational filler
|
|
||||||
|
|
||||||
### When Reporting Progress
|
|
||||||
- What was done
|
|
||||||
- What remains
|
|
||||||
- Any blockers
|
|
||||||
- Next action
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Remember
|
|
||||||
|
|
||||||
1. **Rules are hard constraints** - follow `rules.md` absolutely
|
|
||||||
2. **Context provides understanding** - use `project_context.md` for background
|
|
||||||
3. **Role defines behavior** - follow `agents.md` for how to work
|
|
||||||
4. **Quality over speed** - Enterprise systems require precision
|
|
||||||
5. **Communicate clearly** - Precision in, precision out
|
|
||||||
@@ -32,6 +32,15 @@ AI_API_KEY=sk-cp-change-me
|
|||||||
AI_MAX_TOKENS=1200
|
AI_MAX_TOKENS=1200
|
||||||
AI_ANTHROPIC_VERSION=2023-06-01
|
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)
|
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
|
||||||
# AI_PROVIDER=openai
|
# AI_PROVIDER=openai
|
||||||
# AI_PROVIDER_API=openai-completions
|
# AI_PROVIDER_API=openai-completions
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# Use BuildKit's bundled frontend to avoid a separate Docker Hub fetch.
|
||||||
|
|
||||||
ARG PYTHON_IMAGE=python:3.14-slim
|
ARG PYTHON_IMAGE=python:3.14-slim
|
||||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
|
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
|
||||||
|
ARG AI_PROVIDER_BUILD_FINGERPRINT=unknown
|
||||||
|
|
||||||
FROM ${UV_IMAGE} AS uv
|
FROM ${UV_IMAGE} AS uv
|
||||||
FROM ${PYTHON_IMAGE}
|
FROM ${PYTHON_IMAGE}
|
||||||
@@ -15,16 +16,25 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
ENV UV_COMPILE_BYTECODE=1
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
ENV UV_LINK_MODE=copy
|
ENV UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
RUN mkdir -p /root/.config/uv
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock /app/
|
COPY pyproject.toml uv.lock /app/
|
||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --no-dev
|
--mount=type=secret,id=planet_uv_config,target=/root/.config/uv/uv.toml,required=false \
|
||||||
|
uv sync --frozen --only-group aiprovider
|
||||||
|
|
||||||
COPY aiprovider /app/aiprovider
|
COPY aiprovider /app/aiprovider
|
||||||
|
|
||||||
|
ARG AI_PROVIDER_BUILD_FINGERPRINT
|
||||||
|
LABEL planet.aiprovider.build-fingerprint="${AI_PROVIDER_BUILD_FINGERPRINT}"
|
||||||
|
|
||||||
EXPOSE 8010
|
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 ["/app/.venv/bin/python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
完整使用说明见:
|
完整使用说明见:
|
||||||
|
|
||||||
- [docs/agents/aiprovider.md](/home/ray/dev/linkong/planet/docs/agents/aiprovider.md)
|
- [AI Provider 指南](../docs/technical/zh/agents-aiprovider.md)
|
||||||
|
|
||||||
当前支持:
|
当前支持:
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
- `AI_PROVIDER=ollama`
|
- `AI_PROVIDER=ollama`
|
||||||
- request adapter:
|
- request adapter:
|
||||||
- `AI_PROVIDER_API=openai-completions`
|
- `AI_PROVIDER_API=openai-completions`
|
||||||
|
- `AI_PROVIDER_API=openai-responses`
|
||||||
- `AI_PROVIDER_API=anthropic-messages`
|
- `AI_PROVIDER_API=anthropic-messages`
|
||||||
- `AI_PROVIDER_API=ollama-generate`
|
- `AI_PROVIDER_API=ollama-generate`
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ class Settings(BaseSettings):
|
|||||||
AI_HTTP_RETRY_ATTEMPTS: int = 2
|
AI_HTTP_RETRY_ATTEMPTS: int = 2
|
||||||
AI_MAX_TOKENS: int = 1200
|
AI_MAX_TOKENS: int = 1200
|
||||||
AI_ANTHROPIC_VERSION: str = "2023-06-01"
|
AI_ANTHROPIC_VERSION: str = "2023-06-01"
|
||||||
AI_ANALYSIS_SYSTEM_PROMPT: str = (
|
|
||||||
"你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。"
|
|
||||||
)
|
|
||||||
|
|
||||||
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def get_provider_service(
|
|||||||
x_ai_model: str | None = Header(default=None),
|
x_ai_model: str | None = Header(default=None),
|
||||||
x_ai_max_tokens: str | None = Header(default=None),
|
x_ai_max_tokens: str | None = Header(default=None),
|
||||||
x_ai_anthropic_version: str | None = Header(default=None),
|
x_ai_anthropic_version: str | None = Header(default=None),
|
||||||
|
x_ai_model_provider_apis: str | None = Header(default=None),
|
||||||
) -> ProviderService:
|
) -> ProviderService:
|
||||||
overrides = {
|
overrides = {
|
||||||
"provider": x_ai_provider,
|
"provider": x_ai_provider,
|
||||||
@@ -53,6 +54,7 @@ def get_provider_service(
|
|||||||
"api_key": x_ai_api_key,
|
"api_key": x_ai_api_key,
|
||||||
"model": x_ai_model,
|
"model": x_ai_model,
|
||||||
"anthropic_version": x_ai_anthropic_version,
|
"anthropic_version": x_ai_anthropic_version,
|
||||||
|
"model_provider_apis": x_ai_model_provider_apis,
|
||||||
}
|
}
|
||||||
if x_ai_max_tokens:
|
if x_ai_max_tokens:
|
||||||
overrides["max_tokens"] = x_ai_max_tokens
|
overrides["max_tokens"] = x_ai_max_tokens
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -14,7 +16,6 @@ from aiprovider.schemas import (
|
|||||||
SituationalAnalysisResponse,
|
SituationalAnalysisResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_provider(value: str) -> str:
|
def _normalize_provider(value: str) -> str:
|
||||||
return (value or "disabled").strip().lower()
|
return (value or "disabled").strip().lower()
|
||||||
|
|
||||||
@@ -62,7 +63,10 @@ class ProviderService:
|
|||||||
self.anthropic_version = str(
|
self.anthropic_version = str(
|
||||||
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
|
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
|
||||||
)
|
)
|
||||||
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
|
self.model_provider_apis = self._parse_model_provider_apis(
|
||||||
|
overrides.get("model_provider_apis")
|
||||||
|
)
|
||||||
|
self.session_id = str(uuid4())
|
||||||
|
|
||||||
def get_status(self) -> AIProviderStatusResponse:
|
def get_status(self) -> AIProviderStatusResponse:
|
||||||
enabled = self.provider != "disabled"
|
enabled = self.provider != "disabled"
|
||||||
@@ -93,17 +97,30 @@ class ProviderService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
prompt = self._build_prompt(payload)
|
prompt = self._build_prompt(payload)
|
||||||
|
if payload.context.get("session_id") is not None:
|
||||||
|
self.session_id = str(uuid5(NAMESPACE_URL, f"planet:{payload.context['session_id']}"))
|
||||||
|
|
||||||
if self.provider_api == "openai-completions":
|
provider_api = self._resolve_model_provider_api(model)
|
||||||
data = await self._request_openai_compatible(model, prompt)
|
|
||||||
|
if provider_api == "openai-completions":
|
||||||
|
data = await self._request_openai_compatible(model, prompt, payload.system_prompt)
|
||||||
content = self._extract_openai_content(data)
|
content = self._extract_openai_content(data)
|
||||||
content_blocks = self._extract_openai_blocks(data)
|
content_blocks = self._extract_openai_blocks(data)
|
||||||
elif self.provider_api == "anthropic-messages":
|
elif provider_api == "openai-responses":
|
||||||
data = await self._request_anthropic_messages(model, prompt, payload.thinking)
|
data = await self._request_openai_responses(model, prompt, payload.system_prompt)
|
||||||
|
content_blocks = self._extract_responses_blocks(data)
|
||||||
|
content = "".join(block.text for block in content_blocks if block.text)
|
||||||
|
elif provider_api == "anthropic-messages":
|
||||||
|
data = await self._request_anthropic_messages(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
payload.thinking,
|
||||||
|
payload.system_prompt,
|
||||||
|
)
|
||||||
content = self._extract_anthropic_content(data)
|
content = self._extract_anthropic_content(data)
|
||||||
content_blocks = self._extract_anthropic_blocks(data)
|
content_blocks = self._extract_anthropic_blocks(data)
|
||||||
elif self.provider_api == "ollama-generate":
|
elif 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 = self._extract_ollama_content(data)
|
||||||
content_blocks = self._extract_ollama_blocks(data)
|
content_blocks = self._extract_ollama_blocks(data)
|
||||||
else:
|
else:
|
||||||
@@ -128,6 +145,26 @@ class ProviderService:
|
|||||||
def _requires_api_key(self) -> bool:
|
def _requires_api_key(self) -> bool:
|
||||||
return self.provider_api != "ollama-generate"
|
return self.provider_api != "ollama-generate"
|
||||||
|
|
||||||
|
def _resolve_model_provider_api(self, model: str) -> str:
|
||||||
|
return self.model_provider_apis.get(model) or self.provider_api
|
||||||
|
|
||||||
|
def _parse_model_provider_apis(self, value: Any) -> dict[str, str]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
raw = value
|
||||||
|
elif isinstance(value, str) and value.strip():
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
raw = parsed if isinstance(parsed, dict) else {}
|
||||||
|
else:
|
||||||
|
raw = {}
|
||||||
|
return {
|
||||||
|
str(model): _normalize_provider_api(str(provider_api))
|
||||||
|
for model, provider_api in raw.items()
|
||||||
|
if model and provider_api
|
||||||
|
}
|
||||||
|
|
||||||
def _build_prompt(self, payload: SituationalAnalysisRequest) -> str:
|
def _build_prompt(self, payload: SituationalAnalysisRequest) -> str:
|
||||||
sections = [
|
sections = [
|
||||||
f"任务标题:\n{payload.title}",
|
f"任务标题:\n{payload.title}",
|
||||||
@@ -139,19 +176,28 @@ class ProviderService:
|
|||||||
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
|
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
|
||||||
if payload.context:
|
if payload.context:
|
||||||
sections.append(f"附加上下文:\n{payload.context}")
|
sections.append(f"附加上下文:\n{payload.context}")
|
||||||
sections.append(
|
|
||||||
"请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。"
|
|
||||||
)
|
|
||||||
return "\n\n".join(sections)
|
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 = {
|
request_body = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [
|
"messages": messages,
|
||||||
{"role": "system", "content": self.system_prompt},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
|
"max_tokens": self.max_tokens,
|
||||||
}
|
}
|
||||||
return await self._post(
|
return await self._post(
|
||||||
path="/chat/completions",
|
path="/chat/completions",
|
||||||
@@ -162,15 +208,48 @@ class ProviderService:
|
|||||||
request_body=request_body,
|
request_body=request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _request_openai_responses(
|
||||||
|
self, model: str, prompt: str, system_prompt: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request_body: dict[str, Any] = {
|
||||||
|
"model": model, "input": prompt, "max_output_tokens": self.max_tokens, "store": False,
|
||||||
|
}
|
||||||
|
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||||
|
if resolved_system_prompt:
|
||||||
|
request_body["instructions"] = resolved_system_prompt
|
||||||
|
return await self._post(
|
||||||
|
path="/responses",
|
||||||
|
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_responses_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]:
|
||||||
|
blocks: list[AIContentBlock] = []
|
||||||
|
for item in payload.get("output") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("type") == "message":
|
||||||
|
for part in item.get("content") or []:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
text = part.get("text") or part.get("refusal")
|
||||||
|
if isinstance(text, str) and text:
|
||||||
|
blocks.append(AIContentBlock(type="text", text=text))
|
||||||
|
elif item.get("type") == "reasoning":
|
||||||
|
for part in item.get("summary") or []:
|
||||||
|
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
||||||
|
blocks.append(AIContentBlock(type="thinking", thinking=part["text"]))
|
||||||
|
return blocks
|
||||||
|
|
||||||
async def _request_anthropic_messages(
|
async def _request_anthropic_messages(
|
||||||
self,
|
self,
|
||||||
model: str,
|
model: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
thinking: dict[str, Any] | None = None,
|
thinking: dict[str, Any] | None = None,
|
||||||
|
system_prompt: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
request_body = {
|
request_body = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"system": self.system_prompt,
|
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -185,7 +264,10 @@ class ProviderService:
|
|||||||
"max_tokens": self.max_tokens,
|
"max_tokens": self.max_tokens,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
}
|
}
|
||||||
resolved_thinking = self._resolve_anthropic_thinking(thinking)
|
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, model)
|
||||||
if resolved_thinking:
|
if resolved_thinking:
|
||||||
request_body["thinking"] = resolved_thinking
|
request_body["thinking"] = resolved_thinking
|
||||||
if self.provider == "minimax" and self.base_url.endswith("/anthropic"):
|
if self.provider == "minimax" and self.base_url.endswith("/anthropic"):
|
||||||
@@ -202,8 +284,12 @@ class ProviderService:
|
|||||||
request_body=request_body,
|
request_body=request_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_anthropic_thinking(self, thinking: dict[str, Any] | None) -> dict[str, Any] | None:
|
def _resolve_anthropic_thinking(
|
||||||
|
self, thinking: dict[str, Any] | None, model: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
if thinking:
|
if thinking:
|
||||||
|
if model.casefold() == "minimax-m3" and thinking.get("type") == "enabled":
|
||||||
|
return {"type": "adaptive"}
|
||||||
return thinking
|
return thinking
|
||||||
|
|
||||||
# OpenClaw treats MiniMax's Anthropic-compatible path specially:
|
# OpenClaw treats MiniMax's Anthropic-compatible path specially:
|
||||||
@@ -218,19 +304,28 @@ class ProviderService:
|
|||||||
model: str,
|
model: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
thinking: dict[str, Any] | None = None,
|
thinking: dict[str, Any] | None = None,
|
||||||
|
system_prompt: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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 = {
|
request_body = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"system": self.system_prompt,
|
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"options": {
|
"options": {
|
||||||
"temperature": 0.2,
|
"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(
|
return await self._post(
|
||||||
path="/api/generate",
|
path="/api/generate",
|
||||||
headers={
|
headers={
|
||||||
@@ -245,6 +340,9 @@ class ProviderService:
|
|||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
request_body: dict[str, Any],
|
request_body: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
headers = {"User-Agent": "Planet/1.0", **headers}
|
||||||
|
if self.provider == "opencode-go":
|
||||||
|
headers["x-opencode-session"] = self.session_id
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(1, self.http_retry_attempts + 1):
|
for attempt in range(1, self.http_retry_attempts + 1):
|
||||||
try:
|
try:
|
||||||
@@ -289,13 +387,19 @@ class ProviderService:
|
|||||||
message = choices[0].get("message") or {}
|
message = choices[0].get("message") or {}
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return content
|
if content:
|
||||||
|
return content
|
||||||
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
return reasoning_content if isinstance(reasoning_content, str) else ""
|
||||||
if isinstance(content, list):
|
if isinstance(content, list):
|
||||||
return "".join(
|
return "".join(
|
||||||
item.get("text", "")
|
item.get("text", "")
|
||||||
for item in content
|
for item in content
|
||||||
if isinstance(item, dict)
|
if isinstance(item, dict)
|
||||||
)
|
)
|
||||||
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
if isinstance(reasoning_content, str):
|
||||||
|
return reasoning_content
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _extract_openai_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]:
|
def _extract_openai_blocks(self, payload: dict[str, Any]) -> list[AIContentBlock]:
|
||||||
@@ -306,9 +410,14 @@ class ProviderService:
|
|||||||
message = choices[0].get("message") or {}
|
message = choices[0].get("message") or {}
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
return [AIContentBlock(type="text", text=content)]
|
blocks = [AIContentBlock(type="text", text=content)] if content else []
|
||||||
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
if isinstance(reasoning_content, str) and reasoning_content:
|
||||||
|
blocks.append(AIContentBlock(type="thinking", thinking=reasoning_content))
|
||||||
|
return blocks
|
||||||
if not isinstance(content, list):
|
if not isinstance(content, list):
|
||||||
return []
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
return [AIContentBlock(type="thinking", thinking=reasoning_content)] if isinstance(reasoning_content, str) and reasoning_content else []
|
||||||
|
|
||||||
blocks: list[AIContentBlock] = []
|
blocks: list[AIContentBlock] = []
|
||||||
for item in content:
|
for item in content:
|
||||||
@@ -321,7 +430,11 @@ class ProviderService:
|
|||||||
metadata={k: v for k, v in item.items() if k not in {"type", "text"}},
|
metadata={k: v for k, v in item.items() if k not in {"type", "text"}},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
reasoning_content = message.get("reasoning_content")
|
||||||
|
if isinstance(reasoning_content, str) and reasoning_content:
|
||||||
|
blocks.append(AIContentBlock(type="thinking", thinking=reasoning_content))
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
def _extract_anthropic_content(self, payload: dict[str, Any]) -> str:
|
def _extract_anthropic_content(self, payload: dict[str, Any]) -> str:
|
||||||
content = payload.get("content")
|
content = payload.get("content")
|
||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
|||||||
|
|
||||||
class SituationalAnalysisRequest(BaseModel):
|
class SituationalAnalysisRequest(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=200)
|
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)
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
observations: list[str] = Field(default_factory=list)
|
observations: list[str] = Field(default_factory=list)
|
||||||
constraints: 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)
|
preferred_model: str | None = Field(default=None, max_length=200)
|
||||||
thinking: dict[str, Any] | None = None
|
thinking: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
ARG PYTHON_IMAGE=python:3.14-slim
|
ARG PYTHON_IMAGE=python:3.14-slim
|
||||||
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
|
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
|
||||||
|
|
||||||
@@ -12,17 +14,25 @@ ENV PYTHONDONTWRITEBYTECODE=1
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV UV_COMPILE_BYTECODE=1
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
ENV UV_LINK_MODE=copy
|
ENV UV_LINK_MODE=copy
|
||||||
|
ENV PYTHONPATH=/app/backend
|
||||||
|
|
||||||
|
RUN mkdir -p /root/.config/uv
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock /app/
|
COPY pyproject.toml uv.lock /app/
|
||||||
RUN uv sync --frozen --no-dev
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=secret,id=planet_uv_config,target=/root/.config/uv/uv.toml,required=false \
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
COPY backend /app/backend
|
COPY backend /app/backend
|
||||||
COPY VERSION /app/VERSION
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
EXPOSE 8000
|
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
@@ -0,0 +1,2 @@
|
|||||||
|
"""AI task prompt registry and runtime helpers."""
|
||||||
|
|
||||||
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.2",
|
||||||
|
"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 plus a one-sentence newswire-style Chinese summary based only on the supplied RSS headline, description, source, and date. The summary should read like a concise breaking-news lead, not a label, slogan, or keyword headline."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.2",
|
||||||
|
"system_prompt": "你是 BGP 值班分析师。请直接输出面向值班人员的中文 Markdown 简报,只写最终研判内容;不要复述用户需求、提示词、写作计划、字段清单或“我将如何回答”。",
|
||||||
|
"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
@@ -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,
|
||||||
|
}
|
||||||
@@ -6,16 +6,21 @@ from app.api.v1 import (
|
|||||||
datasource_config,
|
datasource_config,
|
||||||
datasources,
|
datasources,
|
||||||
docs,
|
docs,
|
||||||
|
earth,
|
||||||
tasks,
|
tasks,
|
||||||
dashboard,
|
dashboard,
|
||||||
websocket,
|
|
||||||
alerts,
|
alerts,
|
||||||
settings,
|
settings,
|
||||||
collected_data,
|
collected_data,
|
||||||
|
data_products,
|
||||||
|
layers,
|
||||||
visualization,
|
visualization,
|
||||||
vessel_aggregation,
|
vessel_aggregation,
|
||||||
|
vessels,
|
||||||
bgp,
|
bgp,
|
||||||
news,
|
news,
|
||||||
|
interactables,
|
||||||
|
realtime_sources,
|
||||||
system_control,
|
system_control,
|
||||||
tv,
|
tv,
|
||||||
)
|
)
|
||||||
@@ -31,17 +36,23 @@ api_router.include_router(
|
|||||||
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
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(collected_data.router, prefix="/collected", tags=["collected-data"])
|
||||||
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
|
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(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
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(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(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
vessel_aggregation.router,
|
vessel_aggregation.router,
|
||||||
prefix="/vessel-aggregation",
|
prefix="/vessel-aggregation",
|
||||||
tags=["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(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||||
|
api_router.include_router(interactables.router, prefix="/interactables", tags=["interactables"])
|
||||||
|
api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from uuid import uuid4
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -47,8 +48,10 @@ from app.services.playground_chat_service import (
|
|||||||
stop_message,
|
stop_message,
|
||||||
)
|
)
|
||||||
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
|
from app.services.situational_alert_ai_brief import build_situational_alert_brief_request
|
||||||
|
from app.services.business_logs import emit_business_log, exception_context
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = get_logger(__name__, service="api")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/provider/status", response_model=AIProviderStatusResponse)
|
@router.get("/provider/status", response_model=AIProviderStatusResponse)
|
||||||
@@ -122,6 +125,16 @@ async def create_playground_message(
|
|||||||
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.playground.message.create",
|
||||||
|
message="Playground message creation requested",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"session_key": payload.session_key, "preset": payload.selected_preset_key},
|
||||||
|
)
|
||||||
return await create_turn(
|
return await create_turn(
|
||||||
db,
|
db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -136,6 +149,16 @@ async def stop_playground_message(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.playground.message.stop",
|
||||||
|
message="Playground message stop requested",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"session_key": payload.session_key, "message_id": payload.message_id},
|
||||||
|
)
|
||||||
return await stop_message(
|
return await stop_message(
|
||||||
db,
|
db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -150,6 +173,16 @@ async def resend_playground_message(
|
|||||||
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
provider_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.playground.message.resend",
|
||||||
|
message="Playground message resend requested",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"session_key": payload.session_key, "user_message_id": payload.user_message_id},
|
||||||
|
)
|
||||||
return await resend_turn(
|
return await resend_turn(
|
||||||
db,
|
db,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -214,16 +247,70 @@ async def analyze_bgp_brief(
|
|||||||
anomaly_limit=payload.anomaly_limit,
|
anomaly_limit=payload.anomaly_limit,
|
||||||
collector_limit=payload.collector_limit,
|
collector_limit=payload.collector_limit,
|
||||||
)
|
)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.bgp.facts_collected",
|
||||||
|
message="BGP brief facts collected",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={
|
||||||
|
"incident_limit": payload.incident_limit,
|
||||||
|
"anomaly_limit": payload.anomaly_limit,
|
||||||
|
"collector_limit": payload.collector_limit,
|
||||||
|
"fact_count": len(facts or []),
|
||||||
|
},
|
||||||
|
)
|
||||||
brief_request.preferred_model = payload.preferred_model
|
brief_request.preferred_model = payload.preferred_model
|
||||||
brief_request.thinking = payload.thinking
|
brief_request.thinking = payload.thinking
|
||||||
|
|
||||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
await emit_business_log(
|
||||||
return save_bgp_brief_record(
|
logger,
|
||||||
analysis,
|
event="ai.brief.bgp.start",
|
||||||
|
message="BGP brief AI analysis started",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
facts=facts,
|
user_id=current_user.id,
|
||||||
context=context,
|
context={"preferred_model": payload.preferred_model},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||||
|
record = save_bgp_brief_record(
|
||||||
|
analysis,
|
||||||
|
request_id=request_id,
|
||||||
|
facts=facts,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.bgp.completed",
|
||||||
|
message="BGP brief AI analysis saved",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"provider": analysis.provider, "model": analysis.model, "brief_id": record.id},
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.bgp.failed",
|
||||||
|
message="BGP brief AI analysis failed",
|
||||||
|
category="ai",
|
||||||
|
level="error",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context=exception_context(exc, {"preferred_model": payload.preferred_model}),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/alerts/brief", response_model=AlertBriefResponse)
|
@router.post("/alerts/brief", response_model=AlertBriefResponse)
|
||||||
@@ -242,17 +329,65 @@ async def analyze_alert_brief(
|
|||||||
db,
|
db,
|
||||||
alert_limit=payload.alert_limit,
|
alert_limit=payload.alert_limit,
|
||||||
)
|
)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.alerts.facts_collected",
|
||||||
|
message="Alert brief facts collected",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"alert_limit": payload.alert_limit, "fact_count": len(facts or [])},
|
||||||
|
)
|
||||||
brief_request.preferred_model = payload.preferred_model
|
brief_request.preferred_model = payload.preferred_model
|
||||||
brief_request.thinking = payload.thinking
|
brief_request.thinking = payload.thinking
|
||||||
|
|
||||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
await emit_business_log(
|
||||||
return AlertBriefResponse(
|
logger,
|
||||||
**analysis.model_dump(),
|
event="ai.brief.alerts.start",
|
||||||
title=brief_request.title,
|
message="Alert brief AI analysis started",
|
||||||
objective=brief_request.objective,
|
category="ai",
|
||||||
facts=facts,
|
service="api",
|
||||||
context=context,
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"preferred_model": payload.preferred_model},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.alerts.completed",
|
||||||
|
message="Alert brief AI analysis completed",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"provider": analysis.provider, "model": analysis.model},
|
||||||
|
)
|
||||||
|
return AlertBriefResponse(
|
||||||
|
**analysis.model_dump(),
|
||||||
|
title=brief_request.title,
|
||||||
|
objective=brief_request.objective,
|
||||||
|
facts=facts,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.alerts.failed",
|
||||||
|
message="Alert brief AI analysis failed",
|
||||||
|
category="ai",
|
||||||
|
level="error",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context=exception_context(exc, {"preferred_model": payload.preferred_model}),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post("/situational-alerts/brief", response_model=SituationalAlertBriefResponse)
|
@router.post("/situational-alerts/brief", response_model=SituationalAlertBriefResponse)
|
||||||
@@ -268,14 +403,62 @@ async def analyze_situational_alert_brief(
|
|||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
brief_request, facts, context = await build_situational_alert_brief_request(db)
|
brief_request, facts, context = await build_situational_alert_brief_request(db)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.situational_alerts.facts_collected",
|
||||||
|
message="Situational alert brief facts collected",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"fact_count": len(facts or [])},
|
||||||
|
)
|
||||||
brief_request.preferred_model = payload.preferred_model
|
brief_request.preferred_model = payload.preferred_model
|
||||||
brief_request.thinking = payload.thinking
|
brief_request.thinking = payload.thinking
|
||||||
|
|
||||||
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
await emit_business_log(
|
||||||
return SituationalAlertBriefResponse(
|
logger,
|
||||||
**analysis.model_dump(),
|
event="ai.brief.situational_alerts.start",
|
||||||
title=brief_request.title,
|
message="Situational alert brief AI analysis started",
|
||||||
objective=brief_request.objective,
|
category="ai",
|
||||||
facts=facts,
|
service="api",
|
||||||
context=context,
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"preferred_model": payload.preferred_model},
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
analysis = await provider_client.analyze(brief_request, request_id=request_id)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.situational_alerts.completed",
|
||||||
|
message="Situational alert brief AI analysis completed",
|
||||||
|
category="ai",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context={"provider": analysis.provider, "model": analysis.model},
|
||||||
|
)
|
||||||
|
return SituationalAlertBriefResponse(
|
||||||
|
**analysis.model_dump(),
|
||||||
|
title=brief_request.title,
|
||||||
|
objective=brief_request.objective,
|
||||||
|
facts=facts,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.brief.situational_alerts.failed",
|
||||||
|
message="Situational alert brief AI analysis failed",
|
||||||
|
category="ai",
|
||||||
|
level="error",
|
||||||
|
service="api",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
context=exception_context(exc, {"preferred_model": payload.preferred_model}),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -1,26 +1,86 @@
|
|||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.enums import OtpPurpose, UserRole
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.core.security import (
|
from app.core.security import (
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
blacklist_token,
|
|
||||||
get_current_user,
|
get_current_user,
|
||||||
|
get_password_hash,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.token import Token
|
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()
|
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)
|
@router.post("/login", response_model=Token)
|
||||||
async def login(
|
async def login(
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
@@ -28,7 +88,8 @@ async def login(
|
|||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups 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},
|
{"username": form_data.username},
|
||||||
)
|
)
|
||||||
@@ -47,6 +108,7 @@ async def login(
|
|||||||
user.role = row[4]
|
user.role = row[4]
|
||||||
user.is_active = row[5]
|
user.is_active = row[5]
|
||||||
user.gatekeeper_groups = row[6] or []
|
user.gatekeeper_groups = row[6] or []
|
||||||
|
user.email_verified = bool(row[7])
|
||||||
|
|
||||||
if not verify_password(form_data.password, user.password_hash):
|
if not verify_password(form_data.password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -58,25 +120,13 @@ async def login(
|
|||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="User is inactive",
|
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})
|
return _token_response(user)
|
||||||
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,
|
|
||||||
"gatekeeper_groups": user.gatekeeper_groups or [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=Token)
|
@router.post("/refresh", response_model=Token)
|
||||||
@@ -116,5 +166,188 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
|||||||
"role": current_user.role,
|
"role": current_user.role,
|
||||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||||
"is_active": current_user.is_active,
|
"is_active": current_user.is_active,
|
||||||
|
"email_verified": getattr(current_user, "email_verified", True),
|
||||||
"created_at": current_user.created_at,
|
"created_at": current_user.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: OtpPurpose) -> 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=UserRole.VIEWER.value,
|
||||||
|
is_active=True,
|
||||||
|
email_verified=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = otp.issue_code(payload.email, OtpPurpose.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, OtpPurpose.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 is OtpPurpose.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, OtpPurpose.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=OtpPurpose.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": OtpPurpose.RESET_PASSWORD.value,
|
||||||
|
"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, OtpPurpose.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"}
|
||||||
|
|||||||
@@ -14,10 +14,17 @@ from app.models.bgp_incident import BGPIncident
|
|||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.bgp_collector_locations import (
|
from app.services.bgp_collector_locations import (
|
||||||
|
build_bgp_collector_location_query,
|
||||||
collect_bgp_collector_location_candidates,
|
collect_bgp_collector_location_candidates,
|
||||||
get_bgp_collector_location_dict,
|
get_bgp_collector_location_dict,
|
||||||
)
|
)
|
||||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -282,6 +289,7 @@ async def collect_bgp_collector_location(
|
|||||||
collector_id: str,
|
collector_id: str,
|
||||||
payload: CollectBGPCollectorLocationRequest,
|
payload: CollectBGPCollectorLocationRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Run the shared location pipeline for a BGP route collector.
|
"""Run the shared location pipeline for a BGP route collector.
|
||||||
|
|
||||||
@@ -307,6 +315,47 @@ async def collect_bgp_collector_location(
|
|||||||
country=country,
|
country=country,
|
||||||
operator=operator,
|
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 = {
|
context = {
|
||||||
"collector": collector_id,
|
"collector": collector_id,
|
||||||
@@ -327,6 +376,7 @@ async def collect_bgp_collector_location(
|
|||||||
),
|
),
|
||||||
"candidates": [],
|
"candidates": [],
|
||||||
"attempted_queries": list(attempted_queries),
|
"attempted_queries": list(attempted_queries),
|
||||||
|
"llm_failure_reason": llm_failure_reason,
|
||||||
"context": context,
|
"context": context,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,12 @@ def build_search_rank_sql(search: Optional[str]) -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def serialize_collected_row(row, source_name_map: dict[str, str] | None = None) -> dict:
|
def serialize_collected_row(
|
||||||
|
row,
|
||||||
|
source_name_map: dict[str, str] | None = None,
|
||||||
|
*,
|
||||||
|
include_metadata: bool = True,
|
||||||
|
) -> dict:
|
||||||
metadata = row[7]
|
metadata = row[7]
|
||||||
source = row[1]
|
source = row[1]
|
||||||
return {
|
return {
|
||||||
@@ -120,7 +125,7 @@ def serialize_collected_row(row, source_name_map: dict[str, str] | None = None)
|
|||||||
"longitude": get_metadata_field(metadata, "longitude"),
|
"longitude": get_metadata_field(metadata, "longitude"),
|
||||||
"value": get_metadata_field(metadata, "value"),
|
"value": get_metadata_field(metadata, "value"),
|
||||||
"unit": get_metadata_field(metadata, "unit"),
|
"unit": get_metadata_field(metadata, "unit"),
|
||||||
"metadata": metadata,
|
"metadata": metadata if include_metadata else None,
|
||||||
"cores": get_metadata_field(metadata, "cores"),
|
"cores": get_metadata_field(metadata, "cores"),
|
||||||
"rmax": get_metadata_field(metadata, "rmax"),
|
"rmax": get_metadata_field(metadata, "rmax"),
|
||||||
"rpeak": get_metadata_field(metadata, "rpeak"),
|
"rpeak": get_metadata_field(metadata, "rpeak"),
|
||||||
@@ -145,6 +150,7 @@ async def list_collected_data(
|
|||||||
search: Optional[str] = Query(None, description="搜索名称"),
|
search: Optional[str] = Query(None, description="搜索名称"),
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
include_metadata: bool = Query(True, description="是否返回完整 metadata 字段"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -201,7 +207,7 @@ async def list_collected_data(
|
|||||||
|
|
||||||
data = []
|
data = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
data.append(serialize_collected_row(row[:11], source_name_map))
|
data.append(serialize_collected_row(row[:11], source_name_map, include_metadata=include_metadata))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
|
|||||||
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)
|
||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy import delete, select, func
|
from sqlalchemy import delete, select, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -13,6 +13,7 @@ import httpx
|
|||||||
|
|
||||||
from app.core.target_schema_registry import get_target_schema, list_target_schemas
|
from app.core.target_schema_registry import get_target_schema, list_target_schemas
|
||||||
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
||||||
|
from app.core.enums import AuthType, MappingValidationStatus, UserRole
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.datasource_config import DataSourceConfig
|
from app.models.datasource_config import DataSourceConfig
|
||||||
@@ -22,6 +23,7 @@ from app.models.vessel import AISRawObservation, AISSourceHealth
|
|||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
from app.core.cache import cache
|
from app.core.cache import cache
|
||||||
from app.core.time import to_iso8601_utc
|
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.schemas.ai import SituationalAnalysisRequest
|
||||||
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
from app.services.ai_client import AIProviderClient, get_ai_provider_client
|
||||||
from app.services.datasource_mapping import (
|
from app.services.datasource_mapping import (
|
||||||
@@ -33,7 +35,6 @@ from app.services.datasource_mapping import (
|
|||||||
)
|
)
|
||||||
from app.services.custom_datasource_runtime import (
|
from app.services.custom_datasource_runtime import (
|
||||||
CustomDatasourceRuntimeError,
|
CustomDatasourceRuntimeError,
|
||||||
fetch_rest_payload,
|
|
||||||
get_custom_stream_status,
|
get_custom_stream_status,
|
||||||
run_mapped_rest_config,
|
run_mapped_rest_config,
|
||||||
run_mapped_websocket_config,
|
run_mapped_websocket_config,
|
||||||
@@ -42,21 +43,87 @@ from app.services.custom_datasource_runtime import (
|
|||||||
test_websocket_config,
|
test_websocket_config,
|
||||||
)
|
)
|
||||||
from app.services.datasource_connectivity import (
|
from app.services.datasource_connectivity import (
|
||||||
|
_resolve_aisstream_api_key,
|
||||||
|
_resolve_spacetrack_credentials_with_override,
|
||||||
get_builtin_connection_status,
|
get_builtin_connection_status,
|
||||||
save_connectivity_success,
|
save_connectivity_success,
|
||||||
strip_connectivity_validation,
|
strip_connectivity_validation,
|
||||||
test_builtin_connectivity,
|
test_builtin_connectivity,
|
||||||
)
|
)
|
||||||
|
from app.services.barentswatch import resolve_barentswatch_config
|
||||||
|
from app.services.persistent_logs import record_audit_log
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping"
|
||||||
|
SECRET_REVEAL_ROLES = {UserRole.ADMIN.value, UserRole.SUPER_ADMIN.value}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_role_value(user: User) -> str:
|
||||||
|
role = getattr(user, "role", "")
|
||||||
|
return str(getattr(role, "value", role) or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _user_display_name(user: User) -> str:
|
||||||
|
return str(getattr(user, "username", None) or getattr(user, "email", None) or getattr(user, "id", ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_datasource_secret_reveal(
|
||||||
|
*,
|
||||||
|
current_user: User,
|
||||||
|
request: Request,
|
||||||
|
target_id: str,
|
||||||
|
result: str,
|
||||||
|
details: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
await record_audit_log(
|
||||||
|
action="datasource_config.secret.reveal",
|
||||||
|
actor_id=getattr(current_user, "id", None),
|
||||||
|
actor_name=_user_display_name(current_user),
|
||||||
|
target_type="datasource_config_secret",
|
||||||
|
target_id=target_id,
|
||||||
|
result=result,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_datasource_secret_reveal_allowed(
|
||||||
|
current_user: User,
|
||||||
|
request: Request,
|
||||||
|
target_id: str,
|
||||||
|
details: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
if _user_role_value(current_user) in SECRET_REVEAL_ROLES:
|
||||||
|
return
|
||||||
|
await _record_datasource_secret_reveal(
|
||||||
|
current_user=current_user,
|
||||||
|
request=request,
|
||||||
|
target_id=target_id,
|
||||||
|
result="denied",
|
||||||
|
details={**details, "role": _user_role_value(current_user)},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only administrators can reveal datasource credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
class DataSourceConfigCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
source_type: str = Field(..., description="rest, websocket, http, api, database")
|
source_type: str = Field(..., description="rest, websocket, http, api, database")
|
||||||
endpoint: str = Field(..., max_length=500)
|
endpoint: str = Field(..., max_length=500)
|
||||||
auth_type: str = Field(default="none", description="none, bearer, api_key, basic")
|
auth_type: AuthType = Field(default=AuthType.NONE, description="none, bearer, api_key, basic")
|
||||||
auth_config: dict = Field(default={})
|
auth_config: dict = Field(default={})
|
||||||
headers: dict = Field(default={})
|
headers: dict = Field(default={})
|
||||||
config: dict = Field(default={"timeout": 30, "retry": 3})
|
config: dict = Field(default={"timeout": 30, "retry": 3})
|
||||||
@@ -67,7 +134,7 @@ class DataSourceConfigUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
source_type: Optional[str] = None
|
source_type: Optional[str] = None
|
||||||
endpoint: Optional[str] = Field(None, max_length=500)
|
endpoint: Optional[str] = Field(None, max_length=500)
|
||||||
auth_type: Optional[str] = None
|
auth_type: Optional[AuthType] = None
|
||||||
auth_config: Optional[dict] = None
|
auth_config: Optional[dict] = None
|
||||||
headers: Optional[dict] = None
|
headers: Optional[dict] = None
|
||||||
config: Optional[dict] = None
|
config: Optional[dict] = None
|
||||||
@@ -142,7 +209,7 @@ class MappingTemplateCreate(BaseModel):
|
|||||||
mapping_json: dict
|
mapping_json: dict
|
||||||
sample_payload: Any | None = None
|
sample_payload: Any | None = None
|
||||||
sample_payload_hash: Optional[str] = None
|
sample_payload_hash: Optional[str] = None
|
||||||
validation_status: str = Field(default="draft", pattern="^(draft|valid|invalid)$")
|
validation_status: MappingValidationStatus = MappingValidationStatus.DRAFT
|
||||||
is_active: bool = False
|
is_active: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +218,7 @@ class MappingTemplateUpdate(BaseModel):
|
|||||||
mapping_json: Optional[dict] = None
|
mapping_json: Optional[dict] = None
|
||||||
sample_payload: Any | None = None
|
sample_payload: Any | None = None
|
||||||
sample_payload_hash: Optional[str] = None
|
sample_payload_hash: Optional[str] = None
|
||||||
validation_status: Optional[str] = Field(default=None, pattern="^(draft|valid|invalid)$")
|
validation_status: Optional[MappingValidationStatus] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
@@ -364,7 +431,7 @@ async def list_all_datasources(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List all data sources: YAML defaults + DB overrides"""
|
"""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()
|
config = get_data_sources_config()
|
||||||
|
|
||||||
@@ -372,38 +439,144 @@ async def list_all_datasources(
|
|||||||
db_configs = {c.name: c for c in db_query.scalars().all()}
|
db_configs = {c.name: c for c in db_query.scalars().all()}
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for name, yaml_key in COLLECTOR_URL_KEYS.items():
|
for name, metadata in DEFAULT_DATASOURCES.items():
|
||||||
yaml_url = config.get_yaml_url(name)
|
yaml_url = config.get_yaml_url(name)
|
||||||
db_config = db_configs.get(name)
|
db_config = db_configs.get(name)
|
||||||
|
default_config = _default_builtin_config(name)
|
||||||
|
default_url = yaml_url
|
||||||
|
db_auth_config = db_config.auth_config or {} if db_config else {}
|
||||||
|
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"name": name,
|
"name": name,
|
||||||
"default_url": yaml_url,
|
"requires_credentials": bool(metadata.get("requires_credentials", False)),
|
||||||
"endpoint": db_config.endpoint if db_config else yaml_url,
|
"credential_provider": metadata.get("credential_provider"),
|
||||||
|
"credential_status": metadata.get("credential_status", "none"),
|
||||||
|
"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
|
"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,
|
else db_config is not None,
|
||||||
"is_active": db_config.is_active if db_config else True,
|
"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_type": db_config.auth_type if db_config else "none",
|
||||||
|
"auth_config": {
|
||||||
|
"client_id": db_auth_config.get("client_id") or "",
|
||||||
|
"username": db_auth_config.get("username") or "",
|
||||||
|
"key_name": db_auth_config.get("key_name") or db_auth_config.get("param_name") or "",
|
||||||
|
"param_name": db_auth_config.get("param_name") or db_auth_config.get("key_name") or "",
|
||||||
|
"location": db_auth_config.get("location") or db_auth_config.get("in") or "",
|
||||||
|
"in": db_auth_config.get("in") or db_auth_config.get("location") or "",
|
||||||
|
},
|
||||||
"auth_configured": {
|
"auth_configured": {
|
||||||
"api_key": bool((db_config.auth_config or {}).get("api_key"))
|
"api_key": bool(db_auth_config.get("api_key")),
|
||||||
if db_config
|
"client_id": bool(db_auth_config.get("client_id")),
|
||||||
else False,
|
"client_secret": bool(db_auth_config.get("client_secret")),
|
||||||
|
"username": bool(db_auth_config.get("username")),
|
||||||
|
"password": bool(db_auth_config.get("password")),
|
||||||
},
|
},
|
||||||
"headers": db_config.headers if db_config else {},
|
"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,
|
"config_id": db_config.id if db_config else None,
|
||||||
"description": db_config.description
|
"description": db_config.description
|
||||||
if db_config
|
if db_config
|
||||||
else f"Data source from YAML: {yaml_key}",
|
else f"内置采集器默认配置:{metadata.get('display_name') or metadata.get('name') or name}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"total": len(result), "data": result}
|
return {"total": len(result), "data": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/configs/secrets")
|
||||||
|
async def reveal_builtin_config_secrets(
|
||||||
|
request: Request,
|
||||||
|
name: str = Query(..., min_length=1),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reveal configured built-in datasource credentials for admin editing."""
|
||||||
|
source = name.strip()
|
||||||
|
metadata = DEFAULT_DATASOURCES.get(source)
|
||||||
|
if not metadata or not metadata.get("requires_credentials"):
|
||||||
|
raise HTTPException(status_code=404, detail="Credentialed datasource config not found")
|
||||||
|
|
||||||
|
provider = str(metadata.get("credential_provider") or "")
|
||||||
|
target_id = f"datasource_config:{source}"
|
||||||
|
await _ensure_datasource_secret_reveal_allowed(
|
||||||
|
current_user,
|
||||||
|
request,
|
||||||
|
target_id,
|
||||||
|
{"source": source, "provider": provider},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(select(DataSourceConfig).where(DataSourceConfig.name == source))
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
auth_config = dict(record.auth_config or {}) if record else {}
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"name": source,
|
||||||
|
"provider": provider,
|
||||||
|
}
|
||||||
|
details: dict[str, Any] = {"source": source, "provider": provider}
|
||||||
|
|
||||||
|
if provider == "barentswatch":
|
||||||
|
resolved = await resolve_barentswatch_config(db)
|
||||||
|
client_id = str(auth_config.get("client_id") or resolved.client_id or "")
|
||||||
|
client_secret = str(auth_config.get("client_secret") or resolved.client_secret or "")
|
||||||
|
source_label = "datasource_config" if auth_config.get("client_id") or auth_config.get("client_secret") else resolved.credential_source
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"client_id_source": source_label if client_id else "missing",
|
||||||
|
"client_secret_source": source_label if client_secret else "missing",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
details.update(
|
||||||
|
{
|
||||||
|
"client_id_configured": bool(client_id),
|
||||||
|
"client_secret_configured": bool(client_secret),
|
||||||
|
"credential_source": source_label,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif provider == "aisstream":
|
||||||
|
api_key, api_key_source = await _resolve_aisstream_api_key(db)
|
||||||
|
payload.update({"api_key": api_key, "api_key_source": api_key_source})
|
||||||
|
details.update({"api_key_configured": bool(api_key), "api_key_source": api_key_source})
|
||||||
|
elif provider == "spacetrack":
|
||||||
|
if auth_config.get("username") or auth_config.get("password"):
|
||||||
|
username = str(auth_config.get("username") or "")
|
||||||
|
password = str(auth_config.get("password") or "")
|
||||||
|
credential_source = "datasource_config"
|
||||||
|
else:
|
||||||
|
username, password, credential_source = _resolve_spacetrack_credentials_with_override()
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"username_source": credential_source if username else "missing",
|
||||||
|
"password_source": credential_source if password else "missing",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
details.update(
|
||||||
|
{
|
||||||
|
"username_configured": bool(username),
|
||||||
|
"password_configured": bool(password),
|
||||||
|
"credential_source": credential_source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Datasource credential provider is not supported")
|
||||||
|
|
||||||
|
await _record_datasource_secret_reveal(
|
||||||
|
current_user=current_user,
|
||||||
|
request=request,
|
||||||
|
target_id=target_id,
|
||||||
|
result="success",
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@router.get("/configs/{config_id}")
|
@router.get("/configs/{config_id}")
|
||||||
async def get_config(
|
async def get_config(
|
||||||
config_id: int,
|
config_id: int,
|
||||||
@@ -744,6 +917,7 @@ async def get_datasource_target_schemas(
|
|||||||
@router.post("/mappings/propose")
|
@router.post("/mappings/propose")
|
||||||
async def propose_datasource_mapping(
|
async def propose_datasource_mapping(
|
||||||
payload: MappingProposeRequest,
|
payload: MappingProposeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
ai_client: AIProviderClient = Depends(get_ai_provider_client),
|
ai_client: AIProviderClient = Depends(get_ai_provider_client),
|
||||||
):
|
):
|
||||||
@@ -757,14 +931,12 @@ async def propose_datasource_mapping(
|
|||||||
generated_by = "heuristic"
|
generated_by = "heuristic"
|
||||||
if payload.use_ai:
|
if payload.use_ai:
|
||||||
try:
|
try:
|
||||||
|
prompt = await get_effective_prompt(db, DATASOURCE_MAPPING_PROMPT_KEY)
|
||||||
response = await ai_client.analyze(
|
response = await ai_client.analyze(
|
||||||
SituationalAnalysisRequest(
|
SituationalAnalysisRequest(
|
||||||
title=f"Generate datasource mapping for {schema.key}",
|
title=f"Generate datasource mapping for {schema.key}",
|
||||||
objective=(
|
objective=prompt.prompt,
|
||||||
"Return only JSON for a deterministic mapping DSL. "
|
system_prompt=prompt.system_prompt or None,
|
||||||
"The JSON must contain source.items_path and fields. "
|
|
||||||
"Do not include prose or code."
|
|
||||||
),
|
|
||||||
context={
|
context={
|
||||||
"target_schema": schema.to_dict(),
|
"target_schema": schema.to_dict(),
|
||||||
"sample_payload": redacted_sample,
|
"sample_payload": redacted_sample,
|
||||||
|
|||||||
682
backend/app/api/v1/earth.py
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
"""Earth asset management APIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings as app_settings
|
||||||
|
from app.core.security import decode_token, get_current_user, redis_client
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.collected_data import CollectedData
|
||||||
|
from app.models.datasource import DataSource
|
||||||
|
from app.models.datasource_config import DataSourceConfig
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.tv_streams import get_tv_settings_payload
|
||||||
|
from app.services.earth_news import (
|
||||||
|
get_earth_news_sources_payload,
|
||||||
|
reset_earth_news_sources_payload,
|
||||||
|
save_earth_news_sources_payload,
|
||||||
|
test_news_source_config,
|
||||||
|
)
|
||||||
|
from app.services.earth_news_manual import (
|
||||||
|
broadcast_manual_news_changed,
|
||||||
|
create_manual_news_group,
|
||||||
|
delete_manual_news_item,
|
||||||
|
get_news_record_or_404,
|
||||||
|
import_manual_news_items,
|
||||||
|
list_news_groups,
|
||||||
|
list_news_records,
|
||||||
|
parse_manual_news_import_upload,
|
||||||
|
rename_manual_news_group,
|
||||||
|
reprocess_manual_news_item,
|
||||||
|
serialize_news_record,
|
||||||
|
upsert_manual_news_item,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
EARTH_BRAND_ASSET_DIR = REPO_ROOT / "data" / "earth-brand"
|
||||||
|
EARTH_BRAND_ASSET_URL_PREFIX = "/earth-brand-assets"
|
||||||
|
EARTH_BRAND_CATEGORY = "earth_brand"
|
||||||
|
EARTH_ABOUT_CATEGORY = "earth_about"
|
||||||
|
SYSTEM_SETTINGS_CATEGORY = "system"
|
||||||
|
MAX_EARTH_BRAND_ASSET_BYTES = 3 * 1024 * 1024
|
||||||
|
ALLOWED_EARTH_BRAND_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".svg"}
|
||||||
|
|
||||||
|
|
||||||
|
def _app_version_label() -> str:
|
||||||
|
version = str(app_settings.VERSION or "").strip() or "0.0.0"
|
||||||
|
return version if version.startswith("v") else f"v{version}"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_EARTH_BRAND = {
|
||||||
|
"logo_src": "/earth/assets/brand/earth-logo.png",
|
||||||
|
"title_src": "/earth/assets/brand/title-zh.png",
|
||||||
|
"title_text": "智能星球计划",
|
||||||
|
"subtitle": "现实层宇宙全息感知系统",
|
||||||
|
"description": "卫星 · 海底光缆 · 算力基础设施",
|
||||||
|
"aria_label": "智能星球计划品牌标识",
|
||||||
|
"title_alt": "智能星球计划",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_EARTH_ABOUT = {
|
||||||
|
"logo_src": "/earth/assets/brand/lim-logo.png",
|
||||||
|
"kicker": "About",
|
||||||
|
"title": "智能星球计划",
|
||||||
|
"version": _app_version_label(),
|
||||||
|
"description": "面向临空场景下的智能媒体研究、全球态势感知与多源开放数据巡航,提供可视化观测、事件聚合与交互式探索能力。",
|
||||||
|
"meta": [
|
||||||
|
{"label": "出品方", "value": "浙江大学临空智能媒体研究院"},
|
||||||
|
{"label": "策划人", "value": "方兴东、黄柳青"},
|
||||||
|
{"label": "产品兼开发者", "value": "钱坤、张鸽、齐鹏"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
EARTH_ABOUT_LEGACY_PLANNER_VALUE = "黄柳青"
|
||||||
|
|
||||||
|
|
||||||
|
class EarthBoundaryConfigPayload(BaseModel):
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthBrandPayload(BaseModel):
|
||||||
|
logo_src: str = Field(default=DEFAULT_EARTH_BRAND["logo_src"], max_length=1000)
|
||||||
|
title_src: str = Field(default=DEFAULT_EARTH_BRAND["title_src"], max_length=1000)
|
||||||
|
title_text: str = Field(default=DEFAULT_EARTH_BRAND["title_text"], max_length=120)
|
||||||
|
subtitle: str = Field(default=DEFAULT_EARTH_BRAND["subtitle"], max_length=160)
|
||||||
|
description: str = Field(default=DEFAULT_EARTH_BRAND["description"], max_length=200)
|
||||||
|
aria_label: str = Field(default=DEFAULT_EARTH_BRAND["aria_label"], max_length=200)
|
||||||
|
title_alt: str = Field(default=DEFAULT_EARTH_BRAND["title_alt"], max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthAboutMetaItem(BaseModel):
|
||||||
|
label: str = Field(default="", max_length=80)
|
||||||
|
value: str = Field(default="", max_length=240)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthAboutPayload(BaseModel):
|
||||||
|
logo_src: str = Field(default=DEFAULT_EARTH_ABOUT["logo_src"], max_length=1000)
|
||||||
|
kicker: str = Field(default=DEFAULT_EARTH_ABOUT["kicker"], max_length=80)
|
||||||
|
title: str = Field(default=DEFAULT_EARTH_ABOUT["title"], max_length=160)
|
||||||
|
version: str = Field(default=DEFAULT_EARTH_ABOUT["version"], max_length=80)
|
||||||
|
description: str = Field(default=DEFAULT_EARTH_ABOUT["description"], max_length=800)
|
||||||
|
meta: list[EarthAboutMetaItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthNewsSourcesPayload(BaseModel):
|
||||||
|
cache_version: int | None = None
|
||||||
|
source_tags: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
categories: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
item_tag_rules: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
sources: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
health: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthNewsSourceTestPayload(BaseModel):
|
||||||
|
source: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthNewsManualItemPayload(BaseModel):
|
||||||
|
title: str = Field(default="", max_length=500)
|
||||||
|
summary: str = Field(default="", max_length=1200)
|
||||||
|
content: str = Field(default="", max_length=12000)
|
||||||
|
url: str = Field(default="", max_length=2000)
|
||||||
|
source: str = Field(default="", max_length=255)
|
||||||
|
region: str = Field(default="global", max_length=80)
|
||||||
|
published_at: str | None = None
|
||||||
|
category: str = Field(default="other", max_length=80)
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
location: dict[str, Any] | None = None
|
||||||
|
homepage_url: str = Field(default="", max_length=2000)
|
||||||
|
content_language: str = Field(default="", max_length=32)
|
||||||
|
group_id: str | None = Field(default=None, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthNewsManualGroupPayload(BaseModel):
|
||||||
|
name: str = Field(default="", max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_earth_brand_payload(payload: dict[str, Any] | None) -> dict[str, str]:
|
||||||
|
merged = DEFAULT_EARTH_BRAND.copy()
|
||||||
|
if payload:
|
||||||
|
for key in DEFAULT_EARTH_BRAND:
|
||||||
|
value = payload.get(key)
|
||||||
|
if value is not None:
|
||||||
|
merged[key] = str(value).strip()
|
||||||
|
|
||||||
|
if not merged["title_text"]:
|
||||||
|
merged["title_text"] = DEFAULT_EARTH_BRAND["title_text"]
|
||||||
|
if not merged["aria_label"]:
|
||||||
|
merged["aria_label"] = merged["title_text"]
|
||||||
|
if not merged["title_alt"]:
|
||||||
|
merged["title_alt"] = merged["title_text"]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_earth_about_payload(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
merged: dict[str, Any] = {
|
||||||
|
key: value
|
||||||
|
for key, value in DEFAULT_EARTH_ABOUT.items()
|
||||||
|
if key != "meta"
|
||||||
|
}
|
||||||
|
raw_meta = DEFAULT_EARTH_ABOUT["meta"]
|
||||||
|
if payload:
|
||||||
|
for key in ("logo_src", "kicker", "title", "description"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if value is not None:
|
||||||
|
merged[key] = str(value).strip()
|
||||||
|
raw_meta = payload.get("meta") if isinstance(payload.get("meta"), list) else raw_meta
|
||||||
|
merged["version"] = _app_version_label()
|
||||||
|
|
||||||
|
for key, default_value in DEFAULT_EARTH_ABOUT.items():
|
||||||
|
if key == "meta":
|
||||||
|
continue
|
||||||
|
if not merged.get(key):
|
||||||
|
merged[key] = default_value
|
||||||
|
|
||||||
|
normalized_meta: list[dict[str, str]] = []
|
||||||
|
for item in raw_meta:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
label = str(item.get("label") or "").strip()
|
||||||
|
value = str(item.get("value") or "").strip()
|
||||||
|
if label == "策划人" and value == EARTH_ABOUT_LEGACY_PLANNER_VALUE:
|
||||||
|
value = "方兴东、黄柳青"
|
||||||
|
if label or value:
|
||||||
|
normalized_meta.append({"label": label, "value": value})
|
||||||
|
if not normalized_meta:
|
||||||
|
normalized_meta = [dict(item) for item in DEFAULT_EARTH_ABOUT["meta"]]
|
||||||
|
merged["meta"] = normalized_meta
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _is_demo_mode_enabled(payload: Any) -> bool:
|
||||||
|
return bool(payload.get("demo_mode")) if isinstance(payload, dict) else False
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_earth_brand_record(db: AsyncSession) -> SystemSetting | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_earth_brand_payload(db: AsyncSession) -> dict[str, Any]:
|
||||||
|
record = await _get_earth_brand_record(db)
|
||||||
|
return {
|
||||||
|
"brand": _normalize_earth_brand_payload(record.payload if record else None),
|
||||||
|
"is_default": record is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_earth_about_record(db: AsyncSession) -> SystemSetting | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_earth_about_payload(db: AsyncSession) -> dict[str, Any]:
|
||||||
|
record = await _get_earth_about_record(db)
|
||||||
|
return {
|
||||||
|
"about": _normalize_earth_about_payload(record.payload if record else None),
|
||||||
|
"is_default": record is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/brand")
|
||||||
|
async def get_earth_brand(db: AsyncSession = Depends(get_db)):
|
||||||
|
return await _get_earth_brand_payload(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/brand")
|
||||||
|
async def update_earth_brand(
|
||||||
|
payload: EarthBrandPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
normalized = _normalize_earth_brand_payload(payload.model_dump())
|
||||||
|
record = await _get_earth_brand_record(db)
|
||||||
|
if record is None:
|
||||||
|
record = SystemSetting(category=EARTH_BRAND_CATEGORY, payload=normalized)
|
||||||
|
db.add(record)
|
||||||
|
else:
|
||||||
|
record.payload = normalized
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return {"status": "updated", "brand": _normalize_earth_brand_payload(record.payload), "is_default": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/brand")
|
||||||
|
@router.post("/brand/reset")
|
||||||
|
async def reset_earth_brand(
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_BRAND_CATEGORY))
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "reset", "brand": DEFAULT_EARTH_BRAND.copy(), "is_default": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/brand/assets")
|
||||||
|
async def upload_earth_brand_asset(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
original_name = file.filename or ""
|
||||||
|
extension = Path(original_name).suffix.lower()
|
||||||
|
if extension not in ALLOWED_EARTH_BRAND_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "unsupported_file_type",
|
||||||
|
"message": "Only png, jpg, jpeg, webp, and svg brand assets are supported.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
content = await file.read(MAX_EARTH_BRAND_ASSET_BYTES + 1)
|
||||||
|
if len(content) > MAX_EARTH_BRAND_ASSET_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"code": "file_too_large",
|
||||||
|
"message": "Brand asset must be 3 MB or smaller.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_name = f"{uuid4().hex}{extension}"
|
||||||
|
destination = EARTH_BRAND_ASSET_DIR / safe_name
|
||||||
|
destination.write_bytes(content)
|
||||||
|
asset_url = f"{EARTH_BRAND_ASSET_URL_PREFIX}/{safe_name}"
|
||||||
|
return {"url": asset_url, "filename": safe_name, "content_type": file.content_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/about")
|
||||||
|
async def get_earth_about(db: AsyncSession = Depends(get_db)):
|
||||||
|
return await _get_earth_about_payload(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/about")
|
||||||
|
async def update_earth_about(
|
||||||
|
payload: EarthAboutPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
normalized = _normalize_earth_about_payload(payload.model_dump())
|
||||||
|
record = await _get_earth_about_record(db)
|
||||||
|
if record is None:
|
||||||
|
record = SystemSetting(category=EARTH_ABOUT_CATEGORY, payload=normalized)
|
||||||
|
db.add(record)
|
||||||
|
else:
|
||||||
|
record.payload = normalized
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return {"status": "updated", "about": _normalize_earth_about_payload(record.payload), "is_default": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/about")
|
||||||
|
async def reset_earth_about(
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(delete(SystemSetting).where(SystemSetting.category == EARTH_ABOUT_CATEGORY))
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "reset", "about": _normalize_earth_about_payload(None), "is_default": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/news-sources")
|
||||||
|
async def get_earth_news_sources(db: AsyncSession = Depends(get_db)):
|
||||||
|
return await get_earth_news_sources_payload(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/news-sources")
|
||||||
|
async def update_earth_news_sources(
|
||||||
|
payload: EarthNewsSourcesPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await save_earth_news_sources_payload(db, payload.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/news-sources")
|
||||||
|
@router.post("/news-sources/reset")
|
||||||
|
async def reset_earth_news_sources(
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await reset_earth_news_sources_payload(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/news-sources/test")
|
||||||
|
async def test_earth_news_source(
|
||||||
|
payload: EarthNewsSourceTestPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await test_news_source_config(payload.source, db=db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/news-groups")
|
||||||
|
async def list_earth_news_groups_admin(
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_news_groups(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/news-groups")
|
||||||
|
async def create_earth_news_group_admin(
|
||||||
|
payload: EarthNewsManualGroupPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
group = await create_manual_news_group(db, payload.name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "ok", "group": group}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/news-groups/{group_id:path}")
|
||||||
|
async def rename_earth_news_group_admin(
|
||||||
|
group_id: str,
|
||||||
|
payload: EarthNewsManualGroupPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
group = await rename_manual_news_group(db, group_id, payload.name)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
await broadcast_manual_news_changed()
|
||||||
|
return {"status": "ok", "group": group}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/news-items")
|
||||||
|
async def list_earth_news_items_admin(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=100),
|
||||||
|
source_type: str | None = Query(None),
|
||||||
|
region: str | None = Query(None),
|
||||||
|
category: str | None = Query(None),
|
||||||
|
status_filter: str | None = Query(None, alias="status"),
|
||||||
|
group_id: str | None = Query(None),
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_news_records(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
source_type=source_type,
|
||||||
|
region=region,
|
||||||
|
category=category,
|
||||||
|
status_filter=status_filter,
|
||||||
|
group_id=group_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/news-items")
|
||||||
|
async def create_earth_news_item_admin(
|
||||||
|
payload: EarthNewsManualItemPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
result = await upsert_manual_news_item(db, payload.model_dump())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
await broadcast_manual_news_changed()
|
||||||
|
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/news-items/import")
|
||||||
|
async def import_earth_news_items_admin(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
group_id: str | None = Form(default=None),
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = await parse_manual_news_import_upload(await file.read())
|
||||||
|
result = await import_manual_news_items(db, payload, group_id=group_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
await broadcast_manual_news_changed()
|
||||||
|
return {"status": "ok", **result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/news-items/{item_id:path}")
|
||||||
|
async def update_earth_news_item_admin(
|
||||||
|
item_id: str,
|
||||||
|
payload: EarthNewsManualItemPayload,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await get_news_record_or_404(db, item_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="News item not found.")
|
||||||
|
try:
|
||||||
|
result = await upsert_manual_news_item(
|
||||||
|
db,
|
||||||
|
payload.model_dump(),
|
||||||
|
item_id_override=item_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
await broadcast_manual_news_changed()
|
||||||
|
return {"status": "ok", "created": result.created, "queued": result.queued, "item": serialize_news_record(result.item)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/news-items/{item_id:path}")
|
||||||
|
async def delete_earth_news_item_admin(
|
||||||
|
item_id: str,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
deleted = await delete_manual_news_item(db, item_id)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="News item not found.")
|
||||||
|
await db.commit()
|
||||||
|
await broadcast_manual_news_changed()
|
||||||
|
return {"status": "deleted", "id": item_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/news-items/{item_id:path}/reprocess")
|
||||||
|
async def reprocess_earth_news_item_admin(
|
||||||
|
item_id: str,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await get_news_record_or_404(db, item_id)
|
||||||
|
if existing is None:
|
||||||
|
raise HTTPException(status_code=404, detail="News item not found.")
|
||||||
|
try:
|
||||||
|
queued = await reprocess_manual_news_item(db, item_id)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
await db.commit()
|
||||||
|
await broadcast_manual_news_changed()
|
||||||
|
return {"status": "queued" if queued else "not_queued", "queued": queued, "id": item_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oobe-status")
|
||||||
|
async def get_earth_oobe_status(
|
||||||
|
current_user: User | None = Depends(_get_optional_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
current_count_result = await db.execute(
|
||||||
|
select(func.count(CollectedData.id)).where(CollectedData.is_current.is_(True))
|
||||||
|
)
|
||||||
|
current_record_count = int(current_count_result.scalar() or 0)
|
||||||
|
system_result = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.category == SYSTEM_SETTINGS_CATEGORY)
|
||||||
|
)
|
||||||
|
system_record = system_result.scalar_one_or_none()
|
||||||
|
demo_mode = _is_demo_mode_enabled(system_record.payload if system_record else None)
|
||||||
|
|
||||||
|
datasource_count_result = await db.execute(select(func.count(DataSource.id)))
|
||||||
|
datasource_count = int(datasource_count_result.scalar() or 0)
|
||||||
|
active_datasource_count_result = await db.execute(
|
||||||
|
select(func.count(DataSource.id)).where(DataSource.is_active.is_(True))
|
||||||
|
)
|
||||||
|
active_datasource_count = int(active_datasource_count_result.scalar() or 0)
|
||||||
|
config_result = await db.execute(select(func.count(DataSourceConfig.id)))
|
||||||
|
custom_config_count = int(config_result.scalar() or 0)
|
||||||
|
|
||||||
|
tv_payload = await get_tv_settings_payload(db)
|
||||||
|
tv_sources = tv_payload.get("sources") if isinstance(tv_payload, dict) else []
|
||||||
|
tv_source_count = len(tv_sources) if isinstance(tv_sources, list) else 0
|
||||||
|
|
||||||
|
boundary_status = get_boundary_status()
|
||||||
|
has_core_layers = bool(boundary_status.get("ready") or boundary_status.get("available") or boundary_status.get("status") in {"ready", "built", "ok"})
|
||||||
|
has_collected_data = current_record_count > 0
|
||||||
|
ready = has_collected_data
|
||||||
|
|
||||||
|
suggestions: list[str] = []
|
||||||
|
if demo_mode:
|
||||||
|
suggestions.append("演示模式已开启")
|
||||||
|
if not current_user:
|
||||||
|
suggestions.append("登录控制台")
|
||||||
|
if not has_collected_data:
|
||||||
|
suggestions.append("触发数据源采集")
|
||||||
|
if not custom_config_count:
|
||||||
|
suggestions.append("确认采集器配置")
|
||||||
|
if not has_core_layers:
|
||||||
|
suggestions.append("构建或启用 Earth 图层")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ready": ready,
|
||||||
|
"demo_mode": demo_mode,
|
||||||
|
"authenticated": current_user is not None,
|
||||||
|
"needs_login": current_user is None and not ready and not demo_mode,
|
||||||
|
"has_collected_data": has_collected_data,
|
||||||
|
"has_tv_sources": tv_source_count > 0,
|
||||||
|
"has_core_layers": has_core_layers,
|
||||||
|
"current_record_count": current_record_count,
|
||||||
|
"datasource_count": datasource_count,
|
||||||
|
"active_datasource_count": active_datasource_count,
|
||||||
|
"custom_config_count": custom_config_count,
|
||||||
|
"tv_source_count": tv_source_count,
|
||||||
|
"suggestions": suggestions,
|
||||||
|
"login_url": "/login?next=/datasources",
|
||||||
|
"datasources_url": "/datasources",
|
||||||
|
"collection_url": "/collection-management",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/boundaries/status")
|
||||||
|
async def get_earth_boundary_status():
|
||||||
|
return get_boundary_status()
|
||||||
|
|
||||||
|
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()
|
||||||
190
backend/app/api/v1/interactables.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""CRUD APIs for persistent Earth interactables."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.earth_interactable import EarthInteractable
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.earth_interactables import (
|
||||||
|
build_interactable_event,
|
||||||
|
interactables_to_geojson,
|
||||||
|
invalidate_interactable_cache,
|
||||||
|
list_interactables,
|
||||||
|
normalize_interactable_id,
|
||||||
|
publish_interactable_event,
|
||||||
|
serialize_interactable,
|
||||||
|
)
|
||||||
|
from app.services.earth_layer_cache import (
|
||||||
|
EarthLayerCachePolicy,
|
||||||
|
earth_layer_cache,
|
||||||
|
get_or_build_layer_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
INTERACTABLE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
fresh_ttl_seconds=60,
|
||||||
|
stale_ttl_seconds=10 * 60,
|
||||||
|
max_features=5000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InteractableCreate(BaseModel):
|
||||||
|
id: str | None = Field(default=None, max_length=160)
|
||||||
|
layer: str = Field(default="default", min_length=1, max_length=80)
|
||||||
|
kind: str = Field(default="default", min_length=1, max_length=80)
|
||||||
|
label: str = Field(default="", max_length=255)
|
||||||
|
description: str = Field(default="", max_length=4000)
|
||||||
|
latitude: float = Field(ge=-90, le=90)
|
||||||
|
longitude: float = Field(ge=-180, le=180)
|
||||||
|
altitude: float | None = None
|
||||||
|
properties: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("layer", "kind")
|
||||||
|
@classmethod
|
||||||
|
def normalize_key(cls, value: str) -> str:
|
||||||
|
normalized = str(value or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("must not be empty")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class InteractableUpdate(BaseModel):
|
||||||
|
layer: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
kind: str | None = Field(default=None, min_length=1, max_length=80)
|
||||||
|
label: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
latitude: float | None = Field(default=None, ge=-90, le=90)
|
||||||
|
longitude: float | None = Field(default=None, ge=-180, le=180)
|
||||||
|
altitude: float | None = None
|
||||||
|
properties: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_interactables(
|
||||||
|
response: Response,
|
||||||
|
layer: str | None = Query(default=None),
|
||||||
|
include_deleted: bool = Query(default=False),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
items = await list_interactables(db, layer=layer, include_deleted=include_deleted)
|
||||||
|
response.headers["X-Planet-Interactables-Count"] = str(len(items))
|
||||||
|
return {"items": [serialize_interactable(item) for item in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geojson")
|
||||||
|
async def get_interactables_geojson(
|
||||||
|
response: Response,
|
||||||
|
layer: str | None = Query(default=None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
items = await list_interactables(db, layer=layer)
|
||||||
|
return interactables_to_geojson(items)
|
||||||
|
|
||||||
|
payload = await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("interactables", interactable_layer=layer or "all"),
|
||||||
|
policy=INTERACTABLE_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
response.headers["X-Planet-Interactables-Count"] = str(len(payload.get("features") or []))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_interactable(
|
||||||
|
payload: InteractableCreate,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
record_id = normalize_interactable_id(payload.id)
|
||||||
|
existing = await db.get(EarthInteractable, record_id)
|
||||||
|
if existing and not existing.is_deleted:
|
||||||
|
raise HTTPException(status_code=409, detail="Interactable already exists")
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
record = EarthInteractable(id=record_id)
|
||||||
|
db.add(record)
|
||||||
|
else:
|
||||||
|
record = existing
|
||||||
|
record.is_deleted = False
|
||||||
|
record.deleted_at = None
|
||||||
|
record.revision += 1
|
||||||
|
|
||||||
|
record.layer = payload.layer
|
||||||
|
record.kind = payload.kind
|
||||||
|
record.label = payload.label
|
||||||
|
record.description = payload.description
|
||||||
|
record.latitude = payload.latitude
|
||||||
|
record.longitude = payload.longitude
|
||||||
|
record.altitude = payload.altitude
|
||||||
|
record.properties = payload.properties
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
invalidate_interactable_cache(record.layer)
|
||||||
|
await publish_interactable_event("created", record)
|
||||||
|
return {"item": serialize_interactable(record)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{interactable_id}")
|
||||||
|
async def get_interactable(interactable_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
record = await db.get(EarthInteractable, interactable_id)
|
||||||
|
if record is None or record.is_deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||||
|
return {"item": serialize_interactable(record)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{interactable_id}")
|
||||||
|
async def update_interactable(
|
||||||
|
interactable_id: str,
|
||||||
|
payload: InteractableUpdate,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
record = await db.get(EarthInteractable, interactable_id)
|
||||||
|
if record is None or record.is_deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||||
|
|
||||||
|
previous_layer = record.layer
|
||||||
|
patch = payload.model_dump(exclude_unset=True)
|
||||||
|
for key, value in patch.items():
|
||||||
|
setattr(record, key, value)
|
||||||
|
record.revision += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
invalidate_interactable_cache(previous_layer)
|
||||||
|
if record.layer != previous_layer:
|
||||||
|
invalidate_interactable_cache(record.layer)
|
||||||
|
await publish_interactable_event("updated", record)
|
||||||
|
return {"item": serialize_interactable(record)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{interactable_id}")
|
||||||
|
async def delete_interactable(
|
||||||
|
interactable_id: str,
|
||||||
|
_current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
record = await db.get(EarthInteractable, interactable_id)
|
||||||
|
if record is None or record.is_deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Interactable not found")
|
||||||
|
|
||||||
|
record.is_deleted = True
|
||||||
|
record.deleted_at = datetime.now(UTC)
|
||||||
|
record.revision += 1
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
invalidate_interactable_cache(record.layer)
|
||||||
|
await publish_interactable_event("deleted", record)
|
||||||
|
event = build_interactable_event(action="deleted", record=record, include_item=True)
|
||||||
|
return {"deleted": True, "event": event}
|
||||||
231
backend/app/api/v1/layers.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
|
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: float = 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),
|
||||||
|
response: Response = None,
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,13 +1,92 @@
|
|||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.services.earth_news import get_earth_news_payload
|
from app.db.session import get_db
|
||||||
|
from app.services.earth_news import (
|
||||||
|
ALLOWED_NEWS_CATEGORY_KEYS,
|
||||||
|
SUPPORTED_NEWS_LOCALES,
|
||||||
|
REGION_ANCHORS,
|
||||||
|
get_earth_news_payload,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_categories(raw: str | None) -> set[str] | None:
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return None
|
||||||
|
requested = {item.strip().lower() for item in raw.split(",") if item.strip()}
|
||||||
|
invalid = sorted(requested - set(ALLOWED_NEWS_CATEGORY_KEYS))
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "Unsupported news categories.",
|
||||||
|
"invalid_categories": invalid,
|
||||||
|
"allowed_categories": list(ALLOWED_NEWS_CATEGORY_KEYS),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return requested or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_source_ids(raw: str | None) -> set[str] | None:
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return None
|
||||||
|
return {item.strip() for item in raw.split(",") if item.strip()} or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_limit(raw: int | None) -> int:
|
||||||
|
if raw is None:
|
||||||
|
return 12
|
||||||
|
if raw < 1:
|
||||||
|
raise HTTPException(status_code=422, detail={"message": "News limit must be greater than 0."})
|
||||||
|
return min(raw, 100)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_locale(raw: str | None) -> str:
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
return "zh-CN"
|
||||||
|
requested = raw.strip()
|
||||||
|
if requested not in SUPPORTED_NEWS_LOCALES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "Unsupported news locale.",
|
||||||
|
"invalid_locale": requested,
|
||||||
|
"allowed_locales": sorted(SUPPORTED_NEWS_LOCALES),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return requested
|
||||||
|
|
||||||
|
|
||||||
@router.get("/earth-feed")
|
@router.get("/earth-feed")
|
||||||
async def get_earth_feed(
|
async def get_earth_feed(
|
||||||
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
lat: float | None = Query(None, description="Current Earth view center latitude"),
|
||||||
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
lon: float | None = Query(None, description="Current Earth view center longitude"),
|
||||||
|
region: str | None = Query(None, description="Explicit Earth news region for UE/client integrations"),
|
||||||
|
categories: str | None = Query(None, description="Comma-separated news category keys"),
|
||||||
|
sources: str | None = Query(None, description="Comma-separated news source ids"),
|
||||||
|
limit: int | None = Query(None, description="Maximum news items to return, capped at 100"),
|
||||||
|
locale: str | None = Query(None, description="Display locale, zh-CN or en-US"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return await get_earth_news_payload(lat=lat, lon=lon)
|
normalized_region = region.strip().lower() if isinstance(region, str) and region.strip() else None
|
||||||
|
if normalized_region is not None and normalized_region not in REGION_ANCHORS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "Unsupported news region.",
|
||||||
|
"invalid_region": normalized_region,
|
||||||
|
"allowed_regions": list(REGION_ANCHORS.keys()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return await get_earth_news_payload(
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
region=normalized_region,
|
||||||
|
categories=_parse_categories(categories),
|
||||||
|
source_ids=_parse_source_ids(sources),
|
||||||
|
limit=_parse_limit(limit),
|
||||||
|
locale=_parse_locale(locale),
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|||||||
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)
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import ROOT_DIR
|
from app.core.config import ROOT_DIR, settings
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.persistent_logs import record_audit_log, record_system_log
|
from app.services.persistent_logs import record_audit_log, record_system_log
|
||||||
from app.services.system_control import (
|
from app.services.system_control import (
|
||||||
@@ -33,8 +36,13 @@ from app.services.system_logs import (
|
|||||||
append_buffer_log,
|
append_buffer_log,
|
||||||
list_log_sources,
|
list_log_sources,
|
||||||
normalize_log_level,
|
normalize_log_level,
|
||||||
|
read_database_log_snapshot,
|
||||||
read_log_snapshot,
|
read_log_snapshot,
|
||||||
|
read_observability_group_events,
|
||||||
|
read_observability_groups,
|
||||||
|
read_observability_raw_events,
|
||||||
)
|
)
|
||||||
|
from app.services.earth_layer_cache import earth_layer_cache
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -104,12 +112,115 @@ class EarthClientLogEventCreate(BaseModel):
|
|||||||
url: str | None = None
|
url: str | None = None
|
||||||
module: str | None = None
|
module: str | None = None
|
||||||
detail: str | None = None
|
detail: str | None = None
|
||||||
|
fingerprint: str | None = None
|
||||||
|
occurrence_count: int = 1
|
||||||
|
metadata: dict[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
class EarthClientLogEventResponse(BaseModel):
|
class EarthClientLogEventResponse(BaseModel):
|
||||||
accepted: bool
|
accepted: bool
|
||||||
source_id: str
|
source_id: str
|
||||||
level: str
|
level: str
|
||||||
|
fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceLogEventCreate(BaseModel):
|
||||||
|
source: str = "ai-provider"
|
||||||
|
service: str = "ai-provider"
|
||||||
|
module: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
event: str = "service.runtime_log"
|
||||||
|
level: str = "error"
|
||||||
|
message: str
|
||||||
|
fingerprint: str | None = None
|
||||||
|
occurrence_count: int = 1
|
||||||
|
request_id: str | None = None
|
||||||
|
trace_id: str | None = None
|
||||||
|
task_id: str | None = None
|
||||||
|
source_id: int | str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
context: dict[str, object] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_client_log_event(
|
||||||
|
source_id: str,
|
||||||
|
*,
|
||||||
|
service: str,
|
||||||
|
event: str,
|
||||||
|
default_module: str,
|
||||||
|
default_category: str,
|
||||||
|
payload: EarthClientLogEventCreate,
|
||||||
|
request: Request,
|
||||||
|
) -> EarthClientLogEventResponse:
|
||||||
|
normalized_level = normalize_log_level(payload.level)
|
||||||
|
append_buffer_log(
|
||||||
|
source_id,
|
||||||
|
level=normalized_level,
|
||||||
|
message=payload.message,
|
||||||
|
context={
|
||||||
|
"category": payload.category or "",
|
||||||
|
"url": payload.url or "",
|
||||||
|
"module": payload.module or "",
|
||||||
|
"detail": payload.detail or "",
|
||||||
|
"fingerprint": payload.fingerprint or "",
|
||||||
|
"occurrence_count": max(1, int(payload.occurrence_count or 1)),
|
||||||
|
"metadata": payload.metadata or {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await record_system_log(
|
||||||
|
source=source_id,
|
||||||
|
service=service,
|
||||||
|
module=payload.module or default_module,
|
||||||
|
event=event,
|
||||||
|
level=normalized_level,
|
||||||
|
message=payload.message,
|
||||||
|
category=payload.category or default_category,
|
||||||
|
context={
|
||||||
|
"url": payload.url or "",
|
||||||
|
"detail": payload.detail or "",
|
||||||
|
"module": payload.module or "",
|
||||||
|
"client_ip": request.client.host if request.client else "",
|
||||||
|
"metadata": payload.metadata or {},
|
||||||
|
},
|
||||||
|
fingerprint=payload.fingerprint,
|
||||||
|
occurrence_count=max(1, int(payload.occurrence_count or 1)),
|
||||||
|
)
|
||||||
|
return EarthClientLogEventResponse(accepted=True, source_id=source_id, level=normalized_level, fingerprint=payload.fingerprint)
|
||||||
|
|
||||||
|
|
||||||
|
def require_observability_ingest_token(
|
||||||
|
authorization: str | None,
|
||||||
|
ingest_token: str | None,
|
||||||
|
) -> None:
|
||||||
|
expected_token = settings.OBSERVABILITY_INGEST_TOKEN.strip()
|
||||||
|
if not expected_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Observability service ingestion is not configured",
|
||||||
|
)
|
||||||
|
provided = ""
|
||||||
|
if ingest_token:
|
||||||
|
provided = ingest_token.strip()
|
||||||
|
elif authorization:
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() == "bearer":
|
||||||
|
provided = token.strip()
|
||||||
|
if not provided or not secrets.compare_digest(provided, expected_token):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid observability ingestion token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EarthLayerCacheStatusResponse(BaseModel):
|
||||||
|
prefix: str
|
||||||
|
key_count: int
|
||||||
|
memory_bytes: int
|
||||||
|
layers: dict[str, dict[str, int]]
|
||||||
|
|
||||||
|
|
||||||
|
class EarthLayerCacheClearResponse(BaseModel):
|
||||||
|
deleted: int
|
||||||
|
|
||||||
|
|
||||||
def ensure_super_admin(current_user: User) -> None:
|
def ensure_super_admin(current_user: User) -> None:
|
||||||
@@ -132,6 +243,34 @@ def validate_log_date(raw_value: str | None, field_name: str) -> str | None:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cache/earth-layers", response_model=EarthLayerCacheStatusResponse)
|
||||||
|
async def get_earth_layer_cache_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
try:
|
||||||
|
return earth_layer_cache.status()
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Unable to read Earth layer cache status: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/cache/earth-layers", response_model=EarthLayerCacheClearResponse)
|
||||||
|
async def clear_earth_layer_cache(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
try:
|
||||||
|
return {"deleted": earth_layer_cache.delete_pattern()}
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Unable to clear Earth layer cache: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
@router.post("/restart-tasks", response_model=RestartTaskResponse)
|
||||||
async def create_restart_task(
|
async def create_restart_task(
|
||||||
payload: RestartTaskCreate,
|
payload: RestartTaskCreate,
|
||||||
@@ -270,7 +409,141 @@ async def get_system_log_sources(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
ensure_super_admin(current_user)
|
ensure_super_admin(current_user)
|
||||||
return {"items": list_log_sources()}
|
return {
|
||||||
|
"items": [
|
||||||
|
*list_log_sources(),
|
||||||
|
{
|
||||||
|
"source_id": "system-db",
|
||||||
|
"name": "系统事件",
|
||||||
|
"kind": "database",
|
||||||
|
"location": "table://system_logs",
|
||||||
|
"description": "后端持久化系统事件、AI 和采集器操作日志。",
|
||||||
|
"category": "database",
|
||||||
|
"status": "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source_id": "audit-db",
|
||||||
|
"name": "审计事件",
|
||||||
|
"kind": "database",
|
||||||
|
"location": "table://audit_logs",
|
||||||
|
"description": "管理员敏感操作和密钥 reveal 审计记录。",
|
||||||
|
"category": "audit",
|
||||||
|
"status": "ok",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/observability/groups")
|
||||||
|
async def get_observability_log_groups(
|
||||||
|
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
||||||
|
level: str = "all",
|
||||||
|
levels: str | None = Query(None, description="Comma-separated log levels"),
|
||||||
|
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
|
||||||
|
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
||||||
|
search: str | None = Query(None, description="Case-insensitive substring search"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
|
||||||
|
normalized_start_date = validate_log_date(start_date, "start_date")
|
||||||
|
normalized_end_date = validate_log_date(end_date, "end_date")
|
||||||
|
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
||||||
|
return await read_observability_groups(
|
||||||
|
limit=limit,
|
||||||
|
level=level,
|
||||||
|
levels=levels,
|
||||||
|
start_date=normalized_start_date,
|
||||||
|
end_date=normalized_end_date,
|
||||||
|
search=search,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/observability/groups/{fingerprint}/events")
|
||||||
|
async def get_observability_group_events(
|
||||||
|
fingerprint: str,
|
||||||
|
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
|
||||||
|
payload = await read_observability_group_events(fingerprint, limit=limit, db=db)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Observability group not found")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/observability/raw")
|
||||||
|
async def get_observability_raw_events(
|
||||||
|
limit: int = DEFAULT_LOG_LINE_LIMIT,
|
||||||
|
level: str = "all",
|
||||||
|
levels: str | None = Query(None, description="Comma-separated log levels"),
|
||||||
|
start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"),
|
||||||
|
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
||||||
|
search: str | None = Query(None, description="Case-insensitive substring search"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
ensure_super_admin(current_user)
|
||||||
|
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
|
||||||
|
normalized_start_date = validate_log_date(start_date, "start_date")
|
||||||
|
normalized_end_date = validate_log_date(end_date, "end_date")
|
||||||
|
return await read_observability_raw_events(
|
||||||
|
limit=limit,
|
||||||
|
level=level,
|
||||||
|
levels=levels,
|
||||||
|
start_date=normalized_start_date,
|
||||||
|
end_date=normalized_end_date,
|
||||||
|
search=search,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logs/service", response_model=EarthClientLogEventResponse)
|
||||||
|
async def ingest_service_log(
|
||||||
|
payload: ServiceLogEventCreate,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
ingest_token: str | None = Header(default=None, alias="X-Planet-Observability-Token"),
|
||||||
|
):
|
||||||
|
require_observability_ingest_token(authorization, ingest_token)
|
||||||
|
normalized_level = normalize_log_level(payload.level)
|
||||||
|
source = (payload.source or "ai-provider").strip() or "ai-provider"
|
||||||
|
context = dict(payload.context or {})
|
||||||
|
if payload.request_id:
|
||||||
|
context["request_id"] = payload.request_id
|
||||||
|
if payload.trace_id:
|
||||||
|
context["trace_id"] = payload.trace_id
|
||||||
|
if payload.task_id:
|
||||||
|
context["task_id"] = payload.task_id
|
||||||
|
if payload.source_id is not None:
|
||||||
|
context["source_id"] = payload.source_id
|
||||||
|
if payload.provider:
|
||||||
|
context["provider"] = payload.provider
|
||||||
|
await record_system_log(
|
||||||
|
source=source,
|
||||||
|
service=(payload.service or source).strip() or source,
|
||||||
|
module=payload.module or source,
|
||||||
|
event=(payload.event or "service.runtime_log").strip() or "service.runtime_log",
|
||||||
|
level=normalized_level,
|
||||||
|
message=payload.message,
|
||||||
|
category=payload.category or "service-runtime",
|
||||||
|
context=context,
|
||||||
|
fingerprint=payload.fingerprint,
|
||||||
|
occurrence_count=max(1, int(payload.occurrence_count or 1)),
|
||||||
|
)
|
||||||
|
return EarthClientLogEventResponse(
|
||||||
|
accepted=True,
|
||||||
|
source_id=source,
|
||||||
|
level=normalized_level,
|
||||||
|
fingerprint=payload.fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
||||||
@@ -283,6 +556,7 @@ async def get_system_log_snapshot(
|
|||||||
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"),
|
||||||
search: str | None = Query(None, description="Case-insensitive substring search"),
|
search: str | None = Query(None, description="Case-insensitive substring search"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
ensure_super_admin(current_user)
|
ensure_super_admin(current_user)
|
||||||
|
|
||||||
@@ -305,15 +579,26 @@ async def get_system_log_snapshot(
|
|||||||
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date")
|
||||||
|
|
||||||
snapshot = read_log_snapshot(
|
snapshot = await read_database_log_snapshot(
|
||||||
source_id,
|
source_id,
|
||||||
limit,
|
limit=limit,
|
||||||
level=level,
|
level=level,
|
||||||
levels=levels,
|
levels=levels,
|
||||||
start_date=normalized_start_date,
|
start_date=normalized_start_date,
|
||||||
end_date=normalized_end_date,
|
end_date=normalized_end_date,
|
||||||
search=search,
|
search=search,
|
||||||
|
db=db,
|
||||||
)
|
)
|
||||||
|
if snapshot is None:
|
||||||
|
snapshot = read_log_snapshot(
|
||||||
|
source_id,
|
||||||
|
limit,
|
||||||
|
level=level,
|
||||||
|
levels=levels,
|
||||||
|
start_date=normalized_start_date,
|
||||||
|
end_date=normalized_end_date,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found")
|
||||||
return snapshot
|
return snapshot
|
||||||
@@ -324,31 +609,28 @@ async def ingest_earth_client_log(
|
|||||||
payload: EarthClientLogEventCreate,
|
payload: EarthClientLogEventCreate,
|
||||||
request: Request,
|
request: Request,
|
||||||
):
|
):
|
||||||
normalized_level = normalize_log_level(payload.level)
|
return await ingest_client_log_event(
|
||||||
append_buffer_log(
|
|
||||||
"earth-client",
|
"earth-client",
|
||||||
level=normalized_level,
|
|
||||||
message=payload.message,
|
|
||||||
context={
|
|
||||||
"category": payload.category or "",
|
|
||||||
"url": payload.url or "",
|
|
||||||
"module": payload.module or "",
|
|
||||||
"detail": payload.detail or "",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await record_system_log(
|
|
||||||
source="earth-client",
|
|
||||||
service="earth",
|
service="earth",
|
||||||
module=payload.module or "earth-client",
|
|
||||||
event="earth.client.runtime_log",
|
event="earth.client.runtime_log",
|
||||||
level=normalized_level,
|
default_module="earth-client",
|
||||||
message=payload.message,
|
default_category="client-runtime",
|
||||||
category=payload.category or "client-runtime",
|
payload=payload,
|
||||||
context={
|
request=request,
|
||||||
"url": payload.url or "",
|
)
|
||||||
"detail": payload.detail or "",
|
|
||||||
"module": payload.module or "",
|
|
||||||
"client_ip": request.client.host if request.client else "",
|
@router.post("/logs/admin-client", response_model=EarthClientLogEventResponse)
|
||||||
},
|
async def ingest_admin_client_log(
|
||||||
|
payload: EarthClientLogEventCreate,
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
return await ingest_client_log_event(
|
||||||
|
"admin-client",
|
||||||
|
service="admin",
|
||||||
|
event="admin.client.runtime_log",
|
||||||
|
default_module="admin-client",
|
||||||
|
default_category="client-runtime",
|
||||||
|
payload=payload,
|
||||||
|
request=request,
|
||||||
)
|
)
|
||||||
return {"accepted": True, "source_id": "earth-client", "level": normalized_level}
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ async def list_tasks(
|
|||||||
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
|
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
|
||||||
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
|
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
|
||||||
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
|
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
|
||||||
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress
|
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress,
|
||||||
|
ct.task_type, ct.source, ds.source as datasource_source
|
||||||
FROM collection_tasks ct
|
FROM collection_tasks ct
|
||||||
JOIN data_sources ds ON ct.datasource_id = ds.id
|
JOIN data_sources ds ON ct.datasource_id = ds.id
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@@ -39,12 +40,19 @@ async def list_tasks(
|
|||||||
|
|
||||||
if datasource_id:
|
if datasource_id:
|
||||||
query += " AND ct.datasource_id = :datasource_id"
|
query += " AND ct.datasource_id = :datasource_id"
|
||||||
count_query += " WHERE ct.datasource_id = :datasource_id"
|
count_query += " AND ct.datasource_id = :datasource_id"
|
||||||
params["datasource_id"] = datasource_id
|
params["datasource_id"] = datasource_id
|
||||||
if status:
|
if status:
|
||||||
query += " AND ct.status = :status"
|
statuses = [item.strip() for item in status.split(",") if item.strip()]
|
||||||
count_query += " AND ct.status = :status"
|
if len(statuses) > 1:
|
||||||
params["status"] = status
|
placeholders = ", ".join(f":status_{index}" for index, _item in enumerate(statuses))
|
||||||
|
query += f" AND ct.status IN ({placeholders})"
|
||||||
|
count_query += f" AND ct.status IN ({placeholders})"
|
||||||
|
params.update({f"status_{index}": item for index, item in enumerate(statuses)})
|
||||||
|
else:
|
||||||
|
query += " AND ct.status = :status"
|
||||||
|
count_query += " AND ct.status = :status"
|
||||||
|
params["status"] = statuses[0] if statuses else status
|
||||||
|
|
||||||
query += f" ORDER BY ct.created_at DESC LIMIT {page_size} OFFSET {offset}"
|
query += f" ORDER BY ct.created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||||
|
|
||||||
@@ -76,6 +84,9 @@ async def list_tasks(
|
|||||||
"phase_unit": t[13],
|
"phase_unit": t[13],
|
||||||
"total_records": t[14],
|
"total_records": t[14],
|
||||||
"progress": t[15],
|
"progress": t[15],
|
||||||
|
"task_type": t[16],
|
||||||
|
"source": t[17] or t[18],
|
||||||
|
"datasource_source": t[18],
|
||||||
}
|
}
|
||||||
for t in tasks
|
for t in tasks
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from urllib.parse import quote, urljoin
|
from urllib.parse import quote, urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -6,16 +7,41 @@ from fastapi.responses import Response
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.services.tv_catalog import get_tv_catalog_page
|
||||||
from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url
|
from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_HLS_URI_ATTRIBUTE_RE = re.compile(r'URI="([^"]+)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _proxied_tv_url(url: str) -> str:
|
||||||
|
return f"/api/v1/tv/proxy?url={quote(url, safe='')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _rewrite_hls_uri_attributes(line: str, *, base_url: str) -> str:
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
uri = match.group(1)
|
||||||
|
absolute_url = urljoin(base_url, uri)
|
||||||
|
return f'URI="{_proxied_tv_url(absolute_url)}"'
|
||||||
|
|
||||||
|
return _HLS_URI_ATTRIBUTE_RE.sub(replace, line)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_strip_hls_metadata_line(line: str) -> bool:
|
||||||
|
normalized = line.strip().upper()
|
||||||
|
return normalized.startswith("#EXT-X-MEDIA:") and "TYPE=SUBTITLES" in normalized
|
||||||
|
|
||||||
|
|
||||||
@router.get("/streams")
|
@router.get("/streams")
|
||||||
async def list_public_tv_streams(
|
async def list_public_tv_streams(
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
selected_id: str | None = Query(None, max_length=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return await get_public_tv_payload(db)
|
return await get_tv_catalog_page(db, offset=offset, limit=limit, q=q, selected_id=selected_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/proxy")
|
@router.get("/proxy")
|
||||||
@@ -56,11 +82,16 @@ async def proxy_tv_stream(
|
|||||||
rewritten_lines: list[str] = []
|
rewritten_lines: list[str] = []
|
||||||
for line in manifest_text.splitlines():
|
for line in manifest_text.splitlines():
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not stripped or stripped.startswith("#"):
|
if not stripped:
|
||||||
rewritten_lines.append(line)
|
rewritten_lines.append(line)
|
||||||
continue
|
continue
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
if _should_strip_hls_metadata_line(line):
|
||||||
|
continue
|
||||||
|
rewritten_lines.append(_rewrite_hls_uri_attributes(line, base_url=response_url))
|
||||||
|
continue
|
||||||
absolute_url = urljoin(response_url, stripped)
|
absolute_url = urljoin(response_url, stripped)
|
||||||
rewritten_lines.append(f"/api/v1/tv/proxy?url={quote(absolute_url, safe='')}")
|
rewritten_lines.append(_proxied_tv_url(absolute_url))
|
||||||
return Response(
|
return Response(
|
||||||
content="\n".join(rewritten_lines),
|
content="\n".join(rewritten_lines),
|
||||||
media_type="application/vnd.apple.mpegurl",
|
media_type="application/vnd.apple.mpegurl",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.enums import UserRole
|
||||||
from app.core.security import get_current_user, get_password_hash
|
from app.core.security import get_current_user, get_password_hash
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -13,6 +14,8 @@ from app.schemas.user import UserCreate, UserUpdate
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
||||||
|
ADMIN_ROLES = [UserRole.SUPER_ADMIN.value, UserRole.ADMIN.value]
|
||||||
|
SUPER_ADMIN_ROLES = [UserRole.SUPER_ADMIN.value]
|
||||||
|
|
||||||
|
|
||||||
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
||||||
@@ -32,7 +35,7 @@ async def list_users(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not check_permission(current_user, ["super_admin", "admin"]):
|
if not check_permission(current_user, ADMIN_ROLES):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Insufficient permissions",
|
detail="Insufficient permissions",
|
||||||
@@ -91,7 +94,7 @@ async def get_user(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not check_permission(current_user, ["super_admin", "admin"]) and current_user.id != user_id:
|
if not check_permission(current_user, ADMIN_ROLES) and current_user.id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Insufficient permissions",
|
detail="Insufficient permissions",
|
||||||
@@ -128,7 +131,7 @@ async def create_user(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not check_permission(current_user, ["super_admin"]):
|
if not check_permission(current_user, SUPER_ADMIN_ROLES):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only super_admin can create users",
|
detail="Only super_admin can create users",
|
||||||
@@ -196,18 +199,18 @@ async def update_user(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not check_permission(current_user, ["super_admin", "admin"]) and current_user.id != user_id:
|
if not check_permission(current_user, ADMIN_ROLES) and current_user.id != user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Insufficient permissions",
|
detail="Insufficient permissions",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not check_permission(current_user, ["super_admin"]) and user_data.role is not None:
|
if not check_permission(current_user, SUPER_ADMIN_ROLES) and user_data.role is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only super_admin can change user role",
|
detail="Only super_admin can change user role",
|
||||||
)
|
)
|
||||||
if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None:
|
if not check_permission(current_user, SUPER_ADMIN_ROLES) and user_data.gatekeeper_groups is not None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only super_admin can change Gatekeeper groups",
|
detail="Only super_admin can change Gatekeeper groups",
|
||||||
@@ -260,7 +263,7 @@ async def delete_user(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not check_permission(current_user, ["super_admin"]):
|
if not check_permission(current_user, SUPER_ADMIN_ROLES):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only super_admin can delete users",
|
detail="Only super_admin can delete users",
|
||||||
|
|||||||
41
backend/app/api/v1/vessels.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Bounded vessel snapshot APIs backed by the latest vessel state table."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
|
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="Snapshot bbox as lon_min,lat_min,lon_max,lat_max"),
|
||||||
|
zoom: float = 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),
|
||||||
|
response: Response = None,
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ from sqlalchemy import select, func
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.core.collected_data_fields import get_record_field
|
from app.core.collected_data_fields import get_record_field
|
||||||
|
from app.core.enums import BGPStatus
|
||||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
@@ -25,27 +26,42 @@ from app.models.bgp_anomaly import BGPAnomaly
|
|||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
from app.models.vessel import AISSourceHealth, VesselPosition, VesselStatic
|
from app.models.vessel import AISSourceHealth, VesselCurrentState, VesselPosition, VesselStatic
|
||||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
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.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||||
from app.services.compute_center_locations import (
|
from app.services.compute_center_locations import (
|
||||||
RENDERABLE_PRECISIONS,
|
RENDERABLE_PRECISIONS,
|
||||||
ResolutionDiagnostic,
|
ResolutionDiagnostic,
|
||||||
|
build_compute_center_location_query,
|
||||||
collect_location_candidates,
|
collect_location_candidates,
|
||||||
refresh_compute_center_location_cache,
|
refresh_compute_center_location_cache,
|
||||||
resolve_compute_center_location_full,
|
resolve_compute_center_location_full,
|
||||||
upsert_compute_center_location,
|
upsert_compute_center_location,
|
||||||
)
|
)
|
||||||
|
from app.services.ai_client import get_ai_provider_client
|
||||||
|
from app.api.v1.settings import get_runtime_web_search_config, get_web_search_client
|
||||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
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.persistent_logs import record_system_log
|
||||||
from app.services.vessel_ais_aggregation import (
|
from app.services.vessel_ais_aggregation import (
|
||||||
build_field_conflict_candidates,
|
build_field_conflict_candidates,
|
||||||
count_unique_raw_vessel_mmsi,
|
|
||||||
get_aggregated_vessel,
|
get_aggregated_vessel,
|
||||||
get_aggregated_vessel_track,
|
get_aggregated_vessel_track,
|
||||||
get_aggregated_vessels,
|
get_aggregated_vessels,
|
||||||
|
get_current_vessels_snapshot,
|
||||||
get_vessel_conflict_records,
|
get_vessel_conflict_records,
|
||||||
get_vessel_raw_observations,
|
get_vessel_raw_observations,
|
||||||
|
MAX_SNAPSHOT_LIMIT,
|
||||||
|
)
|
||||||
|
from app.services.earth_layer_cache import (
|
||||||
|
EarthLayerCachePolicy,
|
||||||
|
earth_layer_cache,
|
||||||
|
format_bbox_key,
|
||||||
|
get_or_build_layer_payload,
|
||||||
|
quantize_bbox,
|
||||||
)
|
)
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
@@ -59,6 +75,68 @@ TERRAIN_TILE_BATCH_MAX_ITEMS = 128
|
|||||||
TERRAIN_TILE_BATCH_CONCURRENCY = 16
|
TERRAIN_TILE_BATCH_CONCURRENCY = 16
|
||||||
_terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict()
|
_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_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
|
||||||
|
SECONDS_PER_MINUTE = 60
|
||||||
|
BYTES_PER_MIB = 1024 * 1024
|
||||||
|
CABLE_CACHE_FRESH_SECONDS = 6 * 60 * SECONDS_PER_MINUTE
|
||||||
|
CABLE_CACHE_STALE_SECONDS = 24 * 60 * SECONDS_PER_MINUTE
|
||||||
|
SATELLITE_CACHE_FRESH_SECONDS = 15 * SECONDS_PER_MINUTE
|
||||||
|
SATELLITE_CACHE_STALE_SECONDS = 2 * 60 * SECONDS_PER_MINUTE
|
||||||
|
COMPUTE_CENTER_CACHE_FRESH_SECONDS = 10 * SECONDS_PER_MINUTE
|
||||||
|
COMPUTE_CENTER_CACHE_STALE_SECONDS = 60 * SECONDS_PER_MINUTE
|
||||||
|
BGP_CACHE_FRESH_SECONDS = 60
|
||||||
|
BGP_EVENT_CACHE_FRESH_SECONDS = 30
|
||||||
|
BGP_CACHE_STALE_SECONDS = 10 * SECONDS_PER_MINUTE
|
||||||
|
VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS = 5
|
||||||
|
VESSEL_SNAPSHOT_CACHE_STALE_SECONDS = 30
|
||||||
|
|
||||||
|
CABLE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
CABLE_CACHE_FRESH_SECONDS,
|
||||||
|
CABLE_CACHE_STALE_SECONDS,
|
||||||
|
max_features=6000,
|
||||||
|
max_bytes=10 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
LANDING_POINT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
CABLE_CACHE_FRESH_SECONDS,
|
||||||
|
CABLE_CACHE_STALE_SECONDS,
|
||||||
|
max_features=6000,
|
||||||
|
max_bytes=8 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
SATELLITE_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
SATELLITE_CACHE_FRESH_SECONDS,
|
||||||
|
SATELLITE_CACHE_STALE_SECONDS,
|
||||||
|
max_features=25000,
|
||||||
|
max_bytes=32 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
COMPUTE_CENTER_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
COMPUTE_CENTER_CACHE_FRESH_SECONDS,
|
||||||
|
COMPUTE_CENTER_CACHE_STALE_SECONDS,
|
||||||
|
max_features=1000,
|
||||||
|
max_bytes=4 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
BGP_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
BGP_CACHE_FRESH_SECONDS,
|
||||||
|
BGP_CACHE_STALE_SECONDS,
|
||||||
|
max_features=1000,
|
||||||
|
max_bytes=3 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
BGP_EVENT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
BGP_EVENT_CACHE_FRESH_SECONDS,
|
||||||
|
BGP_CACHE_STALE_SECONDS,
|
||||||
|
max_features=1000,
|
||||||
|
max_bytes=3 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
SUMMARY_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
BGP_EVENT_CACHE_FRESH_SECONDS,
|
||||||
|
BGP_CACHE_STALE_SECONDS,
|
||||||
|
max_features=0,
|
||||||
|
max_bytes=512 * 1024,
|
||||||
|
)
|
||||||
|
VESSEL_SNAPSHOT_CACHE_POLICY = EarthLayerCachePolicy(
|
||||||
|
VESSEL_SNAPSHOT_CACHE_FRESH_SECONDS,
|
||||||
|
VESSEL_SNAPSHOT_CACHE_STALE_SECONDS,
|
||||||
|
max_features=1500,
|
||||||
|
max_bytes=3 * BYTES_PER_MIB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TerrariumTileRequest(BaseModel):
|
class TerrariumTileRequest(BaseModel):
|
||||||
@@ -760,9 +838,14 @@ def convert_aggregated_vessels_to_geojson(vessels: List[dict[str, Any]]) -> Dict
|
|||||||
continue
|
continue
|
||||||
source_summary = {}
|
source_summary = {}
|
||||||
for source, summary in (vessel.get("source_summary") or {}).items():
|
for source, summary in (vessel.get("source_summary") or {}).items():
|
||||||
|
latest_observed_at = summary.get("latest_observed_at")
|
||||||
source_summary[source] = {
|
source_summary[source] = {
|
||||||
**summary,
|
**summary,
|
||||||
"latest_observed_at": to_iso8601_utc(summary.get("latest_observed_at")),
|
"latest_observed_at": (
|
||||||
|
to_iso8601_utc(latest_observed_at)
|
||||||
|
if isinstance(latest_observed_at, datetime)
|
||||||
|
else latest_observed_at
|
||||||
|
),
|
||||||
}
|
}
|
||||||
props = {
|
props = {
|
||||||
"mmsi": vessel["mmsi"],
|
"mmsi": vessel["mmsi"],
|
||||||
@@ -932,6 +1015,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]:
|
def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
|
||||||
by_type: dict[str, int] = {}
|
by_type: dict[str, int] = {}
|
||||||
underway = 0
|
underway = 0
|
||||||
@@ -953,6 +1069,87 @@ 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: float | None,
|
||||||
|
type_filter: str | None,
|
||||||
|
limit: int | None,
|
||||||
|
since_minutes: int = 60,
|
||||||
|
response: Response | None = None,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if use_cache and bbox is not None:
|
||||||
|
safe_limit_for_key = _safe_vessel_limit(limit)
|
||||||
|
safe_since_for_key = min(max(int(since_minutes or 60), 1), 1440)
|
||||||
|
cache_key = earth_layer_cache.key(
|
||||||
|
"vessels-snapshot",
|
||||||
|
bbox=format_bbox_key(quantize_bbox(bbox)),
|
||||||
|
zoom=zoom or "none",
|
||||||
|
type=type_filter or "all",
|
||||||
|
limit=safe_limit_for_key,
|
||||||
|
since=safe_since_for_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def build_uncached() -> dict[str, Any]:
|
||||||
|
return await build_vessel_snapshot_response(
|
||||||
|
db,
|
||||||
|
bbox=bbox,
|
||||||
|
zoom=zoom,
|
||||||
|
type_filter=type_filter,
|
||||||
|
limit=limit,
|
||||||
|
since_minutes=since_minutes,
|
||||||
|
response=None,
|
||||||
|
use_cache=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=cache_key,
|
||||||
|
policy=VESSEL_SNAPSHOT_CACHE_POLICY,
|
||||||
|
builder=build_uncached,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
snapshot_started_at = datetime.now(UTC)
|
||||||
|
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),
|
||||||
|
"generated_at": to_iso8601_utc(snapshot_started_at),
|
||||||
|
"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(
|
def convert_bgp_anomalies_to_geojson(
|
||||||
records: List[BGPAnomaly],
|
records: List[BGPAnomaly],
|
||||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||||
@@ -1355,20 +1552,24 @@ def convert_bgp_incidents_to_geojson(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/geo/cables")
|
@router.get("/geo/cables")
|
||||||
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
async def get_cables_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||||
"""获取海底电缆 GeoJSON 数据 (LineString)"""
|
"""获取海底电缆 GeoJSON 数据 (LineString)"""
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_cables_geojson(db)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("cables"),
|
||||||
|
policy=CABLE_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_cables_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
records = await _load_current_collected_data(db, "arcgis_cables")
|
records = await _load_current_collected_data(db, "arcgis_cables")
|
||||||
|
|
||||||
if not records:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="No cable data found. Please run the arcgis_cables collector first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return convert_cable_to_geojson(records)
|
return convert_cable_to_geojson(records)
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception_event(
|
logger.exception_event(
|
||||||
"Failed to build cables GeoJSON response",
|
"Failed to build cables GeoJSON response",
|
||||||
@@ -1389,7 +1590,19 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/geo/landing-points")
|
@router.get("/geo/landing-points")
|
||||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_landing_points_geojson(db)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("landing-points"),
|
||||||
|
policy=LANDING_POINT_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_landing_points_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
records_by_source = await _load_current_collected_data_by_sources(
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
db,
|
db,
|
||||||
@@ -1410,16 +1623,8 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
relation_records,
|
relation_records,
|
||||||
cable_records,
|
cable_records,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not records:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="No landing point data found. Please run the arcgis_landing_points collector first.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return convert_landing_point_to_geojson(records, city_to_cable_ids_map, cable_id_to_name_map)
|
return convert_landing_point_to_geojson(records, city_to_cable_ids_map, cable_id_to_name_map)
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception_event(
|
logger.exception_event(
|
||||||
"Failed to build landing points GeoJSON response",
|
"Failed to build landing points GeoJSON response",
|
||||||
@@ -1642,8 +1847,25 @@ async def get_satellites_geojson(
|
|||||||
description="Maximum number of satellites to return. Omit for no limit.",
|
description="Maximum number of satellites to return. Omit for no limit.",
|
||||||
),
|
),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
"""获取卫星 TLE GeoJSON 数据"""
|
"""获取卫星 TLE GeoJSON 数据"""
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_satellites_geojson(limit=limit, db=db)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("satellites", limit=limit or "all"),
|
||||||
|
policy=SATELLITE_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_satellites_geojson(
|
||||||
|
*,
|
||||||
|
limit: int | None,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict[str, Any]:
|
||||||
records = await _load_current_or_latest_task_data(
|
records = await _load_current_or_latest_task_data(
|
||||||
db,
|
db,
|
||||||
"celestrak_tle",
|
"celestrak_tle",
|
||||||
@@ -1711,8 +1933,25 @@ async def get_gpu_clusters_geojson(
|
|||||||
async def get_compute_centers_geojson(
|
async def get_compute_centers_geojson(
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
"""获取统一算力中心 GeoJSON 数据"""
|
"""获取统一算力中心 GeoJSON 数据"""
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_compute_centers_geojson(limit=limit, db=db)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("compute-centers", limit=limit),
|
||||||
|
policy=COMPUTE_CENTER_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_compute_centers_geojson(
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict[str, Any]:
|
||||||
records_by_source = await _load_current_collected_data_by_sources(
|
records_by_source = await _load_current_collected_data_by_sources(
|
||||||
db,
|
db,
|
||||||
["top500", "epoch_ai_gpu"],
|
["top500", "epoch_ai_gpu"],
|
||||||
@@ -1822,6 +2061,44 @@ class SaveComputeCenterLocationRequest(BaseModel):
|
|||||||
model_config = {"populate_by_name": True}
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _compute_center_location_web_search_capability(db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
config = await get_runtime_web_search_config(db)
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"provider": None,
|
||||||
|
"reason": f"WebSearch 配置读取失败:{exc}",
|
||||||
|
}
|
||||||
|
provider_config = config.active_provider_config
|
||||||
|
has_api_key = bool((provider_config.api_key or "").strip())
|
||||||
|
if not config.enabled:
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"provider": config.default_provider,
|
||||||
|
"reason": "WebSearch 未开启,无法进行事实核查定位。",
|
||||||
|
}
|
||||||
|
if not has_api_key:
|
||||||
|
return {
|
||||||
|
"enabled": False,
|
||||||
|
"provider": config.default_provider,
|
||||||
|
"reason": f"WebSearch Provider {config.default_provider} 未配置 API Key。",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"provider": config.default_provider,
|
||||||
|
"reason": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compute-centers/location-capability")
|
||||||
|
async def get_compute_center_location_capability(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Return whether fact-checked compute-center location collection can run."""
|
||||||
|
return await _compute_center_location_web_search_capability(db)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/compute-centers/{source_id}/collect-location")
|
@router.post("/compute-centers/{source_id}/collect-location")
|
||||||
async def collect_compute_center_location(
|
async def collect_compute_center_location(
|
||||||
source_id: str,
|
source_id: str,
|
||||||
@@ -1840,6 +2117,9 @@ async def collect_compute_center_location(
|
|||||||
"""
|
"""
|
||||||
if not source_id or not source_id.strip():
|
if not source_id or not source_id.strip():
|
||||||
raise HTTPException(status_code=400, detail="source_id is required")
|
raise HTTPException(status_code=400, detail="source_id is required")
|
||||||
|
capability = await _compute_center_location_web_search_capability(db)
|
||||||
|
if not capability.get("enabled"):
|
||||||
|
raise HTTPException(status_code=409, detail=capability)
|
||||||
|
|
||||||
record = await _load_compute_center_record(db, source_id)
|
record = await _load_compute_center_record(db, source_id)
|
||||||
name = payload.name or (record.name if record else None)
|
name = payload.name or (record.name if record else None)
|
||||||
@@ -1864,8 +2144,70 @@ async def collect_compute_center_location(
|
|||||||
country=country,
|
country=country,
|
||||||
record_id=record_id,
|
record_id=record_id,
|
||||||
)
|
)
|
||||||
|
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:
|
if not candidates:
|
||||||
|
logger.warning_event(
|
||||||
|
"Compute center location collection returned no candidates",
|
||||||
|
event="visualization.compute_center.location_collect.completed",
|
||||||
|
context={
|
||||||
|
"source_id": source_id,
|
||||||
|
"record_id": record_id,
|
||||||
|
"name": name,
|
||||||
|
"success": False,
|
||||||
|
"llm_failure_reason": llm_failure_reason,
|
||||||
|
"attempted_queries": list(attempted_queries),
|
||||||
|
"context": {
|
||||||
|
"operator": operator,
|
||||||
|
"site": site,
|
||||||
|
"city": city,
|
||||||
|
"country": country,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"source_id": source_id,
|
"source_id": source_id,
|
||||||
"record_id": record_id,
|
"record_id": record_id,
|
||||||
@@ -1877,6 +2219,7 @@ async def collect_compute_center_location(
|
|||||||
),
|
),
|
||||||
"candidates": [],
|
"candidates": [],
|
||||||
"attempted_queries": list(attempted_queries),
|
"attempted_queries": list(attempted_queries),
|
||||||
|
"llm_failure_reason": llm_failure_reason,
|
||||||
"context": {
|
"context": {
|
||||||
"name": name,
|
"name": name,
|
||||||
"operator": operator,
|
"operator": operator,
|
||||||
@@ -1886,13 +2229,34 @@ async def collect_compute_center_location(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
best_candidate = candidates[0].to_dict()
|
||||||
|
logger.info_event(
|
||||||
|
"Compute center location collection returned candidates",
|
||||||
|
event="visualization.compute_center.location_collect.completed",
|
||||||
|
context={
|
||||||
|
"source_id": source_id,
|
||||||
|
"record_id": record_id,
|
||||||
|
"name": name,
|
||||||
|
"success": True,
|
||||||
|
"candidate_count": len(candidates),
|
||||||
|
"best_candidate": best_candidate,
|
||||||
|
"llm_failure_reason": llm_failure_reason,
|
||||||
|
"attempted_queries": list(attempted_queries),
|
||||||
|
"context": {
|
||||||
|
"operator": operator,
|
||||||
|
"site": site,
|
||||||
|
"city": city,
|
||||||
|
"country": country,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"source_id": source_id,
|
"source_id": source_id,
|
||||||
"record_id": record_id,
|
"record_id": record_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"success": True,
|
"success": True,
|
||||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||||
"best_candidate": candidates[0].to_dict(),
|
"best_candidate": best_candidate,
|
||||||
"attempted_queries": list(attempted_queries),
|
"attempted_queries": list(attempted_queries),
|
||||||
"context": {
|
"context": {
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -1974,83 +2338,45 @@ async def _load_compute_center_record(db: AsyncSession, source_id: str) -> Colle
|
|||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/geo/vessels")
|
async def _load_raw_vessel_snapshot_features(
|
||||||
async def get_vessels_geojson(
|
db: AsyncSession,
|
||||||
bbox: Optional[str] = Query(
|
*,
|
||||||
None,
|
bbox: tuple[float, float, float, float] | None,
|
||||||
description="Viewport bbox as lon_min,lat_min,lon_max,lat_max",
|
limit: int,
|
||||||
),
|
observed_since: datetime,
|
||||||
type: Optional[str] = Query(
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
None,
|
if bbox is None:
|
||||||
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
|
return [], {
|
||||||
),
|
"source": "vessel_current_state",
|
||||||
limit: Optional[int] = Query(
|
"current_state_count": 0,
|
||||||
None,
|
"final_unique_mmsi": 0,
|
||||||
ge=0,
|
}
|
||||||
description="Maximum vessel features to return. Omit or pass 0 for no limit.",
|
current_vessels = await get_current_vessels_snapshot(
|
||||||
),
|
db,
|
||||||
db: AsyncSession = Depends(get_db),
|
bbox=bbox,
|
||||||
):
|
limit=limit,
|
||||||
"""Return latest vessel positions as GeoJSON points."""
|
observed_since=observed_since,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
if limit and limit > 0:
|
features = convert_aggregated_vessels_to_geojson(current_vessels).get("features", [])
|
||||||
features = features[:limit]
|
unique_mmsi = len(
|
||||||
return {
|
{
|
||||||
"type": "FeatureCollection",
|
key
|
||||||
"features": features,
|
for key in (_feature_mmsi_key(feature) for feature in features)
|
||||||
"count": len(features),
|
if key is not None
|
||||||
"stats": _build_vessel_stats(features),
|
}
|
||||||
"diagnostics": {
|
)
|
||||||
**diagnostics,
|
return features, {
|
||||||
"filtered_count": len(features),
|
"source": "vessel_current_state",
|
||||||
},
|
"current_state_count": len(features),
|
||||||
|
"final_unique_mmsi": unique_mmsi,
|
||||||
|
"raw_feature_count": 0,
|
||||||
|
"raw_unique_mmsi": 0,
|
||||||
|
"legacy_feature_count": 0,
|
||||||
|
"legacy_backfilled_mmsi": 0,
|
||||||
|
"legacy_fallback_enabled": False,
|
||||||
|
"legacy_fallback_used": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
latest_times = (
|
|
||||||
select(
|
|
||||||
VesselPosition.mmsi.label("mmsi"),
|
|
||||||
func.max(VesselPosition.received_at).label("received_at"),
|
|
||||||
)
|
|
||||||
.group_by(VesselPosition.mmsi)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
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())
|
|
||||||
)
|
|
||||||
|
|
||||||
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", [])),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/vessels/custom-supplements")
|
@router.get("/vessels/custom-supplements")
|
||||||
async def get_vessel_custom_supplements(db: AsyncSession = Depends(get_db)):
|
async def get_vessel_custom_supplements(db: AsyncSession = Depends(get_db)):
|
||||||
"""Group custom vessel_ais sources by their declared merge target for diagnostics."""
|
"""Group custom vessel_ais sources by their declared merge target for diagnostics."""
|
||||||
@@ -2278,7 +2604,31 @@ async def get_bgp_anomalies_geojson(
|
|||||||
status: Optional[str] = Query("active"),
|
status: Optional[str] = Query("active"),
|
||||||
limit: int = Query(200, ge=1, le=1000),
|
limit: int = Query(200, ge=1, le=1000),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_bgp_anomalies_geojson(
|
||||||
|
severity=severity,
|
||||||
|
status=status,
|
||||||
|
limit=limit,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("bgp-anomalies", severity=severity or "all", status=status or "all", limit=limit),
|
||||||
|
policy=BGP_EVENT_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_bgp_anomalies_geojson(
|
||||||
|
*,
|
||||||
|
severity: str | None,
|
||||||
|
status: str | None,
|
||||||
|
limit: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict[str, Any]:
|
||||||
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
|
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
|
||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(BGPAnomaly.severity == severity)
|
stmt = stmt.where(BGPAnomaly.severity == severity)
|
||||||
@@ -2298,7 +2648,31 @@ async def get_bgp_incidents_geojson(
|
|||||||
status: Optional[str] = Query("active"),
|
status: Optional[str] = Query("active"),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
response: Response = None,
|
||||||
):
|
):
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_bgp_incidents_geojson(
|
||||||
|
severity=severity,
|
||||||
|
status=status,
|
||||||
|
limit=limit,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("bgp-incidents", severity=severity or "all", status=status or "all", limit=limit),
|
||||||
|
policy=BGP_EVENT_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_bgp_incidents_geojson(
|
||||||
|
*,
|
||||||
|
severity: str | None,
|
||||||
|
status: str | None,
|
||||||
|
limit: int,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict[str, Any]:
|
||||||
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
|
stmt = select(BGPIncident).order_by(BGPIncident.created_at.desc()).limit(limit)
|
||||||
if severity:
|
if severity:
|
||||||
stmt = stmt.where(BGPIncident.severity == severity)
|
stmt = stmt.where(BGPIncident.severity == severity)
|
||||||
@@ -2313,11 +2687,25 @@ async def get_bgp_incidents_geojson(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/geo/bgp-collectors")
|
@router.get("/geo/bgp-collectors")
|
||||||
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
|
async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_bgp_collectors_geojson(db)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("bgp-collectors"),
|
||||||
|
policy=BGP_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_bgp_collectors_geojson(db: AsyncSession) -> dict[str, Any]:
|
||||||
coverage = await build_bgp_collector_coverage(
|
coverage = await build_bgp_collector_coverage(
|
||||||
db,
|
db,
|
||||||
source_filter=("ris_live_bgp", "bgpstream_bgp"),
|
source_filter=("ris_live_bgp", "bgpstream_bgp"),
|
||||||
)
|
)
|
||||||
|
if not any(int(item.get("observation_count") or 0) > 0 for item in coverage):
|
||||||
|
return {"type": "FeatureCollection", "features": [], "count": 0}
|
||||||
coverage_by_collector = {
|
coverage_by_collector = {
|
||||||
item["collector"]: item
|
item["collector"]: item
|
||||||
for item in coverage
|
for item in coverage
|
||||||
@@ -2328,8 +2716,20 @@ async def get_bgp_collectors_geojson(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/geo/summary")
|
@router.get("/geo/summary")
|
||||||
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db), response: Response = None):
|
||||||
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
|
"""Return lightweight Earth HUD counts without loading layer GeoJSON payloads."""
|
||||||
|
async def build_payload() -> dict[str, Any]:
|
||||||
|
return await _build_visualization_geo_summary(db)
|
||||||
|
|
||||||
|
return await get_or_build_layer_payload(
|
||||||
|
key=earth_layer_cache.key("summary"),
|
||||||
|
policy=SUMMARY_CACHE_POLICY,
|
||||||
|
builder=build_payload,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_visualization_geo_summary(db: AsyncSession) -> dict[str, Any]:
|
||||||
cable_count = await _count_current_or_latest_task_data(db, "arcgis_cables")
|
cable_count = await _count_current_or_latest_task_data(db, "arcgis_cables")
|
||||||
landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points")
|
landing_point_count = await _count_current_or_latest_task_data(db, "arcgis_landing_points")
|
||||||
satellite_count = await _count_current_or_latest_task_data(
|
satellite_count = await _count_current_or_latest_task_data(
|
||||||
@@ -2342,10 +2742,10 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
|||||||
compute_center_count = supercomputer_count + gpu_cluster_count
|
compute_center_count = supercomputer_count + gpu_cluster_count
|
||||||
|
|
||||||
active_incident_result = await db.execute(
|
active_incident_result = await db.execute(
|
||||||
select(func.count(BGPIncident.id)).where(BGPIncident.status == "active"),
|
select(func.count(BGPIncident.id)).where(BGPIncident.status == BGPStatus.ACTIVE.value),
|
||||||
)
|
)
|
||||||
active_anomaly_result = await db.execute(
|
active_anomaly_result = await db.execute(
|
||||||
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == "active"),
|
select(func.count(BGPAnomaly.id)).where(BGPAnomaly.status == BGPStatus.ACTIVE.value),
|
||||||
)
|
)
|
||||||
active_incident_count = int(active_incident_result.scalar() or 0)
|
active_incident_count = int(active_incident_result.scalar() or 0)
|
||||||
active_anomaly_count = int(active_anomaly_result.scalar() or 0)
|
active_anomaly_count = int(active_anomaly_result.scalar() or 0)
|
||||||
@@ -2366,16 +2766,14 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
bgp_collector_count = int(bgp_collector_scalar or 0)
|
bgp_collector_count = int(bgp_collector_scalar or 0)
|
||||||
raw_unique_window_hours = 24
|
vessel_current_window_minutes = 60
|
||||||
raw_unique_mmsi = await count_unique_raw_vessel_mmsi(
|
vessel_current_result = await db.execute(
|
||||||
db,
|
select(func.count(VesselCurrentState.mmsi)).where(
|
||||||
observed_since=datetime.now(UTC) - timedelta(hours=raw_unique_window_hours),
|
VesselCurrentState.observed_at
|
||||||
|
>= datetime.now(UTC) - timedelta(minutes=vessel_current_window_minutes)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
legacy_unique_result = await db.execute(
|
vessel_count = int(vessel_current_result.scalar() or 0)
|
||||||
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)
|
|
||||||
aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels")
|
aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2386,9 +2784,10 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
|||||||
"satellite_count": satellite_count,
|
"satellite_count": satellite_count,
|
||||||
"compute_center_count": compute_center_count,
|
"compute_center_count": compute_center_count,
|
||||||
"vessel_count": vessel_count,
|
"vessel_count": vessel_count,
|
||||||
"vessel_raw_unique_mmsi": raw_unique_mmsi,
|
"vessel_count_source": "vessel_current_state",
|
||||||
"vessel_raw_unique_window_hours": raw_unique_window_hours,
|
"vessel_current_window_minutes": vessel_current_window_minutes,
|
||||||
"vessel_legacy_unique_mmsi": legacy_unique_mmsi,
|
"vessel_raw_unique_mmsi": 0,
|
||||||
|
"vessel_legacy_unique_mmsi": 0,
|
||||||
"aisstream_connection_state": aisstream_health.connection_state if aisstream_health else None,
|
"aisstream_connection_state": aisstream_health.connection_state if aisstream_health else None,
|
||||||
"aisstream_last_seen_at": to_iso8601_utc(aisstream_health.last_seen_at) if aisstream_health else None,
|
"aisstream_last_seen_at": to_iso8601_utc(aisstream_health.last_seen_at) if aisstream_health else None,
|
||||||
"aisstream_message_rate": aisstream_health.message_rate if aisstream_health else None,
|
"aisstream_message_rate": aisstream_health.message_rate if aisstream_health else None,
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
"""WebSocket API endpoints"""
|
"""WebSocket API endpoints"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||||
from jose import jwt, JWTError
|
from jose import jwt, JWTError
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.enums import UserRole
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.core.websocket.manager import manager
|
from app.core.websocket.manager import manager
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.services.log_tail import LOG_TAIL_CHANNEL, log_tail_manager
|
||||||
|
|
||||||
logger = get_logger(__name__, service="api")
|
logger = get_logger(__name__, service="api")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
EARTH_UPDATES_CHANNEL = "earth_updates"
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_token(token: str) -> Optional[dict]:
|
async def authenticate_token(token: str) -> Optional[dict]:
|
||||||
@@ -37,6 +41,28 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def load_websocket_user_role(user_id: str | None) -> str | None:
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text("SELECT role, is_active FROM users WHERE id = :id"),
|
||||||
|
{"id": int(user_id)},
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning_event(
|
||||||
|
"WebSocket user role lookup failed",
|
||||||
|
event="auth.websocket.role_lookup_failed",
|
||||||
|
context={"user_id": user_id, "error": str(exc)},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if row is None or not row[1]:
|
||||||
|
return None
|
||||||
|
return str(row[0] or "")
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws")
|
@router.websocket("/ws")
|
||||||
async def websocket_endpoint(
|
async def websocket_endpoint(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
@@ -59,7 +85,8 @@ async def websocket_endpoint(
|
|||||||
|
|
||||||
is_anonymous = payload is None
|
is_anonymous = payload is None
|
||||||
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
||||||
supported_channels = ["vessels"] if is_anonymous else [
|
user_role = await load_websocket_user_role(user_id) if payload else None
|
||||||
|
supported_channels = ["vessels", "earth_news", EARTH_UPDATES_CHANNEL] if is_anonymous else [
|
||||||
"gpu_clusters",
|
"gpu_clusters",
|
||||||
"submarine_cables",
|
"submarine_cables",
|
||||||
"ixp_nodes",
|
"ixp_nodes",
|
||||||
@@ -67,7 +94,11 @@ async def websocket_endpoint(
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
"datasource_tasks",
|
"datasource_tasks",
|
||||||
"vessels",
|
"vessels",
|
||||||
|
"earth_news",
|
||||||
|
EARTH_UPDATES_CHANNEL,
|
||||||
]
|
]
|
||||||
|
if user_role == UserRole.SUPER_ADMIN.value:
|
||||||
|
supported_channels = [*supported_channels, LOG_TAIL_CHANNEL]
|
||||||
await manager.connect(websocket, user_id)
|
await manager.connect(websocket, user_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -95,18 +126,75 @@ async def websocket_endpoint(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif data.get("type") == "subscribe":
|
elif data.get("type") == "subscribe":
|
||||||
channels = data.get("data", {}).get("channels", [])
|
payload_data = data.get("data", {})
|
||||||
|
if not isinstance(payload_data, dict):
|
||||||
|
payload_data = {}
|
||||||
|
log_tail_config = None
|
||||||
|
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 LOG_TAIL_CHANNEL in channels:
|
||||||
|
if user_role != UserRole.SUPER_ADMIN.value:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "subscription_error",
|
||||||
|
"data": {"channel": LOG_TAIL_CHANNEL, "detail": "Only super_admin can subscribe logs"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
channels = [item for item in channels if item != LOG_TAIL_CHANNEL]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
log_tail_config = await log_tail_manager.subscribe(websocket, payload_data)
|
||||||
|
except ValueError as exc:
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "subscription_error",
|
||||||
|
"data": {"channel": LOG_TAIL_CHANNEL, "detail": str(exc)},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
channels = [item for item in channels if item != LOG_TAIL_CHANNEL]
|
||||||
if is_anonymous:
|
if is_anonymous:
|
||||||
channels = [channel for channel in channels if channel in supported_channels]
|
channels = [channel for channel in channels if channel in supported_channels]
|
||||||
|
vessel_subscription = None
|
||||||
|
if "vessels" in channels:
|
||||||
|
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)
|
manager.subscribe(websocket, channels)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "subscription_confirmed",
|
"type": "subscription_confirmed",
|
||||||
"data": {"action": "subscribe", "channels": channels},
|
"data": {
|
||||||
|
"action": "subscribe",
|
||||||
|
"channels": [
|
||||||
|
*channels,
|
||||||
|
*([LOG_TAIL_CHANNEL] if log_tail_config else []),
|
||||||
|
*(["vessels"] if vessel_subscription else []),
|
||||||
|
],
|
||||||
|
"vessels": vessel_subscription,
|
||||||
|
"logs_tail": log_tail_config.__dict__ if log_tail_config else None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif data.get("type") == "unsubscribe":
|
elif data.get("type") == "unsubscribe":
|
||||||
channels = data.get("data", {}).get("channels", [])
|
channels = data.get("data", {}).get("channels", [])
|
||||||
|
if isinstance(channels, str):
|
||||||
|
channels = [channels]
|
||||||
|
if LOG_TAIL_CHANNEL in channels:
|
||||||
|
await log_tail_manager.unsubscribe(websocket)
|
||||||
manager.unsubscribe(websocket, channels)
|
manager.unsubscribe(websocket, channels)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@@ -127,4 +215,5 @@ async def websocket_endpoint(
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
await log_tail_manager.disconnect(websocket)
|
||||||
manager.disconnect(websocket, user_id)
|
manager.disconnect(websocket, user_id)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class Settings(BaseSettings):
|
|||||||
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
||||||
AI_PROVIDER_TIMEOUT_SECONDS: int = 60
|
AI_PROVIDER_TIMEOUT_SECONDS: int = 60
|
||||||
AI_PROVIDER_RETRY_ATTEMPTS: int = 2
|
AI_PROVIDER_RETRY_ATTEMPTS: int = 2
|
||||||
|
OBSERVABILITY_INGEST_TOKEN: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def REDIS_URL(self) -> str:
|
def REDIS_URL(self) -> str:
|
||||||
|
|||||||
@@ -257,6 +257,16 @@ DEFAULT_DATASOURCES = {
|
|||||||
"credential_provider": "aisstream",
|
"credential_provider": "aisstream",
|
||||||
"credential_status": "supported",
|
"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()}
|
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}
|
||||||
|
|||||||
227
backend/app/core/enums.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""Stable backend protocol enums.
|
||||||
|
|
||||||
|
Database columns and JSON payloads continue to store the enum string values.
|
||||||
|
Configurable identifiers, user-authored values, and open-ended taxonomies do
|
||||||
|
not belong in this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EnumT = TypeVar("EnumT", bound=StrEnum)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_enum(enum_type: type[EnumT], value: object, default: EnumT) -> EnumT:
|
||||||
|
"""Parse an external value without breaking reads of legacy data."""
|
||||||
|
|
||||||
|
if value is None or str(value).strip() == "":
|
||||||
|
return default
|
||||||
|
if isinstance(value, enum_type):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return enum_type(str(value).strip().lower())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Unknown %s value %r; falling back to %s",
|
||||||
|
enum_type.__name__,
|
||||||
|
value,
|
||||||
|
default.value,
|
||||||
|
)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class NewsImportanceLevel(StrEnum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class BreakingLevel(StrEnum):
|
||||||
|
NONE = "none"
|
||||||
|
WATCH = "watch"
|
||||||
|
BREAKING = "breaking"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class BreakingScope(StrEnum):
|
||||||
|
REGIONAL = "regional"
|
||||||
|
GLOBAL = "global"
|
||||||
|
|
||||||
|
|
||||||
|
class BreakingSource(StrEnum):
|
||||||
|
RULES = "rules"
|
||||||
|
AI = "ai"
|
||||||
|
MANUAL = "manual"
|
||||||
|
MULTI_SOURCE = "multi_source"
|
||||||
|
|
||||||
|
|
||||||
|
class NewsSourceType(StrEnum):
|
||||||
|
RSS = "rss"
|
||||||
|
ATOM = "atom"
|
||||||
|
AGGREGATED = "aggregated"
|
||||||
|
REFERENCE = "reference"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class NewsEnrichmentStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
QUEUED = "queued"
|
||||||
|
ATTEMPTED = "attempted"
|
||||||
|
SUCCESS = "success"
|
||||||
|
CONTENT_ONLY = "content_only"
|
||||||
|
LOCATION_ONLY = "location_only"
|
||||||
|
UNAVAILABLE = "unavailable"
|
||||||
|
PROVIDER_ERROR = "provider_error"
|
||||||
|
PARSE_ERROR = "parse_error"
|
||||||
|
NO_RESULT = "no_result"
|
||||||
|
|
||||||
|
|
||||||
|
class NewsMarketImpact(StrEnum):
|
||||||
|
NONE = "none"
|
||||||
|
SECTOR = "sector"
|
||||||
|
NATIONAL = "national"
|
||||||
|
GLOBAL = "global"
|
||||||
|
|
||||||
|
|
||||||
|
class NewsTaggingSource(StrEnum):
|
||||||
|
RULES = "rules"
|
||||||
|
AI = "ai"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class JobType(StrEnum):
|
||||||
|
COLLECT = "collect"
|
||||||
|
CLEAR_DATA = "clear_data"
|
||||||
|
CLEAR_CACHE = "clear_cache"
|
||||||
|
EARTH_REFRESH = "earth_refresh"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
CANCELLING = "cancelling"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class RollbackPolicy(StrEnum):
|
||||||
|
KEEP_COMMITTED_BATCHES = "keep_committed_batches"
|
||||||
|
|
||||||
|
|
||||||
|
class MappingValidationStatus(StrEnum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
VALID = "valid"
|
||||||
|
INVALID = "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotStatus(StrEnum):
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceRunStatus(StrEnum):
|
||||||
|
RUNNING = "running"
|
||||||
|
NOT_RUN = "not_run"
|
||||||
|
COLLECTED = "collected"
|
||||||
|
UNCOLLECTED = "uncollected"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderApi(StrEnum):
|
||||||
|
ANTHROPIC_MESSAGES = "anthropic-messages"
|
||||||
|
OPENAI_COMPLETIONS = "openai-completions"
|
||||||
|
OPENAI_RESPONSES = "openai-responses"
|
||||||
|
OLLAMA_GENERATE = "ollama-generate"
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageRole(StrEnum):
|
||||||
|
SYSTEM = "system"
|
||||||
|
USER = "user"
|
||||||
|
ASSISTANT = "assistant"
|
||||||
|
TOOL = "tool"
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageKind(StrEnum):
|
||||||
|
MESSAGE = "message"
|
||||||
|
THINKING = "thinking"
|
||||||
|
ERROR = "error"
|
||||||
|
STATUS = "status"
|
||||||
|
|
||||||
|
|
||||||
|
class PlaygroundMessageStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
THINKING = "thinking"
|
||||||
|
ANSWERING = "answering"
|
||||||
|
DONE = "done"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
ERROR = "error"
|
||||||
|
STOPPED = "stopped"
|
||||||
|
|
||||||
|
|
||||||
|
class OtpPurpose(StrEnum):
|
||||||
|
REGISTER = "register"
|
||||||
|
VERIFY_EMAIL = "verify_email"
|
||||||
|
RESET_PASSWORD = "reset_password"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(StrEnum):
|
||||||
|
VIEWER = "viewer"
|
||||||
|
ADMIN = "admin"
|
||||||
|
SUPER_ADMIN = "super_admin"
|
||||||
|
|
||||||
|
|
||||||
|
class AlertSeverity(StrEnum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
WARNING = "warning"
|
||||||
|
INFO = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class AlertStatus(StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
ACKNOWLEDGED = "acknowledged"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
class BGPStatus(StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
ACKNOWLEDGED = "acknowledged"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
class LogLevel(StrEnum):
|
||||||
|
ALL = "all"
|
||||||
|
ERROR = "error"
|
||||||
|
WARNING = "warning"
|
||||||
|
INFO = "info"
|
||||||
|
DEBUG = "debug"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionState(StrEnum):
|
||||||
|
DISCONNECTED = "disconnected"
|
||||||
|
CONNECTING = "connecting"
|
||||||
|
CONNECTED = "connected"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthType(StrEnum):
|
||||||
|
NONE = "none"
|
||||||
|
BEARER = "bearer"
|
||||||
|
API_KEY = "api_key"
|
||||||
|
BASIC = "basic"
|
||||||
|
|
||||||
|
|
||||||
|
class TVSourceType(StrEnum):
|
||||||
|
IFRAME = "iframe"
|
||||||
|
HLS = "hls"
|
||||||
|
VIDEO = "video"
|
||||||
|
EXTERNAL = "external"
|
||||||
|
YOUTUBE = "youtube"
|
||||||
@@ -2,12 +2,17 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.core.websocket.manager import manager
|
from app.core.websocket.manager import manager
|
||||||
|
|
||||||
|
|
||||||
|
EARTH_UPDATES_CHANNEL = "earth_updates"
|
||||||
|
VESSEL_STATE_QUERY_BATCH_SIZE = 1000
|
||||||
|
logger = get_logger(__name__, service="websocket")
|
||||||
|
|
||||||
|
|
||||||
class DataBroadcaster:
|
class DataBroadcaster:
|
||||||
"""Periodically broadcasts data to connected WebSocket clients"""
|
"""Periodically broadcasts data to connected WebSocket clients"""
|
||||||
@@ -15,6 +20,8 @@ class DataBroadcaster:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.running = False
|
self.running = False
|
||||||
self.tasks: Dict[str, asyncio.Task] = {}
|
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]:
|
async def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||||
"""Get dashboard statistics"""
|
"""Get dashboard statistics"""
|
||||||
@@ -68,6 +75,9 @@ class DataBroadcaster:
|
|||||||
|
|
||||||
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
|
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
|
||||||
"""Broadcast custom data to a specific channel"""
|
"""Broadcast custom data to a specific channel"""
|
||||||
|
if channel == "vessels":
|
||||||
|
self.enqueue_vessel_update(data)
|
||||||
|
return
|
||||||
await manager.broadcast(
|
await manager.broadcast(
|
||||||
{
|
{
|
||||||
"type": "data_frame",
|
"type": "data_frame",
|
||||||
@@ -78,6 +88,87 @@ class DataBroadcaster:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def broadcast_earth_update(self, data: Dict[str, Any]):
|
||||||
|
"""Broadcast Earth visualization refresh hints to connected clients."""
|
||||||
|
await self.broadcast_custom(EARTH_UPDATES_CHANNEL, data)
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
try:
|
||||||
|
vessels = await self._load_current_vessel_updates(list(pending))
|
||||||
|
except Exception:
|
||||||
|
# Preserve newer updates that arrived during the failed database read.
|
||||||
|
self._pending_vessel_updates = {**pending, **self._pending_vessel_updates}
|
||||||
|
raise
|
||||||
|
if not vessels:
|
||||||
|
return
|
||||||
|
vessels = [
|
||||||
|
{
|
||||||
|
**vessel,
|
||||||
|
"action": vessel.get("action", "upsert"),
|
||||||
|
"created": pending.get(str(vessel["mmsi"]), {}).get("_created"),
|
||||||
|
}
|
||||||
|
for vessel in vessels
|
||||||
|
]
|
||||||
|
await manager.broadcast_vessels(
|
||||||
|
{
|
||||||
|
"action": "upsert",
|
||||||
|
"source": "mixed",
|
||||||
|
"created": None,
|
||||||
|
"vessels": vessels,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _load_current_vessel_updates(self, keys: list[str]) -> list[Dict[str, Any]]:
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.services.vessel_ais_aggregation import get_current_vessels_by_mmsi
|
||||||
|
|
||||||
|
mmsis = [int(key) for key in keys if key.isdigit()]
|
||||||
|
vessels = []
|
||||||
|
async with async_session_factory() as db:
|
||||||
|
for offset in range(0, len(mmsis), VESSEL_STATE_QUERY_BATCH_SIZE):
|
||||||
|
vessels.extend(await get_current_vessels_by_mmsi(
|
||||||
|
db, mmsis[offset:offset + VESSEL_STATE_QUERY_BATCH_SIZE]
|
||||||
|
))
|
||||||
|
present = {int(vessel["mmsi"]) for vessel in vessels}
|
||||||
|
vessels.extend({"mmsi": mmsi, "action": "remove"} for mmsi in mmsis if mmsi not in present)
|
||||||
|
return vessels
|
||||||
|
|
||||||
|
async def broadcast_vessels_periodically(self):
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
await self.flush_vessel_updates()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception_event(
|
||||||
|
"Failed to flush vessel updates",
|
||||||
|
event="vessels.broadcast.failed",
|
||||||
|
context={"error": str(exc)},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self._vessel_flush_interval)
|
||||||
|
|
||||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||||
"""Broadcast datasource task progress updates to connected clients."""
|
"""Broadcast datasource task progress updates to connected clients."""
|
||||||
await manager.broadcast(
|
await manager.broadcast(
|
||||||
@@ -87,7 +178,7 @@ class DataBroadcaster:
|
|||||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||||
"payload": data,
|
"payload": data,
|
||||||
},
|
},
|
||||||
channel="all",
|
channel="datasource_tasks",
|
||||||
)
|
)
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
@@ -95,6 +186,7 @@ class DataBroadcaster:
|
|||||||
if not self.running:
|
if not self.running:
|
||||||
self.running = True
|
self.running = True
|
||||||
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
|
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
|
||||||
|
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Stop all broadcasters"""
|
"""Stop all broadcasters"""
|
||||||
@@ -102,6 +194,7 @@ class DataBroadcaster:
|
|||||||
for task in self.tasks.values():
|
for task in self.tasks.values():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
self.tasks.clear()
|
self.tasks.clear()
|
||||||
|
self._pending_vessel_updates.clear()
|
||||||
|
|
||||||
|
|
||||||
broadcaster = DataBroadcaster()
|
broadcaster = DataBroadcaster()
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"""WebSocket Connection Manager"""
|
"""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
|
from fastapi import WebSocket
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
|
|
||||||
from app.core.config import settings
|
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:
|
class ConnectionManager:
|
||||||
"""Manages WebSocket connections"""
|
"""Manages WebSocket connections"""
|
||||||
@@ -14,6 +19,7 @@ class ConnectionManager:
|
|||||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||||
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
||||||
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
||||||
|
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
|
||||||
self.redis_client: Optional[redis.Redis] = None
|
self.redis_client: Optional[redis.Redis] = None
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, user_id: str):
|
async def connect(self, websocket: WebSocket, user_id: str):
|
||||||
@@ -57,6 +63,8 @@ class ConnectionManager:
|
|||||||
|
|
||||||
def unsubscribe(self, websocket: WebSocket, channels: list[str]):
|
def unsubscribe(self, websocket: WebSocket, channels: list[str]):
|
||||||
for channel in {str(channel).strip() for channel in channels if str(channel).strip()}:
|
for channel in {str(channel).strip() for channel in channels if str(channel).strip()}:
|
||||||
|
if channel == "vessels":
|
||||||
|
self.vessel_subscriptions.pop(websocket, None)
|
||||||
subscribers = self.channel_subscriptions.get(channel)
|
subscribers = self.channel_subscriptions.get(channel)
|
||||||
if subscribers is not None:
|
if subscribers is not None:
|
||||||
subscribers.discard(websocket)
|
subscribers.discard(websocket)
|
||||||
@@ -72,6 +80,52 @@ class ConnectionManager:
|
|||||||
channels = list(self.websocket_channels.get(websocket, set()))
|
channels = list(self.websocket_channels.get(websocket, set()))
|
||||||
if channels:
|
if channels:
|
||||||
self.unsubscribe(websocket, 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]:
|
||||||
|
global_scope = config.get("scope") == "global"
|
||||||
|
bbox = [-180, -90, 180, 90] if global_scope else 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 not global_scope and (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 {
|
||||||
|
"scope": "global" if global_scope else "viewport",
|
||||||
|
"bbox": (lon_min, lat_min, lon_max, lat_max),
|
||||||
|
"zoom": zoom,
|
||||||
|
"limit": limit,
|
||||||
|
"type": sorted(vessel_types),
|
||||||
|
"last_sent_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
async def send_personal_message(self, message: dict, user_id: str):
|
async def send_personal_message(self, message: dict, user_id: str):
|
||||||
if user_id in self.active_connections:
|
if user_id in self.active_connections:
|
||||||
@@ -92,6 +146,70 @@ class ConnectionManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.unsubscribe_all(connection)
|
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)
|
||||||
|
]
|
||||||
|
if subscription.get("scope") != "global":
|
||||||
|
matched = matched[:subscription["limit"]]
|
||||||
|
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": [],
|
||||||
|
"subscription": {
|
||||||
|
"bbox": list(subscription["bbox"]),
|
||||||
|
"zoom": subscription["zoom"],
|
||||||
|
"limit": subscription["limit"],
|
||||||
|
"scope": subscription.get("scope", "viewport"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
for offset in range(0, len(matched), MAX_VESSEL_WS_MESSAGE_ITEMS):
|
||||||
|
await connection.send_json({
|
||||||
|
**message,
|
||||||
|
"payload": {
|
||||||
|
**message["payload"],
|
||||||
|
"vessels": matched[offset:offset + MAX_VESSEL_WS_MESSAGE_ITEMS],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
self.unsubscribe_all(connection)
|
||||||
|
|
||||||
|
def _vessel_matches_subscription(
|
||||||
|
self,
|
||||||
|
vessel: dict[str, Any],
|
||||||
|
subscription: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
if vessel.get("action") == "remove" and subscription.get("scope") == "global":
|
||||||
|
return True
|
||||||
|
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):
|
async def close_all(self):
|
||||||
for user_id in self.active_connections:
|
for user_id in self.active_connections:
|
||||||
for connection in self.active_connections[user_id]:
|
for connection in self.active_connections[user_id]:
|
||||||
@@ -99,6 +217,7 @@ class ConnectionManager:
|
|||||||
self.active_connections.clear()
|
self.active_connections.clear()
|
||||||
self.channel_subscriptions.clear()
|
self.channel_subscriptions.clear()
|
||||||
self.websocket_channels.clear()
|
self.websocket_channels.clear()
|
||||||
|
self.vessel_subscriptions.clear()
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
manager = ConnectionManager()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import AsyncGenerator
|
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.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
@@ -72,25 +72,112 @@ async def seed_default_datasources(session: AsyncSession):
|
|||||||
await session.commit()
|
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": "LK12345678",
|
||||||
|
"role": "super_admin",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def ensure_default_admin_user(session: AsyncSession):
|
async def ensure_default_admin_user(session: AsyncSession):
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
result = await session.execute(
|
for default_user in DEFAULT_LOGIN_USERS:
|
||||||
text("SELECT id FROM users WHERE username = 'admin'")
|
result = await session.execute(
|
||||||
)
|
text("SELECT id FROM users WHERE username = :username"),
|
||||||
if result.fetchone():
|
{"username": default_user["username"]},
|
||||||
return
|
)
|
||||||
|
if result.fetchone():
|
||||||
session.add(
|
continue
|
||||||
User(
|
|
||||||
username="admin",
|
session.add(
|
||||||
email="admin@planet.local",
|
User(
|
||||||
password_hash=get_password_hash("admin123"),
|
username=default_user["username"],
|
||||||
role="super_admin",
|
email=default_user["email"],
|
||||||
is_active=True,
|
password_hash=get_password_hash(default_user["password"]),
|
||||||
|
role=default_user["role"],
|
||||||
|
is_active=True,
|
||||||
|
email_verified=True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +202,8 @@ async def init_db():
|
|||||||
import app.models.vessel # noqa: F401
|
import app.models.vessel # noqa: F401
|
||||||
import app.models.vessel_enrichment # noqa: F401
|
import app.models.vessel_enrichment # noqa: F401
|
||||||
import app.models.datasource_mapping # noqa: F401
|
import app.models.datasource_mapping # noqa: F401
|
||||||
|
import app.models.earth_news # noqa: F401
|
||||||
|
import app.models.earth_interactable # noqa: F401
|
||||||
|
|
||||||
logger.warning_event(
|
logger.warning_event(
|
||||||
"Database pool settings active",
|
"Database pool settings active",
|
||||||
@@ -130,14 +219,31 @@ async def init_db():
|
|||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
ALTER TABLE users
|
ALTER TABLE users
|
||||||
ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb
|
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(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -153,6 +259,407 @@ async def init_db():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS earth_data_change_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
table_name VARCHAR(128) NOT NULL,
|
||||||
|
operation VARCHAR(16) NOT NULL,
|
||||||
|
source VARCHAR(128),
|
||||||
|
entity_key VARCHAR(255),
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
consumed_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_earth_data_change_events_unconsumed
|
||||||
|
ON earth_data_change_events (consumed_at, id)
|
||||||
|
WHERE consumed_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION planet_emit_earth_data_changed_statement(
|
||||||
|
change_table TEXT,
|
||||||
|
change_operation TEXT,
|
||||||
|
change_source TEXT,
|
||||||
|
source_record_count INTEGER,
|
||||||
|
source_entity_keys TEXT[]
|
||||||
|
)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
change_event_id BIGINT;
|
||||||
|
change_payload JSONB;
|
||||||
|
BEGIN
|
||||||
|
change_payload := jsonb_build_object(
|
||||||
|
'event', 'earth.layer.changed',
|
||||||
|
'table', change_table,
|
||||||
|
'operation', change_operation,
|
||||||
|
'source', change_source,
|
||||||
|
'entity_key', NULL,
|
||||||
|
'entity_keys', COALESCE(to_jsonb(source_entity_keys), '[]'::jsonb),
|
||||||
|
'records_processed', COALESCE(source_record_count, 0),
|
||||||
|
'occurred_at', NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO earth_data_change_events (
|
||||||
|
table_name,
|
||||||
|
operation,
|
||||||
|
source,
|
||||||
|
entity_key,
|
||||||
|
payload,
|
||||||
|
occurred_at
|
||||||
|
) VALUES (
|
||||||
|
change_table,
|
||||||
|
change_operation,
|
||||||
|
change_source,
|
||||||
|
NULL,
|
||||||
|
change_payload,
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
RETURNING id INTO change_event_id;
|
||||||
|
|
||||||
|
change_payload := change_payload || jsonb_build_object(
|
||||||
|
'event_id', change_event_id
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE earth_data_change_events
|
||||||
|
SET payload = change_payload
|
||||||
|
WHERE id = change_event_id;
|
||||||
|
|
||||||
|
PERFORM pg_notify(
|
||||||
|
'planet_earth_data_changes',
|
||||||
|
change_payload::text
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION planet_emit_collected_data_changed_statement(
|
||||||
|
change_operation TEXT,
|
||||||
|
change_source TEXT,
|
||||||
|
source_record_count INTEGER,
|
||||||
|
source_entity_keys TEXT[]
|
||||||
|
)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM planet_emit_earth_data_changed_statement(
|
||||||
|
'collected_data',
|
||||||
|
change_operation,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION planet_notify_earth_table_changed_statement()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
change_source TEXT;
|
||||||
|
source_record_count INTEGER;
|
||||||
|
source_entity_keys TEXT[];
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
FOR change_source IN
|
||||||
|
SELECT DISTINCT COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME)
|
||||||
|
FROM (SELECT to_jsonb(t) AS row_data FROM new_rows AS t) changed_rows
|
||||||
|
LOOP
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
ARRAY(
|
||||||
|
SELECT DISTINCT COALESCE(
|
||||||
|
NULLIF(row_data->>'entity_key', ''),
|
||||||
|
NULLIF(row_data->>'source_id', ''),
|
||||||
|
NULLIF(row_data->>'incident_key', ''),
|
||||||
|
NULLIF(row_data->>'id', ''),
|
||||||
|
NULLIF(row_data->>'mmsi', '')
|
||||||
|
)
|
||||||
|
FROM (SELECT to_jsonb(t) AS row_data FROM new_rows AS t) rows_for_keys
|
||||||
|
WHERE COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME) = change_source
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
INTO source_record_count, source_entity_keys
|
||||||
|
FROM (SELECT to_jsonb(t) AS row_data FROM new_rows AS t) rows_for_count
|
||||||
|
WHERE COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME) = change_source;
|
||||||
|
|
||||||
|
PERFORM planet_emit_earth_data_changed_statement(
|
||||||
|
TG_TABLE_NAME,
|
||||||
|
TG_OP,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
FOR change_source IN
|
||||||
|
SELECT DISTINCT COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME)
|
||||||
|
FROM (SELECT to_jsonb(t) AS row_data FROM old_rows AS t) changed_rows
|
||||||
|
LOOP
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
ARRAY(
|
||||||
|
SELECT DISTINCT COALESCE(
|
||||||
|
NULLIF(row_data->>'entity_key', ''),
|
||||||
|
NULLIF(row_data->>'source_id', ''),
|
||||||
|
NULLIF(row_data->>'incident_key', ''),
|
||||||
|
NULLIF(row_data->>'id', ''),
|
||||||
|
NULLIF(row_data->>'mmsi', '')
|
||||||
|
)
|
||||||
|
FROM (SELECT to_jsonb(t) AS row_data FROM old_rows AS t) rows_for_keys
|
||||||
|
WHERE COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME) = change_source
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
INTO source_record_count, source_entity_keys
|
||||||
|
FROM (SELECT to_jsonb(t) AS row_data FROM old_rows AS t) rows_for_count
|
||||||
|
WHERE COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME) = change_source;
|
||||||
|
|
||||||
|
PERFORM planet_emit_earth_data_changed_statement(
|
||||||
|
TG_TABLE_NAME,
|
||||||
|
TG_OP,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
FOR change_source IN
|
||||||
|
SELECT DISTINCT COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME)
|
||||||
|
FROM (
|
||||||
|
SELECT to_jsonb(t) AS row_data FROM new_rows AS t
|
||||||
|
UNION ALL
|
||||||
|
SELECT to_jsonb(t) AS row_data FROM old_rows AS t
|
||||||
|
) changed_rows
|
||||||
|
LOOP
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
ARRAY(
|
||||||
|
SELECT DISTINCT COALESCE(
|
||||||
|
NULLIF(row_data->>'entity_key', ''),
|
||||||
|
NULLIF(row_data->>'source_id', ''),
|
||||||
|
NULLIF(row_data->>'incident_key', ''),
|
||||||
|
NULLIF(row_data->>'id', ''),
|
||||||
|
NULLIF(row_data->>'mmsi', '')
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT to_jsonb(t) AS row_data FROM new_rows AS t
|
||||||
|
UNION ALL
|
||||||
|
SELECT to_jsonb(t) AS row_data FROM old_rows AS t
|
||||||
|
) rows_for_keys
|
||||||
|
WHERE COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME) = change_source
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
INTO source_record_count, source_entity_keys
|
||||||
|
FROM (
|
||||||
|
SELECT to_jsonb(t) AS row_data FROM new_rows AS t
|
||||||
|
UNION ALL
|
||||||
|
SELECT to_jsonb(t) AS row_data FROM old_rows AS t
|
||||||
|
) rows_for_count
|
||||||
|
WHERE COALESCE(NULLIF(row_data->>'source', ''), TG_TABLE_NAME) = change_source;
|
||||||
|
|
||||||
|
PERFORM planet_emit_earth_data_changed_statement(
|
||||||
|
TG_TABLE_NAME,
|
||||||
|
TG_OP,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE OR REPLACE FUNCTION planet_notify_collected_data_changed_statement()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
change_source TEXT;
|
||||||
|
source_record_count INTEGER;
|
||||||
|
source_entity_keys TEXT[];
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
FOR change_source IN
|
||||||
|
SELECT DISTINCT source FROM new_rows WHERE source IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
ARRAY(
|
||||||
|
SELECT DISTINCT COALESCE(entity_key, source_id, id::text)
|
||||||
|
FROM new_rows
|
||||||
|
WHERE source = change_source
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
INTO source_record_count, source_entity_keys
|
||||||
|
FROM new_rows
|
||||||
|
WHERE source = change_source;
|
||||||
|
|
||||||
|
PERFORM planet_emit_collected_data_changed_statement(
|
||||||
|
TG_OP,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
FOR change_source IN
|
||||||
|
SELECT DISTINCT source FROM old_rows WHERE source IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
ARRAY(
|
||||||
|
SELECT DISTINCT COALESCE(entity_key, source_id, id::text)
|
||||||
|
FROM old_rows
|
||||||
|
WHERE source = change_source
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
INTO source_record_count, source_entity_keys
|
||||||
|
FROM old_rows
|
||||||
|
WHERE source = change_source;
|
||||||
|
|
||||||
|
PERFORM planet_emit_collected_data_changed_statement(
|
||||||
|
TG_OP,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
FOR change_source IN
|
||||||
|
SELECT DISTINCT source FROM (
|
||||||
|
SELECT source FROM new_rows
|
||||||
|
UNION
|
||||||
|
SELECT source FROM old_rows
|
||||||
|
) changed_sources
|
||||||
|
WHERE source IS NOT NULL
|
||||||
|
LOOP
|
||||||
|
SELECT
|
||||||
|
COUNT(*),
|
||||||
|
ARRAY(
|
||||||
|
SELECT DISTINCT COALESCE(entity_key, source_id, id::text)
|
||||||
|
FROM (
|
||||||
|
SELECT id, source_id, entity_key, source FROM new_rows
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, source_id, entity_key, source FROM old_rows
|
||||||
|
) changed_rows
|
||||||
|
WHERE source = change_source
|
||||||
|
LIMIT 20
|
||||||
|
)
|
||||||
|
INTO source_record_count, source_entity_keys
|
||||||
|
FROM (
|
||||||
|
SELECT id, source_id, entity_key, source FROM new_rows
|
||||||
|
UNION ALL
|
||||||
|
SELECT id, source_id, entity_key, source FROM old_rows
|
||||||
|
) changed_rows
|
||||||
|
WHERE source = change_source;
|
||||||
|
|
||||||
|
PERFORM planet_emit_collected_data_changed_statement(
|
||||||
|
TG_OP,
|
||||||
|
change_source,
|
||||||
|
source_record_count,
|
||||||
|
source_entity_keys
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for statement in (
|
||||||
|
"DROP TRIGGER IF EXISTS tr_planet_collected_data_changed ON collected_data",
|
||||||
|
"DROP TRIGGER IF EXISTS tr_planet_collected_data_changed_insert ON collected_data",
|
||||||
|
"DROP TRIGGER IF EXISTS tr_planet_collected_data_changed_update ON collected_data",
|
||||||
|
"DROP TRIGGER IF EXISTS tr_planet_collected_data_changed_delete ON collected_data",
|
||||||
|
"DROP FUNCTION IF EXISTS planet_notify_collected_data_changed()",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER tr_planet_collected_data_changed_insert
|
||||||
|
AFTER INSERT ON collected_data
|
||||||
|
REFERENCING NEW TABLE AS new_rows
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION planet_notify_collected_data_changed_statement()
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER tr_planet_collected_data_changed_update
|
||||||
|
AFTER UPDATE ON collected_data
|
||||||
|
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION planet_notify_collected_data_changed_statement()
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER tr_planet_collected_data_changed_delete
|
||||||
|
AFTER DELETE ON collected_data
|
||||||
|
REFERENCING OLD TABLE AS old_rows
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION planet_notify_collected_data_changed_statement()
|
||||||
|
""",
|
||||||
|
):
|
||||||
|
await conn.execute(text(statement))
|
||||||
|
for table_name in (
|
||||||
|
"bgp_observations",
|
||||||
|
"bgp_anomalies",
|
||||||
|
"bgp_incidents",
|
||||||
|
"bgp_collector_locations",
|
||||||
|
"vessel_static",
|
||||||
|
"vessel_position",
|
||||||
|
"vessel_current_state",
|
||||||
|
"ais_raw_observations",
|
||||||
|
"ais_source_health",
|
||||||
|
"compute_center_locations",
|
||||||
|
"earth_interactables",
|
||||||
|
"earth_news_items",
|
||||||
|
):
|
||||||
|
for statement in (
|
||||||
|
f"DROP TRIGGER IF EXISTS tr_planet_{table_name}_changed_insert ON {table_name}",
|
||||||
|
f"DROP TRIGGER IF EXISTS tr_planet_{table_name}_changed_update ON {table_name}",
|
||||||
|
f"DROP TRIGGER IF EXISTS tr_planet_{table_name}_changed_delete ON {table_name}",
|
||||||
|
f"""
|
||||||
|
CREATE TRIGGER tr_planet_{table_name}_changed_insert
|
||||||
|
AFTER INSERT ON {table_name}
|
||||||
|
REFERENCING NEW TABLE AS new_rows
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION planet_notify_earth_table_changed_statement()
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TRIGGER tr_planet_{table_name}_changed_update
|
||||||
|
AFTER UPDATE ON {table_name}
|
||||||
|
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION planet_notify_earth_table_changed_statement()
|
||||||
|
""",
|
||||||
|
f"""
|
||||||
|
CREATE TRIGGER tr_planet_{table_name}_changed_delete
|
||||||
|
AFTER DELETE ON {table_name}
|
||||||
|
REFERENCING OLD TABLE AS old_rows
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION planet_notify_earth_table_changed_statement()
|
||||||
|
""",
|
||||||
|
):
|
||||||
|
await conn.execute(text(statement))
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -162,7 +669,39 @@ async def init_db():
|
|||||||
ADD COLUMN IF NOT EXISTS phase_message VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS phase_message VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS phase_current BIGINT,
|
ADD COLUMN IF NOT EXISTS phase_current BIGINT,
|
||||||
ADD COLUMN IF NOT EXISTS phase_total BIGINT,
|
ADD COLUMN IF NOT EXISTS phase_total BIGINT,
|
||||||
ADD COLUMN IF NOT EXISTS phase_unit VARCHAR(30)
|
ADD COLUMN IF NOT EXISTS phase_unit VARCHAR(30),
|
||||||
|
ADD COLUMN IF NOT EXISTS source VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS task_type VARCHAR(30) NOT NULL DEFAULT 'collect',
|
||||||
|
ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS rollback_policy VARCHAR(40) NOT NULL DEFAULT 'keep_committed_batches',
|
||||||
|
ADD COLUMN IF NOT EXISTS dedupe_key VARCHAR(180),
|
||||||
|
ADD COLUMN IF NOT EXISTS worker_id VARCHAR(120),
|
||||||
|
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS requested_cancel_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS cancel_reason TEXT
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
"""
|
||||||
|
ALTER TABLE earth_interactables
|
||||||
|
ADD COLUMN IF NOT EXISTS altitude DOUBLE PRECISION,
|
||||||
|
ADD COLUMN IF NOT EXISTS revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -174,6 +713,64 @@ 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(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_earth_interactables_layer_deleted
|
||||||
|
ON earth_interactables (layer, is_deleted)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_earth_interactables_updated_at
|
||||||
|
ON earth_interactables (updated_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collection_tasks_source_status
|
||||||
|
ON collection_tasks (source, status)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collection_tasks_queue
|
||||||
|
ON collection_tasks (status, created_at, id)
|
||||||
|
WHERE status = 'queued'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collection_tasks_dedupe
|
||||||
|
ON collection_tasks (dedupe_key)
|
||||||
|
WHERE dedupe_key IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -190,6 +787,22 @@ async def init_db():
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vessel_current_bbox
|
||||||
|
ON vessel_current_state (lon, lat)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vessel_current_observed
|
||||||
|
ON vessel_current_state (observed_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -198,6 +811,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(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
@@ -228,4 +861,5 @@ async def init_db():
|
|||||||
await seed_default_bgp_collector_locations(session)
|
await seed_default_bgp_collector_locations(session)
|
||||||
await seed_compute_center_locations_from_source_coords(session)
|
await seed_compute_center_locations_from_source_coords(session)
|
||||||
await seed_default_datasources(session)
|
await seed_default_datasources(session)
|
||||||
|
await purge_legacy_earth_boundary_datasources(session)
|
||||||
await ensure_default_admin_user(session)
|
await ensure_default_admin_user(session)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from app.api.main import api_router
|
from app.api.main import api_router
|
||||||
@@ -18,6 +20,15 @@ from app.services.scheduler import (
|
|||||||
stop_scheduler,
|
stop_scheduler,
|
||||||
sync_scheduler_with_datasources,
|
sync_scheduler_with_datasources,
|
||||||
)
|
)
|
||||||
|
from app.services.earth_news_worker import (
|
||||||
|
start_earth_news_target_worker,
|
||||||
|
stop_earth_news_target_worker,
|
||||||
|
)
|
||||||
|
from app.services.earth_db_change_listener import (
|
||||||
|
start_earth_db_change_listener,
|
||||||
|
stop_earth_db_change_listener,
|
||||||
|
)
|
||||||
|
from app.services.data_jobs import start_data_job_worker, stop_data_job_worker
|
||||||
|
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
@@ -53,7 +64,13 @@ async def lifespan(app: FastAPI):
|
|||||||
start_scheduler()
|
start_scheduler()
|
||||||
await sync_scheduler_with_datasources()
|
await sync_scheduler_with_datasources()
|
||||||
broadcaster.start()
|
broadcaster.start()
|
||||||
|
start_data_job_worker()
|
||||||
|
start_earth_db_change_listener()
|
||||||
|
start_earth_news_target_worker()
|
||||||
yield
|
yield
|
||||||
|
await stop_earth_news_target_worker()
|
||||||
|
await stop_earth_db_change_listener()
|
||||||
|
await stop_data_job_worker()
|
||||||
broadcaster.stop()
|
broadcaster.stop()
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
|
|
||||||
@@ -82,6 +99,14 @@ app.add_middleware(WebSocketCORSMiddleware)
|
|||||||
app.include_router(api_router, prefix="/api/v1")
|
app.include_router(api_router, prefix="/api/v1")
|
||||||
app.include_router(websocket.router)
|
app.include_router(websocket.router)
|
||||||
|
|
||||||
|
EARTH_BRAND_ASSET_DIR = Path(__file__).resolve().parents[2] / "data" / "earth-brand"
|
||||||
|
EARTH_BRAND_ASSET_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
app.mount(
|
||||||
|
"/earth-brand-assets",
|
||||||
|
StaticFiles(directory=str(EARTH_BRAND_ASSET_DIR)),
|
||||||
|
name="earth-brand-assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ from app.models.compute_center_location import ComputeCenterLocationRecord
|
|||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.models.playground_session import PlaygroundSession
|
from app.models.playground_session import PlaygroundSession
|
||||||
from app.models.playground_message import PlaygroundMessage
|
from app.models.playground_message import PlaygroundMessage
|
||||||
from app.models.system_log import SystemLog, AuditLog
|
from app.models.system_log import AuditLog, ObservabilityEvent, ObservabilityEventGroup, SystemLog
|
||||||
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic
|
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth, VesselPosition, VesselStatic
|
||||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||||
|
from app.models.earth_news import EarthNewsItem
|
||||||
|
from app.models.earth_interactable import EarthInteractable
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -35,6 +37,8 @@ __all__ = [
|
|||||||
"ComputeCenterLocationRecord",
|
"ComputeCenterLocationRecord",
|
||||||
"SystemLog",
|
"SystemLog",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"ObservabilityEvent",
|
||||||
|
"ObservabilityEventGroup",
|
||||||
"PlaygroundSession",
|
"PlaygroundSession",
|
||||||
"PlaygroundMessage",
|
"PlaygroundMessage",
|
||||||
"VesselPosition",
|
"VesselPosition",
|
||||||
@@ -43,4 +47,6 @@ __all__ = [
|
|||||||
"AISConflictRecord",
|
"AISConflictRecord",
|
||||||
"AISSourceHealth",
|
"AISSourceHealth",
|
||||||
"DataSourceMappingTemplate",
|
"DataSourceMappingTemplate",
|
||||||
|
"EarthNewsItem",
|
||||||
|
"EarthInteractable",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Enum as SQLEnum
|
from sqlalchemy import Column, Integer, String, DateTime, Text, Enum as SQLEnum
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
|
from app.core.enums import AlertSeverity, AlertStatus
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
class AlertSeverity(str, Enum):
|
|
||||||
CRITICAL = "critical"
|
|
||||||
WARNING = "warning"
|
|
||||||
INFO = "info"
|
|
||||||
|
|
||||||
|
|
||||||
class AlertStatus(str, Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
ACKNOWLEDGED = "acknowledged"
|
|
||||||
RESOLVED = "resolved"
|
|
||||||
|
|
||||||
|
|
||||||
class Alert(Base):
|
class Alert(Base):
|
||||||
__tablename__ = "alerts"
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
|
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
|
||||||
|
|
||||||
|
from app.core.enums import BGPStatus
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ class BGPAnomaly(Base):
|
|||||||
source = Column(String(100), nullable=False, index=True)
|
source = Column(String(100), nullable=False, index=True)
|
||||||
anomaly_type = Column(String(50), nullable=False, index=True)
|
anomaly_type = Column(String(50), nullable=False, index=True)
|
||||||
severity = Column(String(20), nullable=False, index=True)
|
severity = Column(String(20), nullable=False, index=True)
|
||||||
status = Column(String(20), nullable=False, default="active", index=True)
|
status = Column(String(20), nullable=False, default=BGPStatus.ACTIVE.value, index=True)
|
||||||
entity_key = Column(String(255), nullable=False, index=True)
|
entity_key = Column(String(255), nullable=False, index=True)
|
||||||
prefix = Column(String(64), nullable=True, index=True)
|
prefix = Column(String(64), nullable=True, index=True)
|
||||||
origin_asn = Column(Integer, nullable=True, index=True)
|
origin_asn = Column(Integer, nullable=True, index=True)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
|
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
|
||||||
|
|
||||||
|
from app.core.enums import BGPStatus
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ class BGPIncident(Base):
|
|||||||
title = Column(String(255), nullable=False)
|
title = Column(String(255), nullable=False)
|
||||||
summary = Column(Text, nullable=False)
|
summary = Column(Text, nullable=False)
|
||||||
severity = Column(String(20), nullable=False, index=True)
|
severity = Column(String(20), nullable=False, index=True)
|
||||||
status = Column(String(20), nullable=False, default="active", index=True)
|
status = Column(String(20), nullable=False, default=BGPStatus.ACTIVE.value, index=True)
|
||||||
confidence = Column(Float, nullable=False, default=0.5)
|
confidence = Column(Float, nullable=False, default=0.5)
|
||||||
started_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
|
started_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
|
||||||
ended_at = Column(DateTime(timezone=True), nullable=True)
|
ended_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.enums import SnapshotStatus
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ class DataSnapshot(Base):
|
|||||||
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
record_count = Column(Integer, default=0)
|
record_count = Column(Integer, default=0)
|
||||||
status = Column(String(20), nullable=False, default="running")
|
status = Column(String(20), nullable=False, default=SnapshotStatus.RUNNING.value)
|
||||||
is_current = Column(Boolean, default=True, index=True)
|
is_current = Column(Boolean, default=True, index=True)
|
||||||
parent_snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True)
|
parent_snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True)
|
||||||
summary = Column(JSON, default={})
|
summary = Column(JSON, default={})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.enums import MappingValidationStatus
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ class DataSourceMappingTemplate(Base):
|
|||||||
target_schema = Column(String(80), nullable=False, index=True)
|
target_schema = Column(String(80), nullable=False, index=True)
|
||||||
mapping_json = Column(JSON, nullable=False, default={})
|
mapping_json = Column(JSON, nullable=False, default={})
|
||||||
sample_payload_hash = Column(String(64), nullable=True)
|
sample_payload_hash = Column(String(64), nullable=True)
|
||||||
validation_status = Column(String(30), nullable=False, default="draft")
|
validation_status = Column(String(30), nullable=False, default=MappingValidationStatus.DRAFT.value)
|
||||||
version = Column(Integer, nullable=False, default=1)
|
version = Column(Integer, nullable=False, default=1)
|
||||||
is_active = Column(Boolean, nullable=False, default=False, index=True)
|
is_active = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
30
backend/app/models/earth_interactable.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Persistent Earth interactable objects."""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, DateTime, Float, Index, Integer, JSON, String, Text
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class EarthInteractable(Base):
|
||||||
|
__tablename__ = "earth_interactables"
|
||||||
|
|
||||||
|
id = Column(String(160), primary_key=True)
|
||||||
|
layer = Column(String(80), nullable=False, default="interactables", index=True)
|
||||||
|
kind = Column(String(80), nullable=False, default="default", index=True)
|
||||||
|
label = Column(String(255), nullable=False, default="")
|
||||||
|
description = Column(Text, nullable=False, default="")
|
||||||
|
latitude = Column(Float, nullable=False)
|
||||||
|
longitude = Column(Float, nullable=False)
|
||||||
|
altitude = Column(Float, nullable=True)
|
||||||
|
revision = Column(Integer, nullable=False, default=1)
|
||||||
|
properties = Column(JSON, nullable=False, default=dict)
|
||||||
|
is_deleted = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_earth_interactables_layer_deleted", "layer", "is_deleted"),
|
||||||
|
Index("idx_earth_interactables_updated_at", "updated_at"),
|
||||||
|
)
|
||||||
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,6 +1,7 @@
|
|||||||
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.enums import PlaygroundMessageKind, PlaygroundMessageStatus
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -13,8 +14,8 @@ class PlaygroundMessage(Base):
|
|||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
parent_message_id = Column(Integer, ForeignKey("playground_messages.id", ondelete="SET NULL"), nullable=True)
|
parent_message_id = Column(Integer, ForeignKey("playground_messages.id", ondelete="SET NULL"), nullable=True)
|
||||||
role = Column(String(20), nullable=False)
|
role = Column(String(20), nullable=False)
|
||||||
kind = Column(String(20), nullable=False, default="message")
|
kind = Column(String(20), nullable=False, default=PlaygroundMessageKind.MESSAGE.value)
|
||||||
status = Column(String(20), nullable=False, default="done")
|
status = Column(String(20), nullable=False, default=PlaygroundMessageStatus.DONE.value)
|
||||||
title = Column(String(255), nullable=True)
|
title = Column(String(255), nullable=True)
|
||||||
content = Column(Text, nullable=False, default="")
|
content = Column(Text, nullable=False, default="")
|
||||||
thinking_content = Column(Text, nullable=False, default="")
|
thinking_content = Column(Text, nullable=False, default="")
|
||||||
|
|||||||
@@ -38,3 +38,46 @@ class AuditLog(Base):
|
|||||||
ip = Column(String(64), nullable=True)
|
ip = Column(String(64), nullable=True)
|
||||||
details = Column(JSON, nullable=False, default=dict)
|
details = Column(JSON, nullable=False, default=dict)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityEvent(Base):
|
||||||
|
__tablename__ = "observability_events"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
source = Column(String(50), nullable=False, index=True)
|
||||||
|
service = Column(String(50), nullable=True, index=True)
|
||||||
|
module = Column(String(120), nullable=True, index=True)
|
||||||
|
category = Column(String(80), nullable=True, index=True)
|
||||||
|
event = Column(String(160), nullable=True, index=True)
|
||||||
|
level = Column(String(20), nullable=False, index=True)
|
||||||
|
message = Column(Text, nullable=False)
|
||||||
|
fingerprint = Column(String(80), nullable=False, index=True)
|
||||||
|
request_id = Column(String(64), nullable=True, index=True)
|
||||||
|
trace_id = Column(String(64), nullable=True, index=True)
|
||||||
|
task_id = Column(String(120), nullable=True, index=True)
|
||||||
|
source_ref_id = Column(String(120), nullable=True, index=True)
|
||||||
|
provider = Column(String(120), nullable=True, index=True)
|
||||||
|
user_id = Column(Integer, nullable=True, index=True)
|
||||||
|
context = Column(JSON, nullable=False, default=dict)
|
||||||
|
occurrence_count = Column(Integer, nullable=False, default=1)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ObservabilityEventGroup(Base):
|
||||||
|
__tablename__ = "observability_event_groups"
|
||||||
|
|
||||||
|
fingerprint = Column(String(80), primary_key=True)
|
||||||
|
source = Column(String(50), nullable=False, index=True)
|
||||||
|
service = Column(String(50), nullable=True, index=True)
|
||||||
|
module = Column(String(120), nullable=True, index=True)
|
||||||
|
category = Column(String(80), nullable=True, index=True)
|
||||||
|
event = Column(String(160), nullable=True, index=True)
|
||||||
|
last_level = Column(String(20), nullable=False, index=True)
|
||||||
|
sample_message = Column(Text, nullable=False)
|
||||||
|
sample_detail = Column(Text, nullable=True)
|
||||||
|
affected_sources = Column(JSON, nullable=False, default=list)
|
||||||
|
count = Column(Integer, nullable=False, default=0)
|
||||||
|
first_seen_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
last_seen_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Collection Task model"""
|
"""Datasource job model."""
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Column, DateTime, Integer, String, Text, Float
|
from sqlalchemy import BigInteger, Column, DateTime, Float, Integer, JSON, String, Text
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.enums import JobStatus, JobType, RollbackPolicy
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -11,8 +12,10 @@ class CollectionTask(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
datasource_id = Column(Integer, nullable=False, index=True)
|
datasource_id = Column(Integer, nullable=False, index=True)
|
||||||
status = Column(String(20), nullable=False) # pending, running, success, failed, cancelled
|
source = Column(String(100), nullable=True, index=True)
|
||||||
phase = Column(String(30), default="queued")
|
task_type = Column(String(30), nullable=False, default=JobType.COLLECT.value, index=True)
|
||||||
|
status = Column(String(20), nullable=False) # queued, running, cancelling, success, failed, cancelled
|
||||||
|
phase = Column(String(30), default=JobStatus.QUEUED.value)
|
||||||
phase_progress = Column(Float)
|
phase_progress = Column(Float)
|
||||||
phase_message = Column(String(255))
|
phase_message = Column(String(255))
|
||||||
phase_current = Column(BigInteger)
|
phase_current = Column(BigInteger)
|
||||||
@@ -24,6 +27,13 @@ class CollectionTask(Base):
|
|||||||
total_records = Column(Integer, default=0) # Total records to process
|
total_records = Column(Integer, default=0) # Total records to process
|
||||||
progress = Column(Float, default=0.0) # Progress percentage (0-100)
|
progress = Column(Float, default=0.0) # Progress percentage (0-100)
|
||||||
error_message = Column(Text)
|
error_message = Column(Text)
|
||||||
|
payload = Column(JSON, default=dict)
|
||||||
|
rollback_policy = Column(String(40), nullable=False, default=RollbackPolicy.KEEP_COMMITTED_BATCHES.value)
|
||||||
|
dedupe_key = Column(String(180), nullable=True, index=True)
|
||||||
|
worker_id = Column(String(120), nullable=True, index=True)
|
||||||
|
locked_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
requested_cancel_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
cancel_reason = Column(Text)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String
|
from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.enums import UserRole
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ class User(Base):
|
|||||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
password_hash = Column(String(255), nullable=False)
|
password_hash = Column(String(255), nullable=False)
|
||||||
role = Column(String(20), default="viewer")
|
role = Column(String(20), default=UserRole.VIEWER.value)
|
||||||
gatekeeper_groups = Column(JSON, default=list)
|
gatekeeper_groups = Column(JSON, default=list)
|
||||||
is_active = Column(Boolean, default=True)
|
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))
|
last_login_at = Column(DateTime(timezone=True))
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from sqlalchemy import BigInteger, Column, DateTime, Float, Index, Integer, JSON, SmallInteger, String
|
from sqlalchemy import BigInteger, Column, DateTime, Float, Index, Integer, JSON, SmallInteger, String
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.core.enums import ConnectionState
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.db.session import Base
|
from app.db.session import Base
|
||||||
|
|
||||||
@@ -75,6 +76,67 @@ class VesselPosition(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VesselCurrentState(Base):
|
||||||
|
"""Latest renderable state for one vessel, independent from AIS history."""
|
||||||
|
|
||||||
|
__tablename__ = "vessel_current_state"
|
||||||
|
|
||||||
|
mmsi = Column(BigInteger, primary_key=True)
|
||||||
|
lat = Column(Float, nullable=False)
|
||||||
|
lon = Column(Float, nullable=False)
|
||||||
|
sog = Column(Float, nullable=True)
|
||||||
|
cog = Column(Float, nullable=True)
|
||||||
|
heading = Column(SmallInteger, nullable=True)
|
||||||
|
nav_status = Column(SmallInteger, nullable=True, index=True)
|
||||||
|
name = Column(String(128), nullable=True)
|
||||||
|
callsign = Column(String(16), nullable=True)
|
||||||
|
vessel_type = Column(SmallInteger, nullable=True, index=True)
|
||||||
|
vessel_type_name = Column(String(64), nullable=True, index=True)
|
||||||
|
flag = Column(String(4), nullable=True, index=True)
|
||||||
|
length = Column(Float, nullable=True)
|
||||||
|
width = Column(Float, nullable=True)
|
||||||
|
draught = Column(Float, nullable=True)
|
||||||
|
imo = Column(BigInteger, nullable=True)
|
||||||
|
source = Column(String(100), nullable=False, index=True)
|
||||||
|
observed_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
field_sources = Column(JSON, default=dict)
|
||||||
|
selected_reasons = Column(JSON, default=dict)
|
||||||
|
source_summary = Column(JSON, default=dict)
|
||||||
|
quality_flags = Column(JSON, default=list)
|
||||||
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_vessel_current_bbox", "lon", "lat"),
|
||||||
|
Index("idx_vessel_current_observed", "observed_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"mmsi": self.mmsi,
|
||||||
|
"lat": self.lat,
|
||||||
|
"lon": self.lon,
|
||||||
|
"sog": self.sog,
|
||||||
|
"cog": self.cog,
|
||||||
|
"heading": self.heading,
|
||||||
|
"nav_status": self.nav_status,
|
||||||
|
"name": self.name,
|
||||||
|
"callsign": self.callsign,
|
||||||
|
"vessel_type": self.vessel_type,
|
||||||
|
"vessel_type_name": self.vessel_type_name,
|
||||||
|
"flag": self.flag,
|
||||||
|
"length": self.length,
|
||||||
|
"width": self.width,
|
||||||
|
"draught": self.draught,
|
||||||
|
"imo": self.imo,
|
||||||
|
"source": self.source,
|
||||||
|
"received_at": self.observed_at,
|
||||||
|
"field_sources": self.field_sources or {},
|
||||||
|
"selected_reasons": self.selected_reasons or {},
|
||||||
|
"source_summary": self.source_summary or {},
|
||||||
|
"quality_flags": self.quality_flags or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AISRawObservation(Base):
|
class AISRawObservation(Base):
|
||||||
"""Source-level AIS fact before aggregation and conflict resolution."""
|
"""Source-level AIS fact before aggregation and conflict resolution."""
|
||||||
|
|
||||||
@@ -165,7 +227,7 @@ class AISSourceHealth(Base):
|
|||||||
__tablename__ = "ais_source_health"
|
__tablename__ = "ais_source_health"
|
||||||
|
|
||||||
source = Column(String(100), primary_key=True)
|
source = Column(String(100), primary_key=True)
|
||||||
connection_state = Column(String(32), nullable=False, default="disconnected", index=True)
|
connection_state = Column(String(32), nullable=False, default=ConnectionState.DISCONNECTED.value, index=True)
|
||||||
last_seen_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
last_seen_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
last_success_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
last_success_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
last_error = Column(String(500), nullable=True)
|
last_error = Column(String(500), nullable=True)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.core.enums import PlaygroundMessageKind, PlaygroundMessageRole, PlaygroundMessageStatus
|
||||||
|
|
||||||
class AIContentBlock(BaseModel):
|
class AIContentBlock(BaseModel):
|
||||||
type: str
|
type: str
|
||||||
@@ -13,10 +14,11 @@ class AIContentBlock(BaseModel):
|
|||||||
|
|
||||||
class SituationalAnalysisRequest(BaseModel):
|
class SituationalAnalysisRequest(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=200)
|
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)
|
context: dict[str, Any] = Field(default_factory=dict)
|
||||||
observations: list[str] = Field(default_factory=list)
|
observations: list[str] = Field(default_factory=list)
|
||||||
constraints: 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)
|
preferred_model: str | None = Field(default=None, max_length=200)
|
||||||
thinking: dict[str, Any] | None = None
|
thinking: dict[str, Any] | None = None
|
||||||
|
|
||||||
@@ -109,9 +111,9 @@ class PlaygroundSessionUpsertRequest(BaseModel):
|
|||||||
|
|
||||||
class PlaygroundMessageRecord(BaseModel):
|
class PlaygroundMessageRecord(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
role: str
|
role: PlaygroundMessageRole
|
||||||
kind: str = "message"
|
kind: PlaygroundMessageKind = PlaygroundMessageKind.MESSAGE
|
||||||
status: str = "done"
|
status: PlaygroundMessageStatus = PlaygroundMessageStatus.DONE
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
content: str = ""
|
content: str = ""
|
||||||
thinking_content: str = ""
|
thinking_content: str = ""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
from app.core.enums import OtpPurpose, UserRole
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
@@ -11,13 +12,13 @@ class UserBase(BaseModel):
|
|||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str = Field(..., min_length=8)
|
password: str = Field(..., min_length=8)
|
||||||
role: str = "viewer"
|
role: UserRole = UserRole.VIEWER
|
||||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
email: Optional[EmailStr] = None
|
email: Optional[EmailStr] = None
|
||||||
role: Optional[str] = None
|
role: Optional[UserRole] = None
|
||||||
gatekeeper_groups: Optional[list[str]] = None
|
gatekeeper_groups: Optional[list[str]] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
@@ -39,7 +40,34 @@ class UserResponse(UserBase):
|
|||||||
role: str
|
role: str
|
||||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
email_verified: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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: OtpPurpose = OtpPurpose.REGISTER
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from time import perf_counter
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas.ai import (
|
from app.schemas.ai import (
|
||||||
AIProviderStatusResponse,
|
AIProviderStatusResponse,
|
||||||
SituationalAnalysisRequest,
|
SituationalAnalysisRequest,
|
||||||
SituationalAnalysisResponse,
|
SituationalAnalysisResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.business_logs import emit_business_log, exception_context
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__, service="ai")
|
||||||
|
|
||||||
|
|
||||||
class AIProviderClient:
|
class AIProviderClient:
|
||||||
@@ -57,10 +64,25 @@ class AIProviderClient:
|
|||||||
value = self.llm_config.get(key)
|
value = self.llm_config.get(key)
|
||||||
if value not in (None, ""):
|
if value not in (None, ""):
|
||||||
headers[header_name] = str(value)
|
headers[header_name] = str(value)
|
||||||
|
model_provider_apis = self.llm_config.get("model_provider_apis")
|
||||||
|
if isinstance(model_provider_apis, dict) and model_provider_apis:
|
||||||
|
headers["X-AI-Model-Provider-APIs"] = json.dumps(model_provider_apis)
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
async def get_status(self, request_id: str | None = None) -> AIProviderStatusResponse:
|
async def get_status(self, request_id: str | None = None) -> AIProviderStatusResponse:
|
||||||
|
context = self._base_log_context(operation="status")
|
||||||
if not self.service_url:
|
if not self.service_url:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.status.failed",
|
||||||
|
message="AI provider status skipped because service URL is not configured",
|
||||||
|
category="ai",
|
||||||
|
level="warning",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context={**context, "status": "unconfigured"},
|
||||||
|
)
|
||||||
return AIProviderStatusResponse(
|
return AIProviderStatusResponse(
|
||||||
provider="unconfigured",
|
provider="unconfigured",
|
||||||
enabled=False,
|
enabled=False,
|
||||||
@@ -69,27 +91,133 @@ class AIProviderClient:
|
|||||||
base_url=None,
|
base_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
data = await self._request("GET", "/v1/provider/status", request_id=request_id)
|
started_at = perf_counter()
|
||||||
return AIProviderStatusResponse.model_validate(data)
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.status.start",
|
||||||
|
message="AI provider status request started",
|
||||||
|
category="ai",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = await self._request("GET", "/v1/provider/status", request_id=request_id, operation="status")
|
||||||
|
result = AIProviderStatusResponse.model_validate(data)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.status.success",
|
||||||
|
message="AI provider status request completed",
|
||||||
|
category="ai",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context={
|
||||||
|
**context,
|
||||||
|
"status": "success",
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
"result_provider": result.provider,
|
||||||
|
"result_model": result.model,
|
||||||
|
"configured": result.configured,
|
||||||
|
"enabled": result.enabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.status.failed",
|
||||||
|
message="AI provider status request failed",
|
||||||
|
category="ai",
|
||||||
|
level="error",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context=exception_context(exc, {**context, "status": "failed", "duration_ms": self._duration_ms(started_at)}),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def analyze(
|
async def analyze(
|
||||||
self,
|
self,
|
||||||
payload: SituationalAnalysisRequest,
|
payload: SituationalAnalysisRequest,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
) -> SituationalAnalysisResponse:
|
) -> SituationalAnalysisResponse:
|
||||||
|
context = self._base_log_context(
|
||||||
|
operation="analyze",
|
||||||
|
preferred_model=payload.preferred_model,
|
||||||
|
input_summary=self._summarize_analysis_payload(payload),
|
||||||
|
)
|
||||||
if not self.service_url:
|
if not self.service_url:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.analyze.failed",
|
||||||
|
message="AI provider analyze skipped because service URL is not configured",
|
||||||
|
category="ai",
|
||||||
|
level="warning",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context={**context, "status": "unconfigured"},
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="AI provider service URL is not configured.",
|
detail="AI provider service URL is not configured.",
|
||||||
)
|
)
|
||||||
|
|
||||||
data = await self._request(
|
started_at = perf_counter()
|
||||||
"POST",
|
await emit_business_log(
|
||||||
"/v1/analyze",
|
logger,
|
||||||
json=payload.model_dump(),
|
event="ai.provider.analyze.start",
|
||||||
|
message="AI provider analyze request started",
|
||||||
|
category="ai",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
return SituationalAnalysisResponse.model_validate(data)
|
try:
|
||||||
|
data = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/v1/analyze",
|
||||||
|
json=payload.model_dump(),
|
||||||
|
request_id=request_id,
|
||||||
|
operation="analyze",
|
||||||
|
payload_summary=context["input_summary"],
|
||||||
|
)
|
||||||
|
result = SituationalAnalysisResponse.model_validate(data)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.analyze.success",
|
||||||
|
message="AI provider analyze request completed",
|
||||||
|
category="ai",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context={
|
||||||
|
**context,
|
||||||
|
"status": "success",
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
"result_provider": result.provider,
|
||||||
|
"result_model": result.model,
|
||||||
|
"content_block_count": len(result.content_blocks or []),
|
||||||
|
"thinking_block_count": len(result.thinking_blocks or []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai.provider.analyze.failed",
|
||||||
|
message="AI provider analyze request failed",
|
||||||
|
category="ai",
|
||||||
|
level="error",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context=exception_context(exc, {**context, "status": "failed", "duration_ms": self._duration_ms(started_at)}),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
@@ -97,9 +225,12 @@ class AIProviderClient:
|
|||||||
path: str,
|
path: str,
|
||||||
json: dict | None = None,
|
json: dict | None = None,
|
||||||
request_id: str | None = None,
|
request_id: str | None = None,
|
||||||
|
operation: str = "request",
|
||||||
|
payload_summary: dict | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
for attempt in range(1, self.retry_attempts + 1):
|
for attempt in range(1, self.retry_attempts + 1):
|
||||||
|
attempt_started_at = perf_counter()
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
@@ -113,6 +244,15 @@ class AIProviderClient:
|
|||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
if attempt < self.retry_attempts and exc.response.status_code >= 500:
|
if attempt < self.retry_attempts and exc.response.status_code >= 500:
|
||||||
|
await self._log_retry(
|
||||||
|
operation=operation,
|
||||||
|
request_id=request_id,
|
||||||
|
attempt=attempt,
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
duration_ms=self._duration_ms(attempt_started_at),
|
||||||
|
error=exc,
|
||||||
|
payload_summary=payload_summary,
|
||||||
|
)
|
||||||
await asyncio.sleep(0.3 * attempt)
|
await asyncio.sleep(0.3 * attempt)
|
||||||
continue
|
continue
|
||||||
detail = exc.response.text or "AI provider service returned an error"
|
detail = exc.response.text or "AI provider service returned an error"
|
||||||
@@ -123,6 +263,14 @@ class AIProviderClient:
|
|||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
last_error = exc
|
last_error = exc
|
||||||
if attempt < self.retry_attempts:
|
if attempt < self.retry_attempts:
|
||||||
|
await self._log_retry(
|
||||||
|
operation=operation,
|
||||||
|
request_id=request_id,
|
||||||
|
attempt=attempt,
|
||||||
|
duration_ms=self._duration_ms(attempt_started_at),
|
||||||
|
error=exc,
|
||||||
|
payload_summary=payload_summary,
|
||||||
|
)
|
||||||
await asyncio.sleep(0.3 * attempt)
|
await asyncio.sleep(0.3 * attempt)
|
||||||
continue
|
continue
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -135,6 +283,71 @@ class AIProviderClient:
|
|||||||
detail=f"AI provider service request failed: {last_error}",
|
detail=f"AI provider service request failed: {last_error}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _base_log_context(self, **extra: object) -> dict:
|
||||||
|
llm_provider_apis = self.llm_config.get("model_provider_apis")
|
||||||
|
return {
|
||||||
|
"provider": self.llm_config.get("provider") or "",
|
||||||
|
"provider_api": self.llm_config.get("provider_api") or "",
|
||||||
|
"model": self.llm_config.get("model") or "",
|
||||||
|
"base_url_configured": bool(self.llm_config.get("base_url")),
|
||||||
|
"service_url_configured": bool(self.service_url),
|
||||||
|
"timeout_seconds": self.timeout,
|
||||||
|
"retry_attempts": self.retry_attempts,
|
||||||
|
"model_provider_api_count": len(llm_provider_apis or {}) if isinstance(llm_provider_apis, dict) else 0,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _duration_ms(started_at: float) -> int:
|
||||||
|
return int((perf_counter() - started_at) * 1000)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _summarize_analysis_payload(payload: SituationalAnalysisRequest) -> dict:
|
||||||
|
context = payload.context if isinstance(payload.context, dict) else {}
|
||||||
|
thinking = payload.thinking if isinstance(payload.thinking, dict) else payload.thinking
|
||||||
|
return {
|
||||||
|
"title_length": len(payload.title or ""),
|
||||||
|
"objective_length": len(payload.objective or ""),
|
||||||
|
"observation_count": len(payload.observations or []),
|
||||||
|
"constraint_count": len(payload.constraints or []),
|
||||||
|
"has_system_prompt": bool(payload.system_prompt),
|
||||||
|
"thinking_enabled": bool(thinking),
|
||||||
|
"context_keys": sorted(str(key) for key in context.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _log_retry(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
request_id: str | None,
|
||||||
|
attempt: int,
|
||||||
|
duration_ms: int,
|
||||||
|
error: BaseException,
|
||||||
|
status_code: int | None = None,
|
||||||
|
payload_summary: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event=f"ai.provider.{operation}.retry",
|
||||||
|
message="AI provider request will retry",
|
||||||
|
category="ai",
|
||||||
|
level="warning",
|
||||||
|
service="ai",
|
||||||
|
module=__name__,
|
||||||
|
request_id=request_id,
|
||||||
|
context=exception_context(
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
**self._base_log_context(operation=operation),
|
||||||
|
"attempt": attempt,
|
||||||
|
"next_attempt": attempt + 1,
|
||||||
|
"status_code": status_code,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"input_summary": payload_summary,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_ai_provider_client(db: AsyncSession = Depends(get_db)) -> AIProviderClient:
|
async def get_ai_provider_client(db: AsyncSession = Depends(get_db)) -> AIProviderClient:
|
||||||
from app.api.v1.settings import get_runtime_ai_provider_config
|
from app.api.v1.settings import get_runtime_ai_provider_config
|
||||||
|
|||||||
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
@@ -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
@@ -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)
|
||||||
|
|
||||||
122
backend/app/services/ai_tools/web_fetch.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from time import perf_counter
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.services.ai_tools.schemas import FetchedEvidence
|
||||||
|
from app.services.business_logs import emit_business_log, exception_context
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__, service="ai_tool")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
started_at = perf_counter()
|
||||||
|
if not url:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_fetch.failed",
|
||||||
|
message="WebFetch failed because URL is empty",
|
||||||
|
category="ai_tool",
|
||||||
|
level="warning",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={"reason": "empty_url"},
|
||||||
|
)
|
||||||
|
raise WebFetchError("url is required")
|
||||||
|
request_host = urlparse(url).netloc
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_fetch.start",
|
||||||
|
message="WebFetch request started",
|
||||||
|
category="ai_tool",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"url_host": request_host,
|
||||||
|
"timeout_seconds": timeout_seconds,
|
||||||
|
"max_bytes": max_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_fetch.failed",
|
||||||
|
message="WebFetch request failed",
|
||||||
|
category="ai_tool",
|
||||||
|
level="error",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(
|
||||||
|
exc,
|
||||||
|
{
|
||||||
|
"url_host": request_host,
|
||||||
|
"status": "failed",
|
||||||
|
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_fetch.success",
|
||||||
|
message="WebFetch request completed",
|
||||||
|
category="ai_tool",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"url_host": request_host,
|
||||||
|
"final_url_host": urlparse(str(response.url)).netloc,
|
||||||
|
"status": "success",
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"bytes_read": len(content),
|
||||||
|
"content_hash": content_hash,
|
||||||
|
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||||
|
"extractor": "beautifulsoup_basic",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return FetchedEvidence(
|
||||||
|
url=url,
|
||||||
|
final_url=str(response.url),
|
||||||
|
title=title,
|
||||||
|
text=text,
|
||||||
|
content_hash=content_hash,
|
||||||
|
extractor="beautifulsoup_basic",
|
||||||
|
)
|
||||||
476
backend/app/services/ai_tools/web_search.py
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
import hashlib
|
||||||
|
from time import perf_counter
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig
|
||||||
|
from app.services.business_logs import emit_business_log, exception_context
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__, service="ai_tool")
|
||||||
|
|
||||||
|
|
||||||
|
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]:
|
||||||
|
started_at = perf_counter()
|
||||||
|
if not self.config.enabled:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_search.unavailable",
|
||||||
|
message="WebSearch skipped because integration is disabled",
|
||||||
|
category="ai_tool",
|
||||||
|
level="warning",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={"provider": self.config.default_provider, "reason": "disabled"},
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_search.unavailable",
|
||||||
|
message="WebSearch skipped because API key is not configured",
|
||||||
|
category="ai_tool",
|
||||||
|
level="warning",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={"provider": provider, "reason": "missing_api_key"},
|
||||||
|
)
|
||||||
|
raise WebSearchConfigurationError(f"{provider} API key is not configured.")
|
||||||
|
query = " ".join(str(query or "").split())
|
||||||
|
if not query:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_search.failed",
|
||||||
|
message="WebSearch failed because query is empty",
|
||||||
|
category="ai_tool",
|
||||||
|
level="warning",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={"provider": provider, "reason": "empty_query"},
|
||||||
|
)
|
||||||
|
raise WebSearchConfigurationError("search query is required.")
|
||||||
|
limit = max_results or provider_config.max_results
|
||||||
|
context = {
|
||||||
|
"provider": provider,
|
||||||
|
"query_hash": hashlib.sha256(query.encode("utf-8")).hexdigest(),
|
||||||
|
"query_length": len(query),
|
||||||
|
"max_results": limit,
|
||||||
|
"domain_count": len(domains or []),
|
||||||
|
"freshness_days": freshness_days,
|
||||||
|
}
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_search.start",
|
||||||
|
message="WebSearch request started",
|
||||||
|
category="ai_tool",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if provider == "tavily":
|
||||||
|
results = await self._search_tavily(provider_config, query, limit, domains, freshness_days)
|
||||||
|
elif provider == "brave":
|
||||||
|
results = await self._search_brave(provider_config, query, limit, domains)
|
||||||
|
elif provider == "serpapi":
|
||||||
|
results = await self._search_serpapi(provider_config, query, limit)
|
||||||
|
elif provider == "exa":
|
||||||
|
results = await self._search_exa(provider_config, query, limit, domains)
|
||||||
|
elif provider == "firecrawl":
|
||||||
|
results = await self._search_firecrawl(provider_config, query, limit)
|
||||||
|
elif provider == "searxng":
|
||||||
|
results = await self._search_searxng(provider_config, query, limit, domains)
|
||||||
|
else:
|
||||||
|
raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}")
|
||||||
|
event = "ai_tool.web_search.success" if results else "ai_tool.web_search.empty"
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event=event,
|
||||||
|
message="WebSearch request completed" if results else "WebSearch returned no results",
|
||||||
|
category="ai_tool",
|
||||||
|
level="info" if results else "warning",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
**context,
|
||||||
|
"status": "success" if results else "empty",
|
||||||
|
"result_count": len(results),
|
||||||
|
"duration_ms": int((perf_counter() - started_at) * 1000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="ai_tool.web_search.failed",
|
||||||
|
message="WebSearch request failed",
|
||||||
|
category="ai_tool",
|
||||||
|
level="error",
|
||||||
|
service="ai_tool",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(exc, {**context, "status": "failed", "duration_ms": int((perf_counter() - started_at) * 1000)}),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
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.models.alert import Alert, AlertSeverity, AlertStatus
|
||||||
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
|
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:
|
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_datasources": dict(datasource_counts.most_common(6)),
|
||||||
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
|
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
|
||||||
}
|
}
|
||||||
|
prompt = await get_effective_prompt(db, ALERT_BRIEF_PROMPT_KEY)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
SituationalAnalysisRequest(
|
SituationalAnalysisRequest(
|
||||||
title="告警态势 AI 简报",
|
title="告警态势 AI 简报",
|
||||||
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
|
objective=prompt.prompt,
|
||||||
|
system_prompt=prompt.system_prompt or None,
|
||||||
observations=facts,
|
observations=facts,
|
||||||
constraints=[
|
constraints=[
|
||||||
"明确区分事实、推断与建议。",
|
"明确区分事实、推断与建议。",
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ from app.models.bgp_anomaly import BGPAnomaly
|
|||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.bgp_observation import BGPObservation
|
from app.models.bgp_observation import BGPObservation
|
||||||
from app.schemas.ai import SituationalAnalysisRequest
|
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_collectors import build_bgp_collector_coverage
|
||||||
from app.services.bgp_enrichment import lookup_prefix_geography
|
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:
|
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
||||||
if not counter:
|
if not counter:
|
||||||
@@ -243,12 +246,15 @@ async def build_bgp_brief_request(
|
|||||||
for prefix, item in list(prefix_geographies.items())[:8]
|
for prefix, item in list(prefix_geographies.items())[:8]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
prompt = await get_effective_prompt(db, BGP_BRIEF_PROMPT_KEY)
|
||||||
|
|
||||||
return SituationalAnalysisRequest(
|
return SituationalAnalysisRequest(
|
||||||
title="BGP 态势 AI 简报",
|
title="BGP 态势 AI 简报",
|
||||||
objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
|
objective=prompt.prompt,
|
||||||
|
system_prompt=prompt.system_prompt or None,
|
||||||
observations=observations_lines,
|
observations=observations_lines,
|
||||||
constraints=[
|
constraints=[
|
||||||
|
"直接输出中文 Markdown 简报正文,不要输出英文写作计划、提示词复述、字段说明或元评论。",
|
||||||
"明确区分事实、推断与建议。",
|
"明确区分事实、推断与建议。",
|
||||||
"优先指出需要立即关注的高严重度 incident 或异常模式。",
|
"优先指出需要立即关注的高严重度 incident 或异常模式。",
|
||||||
"需要单独指出哪些区域结论来自 prefix geography / affected regions,哪些可能受 collector coverage 偏差影响。",
|
"需要单独指出哪些区域结论来自 prefix geography / affected regions,哪些可能受 collector coverage 偏差影响。",
|
||||||
|
|||||||
@@ -291,9 +291,27 @@ def collect_bgp_collector_location_candidates(
|
|||||||
site: str | None = None,
|
site: str | None = None,
|
||||||
operator: str | None = None,
|
operator: str | None = None,
|
||||||
) -> tuple[list[LocationCandidate], list[str]]:
|
) -> 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 "")
|
stored = get_bgp_collector_location_dict(collector or "")
|
||||||
name = coerce_str(collector) or None
|
name = coerce_str(collector) or None
|
||||||
query = LocationQuery(
|
return LocationQuery(
|
||||||
name=name,
|
name=name,
|
||||||
aliases=tuple(filter(None, (collector,))),
|
aliases=tuple(filter(None, (collector,))),
|
||||||
city=coerce_str(city or stored.get("city")) or None,
|
city=coerce_str(city or stored.get("city")) or None,
|
||||||
@@ -301,6 +319,6 @@ def collect_bgp_collector_location_candidates(
|
|||||||
extra={
|
extra={
|
||||||
"site": coerce_str(site or stored.get("site")),
|
"site": coerce_str(site or stored.get("site")),
|
||||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||||
|
"collector": coerce_str(collector),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from collections import Counter, defaultdict
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.enums import BGPStatus
|
||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
|
|
||||||
|
|
||||||
@@ -127,7 +128,7 @@ def detect_origin_change_anomalies(
|
|||||||
source=source,
|
source=source,
|
||||||
anomaly_type=anomaly_type,
|
anomaly_type=anomaly_type,
|
||||||
severity=severity,
|
severity=severity,
|
||||||
status="active",
|
status=BGPStatus.ACTIVE.value,
|
||||||
entity_key=f"{anomaly_type}:{prefix}:{new_origin}",
|
entity_key=f"{anomaly_type}:{prefix}:{new_origin}",
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
origin_asn=sorted(historic)[0] if historic else None,
|
origin_asn=sorted(historic)[0] if historic else None,
|
||||||
@@ -197,7 +198,7 @@ def detect_more_specific_burst_anomalies(
|
|||||||
source=source,
|
source=source,
|
||||||
anomaly_type="more_specific_burst",
|
anomaly_type="more_specific_burst",
|
||||||
severity="high",
|
severity="high",
|
||||||
status="active",
|
status=BGPStatus.ACTIVE.value,
|
||||||
entity_key=f"more_specific_burst:{root_prefix}:{len(unique_prefixes)}:{len(related_collectors)}",
|
entity_key=f"more_specific_burst:{root_prefix}:{len(unique_prefixes)}:{len(related_collectors)}",
|
||||||
prefix=sample.get("prefix"),
|
prefix=sample.get("prefix"),
|
||||||
origin_asn=sample.get("origin_asn"),
|
origin_asn=sample.get("origin_asn"),
|
||||||
@@ -267,7 +268,7 @@ def detect_mass_withdrawal_anomalies(
|
|||||||
source=source,
|
source=source,
|
||||||
anomaly_type="mass_withdrawal",
|
anomaly_type="mass_withdrawal",
|
||||||
severity=severity,
|
severity=severity,
|
||||||
status="active",
|
status=BGPStatus.ACTIVE.value,
|
||||||
entity_key=f"mass_withdrawal:{prefix}:{origin_asn}:{len(related_collectors)}:{count}",
|
entity_key=f"mass_withdrawal:{prefix}:{origin_asn}:{len(related_collectors)}:{count}",
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
origin_asn=origin_asn,
|
origin_asn=origin_asn,
|
||||||
@@ -354,7 +355,7 @@ def detect_route_leak_anomalies(
|
|||||||
source=source,
|
source=source,
|
||||||
anomaly_type="route_leak_candidate",
|
anomaly_type="route_leak_candidate",
|
||||||
severity="high" if max_path_length >= dominant_length + 3 else "medium",
|
severity="high" if max_path_length >= dominant_length + 3 else "medium",
|
||||||
status="active",
|
status=BGPStatus.ACTIVE.value,
|
||||||
entity_key=f"route_leak_candidate:{prefix}:{max_path_length}:{len(related_collectors)}",
|
entity_key=f"route_leak_candidate:{prefix}:{max_path_length}:{len(related_collectors)}",
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
origin_asn=sample_metadata.get("origin_asn"),
|
origin_asn=sample_metadata.get("origin_asn"),
|
||||||
@@ -435,7 +436,7 @@ def detect_path_flap_anomalies(
|
|||||||
source=source,
|
source=source,
|
||||||
anomaly_type="path_flap",
|
anomaly_type="path_flap",
|
||||||
severity=severity,
|
severity=severity,
|
||||||
status="active",
|
status=BGPStatus.ACTIVE.value,
|
||||||
entity_key=f"path_flap:{prefix}:{transitions}:{len(distinct_paths)}",
|
entity_key=f"path_flap:{prefix}:{transitions}:{len(distinct_paths)}",
|
||||||
prefix=prefix,
|
prefix=prefix,
|
||||||
origin_asn=sample_metadata.get("origin_asn"),
|
origin_asn=sample_metadata.get("origin_asn"),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.collected_data_fields import get_record_field
|
from app.core.collected_data_fields import get_record_field
|
||||||
|
from app.core.enums import BGPStatus
|
||||||
from app.models.bgp_anomaly import BGPAnomaly
|
from app.models.bgp_anomaly import BGPAnomaly
|
||||||
from app.models.bgp_incident import BGPIncident
|
from app.models.bgp_incident import BGPIncident
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
@@ -290,7 +291,7 @@ async def create_bgp_incidents_for_anomalies(
|
|||||||
existing.title = title
|
existing.title = title
|
||||||
existing.summary = summary
|
existing.summary = summary
|
||||||
existing.severity = severity
|
existing.severity = severity
|
||||||
existing.status = "active"
|
existing.status = BGPStatus.ACTIVE.value
|
||||||
existing.confidence = confidence
|
existing.confidence = confidence
|
||||||
existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC)
|
existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC)
|
||||||
existing.ended_at = None
|
existing.ended_at = None
|
||||||
@@ -313,7 +314,7 @@ async def create_bgp_incidents_for_anomalies(
|
|||||||
title=title,
|
title=title,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
severity=severity,
|
severity=severity,
|
||||||
status="active",
|
status=BGPStatus.ACTIVE.value,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
started_at=primary.started_at or datetime.now(UTC),
|
started_at=primary.started_at or datetime.now(UTC),
|
||||||
affected_prefixes=prefixes,
|
affected_prefixes=prefixes,
|
||||||
|
|||||||
117
backend/app/services/business_logs.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.core.logging import PlanetLoggerAdapter, sanitize_log_value
|
||||||
|
from app.core.request_context import get_request_id
|
||||||
|
from app.services.persistent_logs import record_system_log
|
||||||
|
|
||||||
|
|
||||||
|
LEVEL_METHODS = {
|
||||||
|
"debug": "debug_event",
|
||||||
|
"info": "info_event",
|
||||||
|
"warning": "warning_event",
|
||||||
|
"error": "error_event",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_business_level(level: str | None) -> str:
|
||||||
|
normalized = str(level or "info").strip().lower()
|
||||||
|
if normalized in {"warn", "warning"}:
|
||||||
|
return "warning"
|
||||||
|
if normalized in {"err", "error", "critical", "fatal"}:
|
||||||
|
return "error"
|
||||||
|
if normalized == "debug":
|
||||||
|
return "debug"
|
||||||
|
return "info"
|
||||||
|
|
||||||
|
|
||||||
|
def build_business_context(
|
||||||
|
context: Mapping[str, Any] | None = None,
|
||||||
|
**fields: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = dict(context or {})
|
||||||
|
for key, value in fields.items():
|
||||||
|
if value is not None:
|
||||||
|
payload[key] = value
|
||||||
|
return sanitize_log_value(payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def emit_business_log(
|
||||||
|
logger: PlanetLoggerAdapter,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
message: str,
|
||||||
|
category: str,
|
||||||
|
level: str = "info",
|
||||||
|
source: str = "backend",
|
||||||
|
service: str | None = None,
|
||||||
|
module: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
context: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_level = normalize_business_level(level)
|
||||||
|
safe_context = build_business_context(context)
|
||||||
|
log_method = getattr(logger, LEVEL_METHODS[normalized_level])
|
||||||
|
log_method(message, event=event, context=safe_context)
|
||||||
|
await record_system_log(
|
||||||
|
source=source,
|
||||||
|
level=normalized_level,
|
||||||
|
message=message,
|
||||||
|
service=service,
|
||||||
|
module=module,
|
||||||
|
event=event,
|
||||||
|
request_id=request_id or get_request_id(),
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
context=safe_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def emit_business_log_background(
|
||||||
|
logger: PlanetLoggerAdapter,
|
||||||
|
*,
|
||||||
|
event: str,
|
||||||
|
message: str,
|
||||||
|
category: str,
|
||||||
|
level: str = "info",
|
||||||
|
source: str = "backend",
|
||||||
|
service: str | None = None,
|
||||||
|
module: str | None = None,
|
||||||
|
request_id: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
context: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
normalized_level = normalize_business_level(level)
|
||||||
|
safe_context = build_business_context(context)
|
||||||
|
log_method = getattr(logger, LEVEL_METHODS[normalized_level])
|
||||||
|
log_method(message, event=event, context=safe_context)
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
loop.create_task(
|
||||||
|
record_system_log(
|
||||||
|
source=source,
|
||||||
|
level=normalized_level,
|
||||||
|
message=message,
|
||||||
|
service=service,
|
||||||
|
module=module,
|
||||||
|
event=event,
|
||||||
|
request_id=request_id or get_request_id(),
|
||||||
|
user_id=user_id,
|
||||||
|
category=category,
|
||||||
|
context=safe_context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exception_context(exc: BaseException, context: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
return build_business_context(
|
||||||
|
context,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
@@ -36,6 +36,7 @@ from app.services.collectors.iptoasn import IPtoASNPrefixGeoCollector
|
|||||||
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
from app.services.collectors.opengeofeed import OpenGeoFeedPrefixGeoCollector
|
||||||
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
from app.services.collectors.nro_delegated import NRODelegatedPrefixGeoCollector
|
||||||
from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
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.aisstream import AISStreamCollector
|
||||||
from app.services.collectors.vessel_ais import VesselAISCollector
|
from app.services.collectors.vessel_ais import VesselAISCollector
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ collector_registry.register(IPtoASNPrefixGeoCollector())
|
|||||||
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
collector_registry.register(OpenGeoFeedPrefixGeoCollector())
|
||||||
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
collector_registry.register(NRODelegatedPrefixGeoCollector())
|
||||||
collector_registry.register(NewsLiveStreamsCollector())
|
collector_registry.register(NewsLiveStreamsCollector())
|
||||||
|
collector_registry.register(MediaNewsArchiveCollector())
|
||||||
collector_registry.register(VesselAISCollector())
|
collector_registry.register(VesselAISCollector())
|
||||||
collector_registry.register(AISStreamCollector())
|
collector_registry.register(AISStreamCollector())
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ __all__ = [
|
|||||||
"OpenGeoFeedPrefixGeoCollector",
|
"OpenGeoFeedPrefixGeoCollector",
|
||||||
"NRODelegatedPrefixGeoCollector",
|
"NRODelegatedPrefixGeoCollector",
|
||||||
"NewsLiveStreamsCollector",
|
"NewsLiveStreamsCollector",
|
||||||
|
"MediaNewsArchiveCollector",
|
||||||
"VesselAISCollector",
|
"VesselAISCollector",
|
||||||
"AISStreamCollector",
|
"AISStreamCollector",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class AISStreamCollector(BaseCollector):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import websockets
|
import websockets
|
||||||
except ImportError as exc:
|
except ImportError:
|
||||||
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
|
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
|
||||||
|
|
||||||
start_time = datetime.now(UTC)
|
start_time = datetime.now(UTC)
|
||||||
@@ -322,6 +322,21 @@ class AISStreamCollector(BaseCollector):
|
|||||||
last_success_at=now if data else None,
|
last_success_at=now if data else None,
|
||||||
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||||
)
|
)
|
||||||
|
if snapshot_id is not None:
|
||||||
|
from app.models.data_snapshot import DataSnapshot
|
||||||
|
|
||||||
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
|
if snapshot:
|
||||||
|
snapshot.record_count = records_added
|
||||||
|
snapshot.status = "success"
|
||||||
|
snapshot.completed_at = now
|
||||||
|
snapshot.summary = {
|
||||||
|
"created": records_added,
|
||||||
|
"updated": 0,
|
||||||
|
"unchanged": 0,
|
||||||
|
"deleted": 0,
|
||||||
|
"storage": "ais_raw_observations",
|
||||||
|
}
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self.update_progress(records_added, force=True)
|
await self.update_progress(records_added, force=True)
|
||||||
return records_added
|
return records_added
|
||||||
|
|||||||
@@ -4,15 +4,22 @@ import asyncio
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from time import perf_counter
|
||||||
|
from urllib.parse import urlparse
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.collected_data_fields import build_dynamic_metadata, get_record_field
|
from app.core.collected_data_fields import build_dynamic_metadata, get_record_field
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.countries import normalize_country
|
from app.core.countries import normalize_country
|
||||||
|
from app.core.enums import JobStatus, SnapshotStatus
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
from app.core.websocket.broadcaster import broadcaster
|
from app.core.websocket.broadcaster import broadcaster
|
||||||
|
from app.services.business_logs import emit_business_log, exception_context
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__, service="collector")
|
||||||
|
|
||||||
|
|
||||||
class BaseCollector(ABC):
|
class BaseCollector(ABC):
|
||||||
@@ -31,6 +38,7 @@ class BaseCollector(ABC):
|
|||||||
self._datasource_id = 1
|
self._datasource_id = 1
|
||||||
self._resolved_url: Optional[str] = None
|
self._resolved_url: Optional[str] = None
|
||||||
self._last_broadcast_progress: Optional[int] = None
|
self._last_broadcast_progress: Optional[int] = None
|
||||||
|
self._last_save_summary: dict[str, int] = {}
|
||||||
|
|
||||||
async def resolve_url(self, db: AsyncSession) -> None:
|
async def resolve_url(self, db: AsyncSession) -> None:
|
||||||
from app.core.data_sources import get_data_sources_config
|
from app.core.data_sources import get_data_sources_config
|
||||||
@@ -186,7 +194,7 @@ class BaseCollector(ABC):
|
|||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(DataSnapshot)
|
select(DataSnapshot)
|
||||||
.where(DataSnapshot.source == self.name, DataSnapshot.is_current == True)
|
.where(DataSnapshot.source == self.name, DataSnapshot.is_current.is_(True))
|
||||||
.order_by(DataSnapshot.completed_at.desc().nullslast(), DataSnapshot.id.desc())
|
.order_by(DataSnapshot.completed_at.desc().nullslast(), DataSnapshot.id.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -230,7 +238,7 @@ class BaseCollector(ABC):
|
|||||||
snapshot = await db.get(DataSnapshot, snapshot_id)
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
if snapshot:
|
if snapshot:
|
||||||
parent_snapshot_id = snapshot.parent_snapshot_id
|
parent_snapshot_id = snapshot.parent_snapshot_id
|
||||||
snapshot.status = "cancelled"
|
snapshot.status = SnapshotStatus.CANCELLED.value
|
||||||
snapshot.is_current = False
|
snapshot.is_current = False
|
||||||
snapshot.completed_at = datetime.now(UTC)
|
snapshot.completed_at = datetime.now(UTC)
|
||||||
summary = dict(snapshot.summary or {})
|
summary = dict(snapshot.summary or {})
|
||||||
@@ -272,19 +280,39 @@ class BaseCollector(ABC):
|
|||||||
from app.models.data_snapshot import DataSnapshot
|
from app.models.data_snapshot import DataSnapshot
|
||||||
|
|
||||||
start_time = datetime.now(UTC)
|
start_time = datetime.now(UTC)
|
||||||
|
started_at = perf_counter()
|
||||||
datasource_id = getattr(self, "_datasource_id", 1)
|
datasource_id = getattr(self, "_datasource_id", 1)
|
||||||
snapshot_id: Optional[int] = None
|
snapshot_id: Optional[int] = None
|
||||||
|
|
||||||
if not collector_registry.is_active(self.name):
|
if not collector_registry.is_active(self.name):
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.run.skipped_disabled",
|
||||||
|
"Collector skipped because it is disabled",
|
||||||
|
level="info",
|
||||||
|
context={"status": "skipped", "reason": "disabled"},
|
||||||
|
)
|
||||||
return {"status": "skipped", "reason": "Collector is disabled"}
|
return {"status": "skipped", "reason": "Collector is disabled"}
|
||||||
|
|
||||||
task = CollectionTask(
|
task = self._current_task if isinstance(self._current_task, CollectionTask) else None
|
||||||
datasource_id=datasource_id,
|
if task is None:
|
||||||
status="running",
|
task = CollectionTask(
|
||||||
phase="queued",
|
datasource_id=datasource_id,
|
||||||
started_at=start_time,
|
source=self.name,
|
||||||
)
|
task_type="collect",
|
||||||
db.add(task)
|
status="running",
|
||||||
|
phase="queued",
|
||||||
|
started_at=start_time,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
else:
|
||||||
|
task.datasource_id = datasource_id
|
||||||
|
task.source = task.source or self.name
|
||||||
|
task.task_type = task.task_type or "collect"
|
||||||
|
task.status = JobStatus.RUNNING.value
|
||||||
|
task.phase = "queued"
|
||||||
|
task.started_at = task.started_at or start_time
|
||||||
|
task.completed_at = None
|
||||||
|
task.error_message = None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
task_id = task.id
|
task_id = task.id
|
||||||
|
|
||||||
@@ -294,25 +322,78 @@ class BaseCollector(ABC):
|
|||||||
|
|
||||||
await self.resolve_url(db)
|
await self.resolve_url(db)
|
||||||
await self._publish_task_update(force=True)
|
await self._publish_task_update(force=True)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.run.started",
|
||||||
|
"Collector run started",
|
||||||
|
context={"status": "running", "task_id": task_id},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
phase_started_at = perf_counter()
|
||||||
await self.set_phase("fetching", message="正在拉取原始数据")
|
await self.set_phase("fetching", message="正在拉取原始数据")
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.phase.fetching.start",
|
||||||
|
"Collector fetch phase started",
|
||||||
|
context={"task_id": task_id, "snapshot_id": snapshot_id},
|
||||||
|
)
|
||||||
raw_data = await self.fetch()
|
raw_data = await self.fetch()
|
||||||
task.total_records = len(raw_data)
|
task.total_records = len(raw_data)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self._publish_task_update(force=True)
|
await self._publish_task_update(force=True)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.phase.fetching.success",
|
||||||
|
"Collector fetch phase completed",
|
||||||
|
context={
|
||||||
|
"task_id": task_id,
|
||||||
|
"raw_count": len(raw_data),
|
||||||
|
"duration_ms": self._duration_ms(phase_started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if self.fail_on_empty and not raw_data:
|
if self.fail_on_empty and not raw_data:
|
||||||
raise RuntimeError(f"Collector {self.name} returned no data")
|
raise RuntimeError(f"Collector {self.name} returned no data")
|
||||||
|
|
||||||
|
phase_started_at = perf_counter()
|
||||||
await self.set_phase("transforming", message="正在转换采集数据")
|
await self.set_phase("transforming", message="正在转换采集数据")
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.phase.transforming.start",
|
||||||
|
"Collector transform phase started",
|
||||||
|
context={"task_id": task_id, "raw_count": len(raw_data)},
|
||||||
|
)
|
||||||
data = self.transform(raw_data)
|
data = self.transform(raw_data)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.phase.transforming.success",
|
||||||
|
"Collector transform phase completed",
|
||||||
|
context={
|
||||||
|
"task_id": task_id,
|
||||||
|
"raw_count": len(raw_data),
|
||||||
|
"transformed_count": len(data),
|
||||||
|
"duration_ms": self._duration_ms(phase_started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
snapshot_id = await self._create_snapshot(db, task_id, data, start_time)
|
snapshot_id = await self._create_snapshot(db, task_id, data, start_time)
|
||||||
|
|
||||||
|
phase_started_at = perf_counter()
|
||||||
await self.set_phase("saving", message="正在保存采集数据")
|
await self.set_phase("saving", message="正在保存采集数据")
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.phase.saving.start",
|
||||||
|
"Collector save phase started",
|
||||||
|
context={"task_id": task_id, "snapshot_id": snapshot_id, "transformed_count": len(data)},
|
||||||
|
)
|
||||||
records_count = await self._save_data(db, data, task_id=task_id, snapshot_id=snapshot_id)
|
records_count = await self._save_data(db, data, task_id=task_id, snapshot_id=snapshot_id)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.phase.saving.success",
|
||||||
|
"Collector save phase completed",
|
||||||
|
context={
|
||||||
|
"task_id": task_id,
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"saved_count": records_count,
|
||||||
|
**self._last_save_summary,
|
||||||
|
"duration_ms": self._duration_ms(phase_started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
task.status = "success"
|
task.status = JobStatus.SUCCESS.value
|
||||||
task.phase = "completed"
|
task.phase = "completed"
|
||||||
task.phase_progress = 100.0
|
task.phase_progress = 100.0
|
||||||
task.phase_message = "采集完成"
|
task.phase_message = "采集完成"
|
||||||
@@ -324,6 +405,20 @@ class BaseCollector(ABC):
|
|||||||
task.completed_at = datetime.now(UTC)
|
task.completed_at = datetime.now(UTC)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self._publish_task_update(force=True)
|
await self._publish_task_update(force=True)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.run.completed",
|
||||||
|
"Collector run completed",
|
||||||
|
context={
|
||||||
|
"status": "success",
|
||||||
|
"task_id": task_id,
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"raw_count": len(raw_data),
|
||||||
|
"transformed_count": len(data),
|
||||||
|
"saved_count": records_count,
|
||||||
|
**self._last_save_summary,
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -333,7 +428,7 @@ class BaseCollector(ABC):
|
|||||||
}
|
}
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
task.status = "cancelled"
|
task.status = JobStatus.CANCELLED.value
|
||||||
task.phase = "cancelled"
|
task.phase = "cancelled"
|
||||||
task.phase_message = "采集已取消"
|
task.phase_message = "采集已取消"
|
||||||
task.error_message = "Collection cancelled by operator and rolled back"
|
task.error_message = "Collection cancelled by operator and rolled back"
|
||||||
@@ -347,10 +442,21 @@ class BaseCollector(ABC):
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self._publish_task_update(force=True)
|
await self._publish_task_update(force=True)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.run.cancelled",
|
||||||
|
"Collector run cancelled",
|
||||||
|
level="warning",
|
||||||
|
context={
|
||||||
|
"status": "cancelled",
|
||||||
|
"task_id": task_id,
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
task.status = "failed"
|
task.status = JobStatus.FAILED.value
|
||||||
task.phase = "failed"
|
task.phase = "failed"
|
||||||
task.phase_message = str(e)
|
task.phase_message = str(e)
|
||||||
task.error_message = str(e)
|
task.error_message = str(e)
|
||||||
@@ -358,11 +464,25 @@ class BaseCollector(ABC):
|
|||||||
if snapshot_id is not None:
|
if snapshot_id is not None:
|
||||||
snapshot = await db.get(DataSnapshot, snapshot_id)
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
if snapshot:
|
if snapshot:
|
||||||
snapshot.status = "failed"
|
snapshot.status = SnapshotStatus.FAILED.value
|
||||||
snapshot.completed_at = datetime.now(UTC)
|
snapshot.completed_at = datetime.now(UTC)
|
||||||
snapshot.summary = {"error": str(e)}
|
snapshot.summary = {"error": str(e)}
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self._publish_task_update(force=True)
|
await self._publish_task_update(force=True)
|
||||||
|
await self._log_collection_event(
|
||||||
|
"collector.run.failed",
|
||||||
|
"Collector run failed",
|
||||||
|
level="error",
|
||||||
|
context=exception_context(
|
||||||
|
e,
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"task_id": task_id,
|
||||||
|
"snapshot_id": snapshot_id,
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
@@ -383,12 +503,13 @@ class BaseCollector(ABC):
|
|||||||
from app.models.data_snapshot import DataSnapshot
|
from app.models.data_snapshot import DataSnapshot
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
|
self._last_save_summary = {"created": 0, "updated": 0, "unchanged": 0, "deleted": 0}
|
||||||
if snapshot_id is not None:
|
if snapshot_id is not None:
|
||||||
snapshot = await db.get(DataSnapshot, snapshot_id)
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
if snapshot:
|
if snapshot:
|
||||||
snapshot.record_count = 0
|
snapshot.record_count = 0
|
||||||
snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0}
|
snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0}
|
||||||
snapshot.status = "success"
|
snapshot.status = SnapshotStatus.SUCCESS.value
|
||||||
snapshot.completed_at = datetime.now(UTC)
|
snapshot.completed_at = datetime.now(UTC)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return 0
|
return 0
|
||||||
@@ -405,7 +526,7 @@ class BaseCollector(ABC):
|
|||||||
select(CollectedData)
|
select(CollectedData)
|
||||||
.where(
|
.where(
|
||||||
CollectedData.source == self.name,
|
CollectedData.source == self.name,
|
||||||
CollectedData.is_current == True,
|
CollectedData.is_current.is_(True),
|
||||||
)
|
)
|
||||||
.order_by(CollectedData.entity_key.asc(), CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
.order_by(CollectedData.entity_key.asc(), CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
||||||
)
|
)
|
||||||
@@ -521,7 +642,7 @@ class BaseCollector(ABC):
|
|||||||
snapshot = await db.get(DataSnapshot, snapshot_id)
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
if snapshot:
|
if snapshot:
|
||||||
snapshot.record_count = records_added
|
snapshot.record_count = records_added
|
||||||
snapshot.status = "success"
|
snapshot.status = SnapshotStatus.SUCCESS.value
|
||||||
snapshot.completed_at = datetime.now(UTC)
|
snapshot.completed_at = datetime.now(UTC)
|
||||||
snapshot.summary = {
|
snapshot.summary = {
|
||||||
"created": created_count,
|
"created": created_count,
|
||||||
@@ -529,11 +650,51 @@ class BaseCollector(ABC):
|
|||||||
"unchanged": unchanged_count,
|
"unchanged": unchanged_count,
|
||||||
"deleted": len(deleted_keys),
|
"deleted": len(deleted_keys),
|
||||||
}
|
}
|
||||||
|
self._last_save_summary = {
|
||||||
|
"created": created_count,
|
||||||
|
"updated": updated_count,
|
||||||
|
"unchanged": unchanged_count,
|
||||||
|
"deleted": len(deleted_keys),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
self._last_save_summary = {
|
||||||
|
"created": created_count,
|
||||||
|
"updated": updated_count,
|
||||||
|
"unchanged": unchanged_count,
|
||||||
|
"deleted": 0,
|
||||||
|
}
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await self.update_progress(len(data), force=True)
|
await self.update_progress(len(data), force=True)
|
||||||
return records_added
|
return records_added
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _duration_ms(started_at: float) -> int:
|
||||||
|
return int((perf_counter() - started_at) * 1000)
|
||||||
|
|
||||||
|
async def _log_collection_event(
|
||||||
|
self,
|
||||||
|
event: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
level: str = "info",
|
||||||
|
context: Dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event=event,
|
||||||
|
message=message,
|
||||||
|
category="collector",
|
||||||
|
level=level,
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
**(context or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int:
|
async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int:
|
||||||
"""Save data to database (legacy method, use _save_data instead)"""
|
"""Save data to database (legacy method, use _save_data instead)"""
|
||||||
return await self._save_data(db, data)
|
return await self._save_data(db, data)
|
||||||
@@ -546,10 +707,65 @@ class HTTPCollector(BaseCollector):
|
|||||||
headers: Dict[str, str] = {}
|
headers: Dict[str, str] = {}
|
||||||
|
|
||||||
async def fetch(self) -> List[Dict[str, Any]]:
|
async def fetch(self) -> List[Dict[str, Any]]:
|
||||||
|
started_at = perf_counter()
|
||||||
|
request_host = urlparse(self.base_url).netloc
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.http.fetch.start",
|
||||||
|
message="Collector HTTP request started",
|
||||||
|
category="collector",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"url_host": request_host,
|
||||||
|
},
|
||||||
|
)
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
response = await client.get(self.base_url, headers=self.headers)
|
try:
|
||||||
response.raise_for_status()
|
response = await client.get(self.base_url, headers=self.headers)
|
||||||
return self.parse_response(response.json())
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
parsed = self.parse_response(payload)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.http.fetch.success",
|
||||||
|
message="Collector HTTP request completed",
|
||||||
|
category="collector",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"url_host": request_host,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"response_bytes": len(response.content or b""),
|
||||||
|
"parsed_count": len(parsed),
|
||||||
|
"duration_ms": BaseCollector._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.http.fetch.failed",
|
||||||
|
message="Collector HTTP request failed",
|
||||||
|
category="collector",
|
||||||
|
level="error",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(
|
||||||
|
exc,
|
||||||
|
{
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"url_host": request_host,
|
||||||
|
"duration_ms": BaseCollector._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
def parse_response(self, response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from app.services.bgp_collector_locations import (
|
|||||||
)
|
)
|
||||||
from app.services.bgp_event_locations import resolve_bgp_event_geo_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_incidents import create_bgp_incidents_for_anomalies
|
||||||
|
from app.services.earth_layer_cache import invalidate_earth_layer_cache_for_source
|
||||||
from app.services.bgp_detectors import (
|
from app.services.bgp_detectors import (
|
||||||
detect_mass_withdrawal_anomalies,
|
detect_mass_withdrawal_anomalies,
|
||||||
detect_more_specific_burst_anomalies,
|
detect_more_specific_burst_anomalies,
|
||||||
@@ -223,6 +224,8 @@ async def save_bgp_observations_for_batch(
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
for source in {"ris_live_bgp", "bgpstream_bgp"}:
|
||||||
|
invalidate_earth_layer_cache_for_source(source)
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,45 @@
|
|||||||
"""CelesTrak TLE Collector
|
"""CelesTrak TLE Collector.
|
||||||
|
|
||||||
Collects satellite TLE (Two-Line Element) data from CelesTrak.org.
|
Collects the full active satellite GP element set from CelesTrak.
|
||||||
Free, no authentication required.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, List
|
from pathlib import Path
|
||||||
|
from time import perf_counter
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from urllib.parse import urlencode, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||||
|
from app.services.business_logs import emit_business_log, exception_context
|
||||||
from app.services.collectors.base import BaseCollector
|
from app.services.collectors.base import BaseCollector
|
||||||
|
from app.services.collectors.downloads import DownloadHTTPStatusError, ResumableFileDownloader
|
||||||
|
|
||||||
|
|
||||||
|
logger = get_logger(__name__, service="collector")
|
||||||
|
ACTIVE_GROUP = "active"
|
||||||
|
FALLBACK_GROUPS = (
|
||||||
|
"starlink",
|
||||||
|
"gps-ops",
|
||||||
|
"galileo",
|
||||||
|
"glo-ops",
|
||||||
|
"beidou",
|
||||||
|
"geo",
|
||||||
|
"iridium-next",
|
||||||
|
"stations",
|
||||||
|
"visual",
|
||||||
|
"weather",
|
||||||
|
"science",
|
||||||
|
"cubesat",
|
||||||
|
"amateur",
|
||||||
|
"last-30-days",
|
||||||
|
)
|
||||||
|
FETCH_RETRY_ATTEMPTS = 3
|
||||||
|
FETCH_RETRY_BASE_DELAY_SECONDS = 0.8
|
||||||
|
CELESTRAK_NOT_UPDATED_MARKER = "GP data has not updated since your last successful"
|
||||||
|
|
||||||
|
|
||||||
class CelesTrakTLECollector(BaseCollector):
|
class CelesTrakTLECollector(BaseCollector):
|
||||||
@@ -18,55 +48,360 @@ class CelesTrakTLECollector(BaseCollector):
|
|||||||
module = "L3"
|
module = "L3"
|
||||||
frequency_hours = 24
|
frequency_hours = 24
|
||||||
data_type = "satellite_tle"
|
data_type = "satellite_tle"
|
||||||
|
_downloader = ResumableFileDownloader(
|
||||||
|
cache_namespace="celestrak",
|
||||||
|
default_accept="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def base_url(self) -> str:
|
def base_url(self) -> str:
|
||||||
return self._resolved_url or ""
|
return self._resolved_url or ""
|
||||||
|
|
||||||
|
def _active_url(self) -> str:
|
||||||
|
return self._group_url(ACTIVE_GROUP)
|
||||||
|
|
||||||
|
def _group_url(self, group: str) -> str:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("CelesTrak base URL is not configured")
|
||||||
|
return f"{self.base_url}?{urlencode({'GROUP': group, 'FORMAT': 'json'})}"
|
||||||
|
|
||||||
async def fetch(self) -> List[Dict[str, Any]]:
|
async def fetch(self) -> List[Dict[str, Any]]:
|
||||||
satellite_groups = [
|
url = self._active_url()
|
||||||
"starlink",
|
last_error: Exception | None = None
|
||||||
"gps-ops",
|
|
||||||
"galileo",
|
|
||||||
"glonass",
|
|
||||||
"beidou",
|
|
||||||
"leo",
|
|
||||||
"geo",
|
|
||||||
"iridium-next",
|
|
||||||
]
|
|
||||||
|
|
||||||
all_satellites = []
|
async with httpx.AsyncClient(timeout=180.0, follow_redirects=True) as client:
|
||||||
|
for attempt in range(1, FETCH_RETRY_ATTEMPTS + 1):
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
started_at = perf_counter()
|
||||||
for group in satellite_groups:
|
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}?GROUP={group}&FORMAT=json"
|
await emit_business_log(
|
||||||
response = await client.get(url)
|
logger,
|
||||||
|
event="collector.celestrak.download.start",
|
||||||
|
message="CelesTrak active satellite download started",
|
||||||
|
category="collector",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"group": ACTIVE_GROUP,
|
||||||
|
"attempt": attempt,
|
||||||
|
"url_host": urlparse(url).netloc,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
body_path = await self._downloader.download_file(
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
extension=".json",
|
||||||
|
accept="application/json",
|
||||||
|
progress_callback=self._report_download_progress,
|
||||||
|
validate_existing=self._validate_json_file,
|
||||||
|
)
|
||||||
|
data = await self._load_downloaded_payload(body_path, url)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.download.success",
|
||||||
|
message="CelesTrak active satellite download completed",
|
||||||
|
category="collector",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"group": ACTIVE_GROUP,
|
||||||
|
"attempt": attempt,
|
||||||
|
"record_count": len(data),
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
except DownloadHTTPStatusError as exc:
|
||||||
|
if self._is_not_updated_response(exc):
|
||||||
|
cached_path = self._downloader.get_cached_file(
|
||||||
|
url,
|
||||||
|
".json",
|
||||||
|
validate_existing=self._validate_json_file,
|
||||||
|
)
|
||||||
|
if cached_path is not None:
|
||||||
|
data = await self._load_downloaded_payload(cached_path, url)
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.download.cached_not_updated",
|
||||||
|
message="CelesTrak active satellite data has not changed; using cached download",
|
||||||
|
category="collector",
|
||||||
|
level="warning",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"group": ACTIVE_GROUP,
|
||||||
|
"attempt": attempt,
|
||||||
|
"record_count": len(data),
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.download.not_updated_no_cache",
|
||||||
|
message="CelesTrak active satellite data has not changed; trying fallback groups",
|
||||||
|
category="collector",
|
||||||
|
level="warning",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(
|
||||||
|
exc,
|
||||||
|
{
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"group": ACTIVE_GROUP,
|
||||||
|
"attempt": attempt,
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return await self._fetch_fallback_groups(client, active_error=exc)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
is_final_attempt = attempt >= FETCH_RETRY_ATTEMPTS
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event=(
|
||||||
|
"collector.celestrak.download.failed"
|
||||||
|
if is_final_attempt
|
||||||
|
else "collector.celestrak.download.retry"
|
||||||
|
),
|
||||||
|
message=(
|
||||||
|
"CelesTrak active satellite download failed"
|
||||||
|
if is_final_attempt
|
||||||
|
else "CelesTrak active satellite download will retry"
|
||||||
|
),
|
||||||
|
category="collector",
|
||||||
|
level="error" if is_final_attempt else "warning",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(
|
||||||
|
exc,
|
||||||
|
{
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"group": ACTIVE_GROUP,
|
||||||
|
"attempt": attempt,
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not is_final_attempt:
|
||||||
|
await asyncio.sleep(FETCH_RETRY_BASE_DELAY_SECONDS * attempt)
|
||||||
|
|
||||||
if response.status_code == 200:
|
raise RuntimeError(f"CelesTrak active satellite download failed after retries: {last_error}")
|
||||||
data = response.json()
|
|
||||||
if isinstance(data, list):
|
|
||||||
for item in data:
|
|
||||||
if isinstance(item, dict):
|
|
||||||
item["_celestrak_group"] = group
|
|
||||||
all_satellites.extend(data)
|
|
||||||
print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"CelesTrak: Error fetching group '{group}': {e}")
|
|
||||||
|
|
||||||
if not all_satellites:
|
async def _fetch_fallback_groups(
|
||||||
return self._get_sample_data()
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
*,
|
||||||
|
active_error: DownloadHTTPStatusError,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
started_at = perf_counter()
|
||||||
|
records_by_norad: dict[str, Dict[str, Any]] = {}
|
||||||
|
group_counts: dict[str, int] = {}
|
||||||
|
|
||||||
print(f"CelesTrak: Total satellites fetched: {len(all_satellites)}")
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.fallback_groups.start",
|
||||||
|
message="CelesTrak fallback group download started",
|
||||||
|
category="collector",
|
||||||
|
level="warning",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"groups": list(FALLBACK_GROUPS),
|
||||||
|
"reason": "active_not_updated_without_cache",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Return raw data - base.run() will call transform()
|
try:
|
||||||
return all_satellites
|
for group in FALLBACK_GROUPS:
|
||||||
|
group_url = self._group_url(group)
|
||||||
|
cached_path = self._downloader.get_cached_file(
|
||||||
|
group_url,
|
||||||
|
".json",
|
||||||
|
validate_existing=self._validate_json_file,
|
||||||
|
)
|
||||||
|
if cached_path is not None:
|
||||||
|
body_path = cached_path
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
body_path = await self._downloader.download_file(
|
||||||
|
client,
|
||||||
|
group_url,
|
||||||
|
extension=".json",
|
||||||
|
accept="application/json",
|
||||||
|
validate_existing=self._validate_json_file,
|
||||||
|
)
|
||||||
|
except DownloadHTTPStatusError as exc:
|
||||||
|
if not self._is_not_updated_response(exc):
|
||||||
|
raise RuntimeError(f"CelesTrak fallback group '{group}' download failed: {exc}") from exc
|
||||||
|
raise RuntimeError(
|
||||||
|
f"CelesTrak fallback group '{group}' has not updated and no local cached copy is available"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
group_records = await self._load_downloaded_payload(
|
||||||
|
body_path,
|
||||||
|
group_url,
|
||||||
|
query_group=group,
|
||||||
|
constellation_group=group,
|
||||||
|
)
|
||||||
|
group_counts[group] = len(group_records)
|
||||||
|
for item in group_records:
|
||||||
|
norad_cat_id = item.get("NORAD_CAT_ID")
|
||||||
|
if norad_cat_id is None:
|
||||||
|
continue
|
||||||
|
records_by_norad.setdefault(str(norad_cat_id), item)
|
||||||
|
except Exception as exc:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.fallback_groups.failed",
|
||||||
|
message="CelesTrak fallback group download failed",
|
||||||
|
category="collector",
|
||||||
|
level="error",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(
|
||||||
|
exc,
|
||||||
|
{
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"groups": list(FALLBACK_GROUPS),
|
||||||
|
"completed_groups": list(group_counts),
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
"CelesTrak active data has not updated since this network's last successful download, "
|
||||||
|
"no active cache is available, and fallback group mode failed. Wait until CelesTrak "
|
||||||
|
"publishes the next GP update, restore the Planet download cache, or use Space-Track."
|
||||||
|
) from active_error
|
||||||
|
|
||||||
|
records = list(records_by_norad.values())
|
||||||
|
if not records:
|
||||||
|
raise RuntimeError("CelesTrak fallback group mode produced no satellite records")
|
||||||
|
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.fallback_groups.success",
|
||||||
|
message="CelesTrak fallback group download completed",
|
||||||
|
category="collector",
|
||||||
|
level="warning",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context={
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"groups": list(FALLBACK_GROUPS),
|
||||||
|
"group_counts": group_counts,
|
||||||
|
"record_count": len(records),
|
||||||
|
"duration_ms": self._duration_ms(started_at),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
async def _load_downloaded_payload(
|
||||||
|
self,
|
||||||
|
body_path: Path,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
query_group: str = ACTIVE_GROUP,
|
||||||
|
constellation_group: str | None = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
data = self._load_active_payload(body_path)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
await self._log_parse_failure(exc)
|
||||||
|
raise
|
||||||
|
for item in data:
|
||||||
|
item["_celestrak_query_group"] = query_group
|
||||||
|
item["_celestrak_source_url"] = url
|
||||||
|
if constellation_group:
|
||||||
|
item["_celestrak_group"] = constellation_group
|
||||||
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_not_updated_response(exc: DownloadHTTPStatusError) -> bool:
|
||||||
|
return exc.status_code == 403 and CELESTRAK_NOT_UPDATED_MARKER in exc.body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _duration_ms(started_at: float) -> int:
|
||||||
|
return int((perf_counter() - started_at) * 1000)
|
||||||
|
|
||||||
|
async def _report_download_progress(self, downloaded: int, total: int | None) -> None:
|
||||||
|
if total and total > 0:
|
||||||
|
await self.update_phase_progress(
|
||||||
|
current=min(downloaded, total),
|
||||||
|
total=total,
|
||||||
|
unit="bytes",
|
||||||
|
message=f"正在下载 CelesTrak active 卫星数据 {downloaded}/{total} bytes",
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_json_file(path: Path) -> bool:
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return False
|
||||||
|
return isinstance(data, list)
|
||||||
|
|
||||||
|
async def _log_parse_failure(self, exc: Exception) -> None:
|
||||||
|
await emit_business_log(
|
||||||
|
logger,
|
||||||
|
event="collector.celestrak.parse.failed",
|
||||||
|
message="CelesTrak active satellite JSON parsing failed",
|
||||||
|
category="collector",
|
||||||
|
level="error",
|
||||||
|
service="collector",
|
||||||
|
module=__name__,
|
||||||
|
context=exception_context(
|
||||||
|
exc,
|
||||||
|
{
|
||||||
|
"collector_name": self.name,
|
||||||
|
"datasource_id": getattr(self, "_datasource_id", None),
|
||||||
|
"group": ACTIVE_GROUP,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_active_payload(self, path: Path) -> List[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
raise RuntimeError(f"CelesTrak active payload is not valid JSON: {exc}") from exc
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise RuntimeError("CelesTrak active payload is not a JSON array")
|
||||||
|
|
||||||
|
records: List[Dict[str, Any]] = []
|
||||||
|
invalid_count = 0
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, dict) and item.get("NORAD_CAT_ID") is not None:
|
||||||
|
records.append(item)
|
||||||
|
else:
|
||||||
|
invalid_count += 1
|
||||||
|
if invalid_count:
|
||||||
|
raise RuntimeError(f"CelesTrak active payload contains {invalid_count} invalid record(s)")
|
||||||
|
if not records:
|
||||||
|
raise RuntimeError("CelesTrak active payload contains no satellite records")
|
||||||
|
return records
|
||||||
|
|
||||||
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
transformed = []
|
transformed = []
|
||||||
for item in raw_data:
|
for item in raw_data:
|
||||||
|
norad_cat_id = item.get("NORAD_CAT_ID")
|
||||||
tle_line1, tle_line2 = build_tle_lines_from_elements(
|
tle_line1, tle_line2 = build_tle_lines_from_elements(
|
||||||
norad_cat_id=item.get("NORAD_CAT_ID"),
|
norad_cat_id=norad_cat_id,
|
||||||
epoch=item.get("EPOCH"),
|
epoch=item.get("EPOCH"),
|
||||||
inclination=item.get("INCLINATION"),
|
inclination=item.get("INCLINATION"),
|
||||||
raan=item.get("RA_OF_ASC_NODE"),
|
raan=item.get("RA_OF_ASC_NODE"),
|
||||||
@@ -75,14 +410,18 @@ class CelesTrakTLECollector(BaseCollector):
|
|||||||
mean_anomaly=item.get("MEAN_ANOMALY"),
|
mean_anomaly=item.get("MEAN_ANOMALY"),
|
||||||
mean_motion=item.get("MEAN_MOTION"),
|
mean_motion=item.get("MEAN_MOTION"),
|
||||||
)
|
)
|
||||||
|
constellation_group = self._infer_constellation_group(item)
|
||||||
|
|
||||||
transformed.append(
|
transformed.append(
|
||||||
{
|
{
|
||||||
|
"source_id": str(norad_cat_id),
|
||||||
"name": item.get("OBJECT_NAME", "Unknown"),
|
"name": item.get("OBJECT_NAME", "Unknown"),
|
||||||
"reference_date": item.get("EPOCH", ""),
|
"reference_date": item.get("EPOCH", ""),
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"constellation_group": item.get("_celestrak_group"),
|
"constellation_group": constellation_group,
|
||||||
"norad_cat_id": item.get("NORAD_CAT_ID"),
|
"celestrak_query_group": item.get("_celestrak_query_group") or ACTIVE_GROUP,
|
||||||
|
"celestrak_source_url": item.get("_celestrak_source_url"),
|
||||||
|
"norad_cat_id": norad_cat_id,
|
||||||
"international_designator": item.get("OBJECT_ID"),
|
"international_designator": item.get("OBJECT_ID"),
|
||||||
"epoch": item.get("EPOCH"),
|
"epoch": item.get("EPOCH"),
|
||||||
"mean_motion": item.get("MEAN_MOTION"),
|
"mean_motion": item.get("MEAN_MOTION"),
|
||||||
@@ -105,6 +444,19 @@ class CelesTrakTLECollector(BaseCollector):
|
|||||||
)
|
)
|
||||||
return transformed
|
return transformed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_constellation_group(item: Dict[str, Any]) -> str | None:
|
||||||
|
explicit_group = str(item.get("_celestrak_group") or "").strip().lower()
|
||||||
|
if explicit_group and explicit_group != ACTIVE_GROUP:
|
||||||
|
return explicit_group
|
||||||
|
|
||||||
|
name = str(item.get("OBJECT_NAME") or "").strip().upper()
|
||||||
|
if name.startswith("STARLINK"):
|
||||||
|
return "starlink"
|
||||||
|
if name.startswith("IRIDIUM"):
|
||||||
|
return "iridium-next"
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_sample_data(self) -> List[Dict[str, Any]]:
|
def _get_sample_data(self) -> List[Dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,6 +17,31 @@ ProgressCallback = Callable[[int, int | None], Awaitable[None]]
|
|||||||
ValidateCallback = Callable[[Path], bool]
|
ValidateCallback = Callable[[Path], bool]
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadHTTPStatusError(RuntimeError):
|
||||||
|
"""HTTP status error that keeps the upstream response body for caller-specific handling."""
|
||||||
|
|
||||||
|
def __init__(self, *, url: str, status_code: int, body: str) -> None:
|
||||||
|
self.url = url
|
||||||
|
self.status_code = status_code
|
||||||
|
self.body = body
|
||||||
|
preview = body.strip().replace("\r", " ").replace("\n", " ")[:240]
|
||||||
|
suffix = f": {preview}" if preview else ""
|
||||||
|
super().__init__(f"HTTP {status_code} while downloading {url}{suffix}")
|
||||||
|
|
||||||
|
|
||||||
|
def default_download_cache_root() -> Path:
|
||||||
|
configured = os.getenv("PLANET_DOWNLOAD_CACHE_DIR")
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser()
|
||||||
|
planet_cache = os.getenv("PLANET_CACHE_DIR")
|
||||||
|
if planet_cache:
|
||||||
|
return Path(planet_cache).expanduser() / "downloads"
|
||||||
|
xdg_cache = os.getenv("XDG_CACHE_HOME")
|
||||||
|
if xdg_cache:
|
||||||
|
return Path(xdg_cache).expanduser() / "planet" / "downloads"
|
||||||
|
return Path.home() / ".cache" / "planet" / "downloads"
|
||||||
|
|
||||||
|
|
||||||
class ResumableFileDownloader:
|
class ResumableFileDownloader:
|
||||||
"""Download files with cache validators and byte-range resume support."""
|
"""Download files with cache validators and byte-range resume support."""
|
||||||
|
|
||||||
@@ -26,8 +51,9 @@ class ResumableFileDownloader:
|
|||||||
cache_namespace: str,
|
cache_namespace: str,
|
||||||
user_agent: str = "Planet-Intelligence-System/1.0 (Python/collector)",
|
user_agent: str = "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||||
default_accept: str = "*/*",
|
default_accept: str = "*/*",
|
||||||
|
cache_root: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._cache_dir = Path(tempfile.gettempdir()) / "planet-download-cache" / cache_namespace
|
self._cache_dir = (cache_root or default_download_cache_root()) / cache_namespace
|
||||||
self._user_agent = user_agent
|
self._user_agent = user_agent
|
||||||
self._default_accept = default_accept
|
self._default_accept = default_accept
|
||||||
|
|
||||||
@@ -43,6 +69,25 @@ class ResumableFileDownloader:
|
|||||||
meta_path = self._cache_dir / f"{key}.meta.json"
|
meta_path = self._cache_dir / f"{key}.meta.json"
|
||||||
return final_path, part_path, meta_path
|
return final_path, part_path, meta_path
|
||||||
|
|
||||||
|
def cached_file_path(self, url: str, extension: str) -> Path:
|
||||||
|
final_path, _, _ = self._cache_paths(url, extension)
|
||||||
|
return final_path
|
||||||
|
|
||||||
|
def get_cached_file(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
extension: str,
|
||||||
|
*,
|
||||||
|
validate_existing: ValidateCallback | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
final_path = self.cached_file_path(url, extension)
|
||||||
|
if not final_path.exists():
|
||||||
|
return None
|
||||||
|
if validate_existing and not validate_existing(final_path):
|
||||||
|
final_path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
return final_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load_meta(meta_path: Path) -> dict[str, Any]:
|
def _load_meta(meta_path: Path) -> dict[str, Any]:
|
||||||
if not meta_path.exists():
|
if not meta_path.exists():
|
||||||
@@ -140,7 +185,9 @@ class ResumableFileDownloader:
|
|||||||
if progress_callback and expected_size and expected_size > 0:
|
if progress_callback and expected_size and expected_size > 0:
|
||||||
await progress_callback(expected_size, expected_size)
|
await progress_callback(expected_size, expected_size)
|
||||||
return final_path
|
return final_path
|
||||||
response.raise_for_status()
|
if response.status_code >= 400:
|
||||||
|
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||||
|
raise DownloadHTTPStatusError(url=url, status_code=response.status_code, body=body)
|
||||||
|
|
||||||
if response.status_code == 206 and resume_from > 0:
|
if response.status_code == 206 and resume_from > 0:
|
||||||
mode = "ab"
|
mode = "ab"
|
||||||
|
|||||||
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
|
||||||
@@ -35,7 +35,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
|||||||
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
|
DEFAULT_IPTV_ORG_LOGOS_URL = "https://iptv-org.github.io/api/logos.json"
|
||||||
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
|
DEFAULT_IPTV_ORG_NEWS_CATEGORIES = ("news", "business", "weather")
|
||||||
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
|
DEFAULT_IPTV_ORG_EXCLUDE_CATEGORIES = ("music", "sports", "kids", "entertainment")
|
||||||
DEFAULT_IPTV_ORG_MAX_SOURCES = 120
|
DEFAULT_IPTV_ORG_MAX_SOURCES = 0 # Zero keeps the complete matching channel catalog.
|
||||||
|
|
||||||
async def fetch(self) -> list[dict[str, Any]]:
|
async def fetch(self) -> list[dict[str, Any]]:
|
||||||
request_url = (self._resolved_url or "").strip()
|
request_url = (self._resolved_url or "").strip()
|
||||||
@@ -445,7 +445,7 @@ class NewsLiveStreamsCollector(BaseCollector):
|
|||||||
"reference_date": datetime.now(UTC).isoformat(),
|
"reference_date": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if len(normalized) >= max_sources:
|
if max_sources > 0 and len(normalized) >= max_sources:
|
||||||
break
|
break
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ To get higher limits, set PEERINGDB_API_KEY environment variable.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
from typing import Dict, Any, List
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
from app.core.logging import get_logger
|
||||||
from app.services.collectors.base import HTTPCollector
|
from app.services.collectors.base import HTTPCollector
|
||||||
|
|
||||||
|
|
||||||
# PeeringDB API key - read from environment variable
|
# PeeringDB API key - read from environment variable
|
||||||
PEERINGDB_API_KEY = os.environ.get("PEERINGDB_API_KEY", "")
|
PEERINGDB_API_KEY = os.environ.get("PEERINGDB_API_KEY", "")
|
||||||
|
logger = get_logger(__name__, service="collector")
|
||||||
|
|
||||||
|
|
||||||
class PeeringDBIXPCollector(HTTPCollector):
|
class PeeringDBIXPCollector(HTTPCollector):
|
||||||
@@ -39,6 +42,7 @@ class PeeringDBIXPCollector(HTTPCollector):
|
|||||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def request_url(self) -> str:
|
def request_url(self) -> str:
|
||||||
base = self._resolved_url or self.base_url
|
base = self._resolved_url or self.base_url
|
||||||
@@ -61,7 +65,11 @@ class PeeringDBIXPCollector(HTTPCollector):
|
|||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
# Rate limited - wait and retry with exponential backoff
|
# Rate limited - wait and retry with exponential backoff
|
||||||
delay = base_delay * (2**attempt)
|
delay = base_delay * (2**attempt)
|
||||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
logger.warning_event(
|
||||||
|
"PeeringDB rate limited; retrying after delay",
|
||||||
|
event="collector.peeringdb.rate_limited",
|
||||||
|
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
last_error = "Rate limited"
|
last_error = "Rate limited"
|
||||||
continue
|
continue
|
||||||
@@ -72,13 +80,21 @@ class PeeringDBIXPCollector(HTTPCollector):
|
|||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
delay = base_delay * (2**attempt)
|
delay = base_delay * (2**attempt)
|
||||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
logger.warning_event(
|
||||||
|
"PeeringDB rate limited; retrying after delay",
|
||||||
|
event="collector.peeringdb.rate_limited",
|
||||||
|
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
last_error = "Rate limited"
|
last_error = "Rate limited"
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
|
|
||||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
logger.warning_event(
|
||||||
|
"PeeringDB collection failed after retries",
|
||||||
|
event="collector.peeringdb.retries_exhausted",
|
||||||
|
context={"max_retries": max_retries, "last_error": last_error},
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def fetch(self) -> List[Dict[str, Any]]:
|
async def fetch(self) -> List[Dict[str, Any]]:
|
||||||
@@ -146,6 +162,7 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
|||||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def request_url(self) -> str:
|
def request_url(self) -> str:
|
||||||
base = self._resolved_url or self.base_url
|
base = self._resolved_url or self.base_url
|
||||||
@@ -167,7 +184,11 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
|||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
delay = base_delay * (2**attempt)
|
delay = base_delay * (2**attempt)
|
||||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
logger.warning_event(
|
||||||
|
"PeeringDB rate limited; retrying after delay",
|
||||||
|
event="collector.peeringdb.rate_limited",
|
||||||
|
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
last_error = "Rate limited"
|
last_error = "Rate limited"
|
||||||
continue
|
continue
|
||||||
@@ -178,13 +199,21 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
|||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
delay = base_delay * (2**attempt)
|
delay = base_delay * (2**attempt)
|
||||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
logger.warning_event(
|
||||||
|
"PeeringDB rate limited; retrying after delay",
|
||||||
|
event="collector.peeringdb.rate_limited",
|
||||||
|
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
last_error = "Rate limited"
|
last_error = "Rate limited"
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
|
|
||||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
logger.warning_event(
|
||||||
|
"PeeringDB collection failed after retries",
|
||||||
|
event="collector.peeringdb.retries_exhausted",
|
||||||
|
context={"max_retries": max_retries, "last_error": last_error},
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def fetch(self) -> List[Dict[str, Any]]:
|
async def fetch(self) -> List[Dict[str, Any]]:
|
||||||
@@ -254,6 +283,7 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
|||||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def request_url(self) -> str:
|
def request_url(self) -> str:
|
||||||
base = self._resolved_url or self.base_url
|
base = self._resolved_url or self.base_url
|
||||||
@@ -275,7 +305,11 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
|||||||
|
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
delay = base_delay * (2**attempt)
|
delay = base_delay * (2**attempt)
|
||||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
logger.warning_event(
|
||||||
|
"PeeringDB rate limited; retrying after delay",
|
||||||
|
event="collector.peeringdb.rate_limited",
|
||||||
|
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
last_error = "Rate limited"
|
last_error = "Rate limited"
|
||||||
continue
|
continue
|
||||||
@@ -286,13 +320,21 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
|||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
delay = base_delay * (2**attempt)
|
delay = base_delay * (2**attempt)
|
||||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
logger.warning_event(
|
||||||
|
"PeeringDB rate limited; retrying after delay",
|
||||||
|
event="collector.peeringdb.rate_limited",
|
||||||
|
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||||
|
)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
last_error = "Rate limited"
|
last_error = "Rate limited"
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
|
|
||||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
logger.warning_event(
|
||||||
|
"PeeringDB collection failed after retries",
|
||||||
|
event="collector.peeringdb.retries_exhausted",
|
||||||
|
context={"max_retries": max_retries, "last_error": last_error},
|
||||||
|
)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def fetch(self) -> List[Dict[str, Any]]:
|
async def fetch(self) -> List[Dict[str, Any]]:
|
||||||
|
|||||||