Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87cb310fd | ||
|
|
b15d097b9c | ||
|
|
8955c58d19 | ||
|
|
1cb51b1172 | ||
|
|
455b8360d0 | ||
|
|
e1984c7a35 | ||
|
|
bb9183b8a4 |
@@ -46,6 +46,16 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||
- Keep filenames lowercase and hyphenated.
|
||||
- Apply the repository-specific rules file before writing.
|
||||
|
||||
#### Document Audience Routing (Planet)
|
||||
|
||||
In this repository, classify the action's performer before picking a target file:
|
||||
|
||||
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
|
||||
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
|
||||
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
|
||||
|
||||
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
||||
|
||||
### Step 3 — Write
|
||||
|
||||
@@ -236,6 +236,8 @@ bun run build
|
||||
|
||||
推荐按下面顺序排查和配置。
|
||||
|
||||
端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
|
||||
|
||||
### 1. 在 WSL 中启动服务
|
||||
|
||||
```bash
|
||||
|
||||
5
TODO.md
5
TODO.md
@@ -26,6 +26,11 @@
|
||||
- [ ] 重写控制台 UI,逐步抛弃 Ant Design,建立自有组件体系,并统一采用 `tabler.io` / Tabler Icons 作为控制台主图标库
|
||||
- [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题
|
||||
- [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector
|
||||
- [ ] AIS v3.1:修复船只聚合完整性,`/geo/vessels` 合并 raw observation 聚合结果与 legacy `vessel_position + vessel_static` 最新结果,确保 BarentsWatch-only 船只不会因为 AISStream 子集存在而消失,并增加 raw/legacy/final unique MMSI 诊断统计
|
||||
- [ ] AIS v3.2:把 AISStream 从收满 `max_messages` 后结束的批采集改成长连接 streaming service,持续写入 raw observations,通过内部 `/ws` 的 `vessels` channel 推送新船、位置和航向增量,Earth 前端按 MMSI upsert marker
|
||||
- [ ] AIS v3.3:修正 AISStream 采集页面状态语义,使用 connecting/streaming/reconnecting/stopped 与 indeterminate 状态,展示运行时长、消息数、unique MMSI、message rate、最近消息和错误,不再用一次性 REST 进度条表示长连接
|
||||
- [ ] AIS v3.4:修复船只身份字段和名称聚合,MMSI/IMO/callsign 按字符串显示且不带千分位符;查询并列出所有仍以 MMSI 号码或 `MMSI <number>` 作为船名的记录,标注来源、最近观测、message types 和缺失原因,并把这批 fallback-name 船只纳入名称聚合修复集合
|
||||
- [ ] Earth Live Sync:建立统一态势实时同步链路,新增 `earth_summary` WS channel,任意采集器成功后广播轻量 summary invalidation,前端收到后重新拉 `/api/v1/visualization/geo/summary` 并更新 HUD;同时为 BGP 增加 `bgp` WS channel,使 BGP incidents/anomalies/collectors 在不刷新页面时也能 upsert 图层;卫星采集完成后触发 summary 刷新,必要时按 TLE 版本重新 hydrate 卫星数据
|
||||
- [ ] AIS v4:开放船只多源聚合策略配置,支持 source priority、字段级规则、freshness 窗口和高级保护开关;保存时校验未知字段、非法模式和危险动态字段锁定,并在聚合接口返回命中的配置版本
|
||||
- [ ] AIS v5:实现船舶资料 enrichment 与冲突治理,按 `mmsi + imo + name + callsign` 异步补充船型细分、AIS 大类、旗国、尺寸、建造年份、运营方和图片缓存;详情面板展示缓存资料和字段来源,不在实时 AIS 请求链路现场抓第三方页面
|
||||
- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互
|
||||
|
||||
@@ -32,6 +32,15 @@ AI_API_KEY=sk-cp-change-me
|
||||
AI_MAX_TOKENS=1200
|
||||
AI_ANTHROPIC_VERSION=2023-06-01
|
||||
|
||||
# Optional provider-specific keys used by Settings fallback before AI_API_KEY
|
||||
# MINIMAX_API_KEY=sk-cp-change-me
|
||||
# OPENAI_API_KEY=sk-change-me
|
||||
# ANTHROPIC_API_KEY=sk-ant-change-me
|
||||
# DEEPSEEK_API_KEY=sk-change-me
|
||||
# DASHSCOPE_API_KEY=sk-change-me
|
||||
# MOONSHOT_API_KEY=sk-change-me
|
||||
# OPENROUTER_API_KEY=sk-or-change-me
|
||||
|
||||
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
|
||||
# AI_PROVIDER=openai
|
||||
# AI_PROVIDER_API=openai-completions
|
||||
|
||||
@@ -5,13 +5,17 @@ from app.api.v1 import (
|
||||
users,
|
||||
datasource_config,
|
||||
datasources,
|
||||
docs,
|
||||
tasks,
|
||||
dashboard,
|
||||
websocket,
|
||||
alerts,
|
||||
settings,
|
||||
collected_data,
|
||||
data_products,
|
||||
layers,
|
||||
visualization,
|
||||
vessel_aggregation,
|
||||
vessels,
|
||||
bgp,
|
||||
news,
|
||||
system_control,
|
||||
@@ -28,12 +32,21 @@ api_router.include_router(
|
||||
)
|
||||
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
||||
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
|
||||
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
|
||||
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(data_products.router, prefix="/data-products", tags=["data-products"])
|
||||
api_router.include_router(layers.router, prefix="/layers", tags=["layers"])
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(
|
||||
vessel_aggregation.router,
|
||||
prefix="/vessel-aggregation",
|
||||
tags=["vessel-aggregation"],
|
||||
)
|
||||
api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
|
||||
@@ -1,26 +1,85 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
blacklist_token,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.schemas.user import (
|
||||
ForgotPasswordRequest,
|
||||
ResendCodeRequest,
|
||||
ResetPasswordRequest,
|
||||
UserRegister,
|
||||
UserResponse,
|
||||
VerifyEmailRequest,
|
||||
)
|
||||
from app.services import otp
|
||||
from app.services.email import (
|
||||
EmailError,
|
||||
EmailNotConfiguredError,
|
||||
send_verification_email,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _token_response(user: User) -> dict:
|
||||
access_token = create_access_token(data={"sub": user.id})
|
||||
refresh = create_refresh_token(data={"sub": user.id})
|
||||
expires_in = (
|
||||
settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": expires_in,
|
||||
"refresh_token": refresh,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"gatekeeper_groups": user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
||||
"FROM users WHERE email = :email"
|
||||
),
|
||||
{"email": email},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
user.email_verified = bool(row[7])
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
@@ -28,7 +87,8 @@ async def login(
|
||||
):
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
||||
"FROM users WHERE username = :username"
|
||||
),
|
||||
{"username": form_data.username},
|
||||
)
|
||||
@@ -46,6 +106,8 @@ async def login(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
user.email_verified = bool(row[7])
|
||||
|
||||
if not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
@@ -57,24 +119,13 @@ async def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is inactive",
|
||||
)
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "EMAIL_NOT_VERIFIED", "email": user.email},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.id})
|
||||
refresh_token = create_refresh_token(data={"sub": user.id})
|
||||
|
||||
expires_in = None
|
||||
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
|
||||
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": expires_in,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
},
|
||||
}
|
||||
return _token_response(user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@@ -95,6 +146,7 @@ async def refresh_token(
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,6 +163,181 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
"is_active": current_user.is_active,
|
||||
"email_verified": getattr(current_user, "email_verified", True),
|
||||
"created_at": current_user.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: str) -> None:
|
||||
try:
|
||||
await send_verification_email(db, to=email, code=code, purpose=purpose)
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except EmailError as exc:
|
||||
logger.warning_event(
|
||||
"SMTP send failed",
|
||||
event="auth.email.send_failed",
|
||||
context={"email": email, "purpose": purpose, "error": str(exc)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(
|
||||
text("SELECT id, email_verified FROM users WHERE username = :u OR email = :e"),
|
||||
{"u": payload.username, "e": payload.email},
|
||||
)
|
||||
row = existing.fetchone()
|
||||
if row is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "USER_ALREADY_EXISTS", "message": "Username or email already in use"},
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password_hash=get_password_hash(payload.password),
|
||||
role="viewer",
|
||||
is_active=True,
|
||||
email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
code = otp.issue_code(payload.email, "register")
|
||||
except otp.OtpResendRateLimited as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
||||
) from exc
|
||||
await _send_code_or_raise(db, payload.email, code, "register")
|
||||
return {"status": "pending_verification", "email": payload.email}
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=Token)
|
||||
async def verify_email(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "USER_NOT_FOUND"},
|
||||
)
|
||||
try:
|
||||
otp.verify_code(payload.email, "register", payload.code)
|
||||
except otp.OtpExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpAttemptsExceeded as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE users SET email_verified = TRUE WHERE id = :id"),
|
||||
{"id": user.id},
|
||||
)
|
||||
await db.commit()
|
||||
user.email_verified = True
|
||||
return _token_response(user)
|
||||
|
||||
|
||||
@router.post("/resend-code")
|
||||
async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
# Avoid email enumeration; pretend success.
|
||||
return {"status": "ok"}
|
||||
if payload.purpose == "register" and user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "ALREADY_VERIFIED"},
|
||||
)
|
||||
try:
|
||||
code = otp.issue_code(payload.email, payload.purpose)
|
||||
except otp.OtpResendRateLimited as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
||||
) from exc
|
||||
await _send_code_or_raise(db, payload.email, code, payload.purpose)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
# Don't leak whether an email is registered.
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
code = otp.issue_code(payload.email, "reset_password")
|
||||
except otp.OtpResendRateLimited:
|
||||
# Silently accept; the user can retry after the cooldown.
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
await send_verification_email(db, to=payload.email, code=code, purpose="reset_password")
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except EmailError as exc:
|
||||
logger.warning_event(
|
||||
"SMTP send failed",
|
||||
event="auth.email.send_failed",
|
||||
context={"email": payload.email, "purpose": "reset_password", "error": str(exc)},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "OTP_INVALID"},
|
||||
)
|
||||
try:
|
||||
otp.verify_code(payload.email, "reset_password", payload.code)
|
||||
except otp.OtpExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpAttemptsExceeded as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE users SET password_hash = :p, email_verified = TRUE WHERE id = :id"),
|
||||
{"p": get_password_hash(payload.new_password), "id": user.id},
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -5,13 +5,26 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.user import User
|
||||
from app.services.bgp_collector_locations import (
|
||||
build_bgp_collector_location_query,
|
||||
collect_bgp_collector_location_candidates,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.ai_client import get_ai_provider_client
|
||||
from app.api.v1.settings import get_web_search_client
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -264,6 +277,119 @@ async def get_bgp_collector_summary(
|
||||
}
|
||||
|
||||
|
||||
class CollectBGPCollectorLocationRequest(BaseModel):
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/collectors/{collector_id}/collect-location")
|
||||
async def collect_bgp_collector_location(
|
||||
collector_id: str,
|
||||
payload: CollectBGPCollectorLocationRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run the shared location pipeline for a BGP route collector.
|
||||
|
||||
Mirrors ``POST /api/v1/visualization/compute-centers/{source_id}/collect-location``.
|
||||
Returns ranked candidates from source coordinates and Nominatim queries
|
||||
built around the collector's stored context (IXP / city / country). Stored
|
||||
collector locations provide context only; they are not emitted as
|
||||
candidates.
|
||||
"""
|
||||
if not collector_id or not collector_id.strip():
|
||||
raise HTTPException(status_code=400, detail="collector_id is required")
|
||||
|
||||
legacy = get_bgp_collector_location_dict(collector_id) or {}
|
||||
site = payload.site or legacy.get("matched_location_name")
|
||||
city = payload.city or legacy.get("city")
|
||||
country = payload.country or legacy.get("country")
|
||||
operator = payload.operator or "RIPE NCC"
|
||||
|
||||
candidates, attempted_queries = collect_bgp_collector_location_candidates(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
llm_failure_reason = None
|
||||
if not candidates:
|
||||
query = build_bgp_collector_location_query(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
llm_result = None
|
||||
try:
|
||||
web_search_client = await get_web_search_client(db)
|
||||
search_result = await collect_location_search_evidence(
|
||||
web_search_client=web_search_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
)
|
||||
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||
if not search_result.evidence:
|
||||
llm_failure_reason = search_result.failure_reason
|
||||
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||
provider_client = await get_ai_provider_client(db)
|
||||
llm_result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
|
||||
]
|
||||
if llm_result is not None:
|
||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||
candidates = llm_result.candidates
|
||||
llm_failure_reason = llm_result.failure_reason
|
||||
|
||||
context = {
|
||||
"collector": collector_id,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"operator": operator,
|
||||
}
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates or online geocoding result reached"
|
||||
" city-level precision for this collector."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"llm_failure_reason": llm_failure_reason,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": context,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overview/summary")
|
||||
async def get_bgp_overview_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
98
backend/app/api/v1/data_products.py
Normal file
98
backend/app/api/v1/data_products.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import get_visualization_geo_summary
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PRODUCT_DEFINITIONS: dict[str, dict] = {
|
||||
"vessels": {
|
||||
"name": "船只",
|
||||
"sources": ["aisstream_vessels", "barentswatch_vessels"],
|
||||
"primary_stat_key": "vessel_count",
|
||||
"stat_keys": ["vessel_count", "vessel_raw_unique_mmsi", "vessel_legacy_unique_mmsi"],
|
||||
},
|
||||
"cables": {
|
||||
"name": "海底光缆",
|
||||
"sources": [
|
||||
"arcgis_cables",
|
||||
"arcgis_landing_points",
|
||||
"arcgis_cable_landing_relation",
|
||||
"telegeography_cables",
|
||||
"telegeography_landing",
|
||||
"telegeography_systems",
|
||||
"fao_landing_points",
|
||||
],
|
||||
"primary_stat_key": "cable_count",
|
||||
"stat_keys": ["cable_count", "landing_point_count"],
|
||||
},
|
||||
"satellites": {
|
||||
"name": "卫星",
|
||||
"sources": ["celestrak_tle", "spacetrack_tle"],
|
||||
"primary_stat_key": "satellite_count",
|
||||
"stat_keys": ["satellite_count"],
|
||||
},
|
||||
"bgp": {
|
||||
"name": "BGP",
|
||||
"sources": [
|
||||
"ris_live_bgp",
|
||||
"bgpstream_bgp",
|
||||
"iptoasn_prefix_geo",
|
||||
"opengeofeed_prefix_geo",
|
||||
"nro_delegated_prefix_geo",
|
||||
],
|
||||
"primary_stat_key": "bgp_event_count",
|
||||
"stat_keys": ["bgp_event_count", "bgp_incident_count", "bgp_anomaly_count", "bgp_collector_count"],
|
||||
},
|
||||
"compute": {
|
||||
"name": "算力",
|
||||
"sources": ["top500", "epoch_ai_gpu"],
|
||||
"primary_stat_key": "compute_center_count",
|
||||
"stat_keys": ["compute_center_count", "supercomputer_count", "gpu_cluster_count"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_product_status(product_id: str, summary: dict) -> dict:
|
||||
definition = PRODUCT_DEFINITIONS[product_id]
|
||||
stats = summary.get("stats", {})
|
||||
product_stats = {key: stats.get(key, 0) for key in definition["stat_keys"]}
|
||||
total_count = int(product_stats.get(definition["primary_stat_key"]) or 0)
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"name": definition["name"],
|
||||
"sources": definition["sources"],
|
||||
"generated_at": summary.get("generated_at") or to_iso8601_utc(datetime.now(UTC)),
|
||||
"total_count": total_count,
|
||||
"stats": product_stats,
|
||||
"build_state": "ready",
|
||||
"stats_scope": "global",
|
||||
"stats_freshness": "cached_or_indexed",
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_data_products(db: AsyncSession = Depends(get_db)):
|
||||
summary = await get_visualization_geo_summary(db)
|
||||
return {
|
||||
"generated_at": summary.get("generated_at"),
|
||||
"data": [
|
||||
_build_product_status(product_id, summary)
|
||||
for product_id in PRODUCT_DEFINITIONS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{product_id}/status")
|
||||
async def get_data_product_status(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if product_id not in PRODUCT_DEFINITIONS:
|
||||
raise HTTPException(status_code=404, detail="Unknown data product")
|
||||
summary = await get_visualization_geo_summary(db)
|
||||
return _build_product_status(product_id, summary)
|
||||
@@ -5,8 +5,8 @@ from datetime import datetime
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
import httpx
|
||||
@@ -17,6 +17,8 @@ from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.vessel import AISRawObservation, AISSourceHealth
|
||||
from app.core.security import get_current_user
|
||||
from app.core.cache import cache
|
||||
from app.core.time import to_iso8601_utc
|
||||
@@ -26,10 +28,19 @@ from app.services.datasource_mapping import (
|
||||
MappingError,
|
||||
build_heuristic_mapping,
|
||||
execute_mapping,
|
||||
persist_mapped_records,
|
||||
redact_for_llm,
|
||||
stable_payload_hash,
|
||||
)
|
||||
from app.services.custom_datasource_runtime import (
|
||||
CustomDatasourceRuntimeError,
|
||||
fetch_rest_payload,
|
||||
get_custom_stream_status,
|
||||
run_mapped_rest_config,
|
||||
run_mapped_websocket_config,
|
||||
start_custom_stream,
|
||||
stop_custom_stream,
|
||||
test_websocket_config,
|
||||
)
|
||||
from app.services.datasource_connectivity import (
|
||||
get_builtin_connection_status,
|
||||
save_connectivity_success,
|
||||
@@ -43,7 +54,7 @@ router = APIRouter()
|
||||
class DataSourceConfigCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
source_type: str = Field(..., description="http, api, database")
|
||||
source_type: str = Field(..., description="rest, websocket, http, api, database")
|
||||
endpoint: str = Field(..., max_length=500)
|
||||
auth_type: str = Field(default="none", description="none, bearer, api_key, basic")
|
||||
auth_config: dict = Field(default={})
|
||||
@@ -219,6 +230,8 @@ def _build_query_params(auth_type: str, auth_config: dict, config: dict) -> dict
|
||||
|
||||
|
||||
async def fetch_custom_sample_from_config(config: DataSourceConfig, limit_bytes: int) -> Any:
|
||||
if str(config.source_type or "").lower() in {"websocket", "ws"}:
|
||||
raise HTTPException(status_code=400, detail="WebSocket sources must use connection test or run-mapped stream.")
|
||||
request_config = config.config or {}
|
||||
method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
@@ -488,6 +501,8 @@ async def update_config(
|
||||
@router.delete("/configs/{config_id}")
|
||||
async def delete_config(
|
||||
config_id: int,
|
||||
delete_mappings: bool = Query(False),
|
||||
delete_source_data: bool = Query(False),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -498,12 +513,59 @@ async def delete_config(
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||
|
||||
deleted_mappings = 0
|
||||
deleted_records = {
|
||||
"collected_data": 0,
|
||||
"ais_raw_observations": 0,
|
||||
"ais_source_health": 0,
|
||||
}
|
||||
|
||||
if delete_source_data:
|
||||
collected_result = await db.execute(
|
||||
delete(CollectedData).where(CollectedData.source == config.name)
|
||||
)
|
||||
raw_result = await db.execute(
|
||||
delete(AISRawObservation).where(AISRawObservation.source == config.name)
|
||||
)
|
||||
health_result = await db.execute(
|
||||
delete(AISSourceHealth).where(AISSourceHealth.source == config.name)
|
||||
)
|
||||
deleted_records = {
|
||||
"collected_data": collected_result.rowcount or 0,
|
||||
"ais_raw_observations": raw_result.rowcount or 0,
|
||||
"ais_source_health": health_result.rowcount or 0,
|
||||
}
|
||||
|
||||
if delete_mappings or delete_source_data:
|
||||
mapping_result = await db.execute(
|
||||
delete(DataSourceMappingTemplate).where(
|
||||
DataSourceMappingTemplate.datasource_config_id == config_id
|
||||
)
|
||||
)
|
||||
deleted_mappings = mapping_result.rowcount or 0
|
||||
|
||||
await db.delete(config)
|
||||
await db.commit()
|
||||
|
||||
cache.delete_pattern("datasource_configs:*")
|
||||
|
||||
return {"message": "Configuration deleted successfully"}
|
||||
if delete_source_data and (config.config or {}).get("target_schema") == "vessel_ais":
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
|
||||
await broadcaster.broadcast_custom(
|
||||
"vessels",
|
||||
{
|
||||
"action": "reload",
|
||||
"source": config.name,
|
||||
"reason": "custom_source_deleted",
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Configuration deleted successfully",
|
||||
"deleted_mappings": deleted_mappings,
|
||||
"deleted_records": deleted_records,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/configs/{config_id}/test")
|
||||
@@ -520,6 +582,8 @@ async def test_config(
|
||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||
|
||||
try:
|
||||
if str(config.source_type or "").lower() in {"websocket", "ws"}:
|
||||
return await test_websocket_config(config)
|
||||
result = await test_endpoint(
|
||||
endpoint=config.endpoint,
|
||||
auth_type=config.auth_type,
|
||||
@@ -550,6 +614,18 @@ async def test_new_config(
|
||||
):
|
||||
"""Test a new data source configuration without saving"""
|
||||
try:
|
||||
if str(config_data.source_type or "").lower() in {"websocket", "ws"}:
|
||||
config = DataSourceConfig(
|
||||
name=config_data.name,
|
||||
description=config_data.description,
|
||||
source_type=config_data.source_type,
|
||||
endpoint=config_data.endpoint,
|
||||
auth_type=config_data.auth_type,
|
||||
auth_config=config_data.auth_config,
|
||||
headers=config_data.headers,
|
||||
config=config_data.config,
|
||||
)
|
||||
return await test_websocket_config(config)
|
||||
result = await test_endpoint(
|
||||
endpoint=config_data.endpoint,
|
||||
auth_type=config_data.auth_type,
|
||||
@@ -875,6 +951,8 @@ async def update_datasource_mapping(
|
||||
@router.post("/{config_id}/run-mapped")
|
||||
async def run_mapped_datasource(
|
||||
config_id: int,
|
||||
background: bool = Query(False, description="For WebSocket sources, start a background stream task."),
|
||||
debug_max_messages: int | None = Query(None, ge=1),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -883,20 +961,24 @@ async def run_mapped_datasource(
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Configuration not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(DataSourceMappingTemplate)
|
||||
.where(DataSourceMappingTemplate.datasource_config_id == config_id)
|
||||
.where(DataSourceMappingTemplate.is_active.is_(True))
|
||||
.order_by(DataSourceMappingTemplate.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="No active mapping template found")
|
||||
|
||||
try:
|
||||
sample = await fetch_custom_sample_from_config(datasource, 5_000_000)
|
||||
mapped = execute_mapping(sample, mapping.mapping_json, mapping.target_schema)
|
||||
if str(datasource.source_type or "").lower() in {"websocket", "ws"}:
|
||||
if background and debug_max_messages is None:
|
||||
started = start_custom_stream(config_id)
|
||||
if not started:
|
||||
raise HTTPException(status_code=409, detail="Custom WebSocket source is already running")
|
||||
return {
|
||||
"status": "started",
|
||||
"datasource_config_id": config_id,
|
||||
"stream": get_custom_stream_status(config_id),
|
||||
}
|
||||
return await run_mapped_websocket_config(
|
||||
db,
|
||||
datasource,
|
||||
debug_max_messages=debug_max_messages,
|
||||
)
|
||||
|
||||
return await run_mapped_rest_config(db, datasource)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
@@ -904,36 +986,26 @@ async def run_mapped_datasource(
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Datasource request failed: {exc}") from exc
|
||||
except (MappingError, ValueError) as exc:
|
||||
except (CustomDatasourceRuntimeError, MappingError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Mapping failed: {exc}") from exc
|
||||
|
||||
if mapped["failed_count"] > 0:
|
||||
return {
|
||||
"status": "failed",
|
||||
"datasource_config_id": config_id,
|
||||
"mapping_id": mapping.id,
|
||||
"mapping_version": mapping.version,
|
||||
"target_schema": mapping.target_schema,
|
||||
"mapped_count": mapped["mapped_count"],
|
||||
"failed_count": mapped["failed_count"],
|
||||
"errors": mapped["errors"][:20],
|
||||
}
|
||||
|
||||
written_count = await persist_mapped_records(
|
||||
db,
|
||||
datasource_name=datasource.name,
|
||||
datasource_config_id=datasource.id,
|
||||
target_schema=mapping.target_schema,
|
||||
records=mapped["records"],
|
||||
mapping_version=mapping.version,
|
||||
)
|
||||
@router.post("/{config_id}/stop-mapped")
|
||||
async def stop_mapped_datasource(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
stopped = await stop_custom_stream(config_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"status": "stopped" if stopped else "not_running",
|
||||
"datasource_config_id": config_id,
|
||||
"mapping_id": mapping.id,
|
||||
"mapping_version": mapping.version,
|
||||
"target_schema": mapping.target_schema,
|
||||
"fetched_count": mapped["total_items"],
|
||||
"mapped_count": mapped["mapped_count"],
|
||||
"written_count": written_count,
|
||||
"stream": get_custom_stream_status(config_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{config_id}/stream-status")
|
||||
async def get_mapped_stream_status(
|
||||
config_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return get_custom_stream_status(config_id)
|
||||
|
||||
@@ -3,7 +3,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select, text
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
@@ -27,6 +28,29 @@ from app.services.scheduler import (
|
||||
router = APIRouter()
|
||||
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
||||
|
||||
PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("vessels", ("vessel", "ais")),
|
||||
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
|
||||
("satellites", ("tle", "satellite", "spacetrack", "celestrak")),
|
||||
("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")),
|
||||
("compute", ("top500", "gpu", "supercomputer", "compute")),
|
||||
("ai", ("huggingface", "epoch_ai")),
|
||||
("media", ("news", "tv", "live_stream")),
|
||||
)
|
||||
|
||||
|
||||
class DatasourceBatchTriggerRequest(BaseModel):
|
||||
source_ids: list[int] = Field(default_factory=list)
|
||||
force: bool = False
|
||||
module: Optional[str] = None
|
||||
product: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
priority: Optional[str] = None
|
||||
run_status: Optional[str] = None
|
||||
collected: Optional[bool] = None
|
||||
credential_status: Optional[str] = None
|
||||
q: Optional[str] = None
|
||||
|
||||
|
||||
def format_frequency_label(minutes: int) -> str:
|
||||
if minutes % 1440 == 0:
|
||||
@@ -47,6 +71,20 @@ def datasource_metadata(source: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def datasource_product_key(datasource: DataSource) -> str:
|
||||
haystack = " ".join(
|
||||
[
|
||||
datasource.source or "",
|
||||
datasource.name or "",
|
||||
datasource.collector_class or "",
|
||||
]
|
||||
).lower()
|
||||
for product, keywords in PRODUCT_SOURCE_KEYWORDS:
|
||||
if any(keyword in haystack for keyword in keywords):
|
||||
return product
|
||||
return "other"
|
||||
|
||||
|
||||
def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
|
||||
if datasource.last_run_at is None:
|
||||
return True
|
||||
@@ -110,6 +148,22 @@ async def _load_latest_task_ids(
|
||||
return {datasource_id: task_id for datasource_id, task_id in result.all()}
|
||||
|
||||
|
||||
async def _load_collected_record_counts(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
) -> dict[str, int]:
|
||||
if not sources:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(CollectedData.source, func.count(CollectedData.id))
|
||||
.where(CollectedData.source.in_(sources))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.group_by(CollectedData.source)
|
||||
)
|
||||
return {source: int(count or 0) for source, count in result.all()}
|
||||
|
||||
|
||||
async def _load_datasource_endpoint_overrides(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
@@ -161,6 +215,192 @@ async def _load_datasource_list_context(
|
||||
return running_tasks, endpoint_overrides
|
||||
|
||||
|
||||
def _apply_datasource_query_filters(
|
||||
query,
|
||||
*,
|
||||
module: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
priority: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
) -> object:
|
||||
if module:
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
if run_status and run_status not in {"running", "collected", "uncollected"}:
|
||||
if run_status == "not_run":
|
||||
query = query.where(DataSource.last_status.is_(None))
|
||||
else:
|
||||
query = query.where(DataSource.last_status == run_status)
|
||||
if q:
|
||||
like_value = f"%{q.strip()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
DataSource.name.ilike(like_value),
|
||||
DataSource.source.ilike(like_value),
|
||||
DataSource.collector_class.ilike(like_value),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _filter_datasources_in_memory(
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
running_tasks: dict[int, CollectionTask],
|
||||
record_counts: dict[str, int],
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
) -> list[DataSource]:
|
||||
filtered: list[DataSource] = []
|
||||
for datasource in datasources:
|
||||
record_count = record_counts.get(datasource.source, 0)
|
||||
if product and datasource_product_key(datasource) != product:
|
||||
continue
|
||||
if collected is not None and (record_count > 0) != collected:
|
||||
continue
|
||||
if credential_status:
|
||||
metadata = datasource_metadata(datasource.source)
|
||||
if metadata["credential_status"] != credential_status:
|
||||
continue
|
||||
if run_status == "running" and datasource.id not in running_tasks:
|
||||
continue
|
||||
if run_status == "collected" and record_count <= 0:
|
||||
continue
|
||||
if run_status == "uncollected" and record_count > 0:
|
||||
continue
|
||||
filtered.append(datasource)
|
||||
return filtered
|
||||
|
||||
|
||||
async def _trigger_datasource_batch(
|
||||
db: AsyncSession,
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
force: bool,
|
||||
) -> dict:
|
||||
if not datasources:
|
||||
return {
|
||||
"status": "noop",
|
||||
"message": "No matching data sources to trigger",
|
||||
"force": force,
|
||||
"triggered": [],
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
running_tasks = await _load_latest_running_tasks(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
|
||||
for datasource in datasources:
|
||||
if not datasource.is_active:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "disabled",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
if running_task is not None:
|
||||
if not force:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "already_running",
|
||||
"task_id": running_task.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
cancelled = await cancel_running_collector_now(datasource.source)
|
||||
if not cancelled:
|
||||
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "within_frequency_window",
|
||||
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
||||
"next_run_at": to_iso8601_utc(
|
||||
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
|
||||
|
||||
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
|
||||
datasource = None
|
||||
try:
|
||||
@@ -355,16 +595,23 @@ async def list_datasources(
|
||||
module: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
priority: Optional[str] = None,
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if module:
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
query = _apply_datasource_query_filters(
|
||||
query,
|
||||
module=module,
|
||||
is_active=is_active,
|
||||
priority=priority,
|
||||
run_status=run_status,
|
||||
q=q,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
@@ -372,11 +619,22 @@ async def list_datasources(
|
||||
collector_list = []
|
||||
config = get_data_sources_config()
|
||||
running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
record_counts=record_counts,
|
||||
product=product,
|
||||
run_status=run_status,
|
||||
collected=collected,
|
||||
credential_status=credential_status,
|
||||
)
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
|
||||
last_run_at = datasource.last_run_at
|
||||
last_status = datasource.last_status
|
||||
collected_records = record_counts.get(datasource.source, 0)
|
||||
|
||||
collector_list.append(
|
||||
{
|
||||
@@ -384,6 +642,7 @@ async def list_datasources(
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
**datasource_metadata(datasource.source),
|
||||
"product": datasource_product_key(datasource),
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
"frequency": format_frequency_label(datasource.frequency_minutes),
|
||||
@@ -405,6 +664,8 @@ async def list_datasources(
|
||||
"phase_unit": running_task.phase_unit if running_task else None,
|
||||
"records_processed": running_task.records_processed if running_task else None,
|
||||
"total_records": running_task.total_records if running_task else None,
|
||||
"collected_records": collected_records,
|
||||
"has_collected_data": collected_records > 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -419,110 +680,46 @@ async def trigger_all_datasources(
|
||||
):
|
||||
result = await db.execute(
|
||||
select(DataSource)
|
||||
.where(DataSource.is_active == True)
|
||||
.where(DataSource.is_active.is_(True))
|
||||
.order_by(DataSource.module, DataSource.id)
|
||||
)
|
||||
datasources = result.scalars().all()
|
||||
return await _trigger_datasource_batch(db, datasources, force=force)
|
||||
|
||||
if not datasources:
|
||||
return {
|
||||
"status": "noop",
|
||||
"message": "No active data sources to trigger",
|
||||
"triggered": [],
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
running_tasks = await _load_latest_running_tasks(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
if running_task is not None:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "already_running",
|
||||
"task_id": running_task.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "within_frequency_window",
|
||||
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
||||
"next_run_at": to_iso8601_utc(
|
||||
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
}
|
||||
@router.post("/trigger-batch")
|
||||
async def trigger_datasource_batch(
|
||||
payload: DatasourceBatchTriggerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if payload.source_ids:
|
||||
query = query.where(DataSource.id.in_(payload.source_ids))
|
||||
else:
|
||||
query = _apply_datasource_query_filters(
|
||||
query,
|
||||
module=payload.module,
|
||||
is_active=payload.is_active,
|
||||
priority=payload.priority,
|
||||
run_status=payload.run_status,
|
||||
q=payload.q,
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
running_tasks, _ = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
record_counts=record_counts,
|
||||
product=None if payload.source_ids else payload.product,
|
||||
run_status=None if payload.source_ids else payload.run_status,
|
||||
collected=None if payload.source_ids else payload.collected,
|
||||
credential_status=None if payload.source_ids else payload.credential_status,
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
return await _trigger_datasource_batch(db, datasources, force=payload.force)
|
||||
|
||||
|
||||
@router.get("/{source_id}")
|
||||
|
||||
102
backend/app/api/v1/docs.py
Normal file
102
backend/app/api/v1/docs.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Authenticated documentation APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
from app.services.docs_gatekeeper import (
|
||||
DOCS_BY_SLUG,
|
||||
VALID_DOCS_LANGS,
|
||||
can_read_doc,
|
||||
catalog_for_user,
|
||||
doc_path_for,
|
||||
title_for,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None or payload.get("type") != "access" or payload.get("sub") is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(payload["sub"])},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None or not row[5]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def get_docs_catalog(current_user: User | None = Depends(get_optional_current_user)):
|
||||
return {
|
||||
"items": catalog_for_user(current_user),
|
||||
"authenticated": current_user is not None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{lang}/{slug}")
|
||||
async def get_doc_content(
|
||||
lang: str,
|
||||
slug: str,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
):
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
entry = DOCS_BY_SLUG.get(slug)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
path = doc_path_for(entry, lang)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
if not can_read_doc(entry, current_user):
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient Docs permissions")
|
||||
|
||||
return {
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
"markdown": path.read_text(encoding="utf-8"),
|
||||
}
|
||||
229
backend/app/api/v1/layers.py
Normal file
229
backend/app/api/v1/layers.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import (
|
||||
_parse_bbox,
|
||||
get_bgp_anomalies_geojson,
|
||||
get_bgp_collectors_geojson,
|
||||
get_bgp_incidents_geojson,
|
||||
get_cables_geojson,
|
||||
get_landing_points_geojson,
|
||||
get_satellites_geojson,
|
||||
)
|
||||
from app.api.v1.vessels import build_vessel_snapshot_response
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_LAYER_LIMIT = 1000
|
||||
MAX_LAYER_LIMIT = 5000
|
||||
LOW_ZOOM_FEATURE_LIMIT = 500
|
||||
|
||||
|
||||
def _clamp_limit(limit: int, zoom: int) -> tuple[int, bool]:
|
||||
clamped = min(max(limit, 1), MAX_LAYER_LIMIT)
|
||||
if zoom <= 3:
|
||||
return min(clamped, LOW_ZOOM_FEATURE_LIMIT), clamped != limit or clamped > LOW_ZOOM_FEATURE_LIMIT
|
||||
return clamped, clamped != limit
|
||||
|
||||
|
||||
def _coordinate_in_bbox(coord: Any, bbox: tuple[float, float, float, float]) -> bool:
|
||||
if not isinstance(coord, (list, tuple)) or len(coord) < 2:
|
||||
return False
|
||||
try:
|
||||
lon = float(coord[0])
|
||||
lat = float(coord[1])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
return lon_min <= lon <= lon_max and lat_min <= lat <= lat_max
|
||||
|
||||
|
||||
def _geometry_intersects_bbox(geometry: dict, bbox: tuple[float, float, float, float]) -> bool:
|
||||
coordinates = geometry.get("coordinates")
|
||||
geometry_type = geometry.get("type")
|
||||
if geometry_type == "Point":
|
||||
return _coordinate_in_bbox(coordinates, bbox)
|
||||
if geometry_type in {"LineString", "MultiPoint"}:
|
||||
return any(_coordinate_in_bbox(coord, bbox) for coord in coordinates or [])
|
||||
if geometry_type in {"Polygon", "MultiLineString"}:
|
||||
return any(
|
||||
_coordinate_in_bbox(coord, bbox)
|
||||
for line in coordinates or []
|
||||
for coord in line
|
||||
)
|
||||
if geometry_type == "MultiPolygon":
|
||||
return any(
|
||||
_coordinate_in_bbox(coord, bbox)
|
||||
for polygon in coordinates or []
|
||||
for line in polygon
|
||||
for coord in line
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _guard_geojson_layer(
|
||||
geojson: dict,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float],
|
||||
zoom: int,
|
||||
limit: int,
|
||||
) -> dict:
|
||||
bounded_limit, limit_clamped = _clamp_limit(limit, zoom)
|
||||
features = [
|
||||
feature
|
||||
for feature in geojson.get("features", [])
|
||||
if _geometry_intersects_bbox(feature.get("geometry") or {}, bbox)
|
||||
]
|
||||
visible_count = len(features)
|
||||
returned_features = features[:bounded_limit]
|
||||
return {
|
||||
**geojson,
|
||||
"features": returned_features,
|
||||
"visible_count": visible_count,
|
||||
"returned_count": len(returned_features),
|
||||
"diagnostics": {
|
||||
"bbox_limited": True,
|
||||
"limit": bounded_limit,
|
||||
"limit_clamped": limit_clamped,
|
||||
"truncated": visible_count > len(returned_features),
|
||||
"degraded": zoom <= 3 or visible_count > len(returned_features),
|
||||
"stats_scope": "viewport",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_layer_bbox(bbox: str) -> tuple[float, float, float, float]:
|
||||
parsed = _parse_bbox(bbox)
|
||||
if parsed is None:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get("/vessels/snapshot")
|
||||
async def get_vessel_layer_snapshot(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
vessel_type: Optional[str] = Query(None, alias="type"),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
parsed_bbox = _parse_layer_bbox(bbox)
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
vessel_type=vessel_type,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cables")
|
||||
async def get_cable_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_cables_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/landing-points")
|
||||
async def get_landing_point_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_landing_points_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/satellites")
|
||||
async def get_satellite_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_satellites_geojson(limit=bounded_limit, db=db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/anomalies")
|
||||
async def get_bgp_anomaly_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_anomalies_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=bounded_limit,
|
||||
db=db,
|
||||
),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/incidents")
|
||||
async def get_bgp_incident_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_incidents_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=min(bounded_limit, 500),
|
||||
db=db,
|
||||
),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/collectors")
|
||||
async def get_bgp_collector_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_collectors_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -7,10 +8,12 @@ from sqlalchemy import text
|
||||
from app.core.security import get_current_user, get_password_hash
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
|
||||
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
||||
user_role_value = (
|
||||
@@ -52,7 +55,7 @@ async def list_users(
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = text(
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
)
|
||||
count_query = text(f"SELECT COUNT(*) FROM users WHERE {where_sql}")
|
||||
|
||||
@@ -75,6 +78,7 @@ async def list_users(
|
||||
"is_active": u[4],
|
||||
"last_login_at": u[5],
|
||||
"created_at": u[6],
|
||||
"gatekeeper_groups": u[7] or [],
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -95,7 +99,7 @@ async def get_user(
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": user_id},
|
||||
)
|
||||
@@ -114,6 +118,7 @@ async def get_user(
|
||||
"is_active": user[4],
|
||||
"last_login_at": user[5],
|
||||
"created_at": user[6],
|
||||
"gatekeeper_groups": user[7] or [],
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +133,12 @@ async def create_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can create users",
|
||||
)
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
||||
@@ -142,13 +153,14 @@ async def create_user(
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
await db.execute(
|
||||
text("""INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, :is_active, NOW(), NOW())"""),
|
||||
text("""INSERT INTO users (username, email, password_hash, role, gatekeeper_groups, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, CAST(:gatekeeper_groups AS jsonb), :is_active, NOW(), NOW())"""),
|
||||
{
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"password_hash": hashed_password,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": json.dumps(user_data.gatekeeper_groups),
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
@@ -172,6 +184,7 @@ async def create_user(
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": user_data.gatekeeper_groups,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
@@ -194,6 +207,18 @@ async def update_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change user role",
|
||||
)
|
||||
if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change Gatekeeper groups",
|
||||
)
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE id = :id"),
|
||||
@@ -213,6 +238,9 @@ async def update_user(
|
||||
if user_data.role is not None:
|
||||
update_fields.append("role = :role")
|
||||
params["role"] = user_data.role
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
update_fields.append("gatekeeper_groups = CAST(:gatekeeper_groups AS jsonb)")
|
||||
params["gatekeeper_groups"] = json.dumps(user_data.gatekeeper_groups)
|
||||
if user_data.is_active is not None:
|
||||
update_fields.append("is_active = :is_active")
|
||||
params["is_active"] = user_data.is_active
|
||||
|
||||
132
backend/app/api/v1/vessel_aggregation.py
Normal file
132
backend/app/api/v1/vessel_aggregation.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""v4 strategy + v5 conflict-promotion + enrichment APIs for vessel_ais."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.models.vessel import AISConflictRecord
|
||||
from app.services.vessel_aggregation_strategy import (
|
||||
StrategyValidationError,
|
||||
load_strategy,
|
||||
reset_strategy,
|
||||
save_strategy,
|
||||
)
|
||||
from app.services.vessel_enrichment import (
|
||||
get_vessel_enrichment_bundle,
|
||||
upsert_vessel_media_enrichment,
|
||||
upsert_vessel_profile_enrichment,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/strategy")
|
||||
async def get_aggregation_strategy(db: AsyncSession = Depends(get_db)):
|
||||
return await load_strategy(db)
|
||||
|
||||
|
||||
@router.put("/strategy")
|
||||
async def put_aggregation_strategy(
|
||||
payload: dict[str, Any],
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return await save_strategy(db, payload)
|
||||
except StrategyValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/strategy")
|
||||
async def reset_aggregation_strategy(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await reset_strategy(db)
|
||||
|
||||
|
||||
@router.post("/conflicts/{mmsi}/{field}/promote-to-rule")
|
||||
async def promote_conflict_to_rule(
|
||||
mmsi: int,
|
||||
field: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Lift the current conflict resolution into a persistent strategy rule."""
|
||||
|
||||
result = await db.execute(
|
||||
select(AISConflictRecord)
|
||||
.where(AISConflictRecord.target_schema == "vessel_ais")
|
||||
.where(AISConflictRecord.entity_key == str(mmsi))
|
||||
.where(AISConflictRecord.field == field)
|
||||
.order_by(AISConflictRecord.updated_at.desc(), AISConflictRecord.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or not record.selected_source:
|
||||
raise HTTPException(status_code=404, detail="Conflict record with selected_source not found")
|
||||
|
||||
strategy = await load_strategy(db)
|
||||
vessel_ais = dict(strategy.get("vessel_ais") or {})
|
||||
field_rules = dict(vessel_ais.get("field_rules") or {})
|
||||
field_rules[field] = {"mode": "source_priority", "source_priority": [record.selected_source]}
|
||||
vessel_ais["field_rules"] = field_rules
|
||||
|
||||
incoming = {"version": int(strategy.get("version") or 0), "vessel_ais": vessel_ais}
|
||||
try:
|
||||
return await save_strategy(db, incoming)
|
||||
except StrategyValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/conflicts/{mmsi}/{field}/promote-to-rule")
|
||||
async def revert_conflict_rule(
|
||||
mmsi: int,
|
||||
field: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
strategy = await load_strategy(db)
|
||||
vessel_ais = dict(strategy.get("vessel_ais") or {})
|
||||
field_rules = dict(vessel_ais.get("field_rules") or {})
|
||||
if field in field_rules:
|
||||
del field_rules[field]
|
||||
vessel_ais["field_rules"] = field_rules
|
||||
|
||||
incoming = {"version": int(strategy.get("version") or 0), "vessel_ais": vessel_ais}
|
||||
try:
|
||||
return await save_strategy(db, incoming)
|
||||
except StrategyValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/enrichment/{mmsi}")
|
||||
async def get_vessel_enrichment(mmsi: int, db: AsyncSession = Depends(get_db)):
|
||||
return await get_vessel_enrichment_bundle(db, mmsi)
|
||||
|
||||
|
||||
@router.put("/enrichment/{mmsi}/profile")
|
||||
async def put_vessel_profile_enrichment(
|
||||
mmsi: int,
|
||||
payload: dict[str, Any],
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await upsert_vessel_profile_enrichment(db, mmsi=mmsi, payload=payload)
|
||||
|
||||
|
||||
@router.put("/enrichment/{mmsi}/media")
|
||||
async def put_vessel_media_enrichment(
|
||||
mmsi: int,
|
||||
payload: dict[str, Any],
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await upsert_vessel_media_enrichment(db, mmsi=mmsi, payload=payload)
|
||||
39
backend/app/api/v1/vessels.py
Normal file
39
backend/app/api/v1/vessels.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Bounded vessel snapshot APIs for viewport-first consumers."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
|
||||
from app.db.session import get_db
|
||||
from app.services.vessel_ais_aggregation import MAX_SNAPSHOT_LIMIT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/snapshot")
|
||||
async def get_vessel_snapshot(
|
||||
bbox: Optional[str] = Query(None, description="Viewport bbox as lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20, description="Current map zoom level"),
|
||||
type: Optional[str] = Query(
|
||||
None,
|
||||
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
|
||||
),
|
||||
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not bbox:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
parsed_bbox = _parse_bbox(bbox)
|
||||
if parsed_bbox is None:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
type_filter=type,
|
||||
limit=limit,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
"""WebSocket API endpoints"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -40,16 +39,16 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
token: str = Query(...),
|
||||
token: str | None = Query(None),
|
||||
):
|
||||
"""WebSocket endpoint for real-time data"""
|
||||
logger.info_event(
|
||||
"WebSocket connection attempt",
|
||||
event="auth.websocket.connection_attempt",
|
||||
context={"token_preview": f"{token[:8]}..."},
|
||||
context={"token_preview": f"{token[:8]}..." if token else "anonymous"},
|
||||
)
|
||||
payload = await authenticate_token(token)
|
||||
if payload is None:
|
||||
payload = await authenticate_token(token) if token else None
|
||||
if token and payload is None:
|
||||
logger.warning_event(
|
||||
"WebSocket authentication failed, closing connection",
|
||||
event="auth.websocket.connection_rejected",
|
||||
@@ -57,7 +56,17 @@ async def websocket_endpoint(
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
user_id = str(payload.get("sub"))
|
||||
is_anonymous = payload is None
|
||||
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
||||
supported_channels = ["vessels"] if is_anonymous else [
|
||||
"gpu_clusters",
|
||||
"submarine_cables",
|
||||
"ixp_nodes",
|
||||
"alerts",
|
||||
"dashboard",
|
||||
"datasource_tasks",
|
||||
"vessels",
|
||||
]
|
||||
await manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
@@ -68,14 +77,7 @@ async def websocket_endpoint(
|
||||
"connection_id": f"conn_{user_id}",
|
||||
"server_version": settings.VERSION,
|
||||
"heartbeat_interval": 30,
|
||||
"supported_channels": [
|
||||
"gpu_clusters",
|
||||
"submarine_cables",
|
||||
"ixp_nodes",
|
||||
"alerts",
|
||||
"dashboard",
|
||||
"datasource_tasks",
|
||||
],
|
||||
"supported_channels": supported_channels,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -92,11 +94,53 @@ async def websocket_endpoint(
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "subscribe":
|
||||
channels = data.get("data", {}).get("channels", [])
|
||||
payload_data = data.get("data", {})
|
||||
if not isinstance(payload_data, dict):
|
||||
payload_data = {}
|
||||
channels = payload_data.get("channels", [])
|
||||
if isinstance(channels, str):
|
||||
channels = [channels]
|
||||
elif not isinstance(channels, list):
|
||||
channels = []
|
||||
channel = payload_data.get("channel")
|
||||
if channel and channel not in channels:
|
||||
channels = [*channels, channel]
|
||||
if is_anonymous:
|
||||
channels = [channel for channel in channels if channel in supported_channels]
|
||||
vessel_subscription = None
|
||||
if "vessels" in channels and "bbox" in payload_data:
|
||||
try:
|
||||
vessel_subscription = manager.subscribe_vessels(websocket, payload_data)
|
||||
except ValueError as exc:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_error",
|
||||
"data": {"channel": "vessels", "detail": str(exc)},
|
||||
}
|
||||
)
|
||||
continue
|
||||
channels = [channel for channel in channels if channel != "vessels"]
|
||||
manager.subscribe(websocket, channels)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_confirmed",
|
||||
"data": {"action": "subscribe", "channels": channels},
|
||||
"data": {
|
||||
"action": "subscribe",
|
||||
"channels": [
|
||||
*channels,
|
||||
*(["vessels"] if vessel_subscription else []),
|
||||
],
|
||||
"vessels": vessel_subscription,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "unsubscribe":
|
||||
channels = data.get("data", {}).get("channels", [])
|
||||
manager.unsubscribe(websocket, channels)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_confirmed",
|
||||
"data": {"action": "unsubscribe", "channels": channels},
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "control_frame":
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Any, Dict, Optional
|
||||
FIELD_ALIASES = {
|
||||
"country": ("country",),
|
||||
"city": ("city",),
|
||||
"latitude": ("latitude",),
|
||||
"longitude": ("longitude",),
|
||||
"latitude": ("latitude", "lat"),
|
||||
"longitude": ("longitude", "lon", "lng"),
|
||||
"value": ("value",),
|
||||
"unit": ("unit",),
|
||||
"cores": ("cores",),
|
||||
@@ -14,6 +14,28 @@ FIELD_ALIASES = {
|
||||
"power": ("power",),
|
||||
}
|
||||
|
||||
NESTED_FIELD_ALIASES = {
|
||||
"latitude": (
|
||||
("location", "latitude"),
|
||||
("location", "lat"),
|
||||
("geo", "latitude"),
|
||||
("geo", "lat"),
|
||||
("coordinates", "latitude"),
|
||||
("coordinates", "lat"),
|
||||
),
|
||||
"longitude": (
|
||||
("location", "longitude"),
|
||||
("location", "lon"),
|
||||
("location", "lng"),
|
||||
("geo", "longitude"),
|
||||
("geo", "lon"),
|
||||
("geo", "lng"),
|
||||
("coordinates", "longitude"),
|
||||
("coordinates", "lon"),
|
||||
("coordinates", "lng"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any:
|
||||
if isinstance(metadata, dict):
|
||||
@@ -21,9 +43,34 @@ def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback:
|
||||
value = metadata.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
for path in NESTED_FIELD_ALIASES.get(field, ()):
|
||||
current: Any = metadata
|
||||
for key in path:
|
||||
if not isinstance(current, dict):
|
||||
current = None
|
||||
break
|
||||
current = current.get(key)
|
||||
if current not in (None, ""):
|
||||
return current
|
||||
if field in {"latitude", "longitude"}:
|
||||
value = _get_coordinate_sequence_value(metadata, field)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def _get_coordinate_sequence_value(metadata: Dict[str, Any], field: str) -> Any:
|
||||
for key in ("coordinates", "coord", "coords"):
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
continue
|
||||
# GeoJSON uses [longitude, latitude]. Most raw collector tuples in this
|
||||
# codebase use explicit field names, so only sequence aliases are treated
|
||||
# as GeoJSON-shaped to avoid guessing.
|
||||
return value[1] if field == "latitude" else value[0]
|
||||
return None
|
||||
|
||||
|
||||
def build_dynamic_metadata(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
*,
|
||||
|
||||
@@ -105,7 +105,7 @@ async def get_current_user(
|
||||
)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
@@ -122,6 +122,7 @@ async def get_current_user(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@@ -144,7 +145,7 @@ async def get_current_user_refresh(
|
||||
)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
@@ -161,6 +162,7 @@ async def get_current_user_refresh(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,11 @@ class VesselAISRecord(BaseModel):
|
||||
sog: float | None = None
|
||||
cog: float | None = Field(default=None, ge=0, le=360)
|
||||
heading: int | None = Field(default=None, ge=0, le=511)
|
||||
nav_status: int | None = None
|
||||
name: str | None = None
|
||||
callsign: str | None = None
|
||||
vessel_type: str | int | None = None
|
||||
vessel_type_name: str | None = None
|
||||
received_at: datetime | None = None
|
||||
|
||||
|
||||
@@ -104,8 +107,11 @@ TARGET_SCHEMAS: dict[str, TargetSchema] = {
|
||||
TargetField("sog", "float", False, "对地航速,单位节", 12.4),
|
||||
TargetField("cog", "float", False, "对地航向,0-360 度", 184.5),
|
||||
TargetField("heading", "integer", False, "船首向,0-511", 186),
|
||||
TargetField("nav_status", "integer", False, "导航状态码", 0),
|
||||
TargetField("name", "string", False, "船名", "OSLO EXPRESS"),
|
||||
TargetField("vessel_type", "string", False, "船型", "cargo"),
|
||||
TargetField("callsign", "string", False, "呼号", "LAAB"),
|
||||
TargetField("vessel_type", "string", False, "船型代码", 70),
|
||||
TargetField("vessel_type_name", "string", False, "船型名称", "Cargo"),
|
||||
TargetField("received_at", "datetime", False, "数据接收时间", "2026-04-28T00:00:00Z"),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
@@ -15,6 +15,8 @@ class DataBroadcaster:
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.tasks: Dict[str, asyncio.Task] = {}
|
||||
self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {}
|
||||
self._vessel_flush_interval = 1.0
|
||||
|
||||
async def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||
"""Get dashboard statistics"""
|
||||
@@ -68,6 +70,9 @@ class DataBroadcaster:
|
||||
|
||||
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
|
||||
"""Broadcast custom data to a specific channel"""
|
||||
if channel == "vessels":
|
||||
self.enqueue_vessel_update(data)
|
||||
return
|
||||
await manager.broadcast(
|
||||
{
|
||||
"type": "data_frame",
|
||||
@@ -75,9 +80,61 @@ class DataBroadcaster:
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"payload": data,
|
||||
},
|
||||
channel=channel if channel in manager.active_connections else "all",
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
def enqueue_vessel_update(self, data: Dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list):
|
||||
return
|
||||
source = data.get("source")
|
||||
action = data.get("action") or "upsert"
|
||||
created = data.get("created")
|
||||
for vessel in vessels:
|
||||
if not isinstance(vessel, dict):
|
||||
continue
|
||||
mmsi = vessel.get("mmsi")
|
||||
if mmsi in (None, ""):
|
||||
continue
|
||||
self._pending_vessel_updates[str(mmsi)] = {
|
||||
**vessel,
|
||||
"_source": source,
|
||||
"_action": action,
|
||||
"_created": created,
|
||||
}
|
||||
|
||||
async def flush_vessel_updates(self):
|
||||
if not self._pending_vessel_updates:
|
||||
return
|
||||
pending = self._pending_vessel_updates
|
||||
self._pending_vessel_updates = {}
|
||||
vessels = []
|
||||
for item in pending.values():
|
||||
vessel = dict(item)
|
||||
source = vessel.pop("_source", None)
|
||||
action = vessel.pop("_action", "upsert")
|
||||
created = vessel.pop("_created", None)
|
||||
vessel["source"] = source
|
||||
vessel["action"] = action
|
||||
vessel["created"] = created
|
||||
vessels.append(vessel)
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": "mixed",
|
||||
"created": None,
|
||||
"vessels": vessels,
|
||||
}
|
||||
)
|
||||
|
||||
async def broadcast_vessels_periodically(self):
|
||||
while self.running:
|
||||
try:
|
||||
await self.flush_vessel_updates()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(self._vessel_flush_interval)
|
||||
|
||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||
"""Broadcast datasource task progress updates to connected clients."""
|
||||
await manager.broadcast(
|
||||
@@ -95,6 +152,7 @@ class DataBroadcaster:
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
|
||||
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
|
||||
|
||||
def stop(self):
|
||||
"""Stop all broadcasters"""
|
||||
@@ -102,6 +160,7 @@ class DataBroadcaster:
|
||||
for task in self.tasks.values():
|
||||
task.cancel()
|
||||
self.tasks.clear()
|
||||
self._pending_vessel_updates.clear()
|
||||
|
||||
|
||||
broadcaster = DataBroadcaster()
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"""WebSocket Connection Manager"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict, Set, Optional
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Set, Optional
|
||||
from fastapi import WebSocket
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000
|
||||
MAX_VESSEL_WS_MESSAGE_ITEMS = 1000
|
||||
MAX_VESSEL_BBOX_AREA = 2500.0
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
||||
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
||||
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: str):
|
||||
@@ -40,6 +45,83 @@ class ConnectionManager:
|
||||
self.active_connections[user_id].discard(websocket)
|
||||
if not self.active_connections[user_id]:
|
||||
del self.active_connections[user_id]
|
||||
self.unsubscribe_all(websocket)
|
||||
|
||||
def subscribe(self, websocket: WebSocket, channels: list[str]):
|
||||
normalized_channels = {
|
||||
str(channel).strip()
|
||||
for channel in channels
|
||||
if str(channel).strip()
|
||||
}
|
||||
if not normalized_channels:
|
||||
return
|
||||
|
||||
socket_channels = self.websocket_channels.setdefault(websocket, set())
|
||||
for channel in normalized_channels:
|
||||
self.channel_subscriptions.setdefault(channel, set()).add(websocket)
|
||||
socket_channels.add(channel)
|
||||
|
||||
def unsubscribe(self, websocket: WebSocket, channels: list[str]):
|
||||
for channel in {str(channel).strip() for channel in channels if str(channel).strip()}:
|
||||
subscribers = self.channel_subscriptions.get(channel)
|
||||
if subscribers is not None:
|
||||
subscribers.discard(websocket)
|
||||
if not subscribers:
|
||||
del self.channel_subscriptions[channel]
|
||||
socket_channels = self.websocket_channels.get(websocket)
|
||||
if socket_channels is not None:
|
||||
socket_channels.discard(channel)
|
||||
if not socket_channels:
|
||||
del self.websocket_channels[websocket]
|
||||
|
||||
def unsubscribe_all(self, websocket: WebSocket):
|
||||
channels = list(self.websocket_channels.get(websocket, set()))
|
||||
if channels:
|
||||
self.unsubscribe(websocket, channels)
|
||||
self.vessel_subscriptions.pop(websocket, None)
|
||||
|
||||
def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]:
|
||||
subscription = self._normalize_vessel_subscription(config)
|
||||
self.channel_subscriptions.setdefault("vessels", set()).add(websocket)
|
||||
self.websocket_channels.setdefault(websocket, set()).add("vessels")
|
||||
self.vessel_subscriptions[websocket] = subscription
|
||||
return subscription
|
||||
|
||||
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
bbox = config.get("bbox")
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
|
||||
try:
|
||||
lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("bbox values must be numbers") from exc
|
||||
if lat_min > lat_max:
|
||||
lat_min, lat_max = lat_max, lat_min
|
||||
if lon_min > lon_max:
|
||||
lon_min, lon_max = lon_max, lon_min
|
||||
if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
|
||||
raise ValueError("bbox longitude values must be between -180 and 180")
|
||||
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
|
||||
raise ValueError("bbox latitude values must be between -90 and 90")
|
||||
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
|
||||
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
|
||||
|
||||
zoom = int(config.get("zoom") or 1)
|
||||
if zoom < 1 or zoom > 20:
|
||||
raise ValueError("zoom must be between 1 and 20")
|
||||
limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT)
|
||||
vessel_types = {
|
||||
str(item).strip().lower()
|
||||
for item in str(config.get("type") or "").split(",")
|
||||
if str(item).strip()
|
||||
}
|
||||
return {
|
||||
"bbox": (lon_min, lat_min, lon_max, lat_max),
|
||||
"zoom": zoom,
|
||||
"limit": limit,
|
||||
"type": vessel_types,
|
||||
"last_sent_at": None,
|
||||
}
|
||||
|
||||
async def send_personal_message(self, message: dict, user_id: str):
|
||||
if user_id in self.active_connections:
|
||||
@@ -54,13 +136,72 @@ class ConnectionManager:
|
||||
for user_id in self.active_connections:
|
||||
await self.send_personal_message(message, user_id)
|
||||
else:
|
||||
await self.send_personal_message(message, channel)
|
||||
for connection in list(self.channel_subscriptions.get(channel, set())):
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
async def broadcast_vessels(self, data: dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list) or not vessels:
|
||||
return
|
||||
|
||||
for connection, subscription in list(self.vessel_subscriptions.items()):
|
||||
matched = [
|
||||
vessel
|
||||
for vessel in vessels
|
||||
if self._vessel_matches_subscription(vessel, subscription)
|
||||
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
|
||||
if not matched:
|
||||
continue
|
||||
subscription["last_sent_at"] = datetime.now(UTC)
|
||||
message = {
|
||||
"type": "data_frame",
|
||||
"channel": "vessels",
|
||||
"timestamp": subscription["last_sent_at"].isoformat(),
|
||||
"payload": {
|
||||
**data,
|
||||
"vessels": matched,
|
||||
"subscription": {
|
||||
"bbox": list(subscription["bbox"]),
|
||||
"zoom": subscription["zoom"],
|
||||
"limit": subscription["limit"],
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
def _vessel_matches_subscription(
|
||||
self,
|
||||
vessel: dict[str, Any],
|
||||
subscription: dict[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
lon = float(vessel.get("lon"))
|
||||
lat = float(vessel.get("lat"))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = subscription["bbox"]
|
||||
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
|
||||
return False
|
||||
requested_types = subscription.get("type") or set()
|
||||
if not requested_types:
|
||||
return True
|
||||
type_name = str(vessel.get("vessel_type_name") or "").lower()
|
||||
return any(requested_type in type_name for requested_type in requested_types)
|
||||
|
||||
async def close_all(self):
|
||||
for user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
await connection.close()
|
||||
self.active_connections.clear()
|
||||
self.channel_subscriptions.clear()
|
||||
self.websocket_channels.clear()
|
||||
self.vessel_subscriptions.clear()
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"_comment": "Seed payload for the bgp_collector_locations DB table. Coordinates were migrated from the legacy RIPE_RIS_COLLECTOR_COORDS table and default to city-center; seeded rows are unverified and should be upgraded in the database with source evidence when known.",
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc00",
|
||||
"aliases": ["rrc00", "RIPE RIS rrc00", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc01",
|
||||
"aliases": ["rrc01", "RIPE RIS rrc01", "LINX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "LINX",
|
||||
"city": "London",
|
||||
"country": "United Kingdom",
|
||||
"latitude": 51.5072,
|
||||
"longitude": -0.1276,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc03",
|
||||
"aliases": ["rrc03", "RIPE RIS rrc03", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc04",
|
||||
"aliases": ["rrc04", "RIPE RIS rrc04", "CIXP", "CERN Internet Exchange Point"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "CIXP",
|
||||
"city": "Geneva",
|
||||
"country": "Switzerland",
|
||||
"latitude": 46.2044,
|
||||
"longitude": 6.1432,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc05",
|
||||
"aliases": ["rrc05", "RIPE RIS rrc05", "VIX", "Vienna Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "VIX",
|
||||
"city": "Vienna",
|
||||
"country": "Austria",
|
||||
"latitude": 48.2082,
|
||||
"longitude": 16.3738,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc06",
|
||||
"aliases": ["rrc06", "RIPE RIS rrc06", "JPIX", "Otemachi"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "JPIX",
|
||||
"city": "Otemachi",
|
||||
"country": "Japan",
|
||||
"latitude": 35.686,
|
||||
"longitude": 139.7671,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc07",
|
||||
"aliases": ["rrc07", "RIPE RIS rrc07", "Netnod", "Netnod Stockholm"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Netnod Stockholm",
|
||||
"city": "Stockholm",
|
||||
"country": "Sweden",
|
||||
"latitude": 59.3293,
|
||||
"longitude": 18.0686,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc10",
|
||||
"aliases": ["rrc10", "RIPE RIS rrc10", "MIX", "Milan Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "MIX",
|
||||
"city": "Milan",
|
||||
"country": "Italy",
|
||||
"latitude": 45.4642,
|
||||
"longitude": 9.19,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc11",
|
||||
"aliases": ["rrc11", "RIPE RIS rrc11", "NYIIX", "New York International Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "NYIIX",
|
||||
"city": "New York",
|
||||
"country": "United States",
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.006,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc12",
|
||||
"aliases": ["rrc12", "RIPE RIS rrc12", "DE-CIX", "DE-CIX Frankfurt"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "DE-CIX Frankfurt",
|
||||
"city": "Frankfurt",
|
||||
"country": "Germany",
|
||||
"latitude": 50.1109,
|
||||
"longitude": 8.6821,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc13",
|
||||
"aliases": ["rrc13", "RIPE RIS rrc13", "MSK-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "MSK-IX",
|
||||
"city": "Moscow",
|
||||
"country": "Russia",
|
||||
"latitude": 55.7558,
|
||||
"longitude": 37.6173,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc14",
|
||||
"aliases": ["rrc14", "RIPE RIS rrc14", "PAIX", "Palo Alto Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "PAIX",
|
||||
"city": "Palo Alto",
|
||||
"country": "United States",
|
||||
"latitude": 37.4419,
|
||||
"longitude": -122.143,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc15",
|
||||
"aliases": ["rrc15", "RIPE RIS rrc15", "PTT.br Sao Paulo", "PTTMetro Sao Paulo"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "PTT.br",
|
||||
"city": "Sao Paulo",
|
||||
"country": "Brazil",
|
||||
"latitude": -23.5558,
|
||||
"longitude": -46.6396,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc16",
|
||||
"aliases": ["rrc16", "RIPE RIS rrc16", "Equinix Miami", "NOTA Miami"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Equinix Miami",
|
||||
"city": "Miami",
|
||||
"country": "United States",
|
||||
"latitude": 25.7617,
|
||||
"longitude": -80.1918,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc18",
|
||||
"aliases": ["rrc18", "RIPE RIS rrc18", "CATNIX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "CATNIX",
|
||||
"city": "Barcelona",
|
||||
"country": "Spain",
|
||||
"latitude": 41.3874,
|
||||
"longitude": 2.1686,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc19",
|
||||
"aliases": ["rrc19", "RIPE RIS rrc19", "NAPAfrica", "JINX", "NAPAfrica Johannesburg"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "NAPAfrica Johannesburg",
|
||||
"city": "Johannesburg",
|
||||
"country": "South Africa",
|
||||
"latitude": -26.2041,
|
||||
"longitude": 28.0473,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc20",
|
||||
"aliases": ["rrc20", "RIPE RIS rrc20", "SwissIX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "SwissIX",
|
||||
"city": "Zurich",
|
||||
"country": "Switzerland",
|
||||
"latitude": 47.3769,
|
||||
"longitude": 8.5417,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc21",
|
||||
"aliases": ["rrc21", "RIPE RIS rrc21", "France-IX Paris"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "France-IX Paris",
|
||||
"city": "Paris",
|
||||
"country": "France",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc22",
|
||||
"aliases": ["rrc22", "RIPE RIS rrc22", "InterLAN Bucharest"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "InterLAN Bucharest",
|
||||
"city": "Bucharest",
|
||||
"country": "Romania",
|
||||
"latitude": 44.4268,
|
||||
"longitude": 26.1025,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc23",
|
||||
"aliases": ["rrc23", "RIPE RIS rrc23", "Equinix Singapore"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Equinix Singapore",
|
||||
"city": "Singapore",
|
||||
"country": "Singapore",
|
||||
"latitude": 1.3521,
|
||||
"longitude": 103.8198,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc24",
|
||||
"aliases": ["rrc24", "RIPE RIS rrc24", "LACNIC Montevideo"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "LACNIC Montevideo",
|
||||
"city": "Montevideo",
|
||||
"country": "Uruguay",
|
||||
"latitude": -34.9011,
|
||||
"longitude": -56.1645,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc25",
|
||||
"aliases": ["rrc25", "RIPE RIS rrc25", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc26",
|
||||
"aliases": ["rrc26", "RIPE RIS rrc26", "UAE-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "UAE-IX",
|
||||
"city": "Dubai",
|
||||
"country": "United Arab Emirates",
|
||||
"latitude": 25.2048,
|
||||
"longitude": 55.2708,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
}
|
||||
],
|
||||
"city_fallbacks": []
|
||||
}
|
||||
@@ -72,25 +72,44 @@ async def seed_default_datasources(session: AsyncSession):
|
||||
await session.commit()
|
||||
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def ensure_default_admin_user(session: AsyncSession):
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import User
|
||||
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = 'admin'")
|
||||
)
|
||||
if result.fetchone():
|
||||
return
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username="admin",
|
||||
email="admin@planet.local",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username=default_user["username"],
|
||||
email=default_user["email"],
|
||||
password_hash=get_password_hash(default_user["password"]),
|
||||
role=default_user["role"],
|
||||
is_active=True,
|
||||
email_verified=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -103,14 +122,17 @@ async def init_db():
|
||||
import app.models.datasource_config # noqa: F401
|
||||
import app.models.alert # noqa: F401
|
||||
import app.models.bgp_anomaly # noqa: F401
|
||||
import app.models.bgp_collector_location # noqa: F401
|
||||
import app.models.bgp_incident # noqa: F401
|
||||
import app.models.bgp_observation # noqa: F401
|
||||
import app.models.collected_data # noqa: F401
|
||||
import app.models.compute_center_location # noqa: F401
|
||||
import app.models.system_setting # noqa: F401
|
||||
import app.models.playground_session # noqa: F401
|
||||
import app.models.playground_message # noqa: F401
|
||||
import app.models.system_log # noqa: F401
|
||||
import app.models.vessel # noqa: F401
|
||||
import app.models.vessel_enrichment # noqa: F401
|
||||
import app.models.datasource_mapping # noqa: F401
|
||||
|
||||
logger.warning_event(
|
||||
@@ -127,6 +149,31 @@ async def init_db():
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
users_email_verified_existed = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'users' AND column_name = 'email_verified'
|
||||
"""
|
||||
)
|
||||
)
|
||||
).fetchone() is not None
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS pending_email VARCHAR(255)
|
||||
"""
|
||||
)
|
||||
)
|
||||
if not users_email_verified_existed:
|
||||
await conn.execute(
|
||||
text("UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE")
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -163,6 +210,50 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_collected_data_source_current_id
|
||||
ON collected_data (source, is_current, id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_collected_data_source_task_id
|
||||
ON collected_data (source, task_id, id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_entity
|
||||
ON ais_raw_observations (target_schema, observed_at, entity_key)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_desc
|
||||
ON ais_raw_observations (target_schema, observed_at DESC)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_payload_lon_lat
|
||||
ON ais_raw_observations (
|
||||
((normalized_payload->>'lon')::double precision),
|
||||
((normalized_payload->>'lat')::double precision)
|
||||
)
|
||||
WHERE target_schema = 'vessel_ais'
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -183,5 +274,14 @@ async def init_db():
|
||||
)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from app.services.bgp_collector_locations import (
|
||||
seed_default_bgp_collector_locations,
|
||||
)
|
||||
from app.services.compute_center_locations import (
|
||||
seed_compute_center_locations_from_source_coords,
|
||||
)
|
||||
|
||||
await seed_default_bgp_collector_locations(session)
|
||||
await seed_compute_center_locations_from_source_coords(session)
|
||||
await seed_default_datasources(session)
|
||||
await ensure_default_admin_user(session)
|
||||
|
||||
@@ -6,8 +6,10 @@ from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.playground_session import PlaygroundSession
|
||||
from app.models.playground_message import PlaygroundMessage
|
||||
@@ -27,8 +29,10 @@ __all__ = [
|
||||
"AlertSeverity",
|
||||
"AlertStatus",
|
||||
"BGPAnomaly",
|
||||
"BGPCollectorLocation",
|
||||
"BGPIncident",
|
||||
"BGPObservation",
|
||||
"ComputeCenterLocationRecord",
|
||||
"SystemLog",
|
||||
"AuditLog",
|
||||
"PlaygroundSession",
|
||||
|
||||
52
backend/app/models/bgp_collector_location.py
Normal file
52
backend/app/models/bgp_collector_location.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Stored BGP route-collector locations."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class BGPCollectorLocation(Base):
|
||||
"""Current known location for a BGP route collector."""
|
||||
|
||||
__tablename__ = "bgp_collector_locations"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
collector_id = Column(String(100), nullable=False, unique=True, index=True)
|
||||
operator = Column(String(255), nullable=True)
|
||||
site = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
country = Column(String(255), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
precision = Column(String(30), nullable=False, default="city")
|
||||
confidence = Column(Float, nullable=True)
|
||||
source = Column(String(80), nullable=False, default="legacy_seed", index=True)
|
||||
source_url = Column(String(500), nullable=True)
|
||||
source_note = Column(Text, nullable=True)
|
||||
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||
needs_confirmation = Column(Boolean, nullable=False, default=True, index=True)
|
||||
verification_status = Column(String(30), nullable=False, default="unverified", index=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_location_dict(self) -> dict:
|
||||
return {
|
||||
"city": self.city,
|
||||
"country": self.country,
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"precision": self.precision,
|
||||
"source": self.source,
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"matched_location_name": self.site or self.collector_id,
|
||||
"verified_at": to_iso8601_utc(self.verified_at),
|
||||
"confidence": self.confidence,
|
||||
"operator": self.operator,
|
||||
"site": self.site,
|
||||
"verification_status": self.verification_status,
|
||||
"source_note": self.source_note,
|
||||
"source_url": self.source_url,
|
||||
}
|
||||
@@ -48,6 +48,8 @@ class CollectedData(Base):
|
||||
# Indexes for common queries
|
||||
__table_args__ = (
|
||||
Index("idx_collected_data_source_collected", "source", "collected_at"),
|
||||
Index("idx_collected_data_source_current_id", "source", "is_current", "id"),
|
||||
Index("idx_collected_data_source_task_id", "source", "task_id", "id"),
|
||||
Index("idx_collected_data_source_type", "source", "data_type"),
|
||||
Index("idx_collected_data_source_source_id", "source", "source_id"),
|
||||
)
|
||||
|
||||
60
backend/app/models/compute_center_location.py
Normal file
60
backend/app/models/compute_center_location.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Stored compute-center locations."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class ComputeCenterLocationRecord(Base):
|
||||
"""Current known location for a compute-center record."""
|
||||
|
||||
__tablename__ = "compute_center_locations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "source_id", name="uq_compute_center_location_source_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(100), nullable=False, index=True)
|
||||
source_id = Column(String(255), nullable=False, index=True)
|
||||
name = Column(String(500), nullable=True)
|
||||
operator = Column(String(255), nullable=True)
|
||||
site = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
country = Column(String(255), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
precision = Column(String(30), nullable=False, default="city")
|
||||
confidence = Column(Float, nullable=True)
|
||||
location_source = Column(String(80), nullable=False, default="stored_compute_center_location", index=True)
|
||||
source_url = Column(String(500), nullable=True)
|
||||
source_note = Column(Text, nullable=True)
|
||||
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||
needs_confirmation = Column(Boolean, nullable=False, default=False, index=True)
|
||||
verification_status = Column(String(30), nullable=False, default="verified", index=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_location_dict(self) -> dict:
|
||||
return {
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"operator": self.operator,
|
||||
"site": self.site,
|
||||
"city": self.city,
|
||||
"country": self.country,
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"precision": self.precision,
|
||||
"confidence": self.confidence,
|
||||
"location_source": self.location_source,
|
||||
"source_url": self.source_url,
|
||||
"source_note": self.source_note,
|
||||
"raw_payload": self.raw_payload or {},
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"verification_status": self.verification_status,
|
||||
"verified_at": to_iso8601_utc(self.verified_at),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
@@ -12,7 +12,10 @@ class User(Base):
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), default="viewer")
|
||||
gatekeeper_groups = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True)
|
||||
email_verified = Column(Boolean, default=False, nullable=False)
|
||||
pending_email = Column(String(255), nullable=True)
|
||||
last_login_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
|
||||
@@ -97,6 +97,7 @@ class AISRawObservation(Base):
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_ais_raw_entity_observed", "target_schema", "entity_key", "observed_at"),
|
||||
Index("idx_ais_raw_schema_observed_entity", "target_schema", "observed_at", "entity_key"),
|
||||
Index("idx_ais_raw_source_entity", "source", "entity_key"),
|
||||
)
|
||||
|
||||
|
||||
63
backend/app/models/vessel_enrichment.py
Normal file
63
backend/app/models/vessel_enrichment.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Vessel enrichment cache tables (v5).
|
||||
|
||||
Profile and media enrichment are stored separately so cache TTLs can differ
|
||||
and so the conflict-resolution + display layers can read either independently.
|
||||
"""
|
||||
|
||||
from sqlalchemy import BigInteger, Column, DateTime, Float, JSON, String
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class VesselProfileEnrichment(Base):
|
||||
"""Cached static vessel profile (type, flag, dimensions, operator, etc.)."""
|
||||
|
||||
__tablename__ = "vessel_profile_enrichment"
|
||||
|
||||
mmsi = Column(BigInteger, primary_key=True)
|
||||
source = Column(String(100), nullable=False, default="system")
|
||||
payload = Column(JSON, nullable=False, default=dict)
|
||||
fetched_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
confidence = Column(Float, nullable=True)
|
||||
reference_url = Column(String(500), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"mmsi": self.mmsi,
|
||||
"source": self.source,
|
||||
"payload": self.payload or {},
|
||||
"fetched_at": to_iso8601_utc(self.fetched_at),
|
||||
"expires_at": to_iso8601_utc(self.expires_at),
|
||||
"confidence": self.confidence,
|
||||
"reference_url": self.reference_url,
|
||||
}
|
||||
|
||||
|
||||
class VesselMediaEnrichment(Base):
|
||||
"""Cached vessel imagery / external detail references."""
|
||||
|
||||
__tablename__ = "vessel_media_enrichment"
|
||||
|
||||
mmsi = Column(BigInteger, primary_key=True)
|
||||
source = Column(String(100), nullable=False, default="system")
|
||||
payload = Column(JSON, nullable=False, default=dict)
|
||||
fetched_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
confidence = Column(Float, nullable=True)
|
||||
reference_url = Column(String(500), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"mmsi": self.mmsi,
|
||||
"source": self.source,
|
||||
"payload": self.payload or {},
|
||||
"fetched_at": to_iso8601_utc(self.fetched_at),
|
||||
"expires_at": to_iso8601_utc(self.expires_at),
|
||||
"confidence": self.confidence,
|
||||
"reference_url": self.reference_url,
|
||||
}
|
||||
@@ -12,17 +12,20 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
role: str = "viewer"
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
role: Optional[str] = None
|
||||
gatekeeper_groups: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
last_login_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
@@ -34,8 +37,36 @@ class UserInDB(UserBase):
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
email_verified: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class ResendCodeRequest(BaseModel):
|
||||
email: EmailStr
|
||||
purpose: str = Field(default="register", pattern="^(register|verify_email|reset_password)$")
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
7
backend/app/services/ai_tools/__init__.py
Normal file
7
backend/app/services/ai_tools/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Backend-owned AI tool services.
|
||||
|
||||
The services in this package are business tools used by Planet's backend
|
||||
orchestrators. They intentionally live outside ``aiprovider`` so model transport
|
||||
stays separate from evidence collection and domain policy.
|
||||
"""
|
||||
|
||||
48
backend/app/services/ai_tools/evidence_store.py
Normal file
48
backend/app/services/ai_tools/evidence_store.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Iterable
|
||||
|
||||
from app.services.ai_tools.schemas import FetchedEvidence, SearchEvidence
|
||||
|
||||
|
||||
def evidence_content_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_search_evidence(items: Iterable[SearchEvidence], *, limit: int = 5) -> list[dict]:
|
||||
normalized: list[dict] = []
|
||||
seen_urls: set[str] = set()
|
||||
for item in items:
|
||||
if not item.url or item.url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(item.url)
|
||||
normalized.append(
|
||||
{
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"snippet": item.snippet,
|
||||
"content": item.compact_text(),
|
||||
"score": item.score,
|
||||
"source_provider": item.source_provider,
|
||||
"retrieved_at": item.retrieved_at.isoformat(),
|
||||
"metadata": item.metadata,
|
||||
}
|
||||
)
|
||||
if len(normalized) >= limit:
|
||||
break
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_fetched_evidence(item: FetchedEvidence, *, text_limit: int = 1200) -> dict:
|
||||
text = " ".join(item.text.split())[:text_limit]
|
||||
return {
|
||||
"title": item.title,
|
||||
"url": item.final_url or item.url,
|
||||
"text": text,
|
||||
"content_hash": item.content_hash or evidence_content_hash(item.text),
|
||||
"extractor": item.extractor,
|
||||
"retrieved_at": item.retrieved_at.isoformat(),
|
||||
"metadata": item.metadata,
|
||||
}
|
||||
|
||||
63
backend/app/services/ai_tools/schemas.py
Normal file
63
backend/app/services/ai_tools/schemas.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchEvidence(BaseModel):
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
content: str = ""
|
||||
score: float | None = None
|
||||
source_provider: str = ""
|
||||
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def compact_text(self, limit: int = 700) -> str:
|
||||
text = " ".join((self.content or self.snippet or "").split())
|
||||
return text[:limit]
|
||||
|
||||
|
||||
class FetchedEvidence(BaseModel):
|
||||
url: str
|
||||
final_url: str = ""
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
content_hash: str = ""
|
||||
extractor: str = "basic_html"
|
||||
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebSearchProviderConfig(BaseModel):
|
||||
provider: str = "tavily"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
max_results: int = Field(default=5, ge=1, le=20)
|
||||
timeout_seconds: int = Field(default=20, ge=3, le=120)
|
||||
endpoint_path: str = ""
|
||||
search_depth: str = "basic"
|
||||
engine: str = "google"
|
||||
include_answer: bool = False
|
||||
include_raw_content: bool = False
|
||||
include_text: bool = False
|
||||
categories: str = "general"
|
||||
engines: list[str] = Field(default_factory=list)
|
||||
search_path: str = ""
|
||||
scrape_path: str = ""
|
||||
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
|
||||
|
||||
|
||||
class WebSearchConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
default_provider: str = "tavily"
|
||||
provider: str = "tavily"
|
||||
providers: dict[str, WebSearchProviderConfig] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def active_provider_config(self) -> WebSearchProviderConfig:
|
||||
return self.providers.get(self.default_provider) or self.providers.get(self.provider) or WebSearchProviderConfig(provider=self.default_provider or self.provider)
|
||||
|
||||
56
backend/app/services/ai_tools/web_fetch.py
Normal file
56
backend/app/services/ai_tools/web_fetch.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.services.ai_tools.schemas import FetchedEvidence
|
||||
|
||||
|
||||
class WebFetchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _extract_title_and_text(html: str) -> tuple[str, str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "svg"]):
|
||||
tag.decompose()
|
||||
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
||||
main = soup.find("main") or soup.find("article") or soup.body or soup
|
||||
text = main.get_text("\n", strip=True)
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
return title, "\n".join(lines)
|
||||
|
||||
|
||||
async def fetch_url_evidence(
|
||||
url: str,
|
||||
*,
|
||||
timeout_seconds: int = 20,
|
||||
max_bytes: int = 1_500_000,
|
||||
) -> FetchedEvidence:
|
||||
if not url:
|
||||
raise WebFetchError("url is required")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout_seconds,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "PlanetEvidenceFetcher/1.0"},
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
content = response.content[:max_bytes]
|
||||
except httpx.HTTPError as exc:
|
||||
raise WebFetchError(f"failed to fetch page: {exc}") from exc
|
||||
|
||||
title, text = _extract_title_and_text(content.decode(response.encoding or "utf-8", errors="ignore"))
|
||||
content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return FetchedEvidence(
|
||||
url=url,
|
||||
final_url=str(response.url),
|
||||
title=title,
|
||||
text=text,
|
||||
content_hash=content_hash,
|
||||
extractor="beautifulsoup_basic",
|
||||
)
|
||||
|
||||
391
backend/app/services/ai_tools/web_search.py
Normal file
391
backend/app/services/ai_tools/web_search.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig
|
||||
|
||||
|
||||
WEB_SEARCH_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"tavily": {
|
||||
"provider": "tavily",
|
||||
"label": "Tavily",
|
||||
"api_key_env": "TAVILY_API_KEY",
|
||||
"base_url": "https://api.tavily.com",
|
||||
"endpoint_path": "/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"search_depth": "basic",
|
||||
"include_answer": False,
|
||||
"include_raw_content": False,
|
||||
},
|
||||
"brave": {
|
||||
"provider": "brave",
|
||||
"label": "Brave Search API",
|
||||
"api_key_env": "BRAVE_SEARCH_API_KEY",
|
||||
"base_url": "https://api.search.brave.com",
|
||||
"endpoint_path": "/res/v1/web/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
"serpapi": {
|
||||
"provider": "serpapi",
|
||||
"label": "SerpAPI",
|
||||
"api_key_env": "SERPAPI_API_KEY",
|
||||
"base_url": "https://serpapi.com",
|
||||
"endpoint_path": "/search.json",
|
||||
"engine": "google",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
"exa": {
|
||||
"provider": "exa",
|
||||
"label": "Exa",
|
||||
"api_key_env": "EXA_API_KEY",
|
||||
"base_url": "https://api.exa.ai",
|
||||
"endpoint_path": "/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"include_text": False,
|
||||
},
|
||||
"firecrawl": {
|
||||
"provider": "firecrawl",
|
||||
"label": "Firecrawl Search / Scrape",
|
||||
"api_key_env": "FIRECRAWL_API_KEY",
|
||||
"base_url": "https://api.firecrawl.dev",
|
||||
"search_path": "/v2/search",
|
||||
"scrape_path": "/v2/scrape",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 30,
|
||||
"scrape_formats": ["markdown"],
|
||||
},
|
||||
"searxng": {
|
||||
"provider": "searxng",
|
||||
"label": "SearXNG",
|
||||
"api_key_env": "SEARXNG_API_KEY",
|
||||
"base_url": "http://localhost:8080",
|
||||
"endpoint_path": "/",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"categories": "general",
|
||||
"engines": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WebSearchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WebSearchConfigurationError(WebSearchError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_web_search_provider(provider: str | None) -> str:
|
||||
return (provider or "tavily").strip().lower() or "tavily"
|
||||
|
||||
|
||||
def get_web_search_provider_preset(provider: str) -> dict[str, Any]:
|
||||
provider_id = normalize_web_search_provider(provider)
|
||||
preset = WEB_SEARCH_PROVIDER_PRESETS.get(provider_id)
|
||||
if not preset:
|
||||
raise ValueError(f"Unsupported web search provider: {provider}")
|
||||
return deepcopy(preset)
|
||||
|
||||
|
||||
def list_web_search_provider_presets() -> list[dict[str, Any]]:
|
||||
return [get_web_search_provider_preset(provider) for provider in WEB_SEARCH_PROVIDER_PRESETS]
|
||||
|
||||
|
||||
def provider_defaults(provider: str) -> WebSearchProviderConfig:
|
||||
preset = get_web_search_provider_preset(provider)
|
||||
return WebSearchProviderConfig(**{
|
||||
key: value
|
||||
for key, value in preset.items()
|
||||
if key in WebSearchProviderConfig.model_fields
|
||||
})
|
||||
|
||||
|
||||
class WebSearchClient:
|
||||
def __init__(self, config: WebSearchConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
max_results: int | None = None,
|
||||
domains: list[str] | None = None,
|
||||
freshness_days: int | None = None,
|
||||
) -> list[SearchEvidence]:
|
||||
if not self.config.enabled:
|
||||
raise WebSearchConfigurationError("WebSearch is disabled.")
|
||||
provider_config = self.config.active_provider_config
|
||||
provider = normalize_web_search_provider(provider_config.provider)
|
||||
if provider != "searxng" and not provider_config.api_key:
|
||||
raise WebSearchConfigurationError(f"{provider} API key is not configured.")
|
||||
query = " ".join(str(query or "").split())
|
||||
if not query:
|
||||
raise WebSearchConfigurationError("search query is required.")
|
||||
limit = max_results or provider_config.max_results
|
||||
if provider == "tavily":
|
||||
return await self._search_tavily(provider_config, query, limit, domains, freshness_days)
|
||||
if provider == "brave":
|
||||
return await self._search_brave(provider_config, query, limit, domains)
|
||||
if provider == "serpapi":
|
||||
return await self._search_serpapi(provider_config, query, limit)
|
||||
if provider == "exa":
|
||||
return await self._search_exa(provider_config, query, limit, domains)
|
||||
if provider == "firecrawl":
|
||||
return await self._search_firecrawl(provider_config, query, limit)
|
||||
if provider == "searxng":
|
||||
return await self._search_searxng(provider_config, query, limit, domains)
|
||||
raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}")
|
||||
|
||||
async def test_connection(self) -> list[SearchEvidence]:
|
||||
return await self.search("Planet WebSearch connectivity test", max_results=1)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
provider_config: WebSearchProviderConfig,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=provider_config.timeout_seconds) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text or exc.response.reason_phrase
|
||||
raise WebSearchError(f"{provider_config.provider} request failed: {detail}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise WebSearchError(f"{provider_config.provider} request failed: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise WebSearchError(f"{provider_config.provider} returned invalid JSON") from exc
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def _search_tavily(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
freshness_days: int | None,
|
||||
) -> list[SearchEvidence]:
|
||||
body: dict[str, Any] = {
|
||||
"api_key": config.api_key,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
"search_depth": config.search_depth or "basic",
|
||||
"include_answer": config.include_answer,
|
||||
"include_raw_content": config.include_raw_content,
|
||||
}
|
||||
if domains:
|
||||
body["include_domains"] = domains
|
||||
if freshness_days:
|
||||
body["days"] = freshness_days
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||
provider_config=config,
|
||||
json=body,
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("content") or ""),
|
||||
content=str(item.get("raw_content") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="tavily",
|
||||
metadata={"query": data.get("query") or query},
|
||||
)
|
||||
for item in data.get("results") or []
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_brave(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
search_query = query
|
||||
if domains:
|
||||
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/res/v1/web/search"),
|
||||
provider_config=config,
|
||||
headers={"X-Subscription-Token": config.api_key},
|
||||
params={"q": search_query, "count": max_results},
|
||||
)
|
||||
results = (data.get("web") or {}).get("results") or []
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("description") or ""),
|
||||
source_provider="brave",
|
||||
metadata={"age": item.get("age")},
|
||||
)
|
||||
for item in results
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_serpapi(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
) -> list[SearchEvidence]:
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search.json"),
|
||||
provider_config=config,
|
||||
params={
|
||||
"api_key": config.api_key,
|
||||
"engine": config.engine or "google",
|
||||
"q": query,
|
||||
"num": max_results,
|
||||
},
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("link") or ""),
|
||||
snippet=str(item.get("snippet") or ""),
|
||||
source_provider="serpapi",
|
||||
metadata={"position": item.get("position")},
|
||||
)
|
||||
for item in data.get("organic_results") or []
|
||||
if isinstance(item, dict) and item.get("link")
|
||||
]
|
||||
|
||||
async def _search_exa(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
body: dict[str, Any] = {
|
||||
"query": query,
|
||||
"numResults": max_results,
|
||||
}
|
||||
if domains:
|
||||
body["includeDomains"] = domains
|
||||
if config.include_text:
|
||||
body["contents"] = {"text": True}
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||
provider_config=config,
|
||||
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||
json=body,
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("summary") or ""),
|
||||
content=str(item.get("text") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="exa",
|
||||
metadata={"id": item.get("id")},
|
||||
)
|
||||
for item in data.get("results") or []
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_firecrawl(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
) -> list[SearchEvidence]:
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.search_path or "/v2/search"),
|
||||
provider_config=config,
|
||||
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||
json={"query": query, "limit": max_results},
|
||||
)
|
||||
raw_results = data.get("data") or data.get("results") or []
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or item.get("sourceURL") or ""),
|
||||
snippet=str(item.get("description") or item.get("markdown") or ""),
|
||||
source_provider="firecrawl",
|
||||
metadata={"status": item.get("status")},
|
||||
)
|
||||
for item in raw_results
|
||||
if isinstance(item, dict) and (item.get("url") or item.get("sourceURL"))
|
||||
]
|
||||
|
||||
async def _search_searxng(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
search_query = query
|
||||
if domains:
|
||||
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||
params: dict[str, Any] = {
|
||||
"q": search_query,
|
||||
"format": "json",
|
||||
"categories": config.categories or "general",
|
||||
}
|
||||
if config.engines:
|
||||
params["engines"] = ",".join(config.engines)
|
||||
headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else None
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/"),
|
||||
provider_config=config,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
results = data.get("results") or []
|
||||
evidence = [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("content") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="searxng",
|
||||
metadata={"engine": item.get("engine")},
|
||||
)
|
||||
for item in results
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
return evidence[:max_results]
|
||||
|
||||
|
||||
def _join_url(base_url: str, path: str) -> str:
|
||||
return f"{(base_url or '').rstrip('/')}/{(path or '').lstrip('/')}"
|
||||
|
||||
|
||||
def _float_or_none(value: Any) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
324
backend/app/services/bgp_collector_locations.py
Normal file
324
backend/app/services/bgp_collector_locations.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""BGP route-collector location resolver.
|
||||
|
||||
Collector positions are stored in the ``bgp_collector_locations`` database
|
||||
table. The old JSON registry is now only a seed payload used during database
|
||||
initialization, not a runtime resolver or candidate source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.services.location import (
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
coerce_str,
|
||||
normalize_text,
|
||||
)
|
||||
|
||||
SEED_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "data"
|
||||
/ "seeds"
|
||||
/ "ripe_ris_collector_locations_seed.json"
|
||||
)
|
||||
|
||||
# ── Geocoder (kept at module level for monkeypatching + cache_clear) ──
|
||||
|
||||
_geocode_online = build_default_nominatim_geocoder()
|
||||
|
||||
|
||||
# ── In-process compatibility cache ──────────────────────────────────
|
||||
|
||||
|
||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]:
|
||||
return record.to_location_dict()
|
||||
|
||||
|
||||
def set_bgp_collector_location_cache(
|
||||
locations: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Replace the legacy compatibility cache in-place."""
|
||||
RIPE_RIS_COLLECTOR_COORDS.clear()
|
||||
RIPE_RIS_COLLECTOR_COORDS.update(
|
||||
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||
)
|
||||
|
||||
|
||||
async def refresh_bgp_collector_location_cache(
|
||||
session: AsyncSession,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result = await session.execute(select(BGPCollectorLocation))
|
||||
records = result.scalars().all()
|
||||
cache = {
|
||||
record.collector_id: _collector_record_to_dict(record)
|
||||
for record in records
|
||||
if record.collector_id
|
||||
}
|
||||
set_bgp_collector_location_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def _load_seed_payload() -> dict[str, Any]:
|
||||
with SEED_PATH.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"operator": entry.get("operator") or "RIPE NCC",
|
||||
"site": entry.get("site"),
|
||||
"city": entry.get("city"),
|
||||
"country": entry.get("country"),
|
||||
"latitude": entry.get("latitude"),
|
||||
"longitude": entry.get("longitude"),
|
||||
"precision": entry.get("precision") or "city",
|
||||
"confidence": entry.get("confidence"),
|
||||
"source": "legacy_seed",
|
||||
"source_url": None,
|
||||
"source_note": entry.get("source_note")
|
||||
or "Seeded from legacy RIPE RIS collector coordinates",
|
||||
"raw_payload": entry,
|
||||
"needs_confirmation": True,
|
||||
"verification_status": "unverified",
|
||||
"verified_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def seed_default_bgp_collector_locations(session: AsyncSession) -> None:
|
||||
"""Seed default RIPE RIS collector locations without overwriting users."""
|
||||
payload = _load_seed_payload()
|
||||
for entry in payload.get("locations", []):
|
||||
aliases = entry.get("aliases") or []
|
||||
collector_ids = [
|
||||
coerce_str(alias)
|
||||
for alias in aliases
|
||||
if coerce_str(alias).startswith("rrc")
|
||||
]
|
||||
if not collector_ids:
|
||||
continue
|
||||
collector_id = collector_ids[0]
|
||||
existing = await session.scalar(
|
||||
select(BGPCollectorLocation).where(
|
||||
BGPCollectorLocation.collector_id == collector_id
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
session.add(
|
||||
BGPCollectorLocation(
|
||||
**_seed_entry_to_record_kwargs(entry, collector_id)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await refresh_bgp_collector_location_cache(session)
|
||||
|
||||
|
||||
def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]:
|
||||
"""Return the current cached collector location dict, or ``{}`` if unknown."""
|
||||
return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {}))
|
||||
|
||||
|
||||
def iter_known_collector_names() -> Iterator[str]:
|
||||
"""Yield every collector technical name (rrcXX) known in the cache."""
|
||||
return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys()))
|
||||
|
||||
|
||||
# ── Pipeline construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
class StoredCollectorLocationResolver:
|
||||
"""Resolve a collector through the DB-backed compatibility cache."""
|
||||
|
||||
name = "stored_collector_location"
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
collector = coerce_str(query.name)
|
||||
if not collector:
|
||||
for alias in query.aliases:
|
||||
collector = coerce_str(alias)
|
||||
if collector:
|
||||
break
|
||||
if not collector:
|
||||
return ResolverOutput()
|
||||
location = get_bgp_collector_location_dict(collector)
|
||||
if not location:
|
||||
return ResolverOutput()
|
||||
latitude = location.get("latitude")
|
||||
longitude = location.get("longitude")
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=float(latitude),
|
||||
longitude=float(longitude),
|
||||
display_name=location.get("matched_location_name") or collector,
|
||||
precision=location.get("precision") or "city",
|
||||
confidence=float(location.get("confidence") or 0.85),
|
||||
query=f"stored_collector_location::{collector}",
|
||||
source=location.get("source") or self.name,
|
||||
source_note=location.get("source_note"),
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=bool(location.get("needs_confirmation")),
|
||||
city=location.get("city"),
|
||||
region=None,
|
||||
country=location.get("country"),
|
||||
matched_location_name=(
|
||||
location.get("matched_location_name") or collector
|
||||
),
|
||||
location_verified_at=location.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bgp_collector_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Build the Nominatim query plan for a BGP collector."""
|
||||
extra = query.extra or {}
|
||||
site = str(extra.get("site") or "")
|
||||
operator = str(extra.get("operator") or "")
|
||||
city = query.city or ""
|
||||
country = query.country or ""
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
|
||||
def add(parts: list[tuple[str, str]]) -> None:
|
||||
non_empty = [(field, value) for field, value in parts if value]
|
||||
if not non_empty:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
fields: list[str] = []
|
||||
for field, value in non_empty:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(value)
|
||||
fields.append(field)
|
||||
if not cleaned:
|
||||
return
|
||||
composed = ", ".join(cleaned)
|
||||
if not any(composed == existing for existing, _ in plan):
|
||||
plan.append((composed, tuple(fields)))
|
||||
|
||||
add([("site", site), ("city", city), ("country", country)])
|
||||
add([("site", site), ("country", country)])
|
||||
add([("operator", operator), ("city", city), ("country", country)])
|
||||
add([("city", city), ("country", country)])
|
||||
return plan
|
||||
|
||||
|
||||
BGP_COLLECTOR_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
StoredCollectorLocationResolver(),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP collector to renderable coordinates from"
|
||||
" source coordinates or stored collector location."
|
||||
),
|
||||
)
|
||||
|
||||
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
NominatimResolver(
|
||||
query_plan_builder=_bgp_collector_query_plan,
|
||||
# Late-binding so tests can monkeypatch ``_geocode_online``.
|
||||
geocoder=lambda q: _geocode_online(q),
|
||||
),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP collector to renderable coordinates from"
|
||||
" source coordinates or online geocoding."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_bgp_collector_location(
|
||||
collector_name: str,
|
||||
*,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve a BGP collector to its best-known stored location."""
|
||||
stored = get_bgp_collector_location_dict(collector_name)
|
||||
name = coerce_str(collector_name) or None
|
||||
query = LocationQuery(
|
||||
name=name,
|
||||
aliases=tuple(filter(None, (collector_name,))),
|
||||
city=coerce_str(city or stored.get("city")) or None,
|
||||
country=coerce_str(country or stored.get("country")) or None,
|
||||
extra={
|
||||
"site": coerce_str(site or stored.get("site")),
|
||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||
},
|
||||
)
|
||||
return BGP_COLLECTOR_PIPELINE.resolve_best(query)
|
||||
|
||||
|
||||
def collect_bgp_collector_location_candidates(
|
||||
*,
|
||||
collector: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
query = build_bgp_collector_location_query(
|
||||
collector=collector,
|
||||
city=city,
|
||||
country=country,
|
||||
site=site,
|
||||
operator=operator,
|
||||
)
|
||||
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
|
||||
|
||||
|
||||
def build_bgp_collector_location_query(
|
||||
*,
|
||||
collector: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> LocationQuery:
|
||||
stored = get_bgp_collector_location_dict(collector or "")
|
||||
name = coerce_str(collector) or None
|
||||
return LocationQuery(
|
||||
name=name,
|
||||
aliases=tuple(filter(None, (collector,))),
|
||||
city=coerce_str(city or stored.get("city")) or None,
|
||||
country=coerce_str(country or stored.get("country")) or None,
|
||||
extra={
|
||||
"site": coerce_str(site or stored.get("site")),
|
||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||
"collector": coerce_str(collector),
|
||||
},
|
||||
)
|
||||
155
backend/app/services/bgp_event_locations.py
Normal file
155
backend/app/services/bgp_event_locations.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""BGP event location resolver.
|
||||
|
||||
A BGP event (announcement / withdrawal / RIB entry) is geographically tied to
|
||||
the route collector that observed it. This module defines the pipeline that
|
||||
turns an event payload into renderable coordinates.
|
||||
|
||||
Current resolver chain:
|
||||
|
||||
SourceCoordinates → event payload itself carries lat/lon (rare; some
|
||||
enriched feeds do).
|
||||
InheritFromCollector → look up the owning collector via
|
||||
:func:`resolve_bgp_collector_location`.
|
||||
|
||||
Future plug-ins (no consumer changes required, just append to the list):
|
||||
|
||||
ASNFacilityResolver — origin/peer ASN → peeringdb facility.
|
||||
PrefixGeoResolver — prefix → IP range geo lookup (iptoasn / opengeofeed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.bgp_collector_locations import (
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.location import (
|
||||
InheritFromAnotherEntityResolver,
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
ResolutionResult,
|
||||
SourceCoordinatesResolver,
|
||||
coerce_str,
|
||||
)
|
||||
|
||||
|
||||
def _inherit_from_owning_collector(
|
||||
query: LocationQuery,
|
||||
) -> LocationCandidate | None:
|
||||
"""Look up the event's owning collector by exact name in the DB-backed cache."""
|
||||
extra = query.extra or {}
|
||||
collector_name = coerce_str(extra.get("collector"))
|
||||
if not collector_name:
|
||||
return None
|
||||
legacy = get_bgp_collector_location_dict(collector_name)
|
||||
if not legacy:
|
||||
return None
|
||||
latitude = legacy.get("latitude")
|
||||
longitude = legacy.get("longitude")
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return None
|
||||
return LocationCandidate(
|
||||
latitude=float(latitude),
|
||||
longitude=float(longitude),
|
||||
display_name=legacy.get("matched_location_name") or collector_name,
|
||||
precision=legacy.get("precision") or "city",
|
||||
confidence=float(legacy.get("confidence") or 0.85),
|
||||
query=f"inherit_from_collector::{collector_name}",
|
||||
source="inherited_from_collector",
|
||||
source_note=(
|
||||
f"Inherited from owning collector {collector_name}"
|
||||
),
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=bool(legacy.get("needs_confirmation")),
|
||||
city=legacy.get("city"),
|
||||
region=None,
|
||||
country=legacy.get("country"),
|
||||
matched_location_name=legacy.get("matched_location_name"),
|
||||
location_verified_at=legacy.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
|
||||
|
||||
BGP_EVENT_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
InheritFromAnotherEntityResolver(
|
||||
source_lookup=_inherit_from_owning_collector,
|
||||
name="inherited_from_collector",
|
||||
),
|
||||
# Plug new resolvers (peeringdb / ASN facility / prefix-geo) here.
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP event coordinates: no source coords, owning"
|
||||
" collector unknown, and no fallback resolver matched."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_bgp_event_location(
|
||||
*,
|
||||
collector: str,
|
||||
source_latitude: float | None = None,
|
||||
source_longitude: float | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
peer_asn: int | None = None,
|
||||
origin_asn: int | None = None,
|
||||
prefix: str | None = None,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve a BGP event to its renderable coordinates.
|
||||
|
||||
The ``peer_asn`` / ``origin_asn`` / ``prefix`` arguments are accepted
|
||||
today so future resolvers (ASN→facility, prefix→geo) can consume them
|
||||
without callers needing to change.
|
||||
"""
|
||||
query = LocationQuery(
|
||||
name=collector or None,
|
||||
aliases=tuple(filter(None, (collector,))),
|
||||
source_latitude=source_latitude,
|
||||
source_longitude=source_longitude,
|
||||
extra={
|
||||
"collector": collector or "",
|
||||
"site": coerce_str(site),
|
||||
"operator": coerce_str(operator),
|
||||
"peer_asn": peer_asn,
|
||||
"origin_asn": origin_asn,
|
||||
"prefix": coerce_str(prefix),
|
||||
},
|
||||
)
|
||||
return BGP_EVENT_PIPELINE.resolve_best(query)
|
||||
|
||||
|
||||
def resolve_bgp_event_geo_dict(
|
||||
collector: str,
|
||||
*,
|
||||
source_latitude: float | None = None,
|
||||
source_longitude: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convenience wrapper returning the legacy ``collector_geo`` dict shape.
|
||||
|
||||
Preserves ``city``/``country``/``latitude``/``longitude`` keys (consumed
|
||||
by existing detectors / enrichment / DB serialization) and adds
|
||||
``precision``/``source``/``needs_confirmation`` for richer downstream use.
|
||||
"""
|
||||
result = resolve_bgp_event_location(
|
||||
collector=collector,
|
||||
source_latitude=source_latitude,
|
||||
source_longitude=source_longitude,
|
||||
)
|
||||
candidate = result.location
|
||||
if candidate is None:
|
||||
return {}
|
||||
return {
|
||||
"city": candidate.city,
|
||||
"country": candidate.country,
|
||||
"latitude": candidate.latitude,
|
||||
"longitude": candidate.longitude,
|
||||
"precision": candidate.precision,
|
||||
"source": candidate.source,
|
||||
"needs_confirmation": candidate.needs_confirmation,
|
||||
"matched_location_name": candidate.matched_location_name,
|
||||
"confidence": candidate.confidence,
|
||||
}
|
||||
@@ -10,7 +10,10 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.task import CollectionTask
|
||||
from app.services.collectors.base import BaseCollector
|
||||
from app.services.vessel_ais_aggregation import (
|
||||
AISSTREAM_DELIVERY_MODE,
|
||||
@@ -66,9 +69,20 @@ class AISStreamCollector(BaseCollector):
|
||||
"bounding_boxes": config.get("bounding_boxes") or DEFAULT_BOUNDING_BOXES,
|
||||
"message_types": config.get("message_types") or DEFAULT_MESSAGE_TYPES,
|
||||
"max_messages": int(config.get("max_messages") or 500),
|
||||
"streaming_enabled": config.get("streaming_enabled", True) is not False,
|
||||
"streaming_commit_interval": int(config.get("streaming_commit_interval") or 1),
|
||||
"streaming_max_messages": int(config.get("streaming_max_messages") or 0),
|
||||
"reconnect_delay_seconds": float(config.get("reconnect_delay_seconds") or 5),
|
||||
"receive_timeout_seconds": float(config.get("receive_timeout_seconds") or 30),
|
||||
}
|
||||
|
||||
def _build_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"APIKey": config["api_key"],
|
||||
"BoundingBoxes": config["bounding_boxes"],
|
||||
"FilterMessageTypes": config["message_types"],
|
||||
}
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
config = await self._get_effective_config()
|
||||
if not config["api_key"]:
|
||||
@@ -79,11 +93,7 @@ class AISStreamCollector(BaseCollector):
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Python package 'websockets' is required for AISStream") from exc
|
||||
|
||||
subscription = {
|
||||
"APIKey": config["api_key"],
|
||||
"BoundingBoxes": config["bounding_boxes"],
|
||||
"FilterMessageTypes": config["message_types"],
|
||||
}
|
||||
subscription = self._build_subscription(config)
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
try:
|
||||
@@ -113,6 +123,157 @@ class AISStreamCollector(BaseCollector):
|
||||
|
||||
return messages
|
||||
|
||||
async def run(self, db: AsyncSession) -> dict[str, Any]:
|
||||
"""Run AISStream as a long-lived streaming collector by default."""
|
||||
config = await self._get_effective_config()
|
||||
if not config.get("streaming_enabled", True):
|
||||
return await super().run(db)
|
||||
if not config["api_key"]:
|
||||
return {"status": "failed", "error": "AISStream API key is not configured"}
|
||||
|
||||
from app.services.collectors.registry import collector_registry
|
||||
|
||||
if not collector_registry.is_active(self.name):
|
||||
return {"status": "skipped", "reason": "Collector is disabled"}
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
|
||||
|
||||
start_time = datetime.now(UTC)
|
||||
task = CollectionTask(
|
||||
datasource_id=getattr(self, "_datasource_id", 1),
|
||||
status="running",
|
||||
phase="connecting",
|
||||
phase_message="正在连接 AISStream 实时流",
|
||||
phase_unit="messages",
|
||||
started_at=start_time,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
self._current_task = task
|
||||
self._db_session = db
|
||||
self._last_broadcast_progress = None
|
||||
await self.resolve_url(db)
|
||||
await self._publish_task_update(force=True)
|
||||
|
||||
records_added = 0
|
||||
messages_seen = 0
|
||||
unique_mmsi: set[str] = set()
|
||||
reconnect_delay = config["reconnect_delay_seconds"]
|
||||
|
||||
try:
|
||||
while True:
|
||||
config = await self._get_effective_config()
|
||||
subscription = self._build_subscription(config)
|
||||
try:
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=self.name,
|
||||
connection_state="connecting",
|
||||
)
|
||||
await self.set_phase("connecting", message="正在连接 AISStream 实时流")
|
||||
await db.commit()
|
||||
|
||||
async with websockets.connect(config["endpoint"]) as websocket:
|
||||
await websocket.send(json.dumps(subscription))
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=self.name,
|
||||
connection_state="connected",
|
||||
last_success_at=datetime.now(UTC),
|
||||
)
|
||||
await self.set_phase(
|
||||
"streaming",
|
||||
message="正在接收 AISStream 实时消息",
|
||||
reset_progress=False,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
while True:
|
||||
try:
|
||||
raw_message = await asyncio.wait_for(
|
||||
websocket.recv(),
|
||||
timeout=config["receive_timeout_seconds"],
|
||||
)
|
||||
except TimeoutError:
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=self.name,
|
||||
connection_state="connected",
|
||||
last_success_at=datetime.now(UTC),
|
||||
)
|
||||
await db.commit()
|
||||
continue
|
||||
|
||||
payload = json.loads(raw_message)
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
messages_seen += 1
|
||||
record = self._normalize_message(payload)
|
||||
if not record:
|
||||
continue
|
||||
unique_mmsi.add(str(record["mmsi"]))
|
||||
created = await self._save_stream_record(db, record)
|
||||
if created:
|
||||
records_added += 1
|
||||
|
||||
task.records_processed = messages_seen
|
||||
task.total_records = None
|
||||
task.progress = None
|
||||
task.phase = "streaming"
|
||||
task.phase_message = "正在接收 AISStream 实时消息"
|
||||
task.phase_current = messages_seen
|
||||
task.phase_total = None
|
||||
task.phase_unit = "messages"
|
||||
await self._publish_task_update(force=True)
|
||||
|
||||
if config["streaming_max_messages"] and messages_seen >= config["streaming_max_messages"]:
|
||||
task.status = "success"
|
||||
task.phase = "stopped"
|
||||
task.phase_message = "AISStream 测试流已停止"
|
||||
task.completed_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await self._publish_task_update(force=True)
|
||||
return {
|
||||
"status": "success",
|
||||
"task_id": task.id,
|
||||
"records_processed": records_added,
|
||||
"messages_seen": messages_seen,
|
||||
"unique_mmsi": len(unique_mmsi),
|
||||
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=self.name,
|
||||
connection_state="reconnecting",
|
||||
last_error=f"{exc.__class__.__name__}: {exc}",
|
||||
)
|
||||
task.phase = "reconnecting"
|
||||
task.phase_message = "AISStream 连接中断,正在重连"
|
||||
task.error_message = f"{exc.__class__.__name__}: {exc}"
|
||||
await db.commit()
|
||||
await self._publish_task_update(force=True)
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
except asyncio.CancelledError:
|
||||
task.status = "cancelled"
|
||||
task.phase = "stopped"
|
||||
task.phase_message = "AISStream 实时流已停止"
|
||||
task.completed_at = datetime.now(UTC)
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=self.name,
|
||||
connection_state="disconnected",
|
||||
last_error=None,
|
||||
)
|
||||
await db.commit()
|
||||
await self._publish_task_update(force=True)
|
||||
raise
|
||||
|
||||
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
records = []
|
||||
for item in raw_data:
|
||||
@@ -165,6 +326,60 @@ class AISStreamCollector(BaseCollector):
|
||||
await self.update_progress(records_added, force=True)
|
||||
return records_added
|
||||
|
||||
async def _save_stream_record(self, db: AsyncSession, item: dict[str, Any]) -> bool:
|
||||
now = datetime.now(UTC)
|
||||
observed_at = item.get("received_at") or now
|
||||
observation = await record_vessel_ais_observation(
|
||||
db,
|
||||
source=self.name,
|
||||
normalized_payload=item,
|
||||
raw_payload=item.get("_raw_payload") or item,
|
||||
delivery_mode=AISSTREAM_DELIVERY_MODE,
|
||||
transport=AISSTREAM_TRANSPORT,
|
||||
message_type=item.get("_message_type") or "PositionReport",
|
||||
source_message_id=item.get("_source_message_id"),
|
||||
observed_at=observed_at,
|
||||
collected_at=now,
|
||||
)
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=self.name,
|
||||
connection_state="connected",
|
||||
observed_count=1,
|
||||
last_seen_at=observed_at if isinstance(observed_at, datetime) else now,
|
||||
last_success_at=now,
|
||||
lag_seconds=max((now - observed_at).total_seconds(), 0) if isinstance(observed_at, datetime) else None,
|
||||
)
|
||||
await db.commit()
|
||||
await self._broadcast_vessel_delta(item, created=observation is not None)
|
||||
return observation is not None
|
||||
|
||||
async def _broadcast_vessel_delta(self, item: dict[str, Any], *, created: bool) -> None:
|
||||
await broadcaster.broadcast_custom(
|
||||
"vessels",
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": self.name,
|
||||
"created": created,
|
||||
"vessels": [
|
||||
{
|
||||
"mmsi": item.get("mmsi"),
|
||||
"mmsi_display": str(item.get("mmsi")) if item.get("mmsi") is not None else None,
|
||||
"name": item.get("name"),
|
||||
"lat": item.get("lat"),
|
||||
"lon": item.get("lon"),
|
||||
"sog": item.get("sog"),
|
||||
"cog": item.get("cog"),
|
||||
"heading": item.get("heading"),
|
||||
"nav_status": item.get("nav_status"),
|
||||
"vessel_type": item.get("vessel_type"),
|
||||
"vessel_type_name": item.get("vessel_type_name"),
|
||||
"received_at": to_iso8601_utc(item.get("received_at")),
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def _normalize_message(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
message_type = str(item.get("MessageType") or item.get("message_type") or "")
|
||||
metadata = item.get("MetaData") if isinstance(item.get("MetaData"), dict) else {}
|
||||
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_event_locations import resolve_bgp_event_geo_dict
|
||||
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
||||
from app.services.bgp_detectors import (
|
||||
detect_mass_withdrawal_anomalies,
|
||||
@@ -23,32 +28,17 @@ from app.services.bgp_detectors import (
|
||||
)
|
||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
||||
|
||||
|
||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {
|
||||
"rrc00": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc01": {"city": "London", "country": "United Kingdom", "latitude": 51.5072, "longitude": -0.1276},
|
||||
"rrc03": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc04": {"city": "Geneva", "country": "Switzerland", "latitude": 46.2044, "longitude": 6.1432},
|
||||
"rrc05": {"city": "Vienna", "country": "Austria", "latitude": 48.2082, "longitude": 16.3738},
|
||||
"rrc06": {"city": "Otemachi", "country": "Japan", "latitude": 35.686, "longitude": 139.7671},
|
||||
"rrc07": {"city": "Stockholm", "country": "Sweden", "latitude": 59.3293, "longitude": 18.0686},
|
||||
"rrc10": {"city": "Milan", "country": "Italy", "latitude": 45.4642, "longitude": 9.19},
|
||||
"rrc11": {"city": "New York", "country": "United States", "latitude": 40.7128, "longitude": -74.006},
|
||||
"rrc12": {"city": "Frankfurt", "country": "Germany", "latitude": 50.1109, "longitude": 8.6821},
|
||||
"rrc13": {"city": "Moscow", "country": "Russia", "latitude": 55.7558, "longitude": 37.6173},
|
||||
"rrc14": {"city": "Palo Alto", "country": "United States", "latitude": 37.4419, "longitude": -122.143},
|
||||
"rrc15": {"city": "Sao Paulo", "country": "Brazil", "latitude": -23.5558, "longitude": -46.6396},
|
||||
"rrc16": {"city": "Miami", "country": "United States", "latitude": 25.7617, "longitude": -80.1918},
|
||||
"rrc18": {"city": "Barcelona", "country": "Spain", "latitude": 41.3874, "longitude": 2.1686},
|
||||
"rrc19": {"city": "Johannesburg", "country": "South Africa", "latitude": -26.2041, "longitude": 28.0473},
|
||||
"rrc20": {"city": "Zurich", "country": "Switzerland", "latitude": 47.3769, "longitude": 8.5417},
|
||||
"rrc21": {"city": "Paris", "country": "France", "latitude": 48.8566, "longitude": 2.3522},
|
||||
"rrc22": {"city": "Bucharest", "country": "Romania", "latitude": 44.4268, "longitude": 26.1025},
|
||||
"rrc23": {"city": "Singapore", "country": "Singapore", "latitude": 1.3521, "longitude": 103.8198},
|
||||
"rrc24": {"city": "Montevideo", "country": "Uruguay", "latitude": -34.9011, "longitude": -56.1645},
|
||||
"rrc25": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc26": {"city": "Dubai", "country": "United Arab Emirates", "latitude": 25.2048, "longitude": 55.2708},
|
||||
}
|
||||
# Re-exported for backward compatibility with anything that imports
|
||||
# ``RIPE_RIS_COLLECTOR_COORDS`` from this module. New code should call
|
||||
# ``app.services.bgp_collector_locations.get_bgp_collector_location_dict()``
|
||||
# or ``resolve_bgp_collector_location()`` instead — those use the DB-backed
|
||||
# collector-location cache.
|
||||
__all__ = [
|
||||
"RIPE_RIS_COLLECTOR_COORDS",
|
||||
"normalize_bgp_event",
|
||||
"save_bgp_observations_for_batch",
|
||||
"create_bgp_anomalies_for_batch",
|
||||
]
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
@@ -131,7 +121,19 @@ def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, A
|
||||
)
|
||||
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
||||
# Routes through the BGP event pipeline: source coords (if any) →
|
||||
# collector inheritance. Returned dict keeps the legacy
|
||||
# {city, country, latitude, longitude} keys plus richer
|
||||
# {precision, source, needs_confirmation, matched_location_name, confidence}.
|
||||
collector_location = resolve_bgp_event_geo_dict(
|
||||
collector,
|
||||
source_latitude=payload.get("latitude"),
|
||||
source_longitude=payload.get("longitude"),
|
||||
)
|
||||
# Empty result (unknown collector & no source coords) — keep the
|
||||
# downstream-expected dict shape so detectors / serializers don't crash.
|
||||
if not collector_location:
|
||||
collector_location = get_bgp_collector_location_dict(collector)
|
||||
network_fields = extract_bgp_network_fields(prefix)
|
||||
metadata = {
|
||||
"project": project,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""BarentsWatch AIS collector for vessel tracking."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vessel import VesselPosition, VesselStatic
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.services.barentswatch import (
|
||||
BARENTSWATCH_LATEST_URL,
|
||||
fetch_barentswatch_access_token,
|
||||
@@ -101,40 +101,6 @@ class VesselAISCollector(BaseCollector):
|
||||
observed_at=observed_at,
|
||||
collected_at=now,
|
||||
)
|
||||
|
||||
static = await db.get(VesselStatic, item["mmsi"])
|
||||
if static is None:
|
||||
static = VesselStatic(mmsi=item["mmsi"])
|
||||
db.add(static)
|
||||
|
||||
for field in (
|
||||
"name",
|
||||
"callsign",
|
||||
"vessel_type",
|
||||
"vessel_type_name",
|
||||
"flag",
|
||||
"length",
|
||||
"width",
|
||||
"draught",
|
||||
"imo",
|
||||
):
|
||||
value = item.get(field)
|
||||
if value not in (None, ""):
|
||||
setattr(static, field, value)
|
||||
static.updated_at = now
|
||||
|
||||
db.add(
|
||||
VesselPosition(
|
||||
mmsi=item["mmsi"],
|
||||
lat=item["lat"],
|
||||
lon=item["lon"],
|
||||
sog=item.get("sog"),
|
||||
cog=item.get("cog"),
|
||||
heading=item.get("heading"),
|
||||
nav_status=item.get("nav_status"),
|
||||
received_at=observed_at,
|
||||
)
|
||||
)
|
||||
records_added += 1
|
||||
|
||||
if (index + 1) % 1000 == 0:
|
||||
@@ -153,13 +119,46 @@ class VesselAISCollector(BaseCollector):
|
||||
last_success_at=now if data else None,
|
||||
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||
)
|
||||
await db.execute(
|
||||
delete(VesselPosition).where(VesselPosition.received_at < now - timedelta(hours=24))
|
||||
)
|
||||
await db.commit()
|
||||
await self._broadcast_vessel_snapshot(data)
|
||||
await self.update_progress(records_added, force=True)
|
||||
return records_added
|
||||
|
||||
async def _broadcast_vessel_snapshot(self, data: list[dict[str, Any]]) -> None:
|
||||
"""Push REST collector updates through the same realtime vessel channel."""
|
||||
if not data:
|
||||
return
|
||||
|
||||
batch_size = 500
|
||||
for offset in range(0, len(data), batch_size):
|
||||
batch = data[offset : offset + batch_size]
|
||||
await broadcaster.broadcast_custom(
|
||||
"vessels",
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": self.name,
|
||||
"created": True,
|
||||
"vessels": [
|
||||
{
|
||||
"mmsi": item.get("mmsi"),
|
||||
"mmsi_display": str(item.get("mmsi")) if item.get("mmsi") is not None else None,
|
||||
"name": item.get("name"),
|
||||
"callsign": item.get("callsign"),
|
||||
"lat": item.get("lat"),
|
||||
"lon": item.get("lon"),
|
||||
"sog": item.get("sog"),
|
||||
"cog": item.get("cog"),
|
||||
"heading": item.get("heading"),
|
||||
"nav_status": item.get("nav_status"),
|
||||
"vessel_type": item.get("vessel_type"),
|
||||
"vessel_type_name": item.get("vessel_type_name"),
|
||||
"received_at": to_iso8601_utc(item.get("received_at")),
|
||||
}
|
||||
for item in batch
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def _normalize_record(self, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
mmsi = _as_int(_pick(item, "mmsi", "MMSI", "Mmsi"))
|
||||
lat = _as_float(_pick(item, "lat", "latitude", "Latitude"))
|
||||
|
||||
886
backend/app/services/compute_center_locations.py
Normal file
886
backend/app/services/compute_center_locations.py
Normal file
@@ -0,0 +1,886 @@
|
||||
"""Compute-center location resolver, built on the shared location pipeline.
|
||||
|
||||
This module is a thin domain wrapper that wires up
|
||||
:mod:`app.services.location` for compute centers:
|
||||
|
||||
SourceCoordinates
|
||||
|
||||
The online Nominatim step is intentionally reserved for the user-triggered
|
||||
``collect-location`` flow. The regular GeoJSON endpoint runs during Earth
|
||||
startup, so it must stay local and deterministic.
|
||||
|
||||
For the full design and the reason behind the abstraction (compute centers,
|
||||
BGP collectors, BGP events, and future entities all share one pipeline),
|
||||
see ``docs/plans/location-resolver-shared-pipeline-plan.md``.
|
||||
|
||||
The ``ComputeCenterLocation`` dataclass and the public function signatures are
|
||||
preserved verbatim so existing callers and tests do not need to change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
|
||||
from app.services.location import (
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
ROR_SEARCH_URL = "https://api.ror.org/v2/organizations"
|
||||
DEFAULT_ROR_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||
DEFAULT_ROR_TIMEOUT_SECONDS = 8.0
|
||||
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||
FORBIDDEN_PRECISIONS: tuple[str, ...] = (
|
||||
"country",
|
||||
"estimated_country",
|
||||
"country_major_compute_city",
|
||||
"region",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
# ── Public dataclasses ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComputeCenterLocation:
|
||||
latitude: float | None
|
||||
longitude: float | None
|
||||
location_precision: str
|
||||
geography_mode: str
|
||||
is_estimated: bool
|
||||
estimated_reason: str | None = None
|
||||
location_confidence: float | None = None
|
||||
location_source: str | None = None
|
||||
location_source_note: str | None = None
|
||||
location_verified_at: str | None = None
|
||||
matched_location_name: str | None = None
|
||||
needs_confirmation: bool = False
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
|
||||
@property
|
||||
def is_renderable(self) -> bool:
|
||||
if self.latitude in (None, 0.0) or self.longitude in (None, 0.0):
|
||||
return False
|
||||
return self.location_precision in RENDERABLE_PRECISIONS
|
||||
|
||||
def to_geojson_properties(self) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"location_precision": self.location_precision,
|
||||
"geography_mode": self.geography_mode,
|
||||
"is_estimated": self.is_estimated,
|
||||
"estimated_reason": self.estimated_reason,
|
||||
"location_confidence": self.location_confidence,
|
||||
"location_source": self.location_source,
|
||||
"location_source_note": self.location_source_note,
|
||||
"location_verified_at": self.location_verified_at,
|
||||
"matched_location_name": self.matched_location_name,
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionDiagnostic:
|
||||
failure_reason: str
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
record_id: int | None = None
|
||||
source: str | None = None
|
||||
source_id: str | None = None
|
||||
name: str | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
site: str | None = None
|
||||
operator: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"failure_reason": self.failure_reason,
|
||||
"attempted_queries": list(self.attempted_queries),
|
||||
"record_id": self.record_id,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"country": self.country,
|
||||
"city": self.city,
|
||||
"site": self.site,
|
||||
"operator": self.operator,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
location: ComputeCenterLocation | None
|
||||
diagnostic: ResolutionDiagnostic | None
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return bool(self.location and self.location.is_renderable)
|
||||
|
||||
|
||||
# ── Geocoder (kept at module level so tests can monkeypatch + cache_clear) ──
|
||||
|
||||
_geocode_online = build_default_nominatim_geocoder()
|
||||
|
||||
|
||||
# ── Stored location cache ───────────────────────────────────────────
|
||||
|
||||
|
||||
COMPUTE_CENTER_LOCATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _cache_key(source: str | None, source_id: str | None) -> str:
|
||||
return f"{coerce_str(source)}:{coerce_str(source_id)}"
|
||||
|
||||
|
||||
def set_compute_center_location_cache(
|
||||
locations: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
COMPUTE_CENTER_LOCATION_CACHE.clear()
|
||||
COMPUTE_CENTER_LOCATION_CACHE.update(
|
||||
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||
)
|
||||
|
||||
|
||||
async def refresh_compute_center_location_cache(
|
||||
session: AsyncSession,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result = await session.execute(select(ComputeCenterLocationRecord))
|
||||
records = result.scalars().all()
|
||||
cache = {}
|
||||
for record in records:
|
||||
if not hasattr(record, "to_location_dict"):
|
||||
continue
|
||||
if not record.source or not record.source_id:
|
||||
continue
|
||||
cache[_cache_key(record.source, record.source_id)] = record.to_location_dict()
|
||||
set_compute_center_location_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def get_compute_center_location_dict(
|
||||
source: str | None,
|
||||
source_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return dict(COMPUTE_CENTER_LOCATION_CACHE.get(_cache_key(source, source_id), {}))
|
||||
|
||||
|
||||
# ── Pipeline construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _lookup_ror_organization(query: str) -> dict[str, Any] | None:
|
||||
"""Lookup a research organization in ROR for user-triggered candidates."""
|
||||
if not query:
|
||||
return None
|
||||
response = httpx.get(
|
||||
ROR_SEARCH_URL,
|
||||
params={"query": query},
|
||||
headers={"User-Agent": DEFAULT_ROR_USER_AGENT},
|
||||
timeout=DEFAULT_ROR_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
first = items[0]
|
||||
if not isinstance(first, dict):
|
||||
return None
|
||||
organization = first.get("organization")
|
||||
if isinstance(organization, dict):
|
||||
return organization
|
||||
return first
|
||||
|
||||
|
||||
def _compute_center_ror_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
extra = query.extra or {}
|
||||
raw_parts: list[tuple[str, str]] = [
|
||||
("site", coerce_str(extra.get("site"))),
|
||||
("operator", coerce_str(extra.get("operator"))),
|
||||
("organization", coerce_str(extra.get("organization"))),
|
||||
]
|
||||
for field, value in tuple(raw_parts):
|
||||
if "/" not in value:
|
||||
continue
|
||||
raw_parts.extend(
|
||||
(field, part.strip())
|
||||
for part in value.split("/")
|
||||
if len(part.strip()) >= 3
|
||||
)
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
seen: set[str] = set()
|
||||
for field, value in raw_parts:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
plan.append((value, (field,)))
|
||||
return plan
|
||||
|
||||
|
||||
def _organization_label(organization: dict[str, Any], fallback: str) -> str:
|
||||
names = organization.get("names")
|
||||
if isinstance(names, list):
|
||||
for name in names:
|
||||
if not isinstance(name, dict):
|
||||
continue
|
||||
types = name.get("types")
|
||||
if isinstance(types, list) and "ror_display" in types:
|
||||
value = coerce_str(name.get("value"))
|
||||
if value:
|
||||
return value
|
||||
for name in names:
|
||||
if isinstance(name, dict):
|
||||
value = coerce_str(name.get("value"))
|
||||
if value:
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
class ROROrganizationResolver:
|
||||
"""Resolve source-provided organization/site text through the open ROR API."""
|
||||
|
||||
name = "ror_organization_registry"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query_plan_builder=_compute_center_ror_query_plan,
|
||||
lookup=lambda q: _lookup_ror_organization(q),
|
||||
confidence: float = 0.68,
|
||||
) -> None:
|
||||
self._query_plan_builder = query_plan_builder
|
||||
self._lookup = lookup
|
||||
self._confidence = confidence
|
||||
|
||||
def resolve(self, query: LocationQuery):
|
||||
from app.services.location import ResolverOutput
|
||||
from app.services.location.text import parse_float
|
||||
|
||||
attempted: list[str] = []
|
||||
candidates: list[LocationCandidate] = []
|
||||
context_country = normalize_text(normalize_country_text(query.country))
|
||||
|
||||
for ror_query, matched_fields in self._query_plan_builder(query):
|
||||
attempted.append(f"ror:{ror_query}")
|
||||
try:
|
||||
organization = self._lookup(ror_query)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(organization, dict):
|
||||
continue
|
||||
locations = organization.get("locations")
|
||||
if not isinstance(locations, list) or not locations:
|
||||
continue
|
||||
location = locations[0]
|
||||
if not isinstance(location, dict):
|
||||
continue
|
||||
details = location.get("geonames_details")
|
||||
if not isinstance(details, dict):
|
||||
continue
|
||||
latitude = parse_float(details.get("lat"))
|
||||
longitude = parse_float(details.get("lng"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
|
||||
country = normalize_country_text(details.get("country_name"))
|
||||
if context_country and normalize_text(country) != context_country:
|
||||
continue
|
||||
|
||||
city = coerce_str(details.get("name")) or None
|
||||
region = coerce_str(details.get("country_subdivision_name")) or None
|
||||
display_name = _organization_label(organization, ror_query)
|
||||
ror_id = coerce_str(organization.get("id"))
|
||||
geonames_id = location.get("geonames_id")
|
||||
source_note = (
|
||||
f"ROR organization match: {display_name}"
|
||||
+ (f" ({ror_id})" if ror_id else "")
|
||||
+ (f"; GeoNames {geonames_id}" if geonames_id else "")
|
||||
)
|
||||
candidates.append(
|
||||
LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=display_name,
|
||||
precision="city",
|
||||
confidence=self._confidence,
|
||||
query=ror_query,
|
||||
source=self.name,
|
||||
source_note=source_note,
|
||||
matched_fields=matched_fields,
|
||||
needs_confirmation=True,
|
||||
city=city,
|
||||
region=region,
|
||||
country=country or query.country,
|
||||
matched_location_name=display_name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
)
|
||||
|
||||
return ResolverOutput(
|
||||
candidates=tuple(candidates),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
|
||||
|
||||
class StoredComputeCenterLocationResolver:
|
||||
"""Resolve a compute center through the DB-backed current-location cache."""
|
||||
|
||||
name = "stored_compute_center_location"
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
extra = query.extra or {}
|
||||
stored = get_compute_center_location_dict(
|
||||
coerce_str(extra.get("source")),
|
||||
coerce_str(extra.get("source_id")),
|
||||
)
|
||||
if not stored:
|
||||
return ResolverOutput()
|
||||
latitude = parse_float(stored.get("latitude"))
|
||||
longitude = parse_float(stored.get("longitude"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=stored.get("name") or query.name or "Compute center",
|
||||
precision=stored.get("precision") or "city",
|
||||
confidence=float(stored.get("confidence") or 0.85),
|
||||
query=f"stored_compute_center_location::{stored.get('source')}:{stored.get('source_id')}",
|
||||
source=self.name,
|
||||
source_note=stored.get("source_note"),
|
||||
matched_fields=("source", "source_id"),
|
||||
needs_confirmation=bool(stored.get("needs_confirmation")),
|
||||
city=stored.get("city") or query.city,
|
||||
region=None,
|
||||
country=stored.get("country") or query.country,
|
||||
matched_location_name=stored.get("site") or stored.get("name") or query.name,
|
||||
location_verified_at=stored.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _short_system_name(name: Any) -> str:
|
||||
"""Strip vendor/system suffix from TOP500 names like ``"El Capitan - HPE Cray ..."``."""
|
||||
text = coerce_str(name)
|
||||
if not text:
|
||||
return ""
|
||||
head = text.split(" - ", 1)[0].strip()
|
||||
return head or text
|
||||
|
||||
|
||||
def _record_context(record: Any, metadata: dict[str, Any]) -> dict[str, str]:
|
||||
name = coerce_str(getattr(record, "name", None))
|
||||
return {
|
||||
"source": coerce_str(getattr(record, "source", None)),
|
||||
"source_id": coerce_str(getattr(record, "source_id", None)),
|
||||
"name": name,
|
||||
"name_short": _short_system_name(name),
|
||||
"city": coerce_str(get_record_field(record, "city")),
|
||||
"country": coerce_str(get_record_field(record, "country")),
|
||||
"site": coerce_str(metadata.get("site") or metadata.get("organization")),
|
||||
"operator": coerce_str(
|
||||
metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
),
|
||||
"organization": coerce_str(metadata.get("organization")),
|
||||
}
|
||||
|
||||
|
||||
def _context_to_query(
|
||||
context: dict[str, str],
|
||||
*,
|
||||
source_lat: float | None = None,
|
||||
source_lon: float | None = None,
|
||||
) -> LocationQuery:
|
||||
name = context.get("name") or None
|
||||
name_short = context.get("name_short") or ""
|
||||
aliases: tuple[str, ...] = ()
|
||||
if name_short and name_short != name:
|
||||
aliases = (name_short,)
|
||||
return LocationQuery(
|
||||
name=name,
|
||||
aliases=aliases,
|
||||
city=context.get("city") or None,
|
||||
country=context.get("country") or None,
|
||||
source_latitude=source_lat,
|
||||
source_longitude=source_lon,
|
||||
extra={
|
||||
"source": context.get("source") or "",
|
||||
"source_id": context.get("source_id") or "",
|
||||
"site": context.get("site") or "",
|
||||
"operator": context.get("operator") or "",
|
||||
"organization": context.get("organization") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _compute_center_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Build the Nominatim query plan for a compute-center query.
|
||||
|
||||
Mirrors the legacy ``_build_online_query_plan`` ordering exactly.
|
||||
"""
|
||||
name = query.name or ""
|
||||
name_short = (query.aliases[0] if query.aliases else "") or name
|
||||
extra = query.extra or {}
|
||||
site = str(extra.get("site") or "")
|
||||
operator = str(extra.get("operator") or "")
|
||||
city = query.city or ""
|
||||
country = query.country or ""
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
|
||||
def add(parts: list[tuple[str, str]]) -> None:
|
||||
non_empty = [(field, value) for field, value in parts if value]
|
||||
if not non_empty:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
fields: list[str] = []
|
||||
for field, value in non_empty:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(value)
|
||||
fields.append(field)
|
||||
if not cleaned:
|
||||
return
|
||||
composed = ", ".join(cleaned)
|
||||
if not any(composed == existing for existing, _ in plan):
|
||||
plan.append((composed, tuple(fields)))
|
||||
|
||||
add([("site", site), ("country", country)])
|
||||
add([("operator", operator), ("city", city), ("country", country)])
|
||||
add([("name", name_short), ("operator", operator), ("country", country)])
|
||||
add([("name", name_short), ("site", site)])
|
||||
add([("name", name_short), ("country", country)])
|
||||
add([("name", name_short), ("city", city), ("country", country)])
|
||||
add([("city", city), ("country", country)])
|
||||
if name and name != name_short:
|
||||
add([("name", name), ("country", country)])
|
||||
return plan
|
||||
|
||||
|
||||
COMPUTE_CENTER_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
StoredComputeCenterLocationResolver(),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
" or stored compute-center location."
|
||||
),
|
||||
)
|
||||
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
ROROrganizationResolver(),
|
||||
NominatimResolver(
|
||||
query_plan_builder=_compute_center_query_plan,
|
||||
# Late-binding so test monkeypatching of ``_geocode_online`` works.
|
||||
geocoder=lambda q: _geocode_online(q),
|
||||
),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
", ROR organization lookup, or online geocoding."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── Candidate → ComputeCenterLocation conversion ───────────────────
|
||||
|
||||
|
||||
_GEOGRAPHY_MODE_BY_SOURCE = {
|
||||
"source_coordinates": "source_coordinates",
|
||||
"stored_compute_center_location": "stored_compute_center_location",
|
||||
"ror_organization_registry": "ror_organization",
|
||||
"nominatim_online_geocode": "online_geocode",
|
||||
}
|
||||
|
||||
|
||||
def _candidate_to_location(
|
||||
candidate: LocationCandidate,
|
||||
*,
|
||||
context: dict[str, str],
|
||||
) -> ComputeCenterLocation:
|
||||
geography_mode = _GEOGRAPHY_MODE_BY_SOURCE.get(candidate.source, "online_geocode")
|
||||
is_estimated = candidate.needs_confirmation or candidate.source.startswith(
|
||||
"nominatim"
|
||||
)
|
||||
estimated_reason: str | None
|
||||
if candidate.source == "source_coordinates":
|
||||
estimated_reason = None
|
||||
elif candidate.source == "stored_compute_center_location":
|
||||
estimated_reason = candidate.source_note
|
||||
elif candidate.source == "ror_organization_registry":
|
||||
fields_summary = ", ".join(candidate.matched_fields) or "organization"
|
||||
estimated_reason = (
|
||||
f"Resolved by ROR organization lookup '{candidate.query}' "
|
||||
f"(matched fields: {fields_summary})"
|
||||
)
|
||||
elif candidate.source == "nominatim_online_geocode":
|
||||
fields_summary = ", ".join(candidate.matched_fields) or "name"
|
||||
estimated_reason = (
|
||||
f"Resolved by online geocoding query '{candidate.query}' "
|
||||
f"(matched fields: {fields_summary})"
|
||||
)
|
||||
else:
|
||||
estimated_reason = candidate.source_note
|
||||
|
||||
country = (
|
||||
candidate.country
|
||||
or normalize_country_text(context.get("country"))
|
||||
or context.get("country")
|
||||
or None
|
||||
)
|
||||
return ComputeCenterLocation(
|
||||
latitude=candidate.latitude,
|
||||
longitude=candidate.longitude,
|
||||
location_precision=candidate.precision,
|
||||
geography_mode=geography_mode,
|
||||
is_estimated=is_estimated,
|
||||
estimated_reason=estimated_reason,
|
||||
location_confidence=candidate.confidence,
|
||||
location_source=candidate.source,
|
||||
location_source_note=candidate.source_note,
|
||||
location_verified_at=candidate.location_verified_at,
|
||||
matched_location_name=candidate.matched_location_name
|
||||
or context.get("name")
|
||||
or None,
|
||||
needs_confirmation=candidate.needs_confirmation,
|
||||
city=candidate.city or context.get("city") or None,
|
||||
region=candidate.region,
|
||||
country=country,
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic_for(
|
||||
record: Any,
|
||||
context: dict[str, str],
|
||||
*,
|
||||
failure_reason: str,
|
||||
attempted_queries: tuple[str, ...] = (),
|
||||
) -> ResolutionDiagnostic:
|
||||
return ResolutionDiagnostic(
|
||||
failure_reason=failure_reason,
|
||||
attempted_queries=attempted_queries,
|
||||
record_id=getattr(record, "id", None),
|
||||
source=getattr(record, "source", None),
|
||||
source_id=getattr(record, "source_id", None),
|
||||
name=context.get("name") or getattr(record, "name", None),
|
||||
country=context.get("country") or None,
|
||||
city=context.get("city") or None,
|
||||
site=context.get("site") or None,
|
||||
operator=context.get("operator") or None,
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_compute_center_location(
|
||||
record: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ComputeCenterLocation:
|
||||
"""Backwards-compatible thin wrapper returning the renderable location only.
|
||||
|
||||
Records that cannot be resolved to city-level get a placeholder
|
||||
:class:`ComputeCenterLocation` with ``location_precision='unknown'``.
|
||||
Callers should generally prefer :func:`resolve_compute_center_location_full`.
|
||||
"""
|
||||
full = resolve_compute_center_location_full(record, metadata)
|
||||
return full.location or ComputeCenterLocation(
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
location_precision="unknown",
|
||||
geography_mode="unresolved",
|
||||
is_estimated=True,
|
||||
estimated_reason="No resolvable location hints",
|
||||
location_confidence=0.0,
|
||||
location_source="unknown",
|
||||
location_source_note=(
|
||||
"No source coordinates, ROR organization match, or online"
|
||||
" geocoding result."
|
||||
),
|
||||
matched_location_name=None,
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
|
||||
def resolve_compute_center_location_full(
|
||||
record: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
allow_online: bool = False,
|
||||
) -> ResolutionResult:
|
||||
metadata = metadata or {}
|
||||
context = _record_context(record, metadata)
|
||||
|
||||
from app.services.location.text import parse_float as _parse_float
|
||||
|
||||
source_lat = _parse_float(get_record_field(record, "latitude"))
|
||||
source_lon = _parse_float(get_record_field(record, "longitude"))
|
||||
if source_lat in (None, 0.0):
|
||||
source_lat = None
|
||||
if source_lon in (None, 0.0):
|
||||
source_lon = None
|
||||
|
||||
query = _context_to_query(
|
||||
context, source_lat=source_lat, source_lon=source_lon
|
||||
)
|
||||
pipeline = (
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE
|
||||
if allow_online
|
||||
else COMPUTE_CENTER_PIPELINE
|
||||
)
|
||||
pipeline_result = pipeline.resolve_best(query)
|
||||
|
||||
if pipeline_result.location and pipeline_result.location.precision in RENDERABLE_PRECISIONS:
|
||||
location = _candidate_to_location(pipeline_result.location, context=context)
|
||||
return ResolutionResult(location=location, diagnostic=None)
|
||||
|
||||
return ResolutionResult(
|
||||
location=None,
|
||||
diagnostic=_diagnostic_for(
|
||||
record,
|
||||
context,
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
", ROR organization lookup, or online geocoding."
|
||||
if allow_online
|
||||
else (
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
" or stored compute-center location."
|
||||
)
|
||||
),
|
||||
attempted_queries=pipeline_result.attempted_queries,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def collect_location_candidates(
|
||||
*,
|
||||
name: str | None = None,
|
||||
source: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
organization: str | None = None,
|
||||
record_id: int | None = None,
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
"""Run the full resolution chain and return ranked candidates with attempted queries.
|
||||
|
||||
The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept
|
||||
for backward compatibility with the API handler that calls this function.
|
||||
"""
|
||||
query = build_compute_center_location_query(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
organization=organization,
|
||||
)
|
||||
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
|
||||
|
||||
|
||||
def build_compute_center_location_query(
|
||||
*,
|
||||
name: str | None = None,
|
||||
source: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
organization: str | None = None,
|
||||
) -> LocationQuery:
|
||||
name_value = coerce_str(name)
|
||||
context: dict[str, str] = {
|
||||
"source": coerce_str(source),
|
||||
"source_id": coerce_str(source_id),
|
||||
"name": name_value,
|
||||
"name_short": _short_system_name(name_value),
|
||||
"city": coerce_str(city),
|
||||
"country": coerce_str(country),
|
||||
"site": coerce_str(site or organization),
|
||||
"operator": coerce_str(operator or organization),
|
||||
"organization": coerce_str(organization),
|
||||
}
|
||||
return _context_to_query(context)
|
||||
|
||||
|
||||
def _record_operator(metadata: dict[str, Any]) -> str | None:
|
||||
return coerce_str(
|
||||
metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
) or None
|
||||
|
||||
|
||||
async def seed_compute_center_locations_from_source_coords(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Seed stored compute-center locations only from real source coordinates."""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
changed = False
|
||||
|
||||
for record in records:
|
||||
source_value = coerce_str(getattr(record, "source", None))
|
||||
source_id = coerce_str(getattr(record, "source_id", None))
|
||||
if not source_value or not source_id:
|
||||
continue
|
||||
latitude = parse_float(get_record_field(record, "latitude"))
|
||||
longitude = parse_float(get_record_field(record, "longitude"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
existing = await session.scalar(
|
||||
select(ComputeCenterLocationRecord)
|
||||
.where(ComputeCenterLocationRecord.source == source_value)
|
||||
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
metadata = record.extra_data or {}
|
||||
session.add(
|
||||
ComputeCenterLocationRecord(
|
||||
source=source_value,
|
||||
source_id=source_id,
|
||||
name=getattr(record, "name", None),
|
||||
operator=_record_operator(metadata),
|
||||
site=coerce_str(metadata.get("site") or metadata.get("organization")) or None,
|
||||
city=coerce_str(get_record_field(record, "city")) or None,
|
||||
country=coerce_str(get_record_field(record, "country")) or None,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
location_source="source_coordinates",
|
||||
source_note="Seeded from source-provided compute-center coordinates",
|
||||
raw_payload={
|
||||
"record_id": getattr(record, "id", None),
|
||||
"source": source_value,
|
||||
"source_id": source_id,
|
||||
},
|
||||
needs_confirmation=False,
|
||||
verification_status="source_provided",
|
||||
verified_at=None,
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await session.commit()
|
||||
await refresh_compute_center_location_cache(session)
|
||||
|
||||
|
||||
async def upsert_compute_center_location(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str,
|
||||
name: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
precision: str = "city",
|
||||
confidence: float | None = None,
|
||||
location_source: str = "manual_selection",
|
||||
source_url: str | None = None,
|
||||
source_note: str | None = None,
|
||||
raw_payload: dict[str, Any] | None = None,
|
||||
needs_confirmation: bool = False,
|
||||
verification_status: str = "verified",
|
||||
) -> ComputeCenterLocationRecord:
|
||||
existing = await session.scalar(
|
||||
select(ComputeCenterLocationRecord)
|
||||
.where(ComputeCenterLocationRecord.source == source)
|
||||
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||
)
|
||||
verified_at = None if needs_confirmation else datetime.now(UTC)
|
||||
values = {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"precision": precision,
|
||||
"confidence": confidence,
|
||||
"location_source": location_source,
|
||||
"source_url": source_url,
|
||||
"source_note": source_note,
|
||||
"raw_payload": raw_payload or {},
|
||||
"needs_confirmation": needs_confirmation,
|
||||
"verification_status": verification_status,
|
||||
"verified_at": verified_at,
|
||||
}
|
||||
if existing:
|
||||
for key, value in values.items():
|
||||
setattr(existing, key, value)
|
||||
record = existing
|
||||
else:
|
||||
record = ComputeCenterLocationRecord(
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
**values,
|
||||
)
|
||||
session.add(record)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
await refresh_compute_center_location_cache(session)
|
||||
return record
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.services.ai_tools.evidence_store import normalize_search_evidence
|
||||
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||
|
||||
|
||||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||||
@@ -153,10 +155,26 @@ async def get_credential_guide(db, provider: str) -> dict[str, Any]:
|
||||
"markdown": custom.get("markdown") if custom else default.markdown,
|
||||
"prompt": default.prompt,
|
||||
"source": "ai" if custom else "default",
|
||||
"sources": custom.get("sources", []) if custom else [],
|
||||
"verification_status": (
|
||||
custom.get("verification_status", "verified_with_search_evidence")
|
||||
if custom
|
||||
else "default_unverified"
|
||||
),
|
||||
"verification_error": custom.get("verification_error") if custom else None,
|
||||
}
|
||||
|
||||
|
||||
async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]:
|
||||
async def save_credential_guide(
|
||||
db,
|
||||
provider: str,
|
||||
title: str,
|
||||
markdown: str,
|
||||
*,
|
||||
sources: list[dict[str, Any]] | None = None,
|
||||
verification_status: str = "verified_with_search_evidence",
|
||||
verification_error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||
if default is None:
|
||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||
@@ -165,6 +183,9 @@ async def save_credential_guide(db, provider: str, title: str, markdown: str) ->
|
||||
store[provider] = {
|
||||
"title": title or default.title,
|
||||
"markdown": markdown,
|
||||
"sources": sources or [],
|
||||
"verification_status": verification_status,
|
||||
"verification_error": verification_error,
|
||||
}
|
||||
if record is None:
|
||||
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
|
||||
@@ -192,28 +213,58 @@ async def generate_credential_guide(
|
||||
db,
|
||||
provider: str,
|
||||
ai_client: AIProviderClient,
|
||||
web_search_client: WebSearchClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||
if default is None:
|
||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||
|
||||
search_evidence: list[dict[str, Any]] = []
|
||||
search_error: str | None = None
|
||||
if web_search_client is not None:
|
||||
try:
|
||||
evidence = await web_search_client.search(
|
||||
_credential_guide_search_query(default),
|
||||
max_results=5,
|
||||
)
|
||||
search_evidence = normalize_search_evidence(evidence, limit=5)
|
||||
except WebSearchError as exc:
|
||||
search_error = str(exc)
|
||||
except Exception as exc:
|
||||
search_error = f"WebSearch unavailable: {exc}"
|
||||
|
||||
if not search_evidence:
|
||||
guide = await get_credential_guide(db, provider)
|
||||
guide["verification_status"] = "unverified_no_search_evidence"
|
||||
guide["verification_error"] = search_error
|
||||
guide["sources"] = []
|
||||
return guide
|
||||
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate credential guide for {provider}",
|
||||
objective=default.prompt,
|
||||
objective=(
|
||||
default.prompt
|
||||
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
|
||||
+ "如果证据不足,明确说明需要以官方页面为准。"
|
||||
),
|
||||
context={
|
||||
"provider": provider,
|
||||
"current_default_guide": default.markdown,
|
||||
"product_context": "Planet collector credential settings",
|
||||
"search_evidence": search_evidence,
|
||||
},
|
||||
observations=[
|
||||
"Use concise Chinese markdown.",
|
||||
"Prefer stable concepts over brittle UI labels.",
|
||||
"Include verification and troubleshooting steps.",
|
||||
"Include a short sources section with the provided URLs.",
|
||||
],
|
||||
constraints=[
|
||||
"Do not ask the user for secrets.",
|
||||
"Do not include fabricated screenshots.",
|
||||
"Do not invent source URLs or product UI labels.",
|
||||
"Use only the provided search_evidence as factual support.",
|
||||
"Return markdown only.",
|
||||
],
|
||||
)
|
||||
@@ -221,4 +272,19 @@ async def generate_credential_guide(
|
||||
markdown = response.content.strip()
|
||||
if not markdown:
|
||||
markdown = default.markdown
|
||||
return await save_credential_guide(db, provider, default.title, markdown)
|
||||
return await save_credential_guide(
|
||||
db,
|
||||
provider,
|
||||
default.title,
|
||||
markdown,
|
||||
sources=search_evidence,
|
||||
verification_status="verified_with_search_evidence",
|
||||
)
|
||||
|
||||
|
||||
def _credential_guide_search_query(default: CredentialGuideDefault) -> str:
|
||||
if default.provider == "barentswatch":
|
||||
return "BarentsWatch developer tutorial AIS API OAuth client credentials"
|
||||
if default.provider == "aisstream":
|
||||
return "AISStream API key documentation websocket stream"
|
||||
return f"{default.provider} API credentials documentation"
|
||||
|
||||
391
backend/app/services/custom_datasource_runtime.py
Normal file
391
backend/app/services/custom_datasource_runtime.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Runtime helpers for mapped custom data sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.target_schema_registry import TARGET_SCHEMAS
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.datasource_mapping import DataSourceMappingTemplate
|
||||
from app.services.datasource_mapping import (
|
||||
MappingError,
|
||||
execute_mapping,
|
||||
extract_path,
|
||||
persist_mapped_records,
|
||||
)
|
||||
|
||||
DEFAULT_MAPPING_TEMPLATES: dict[str, dict[str, Any]] = {
|
||||
"vessel_ais": {
|
||||
"source": {"items_path": "$"},
|
||||
"fields": {
|
||||
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||
"name": {"path": "$.name", "type": "string", "default": None},
|
||||
"lat": {"path": "$.lat", "type": "float"},
|
||||
"lon": {"path": "$.lon", "type": "float"},
|
||||
"sog": {"path": "$.sog", "type": "float", "default": None},
|
||||
"cog": {"path": "$.cog", "type": "float", "default": None},
|
||||
"heading": {"path": "$.heading", "type": "integer", "default": None},
|
||||
"nav_status": {"path": "$.nav_status", "type": "integer", "default": None},
|
||||
"callsign": {"path": "$.callsign", "type": "string", "default": None},
|
||||
"vessel_type": {"path": "$.vessel_type", "type": "string", "default": None},
|
||||
"vessel_type_name": {"path": "$.vessel_type_name", "type": "string", "default": None},
|
||||
"received_at": {"path": "$.received_at", "type": "datetime", "default": None},
|
||||
},
|
||||
"meta": {"generated_by": "default_template", "requires_review": False},
|
||||
},
|
||||
}
|
||||
|
||||
RUNNING_CUSTOM_STREAM_TASKS: dict[int, asyncio.Task[Any]] = {}
|
||||
|
||||
|
||||
class CustomDatasourceRuntimeError(RuntimeError):
|
||||
"""Raised when a custom datasource cannot run."""
|
||||
|
||||
|
||||
def build_request_headers(auth_type: str, auth_config: dict, headers: dict) -> dict[str, str]:
|
||||
request_headers = {str(key): str(value) for key, value in (headers or {}).items()}
|
||||
auth_type = str(auth_type or "none").lower()
|
||||
auth_config = auth_config or {}
|
||||
|
||||
if auth_type == "bearer" and auth_config.get("token"):
|
||||
request_headers["Authorization"] = f"Bearer {auth_config['token']}"
|
||||
elif auth_type == "api_key" and auth_config.get("api_key"):
|
||||
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
|
||||
if location != "query":
|
||||
key_name = auth_config.get("key_name", "X-API-Key")
|
||||
request_headers[str(key_name)] = str(auth_config["api_key"])
|
||||
elif auth_type == "basic":
|
||||
username = auth_config.get("username", "")
|
||||
password = auth_config.get("password", "")
|
||||
credentials = f"{username}:{password}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
request_headers["Authorization"] = f"Basic {encoded}"
|
||||
return request_headers
|
||||
|
||||
|
||||
def build_query_params(auth_type: str, auth_config: dict, config: dict) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {}
|
||||
candidate = (config or {}).get("params") or (config or {}).get("query_params")
|
||||
if isinstance(candidate, dict):
|
||||
params.update(candidate)
|
||||
|
||||
auth_type = str(auth_type or "none").lower()
|
||||
auth_config = auth_config or {}
|
||||
if auth_type == "api_key" and auth_config.get("api_key"):
|
||||
location = str(auth_config.get("in") or auth_config.get("location") or "header").lower()
|
||||
if location == "query":
|
||||
key_name = auth_config.get("key_name") or auth_config.get("param_name") or "api_key"
|
||||
params[str(key_name)] = auth_config["api_key"]
|
||||
return params
|
||||
|
||||
|
||||
async def load_active_mapping(
|
||||
db: AsyncSession,
|
||||
datasource_config_id: int,
|
||||
) -> DataSourceMappingTemplate:
|
||||
result = await db.execute(
|
||||
select(DataSourceMappingTemplate)
|
||||
.where(DataSourceMappingTemplate.datasource_config_id == datasource_config_id)
|
||||
.where(DataSourceMappingTemplate.is_active.is_(True))
|
||||
.order_by(DataSourceMappingTemplate.version.desc())
|
||||
.limit(1)
|
||||
)
|
||||
mapping = result.scalar_one_or_none()
|
||||
if mapping is not None:
|
||||
return mapping
|
||||
|
||||
datasource = await db.get(DataSourceConfig, datasource_config_id)
|
||||
if datasource is None:
|
||||
raise CustomDatasourceRuntimeError("Configuration not found")
|
||||
target_schema = (datasource.config or {}).get("target_schema")
|
||||
template_body = DEFAULT_MAPPING_TEMPLATES.get(str(target_schema or "")) if target_schema else None
|
||||
if not template_body or target_schema not in TARGET_SCHEMAS:
|
||||
raise CustomDatasourceRuntimeError(
|
||||
"No active mapping template found and no default template available for this target schema"
|
||||
)
|
||||
|
||||
mapping = DataSourceMappingTemplate(
|
||||
datasource_config_id=datasource_config_id,
|
||||
target_schema=str(target_schema),
|
||||
mapping_json=template_body,
|
||||
sample_payload_hash=None,
|
||||
validation_status="valid",
|
||||
version=1,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
await db.refresh(mapping)
|
||||
return mapping
|
||||
|
||||
|
||||
async def fetch_rest_payload(config: DataSourceConfig, limit_bytes: int) -> Any:
|
||||
request_config = config.config or {}
|
||||
method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
raise CustomDatasourceRuntimeError("Only GET and POST sample requests are supported.")
|
||||
|
||||
headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {})
|
||||
params = build_query_params(config.auth_type, config.auth_config or {}, request_config)
|
||||
timeout = float(request_config.get("timeout", 30))
|
||||
json_body = request_config.get("json_body")
|
||||
if json_body is None and str(request_config.get("body_type") or "").lower() in {"json", ""}:
|
||||
candidate = request_config.get("body")
|
||||
if isinstance(candidate, (dict, list)):
|
||||
json_body = candidate
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
config.endpoint,
|
||||
headers=headers,
|
||||
params=params or None,
|
||||
json=json_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.content[:limit_bytes]
|
||||
if "application/json" in response.headers.get("content-type", ""):
|
||||
return json.loads(content.decode(response.encoding or "utf-8"))
|
||||
return {"text": content.decode(response.encoding or "utf-8", errors="replace")}
|
||||
|
||||
|
||||
async def run_mapped_rest_config(
|
||||
db: AsyncSession,
|
||||
datasource: DataSourceConfig,
|
||||
) -> dict[str, Any]:
|
||||
mapping = await load_active_mapping(db, datasource.id)
|
||||
sample = await fetch_rest_payload(datasource, 5_000_000)
|
||||
mapped = execute_mapping(sample, mapping.mapping_json, mapping.target_schema)
|
||||
if mapped["failed_count"] > 0:
|
||||
return {
|
||||
"status": "failed",
|
||||
"datasource_config_id": datasource.id,
|
||||
"mapping_id": mapping.id,
|
||||
"mapping_version": mapping.version,
|
||||
"target_schema": mapping.target_schema,
|
||||
"mapped_count": mapped["mapped_count"],
|
||||
"failed_count": mapped["failed_count"],
|
||||
"errors": mapped["errors"][:20],
|
||||
}
|
||||
|
||||
request_config = datasource.config or {}
|
||||
written_count = await persist_mapped_records(
|
||||
db,
|
||||
datasource_name=datasource.name,
|
||||
datasource_config_id=datasource.id,
|
||||
target_schema=mapping.target_schema,
|
||||
records=mapped["records"],
|
||||
mapping_version=mapping.version,
|
||||
delivery_mode=request_config.get("delivery_mode") or "polling",
|
||||
transport="http",
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"datasource_config_id": datasource.id,
|
||||
"mapping_id": mapping.id,
|
||||
"mapping_version": mapping.version,
|
||||
"target_schema": mapping.target_schema,
|
||||
"fetched_count": mapped["total_items"],
|
||||
"mapped_count": mapped["mapped_count"],
|
||||
"written_count": written_count,
|
||||
}
|
||||
|
||||
|
||||
def _items_from_ws_message(payload: Any, config: dict) -> Any:
|
||||
message_path = config.get("ws_message_path")
|
||||
items_path = config.get("ws_items_path")
|
||||
value = extract_path(payload, message_path) if message_path else payload
|
||||
return extract_path(value, items_path) if items_path else value
|
||||
|
||||
|
||||
async def _connect_websocket(endpoint: str, headers: dict[str, str]):
|
||||
import websockets
|
||||
|
||||
try:
|
||||
return await websockets.connect(endpoint, additional_headers=headers or None)
|
||||
except TypeError:
|
||||
return await websockets.connect(endpoint, extra_headers=headers or None)
|
||||
|
||||
|
||||
async def test_websocket_config(config: DataSourceConfig) -> dict[str, Any]:
|
||||
if not str(config.endpoint or "").startswith(("ws://", "wss://")):
|
||||
raise CustomDatasourceRuntimeError("WebSocket datasource endpoint must start with ws:// or wss://")
|
||||
|
||||
runtime_config = config.config or {}
|
||||
headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {})
|
||||
receive_timeout = float(runtime_config.get("receive_timeout_seconds") or runtime_config.get("timeout") or 10)
|
||||
async with await _connect_websocket(config.endpoint, headers) as websocket:
|
||||
subscribe_message = runtime_config.get("ws_subscribe_message")
|
||||
if isinstance(subscribe_message, (dict, list)):
|
||||
await websocket.send(json.dumps(subscribe_message))
|
||||
elif isinstance(subscribe_message, str) and subscribe_message.strip():
|
||||
await websocket.send(subscribe_message)
|
||||
raw_message = await asyncio.wait_for(websocket.recv(), timeout=receive_timeout)
|
||||
return {
|
||||
"success": True,
|
||||
"message_preview": raw_message[:1000] if isinstance(raw_message, str) else str(raw_message)[:1000],
|
||||
}
|
||||
|
||||
|
||||
async def run_mapped_websocket_config(
|
||||
db: AsyncSession,
|
||||
datasource: DataSourceConfig,
|
||||
*,
|
||||
debug_max_messages: int | None = None,
|
||||
use_config_debug_max_messages: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not str(datasource.endpoint or "").startswith(("ws://", "wss://")):
|
||||
raise CustomDatasourceRuntimeError("WebSocket datasource endpoint must start with ws:// or wss://")
|
||||
|
||||
mapping = await load_active_mapping(db, datasource.id)
|
||||
runtime_config = datasource.config or {}
|
||||
max_messages = debug_max_messages
|
||||
if max_messages is None and use_config_debug_max_messages:
|
||||
max_messages = runtime_config.get("debug_max_messages")
|
||||
max_messages = int(max_messages) if max_messages else None
|
||||
receive_timeout = float(runtime_config.get("receive_timeout_seconds") or runtime_config.get("timeout") or 30)
|
||||
reconnect = bool(runtime_config.get("ws_reconnect", True))
|
||||
reconnect_delay = float(runtime_config.get("reconnect_delay_seconds") or 3)
|
||||
headers = build_request_headers(datasource.auth_type, datasource.auth_config or {}, datasource.headers or {})
|
||||
|
||||
messages_seen = 0
|
||||
mapped_count = 0
|
||||
failed_count = 0
|
||||
written_count = 0
|
||||
errors: list[dict[str, Any]] = []
|
||||
started_at = datetime.now(UTC)
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with await _connect_websocket(datasource.endpoint, headers) as websocket:
|
||||
subscribe_message = runtime_config.get("ws_subscribe_message")
|
||||
if isinstance(subscribe_message, (dict, list)):
|
||||
await websocket.send(json.dumps(subscribe_message))
|
||||
elif isinstance(subscribe_message, str) and subscribe_message.strip():
|
||||
await websocket.send(subscribe_message)
|
||||
|
||||
while True:
|
||||
raw_message = await asyncio.wait_for(websocket.recv(), timeout=receive_timeout)
|
||||
messages_seen += 1
|
||||
try:
|
||||
payload = json.loads(raw_message)
|
||||
except json.JSONDecodeError as exc:
|
||||
failed_count += 1
|
||||
errors.append({"message": "invalid_json", "error": str(exc)})
|
||||
continue
|
||||
|
||||
extracted = _items_from_ws_message(payload, runtime_config)
|
||||
try:
|
||||
mapped = execute_mapping(extracted, mapping.mapping_json, mapping.target_schema)
|
||||
except (MappingError, ValueError) as exc:
|
||||
failed_count += 1
|
||||
errors.append({"message": "mapping_failed", "error": str(exc)})
|
||||
continue
|
||||
|
||||
mapped_count += mapped["mapped_count"]
|
||||
failed_count += mapped["failed_count"]
|
||||
if mapped["errors"]:
|
||||
errors.extend(mapped["errors"][:5])
|
||||
if mapped["records"]:
|
||||
written_count += await persist_mapped_records(
|
||||
db,
|
||||
datasource_name=datasource.name,
|
||||
datasource_config_id=datasource.id,
|
||||
target_schema=mapping.target_schema,
|
||||
records=mapped["records"],
|
||||
mapping_version=mapping.version,
|
||||
delivery_mode=runtime_config.get("delivery_mode") or "realtime_stream",
|
||||
transport="websocket",
|
||||
)
|
||||
|
||||
if max_messages and messages_seen >= max_messages:
|
||||
return {
|
||||
"status": "success",
|
||||
"datasource_config_id": datasource.id,
|
||||
"mapping_id": mapping.id,
|
||||
"mapping_version": mapping.version,
|
||||
"target_schema": mapping.target_schema,
|
||||
"messages_seen": messages_seen,
|
||||
"mapped_count": mapped_count,
|
||||
"failed_count": failed_count,
|
||||
"written_count": written_count,
|
||||
"errors": errors[:20],
|
||||
"execution_time_seconds": (datetime.now(UTC) - started_at).total_seconds(),
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
failed_count += 1
|
||||
errors.append({"message": "websocket_error", "error": f"{exc.__class__.__name__}: {exc}"})
|
||||
if not reconnect or max_messages:
|
||||
return {
|
||||
"status": "failed" if written_count == 0 else "partial",
|
||||
"datasource_config_id": datasource.id,
|
||||
"mapping_id": mapping.id,
|
||||
"mapping_version": mapping.version,
|
||||
"target_schema": mapping.target_schema,
|
||||
"messages_seen": messages_seen,
|
||||
"mapped_count": mapped_count,
|
||||
"failed_count": failed_count,
|
||||
"written_count": written_count,
|
||||
"errors": errors[:20],
|
||||
}
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
|
||||
|
||||
async def run_custom_stream_by_id(config_id: int) -> dict[str, Any]:
|
||||
async with async_session_factory() as db:
|
||||
datasource = await db.get(DataSourceConfig, config_id)
|
||||
if not datasource:
|
||||
raise CustomDatasourceRuntimeError("Configuration not found")
|
||||
return await run_mapped_websocket_config(
|
||||
db,
|
||||
datasource,
|
||||
use_config_debug_max_messages=False,
|
||||
)
|
||||
|
||||
|
||||
def start_custom_stream(config_id: int) -> bool:
|
||||
existing = RUNNING_CUSTOM_STREAM_TASKS.get(config_id)
|
||||
if existing is not None and not existing.done():
|
||||
return False
|
||||
task = asyncio.create_task(run_custom_stream_by_id(config_id), name=f"custom-stream:{config_id}")
|
||||
RUNNING_CUSTOM_STREAM_TASKS[config_id] = task
|
||||
|
||||
def _cleanup(done_task: asyncio.Task[Any]) -> None:
|
||||
if RUNNING_CUSTOM_STREAM_TASKS.get(config_id) is done_task:
|
||||
RUNNING_CUSTOM_STREAM_TASKS.pop(config_id, None)
|
||||
|
||||
task.add_done_callback(_cleanup)
|
||||
return True
|
||||
|
||||
|
||||
async def stop_custom_stream(config_id: int) -> bool:
|
||||
task = RUNNING_CUSTOM_STREAM_TASKS.get(config_id)
|
||||
if task is None or task.done():
|
||||
RUNNING_CUSTOM_STREAM_TASKS.pop(config_id, None)
|
||||
return False
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
return True
|
||||
return task.cancelled()
|
||||
|
||||
|
||||
def get_custom_stream_status(config_id: int) -> dict[str, Any]:
|
||||
task = RUNNING_CUSTOM_STREAM_TASKS.get(config_id)
|
||||
return {
|
||||
"config_id": config_id,
|
||||
"running": bool(task and not task.done()),
|
||||
"done": bool(task and task.done()),
|
||||
}
|
||||
@@ -290,25 +290,77 @@ async def persist_mapped_records(
|
||||
target_schema: str,
|
||||
records: list[dict[str, Any]],
|
||||
mapping_version: int,
|
||||
delivery_mode: str | None = None,
|
||||
transport: str | None = None,
|
||||
) -> int:
|
||||
"""Persist validated mapped records to the destination for a target schema."""
|
||||
if target_schema == "vessel_ais":
|
||||
from app.models.vessel import VesselPosition
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.services.vessel_ais_aggregation import (
|
||||
record_vessel_ais_observation,
|
||||
update_ais_source_health,
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
latest_observed_at = now
|
||||
written_count = 0
|
||||
for record in records:
|
||||
db.add(
|
||||
VesselPosition(
|
||||
mmsi=record["mmsi"],
|
||||
lat=record["lat"],
|
||||
lon=record["lon"],
|
||||
sog=record.get("sog"),
|
||||
cog=record.get("cog"),
|
||||
heading=record.get("heading"),
|
||||
received_at=_parse_datetime(record.get("received_at")) or datetime.now(UTC),
|
||||
)
|
||||
observed_at = _parse_datetime(record.get("received_at")) or now
|
||||
observation = await record_vessel_ais_observation(
|
||||
db,
|
||||
source=datasource_name,
|
||||
normalized_payload=record,
|
||||
raw_payload=record,
|
||||
delivery_mode=delivery_mode or "polling",
|
||||
transport=transport or "http",
|
||||
message_type="PositionReport",
|
||||
observed_at=observed_at,
|
||||
collected_at=now,
|
||||
)
|
||||
if observation is not None:
|
||||
written_count += 1
|
||||
if observed_at > latest_observed_at:
|
||||
latest_observed_at = observed_at
|
||||
|
||||
await update_ais_source_health(
|
||||
db,
|
||||
source=datasource_name,
|
||||
connection_state="connected",
|
||||
observed_count=len(records),
|
||||
last_seen_at=latest_observed_at,
|
||||
last_success_at=now if records else None,
|
||||
lag_seconds=max((now - latest_observed_at).total_seconds(), 0),
|
||||
)
|
||||
await db.commit()
|
||||
return len(records)
|
||||
if records:
|
||||
await broadcaster.broadcast_custom(
|
||||
"vessels",
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": datasource_name,
|
||||
"created": True,
|
||||
"vessels": [
|
||||
{
|
||||
"mmsi": record.get("mmsi"),
|
||||
"mmsi_display": str(record.get("mmsi")) if record.get("mmsi") is not None else None,
|
||||
"name": record.get("name"),
|
||||
"callsign": record.get("callsign"),
|
||||
"lat": record.get("lat"),
|
||||
"lon": record.get("lon"),
|
||||
"sog": record.get("sog"),
|
||||
"cog": record.get("cog"),
|
||||
"heading": record.get("heading"),
|
||||
"nav_status": record.get("nav_status"),
|
||||
"vessel_type": record.get("vessel_type"),
|
||||
"vessel_type_name": record.get("vessel_type_name"),
|
||||
"received_at": to_iso8601_utc(_parse_datetime(record.get("received_at"))),
|
||||
}
|
||||
for record in records
|
||||
],
|
||||
},
|
||||
)
|
||||
return written_count
|
||||
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
|
||||
121
backend/app/services/docs_gatekeeper.py
Normal file
121
backend/app/services/docs_gatekeeper.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Server-side Docs metadata and Gatekeeper authorization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
DocsAccess = Literal["public", "docs_user", "docs_developer", "docs_admin"]
|
||||
DocsLang = Literal["zh", "en"]
|
||||
|
||||
VALID_DOCS_LANGS = {"zh", "en"}
|
||||
DOCS_README_FILENAME = "README.md"
|
||||
DEFAULT_DOCS_SLUG = "overview"
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
TECHNICAL_DOCS_ROOT = REPO_ROOT / "docs" / "technical"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocsMetadata:
|
||||
filename: str
|
||||
slug: str
|
||||
access: DocsAccess
|
||||
group: str
|
||||
order: int
|
||||
zh_title: str
|
||||
en_title: str
|
||||
|
||||
|
||||
DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
|
||||
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"),
|
||||
DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"),
|
||||
DocsMetadata("faq.md", "faq", "public", "Manual", 3, "常见问题", "FAQ"),
|
||||
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 4, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
|
||||
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
|
||||
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
|
||||
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),
|
||||
DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "Earth 卫星覆盖策略", "Earth Satellite Footprint Policy"),
|
||||
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Earth", 15, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "Earth 可交互图标接入", "Earth Interactable Usage"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "Earth 工具栏与浮层协同", "Earth Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||
DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"),
|
||||
DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"),
|
||||
DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"),
|
||||
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||
DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "Planet 运维手册", "Planet Ops Runbook"),
|
||||
DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"),
|
||||
DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"),
|
||||
)
|
||||
|
||||
DOCS_BY_SLUG = {entry.slug: entry for entry in DOCS_METADATA}
|
||||
|
||||
|
||||
def get_user_gatekeeper_groups(user: User | None) -> set[str]:
|
||||
if user is None:
|
||||
return set()
|
||||
|
||||
role = user.role.value if hasattr(user.role, "value") else str(user.role or "")
|
||||
if role == "super_admin":
|
||||
return {"docs_user", "docs_developer", "docs_admin"}
|
||||
if role == "admin":
|
||||
return {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
groups = set()
|
||||
raw_groups = user.gatekeeper_groups or []
|
||||
if isinstance(raw_groups, list):
|
||||
groups.update(str(group) for group in raw_groups)
|
||||
|
||||
if "docs_admin" in groups:
|
||||
groups.update({"docs_developer", "docs_user"})
|
||||
if "docs_developer" in groups:
|
||||
groups.add("docs_user")
|
||||
return groups
|
||||
|
||||
|
||||
def can_read_doc(entry: DocsMetadata, user: User | None) -> bool:
|
||||
if entry.access == "public":
|
||||
return True
|
||||
return entry.access in get_user_gatekeeper_groups(user)
|
||||
|
||||
|
||||
def doc_path_for(entry: DocsMetadata, lang: str) -> Path:
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise ValueError("Unsupported docs language")
|
||||
return TECHNICAL_DOCS_ROOT / lang / entry.filename
|
||||
|
||||
|
||||
def title_for(entry: DocsMetadata, lang: str) -> str:
|
||||
return entry.zh_title if lang == "zh" else entry.en_title
|
||||
|
||||
|
||||
def catalog_for_user(user: User | None) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for entry in DOCS_METADATA:
|
||||
if not can_read_doc(entry, user):
|
||||
continue
|
||||
for lang in sorted(VALID_DOCS_LANGS):
|
||||
if not doc_path_for(entry, lang).exists():
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
}
|
||||
)
|
||||
return sorted(items, key=lambda item: (item["lang"], item["order"], item["title"]))
|
||||
123
backend/app/services/email.py
Normal file
123
backend/app/services/email.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""SMTP-backed email sender.
|
||||
|
||||
Generic primitive used by registration/verification today, reusable for alert
|
||||
digests and other notifications later. Configuration lives in the `smtp` row of
|
||||
`system_settings` and is loaded once per send (small surface, no caching layer
|
||||
yet to keep behavior obvious after settings changes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal, Optional
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
OtpPurpose = Literal["register", "verify_email", "reset_password"]
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
code: str = "EMAIL_ERROR"
|
||||
|
||||
|
||||
class EmailNotConfiguredError(EmailError):
|
||||
code = "EMAIL_PROVIDER_NOT_CONFIGURED"
|
||||
|
||||
|
||||
class EmailSendError(EmailError):
|
||||
code = "EMAIL_SEND_FAILED"
|
||||
|
||||
|
||||
async def _load_smtp_config(db: AsyncSession) -> dict:
|
||||
from app.api.v1.settings import get_setting_payload # local import avoids cycle
|
||||
|
||||
payload = await get_setting_payload(db, "smtp")
|
||||
if not payload.get("host") or not payload.get("from_address"):
|
||||
raise EmailNotConfiguredError("SMTP host/from_address not set")
|
||||
return payload
|
||||
|
||||
|
||||
async def send_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: Optional[str] = None,
|
||||
config: Optional[dict] = None,
|
||||
) -> None:
|
||||
cfg = config or await _load_smtp_config(db)
|
||||
|
||||
message = EmailMessage()
|
||||
from_name = (cfg.get("from_name") or "").strip()
|
||||
from_address = cfg["from_address"]
|
||||
message["From"] = f"{from_name} <{from_address}>" if from_name else from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(text_body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
use_tls = bool(cfg.get("use_tls", True))
|
||||
use_starttls = bool(cfg.get("use_starttls", False))
|
||||
port = int(cfg.get("port") or (465 if use_tls else 587))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=cfg["host"],
|
||||
port=port,
|
||||
username=cfg.get("username") or None,
|
||||
password=cfg.get("password") or None,
|
||||
use_tls=use_tls and not use_starttls,
|
||||
start_tls=use_starttls,
|
||||
timeout=int(cfg.get("timeout_seconds") or 20),
|
||||
)
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
except OSError as exc:
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
|
||||
|
||||
_SUBJECTS: dict[OtpPurpose, str] = {
|
||||
"register": "Confirm your Planet account",
|
||||
"verify_email": "Verify your Planet email",
|
||||
"reset_password": "Reset your Planet password",
|
||||
}
|
||||
|
||||
_HEADLINES: dict[OtpPurpose, str] = {
|
||||
"register": "Welcome to Planet — confirm your email to activate your account.",
|
||||
"verify_email": "Confirm your new email address to keep your Planet account active.",
|
||||
"reset_password": "Use this code to set a new password for your Planet account.",
|
||||
}
|
||||
|
||||
|
||||
async def send_verification_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
to: str,
|
||||
code: str,
|
||||
purpose: OtpPurpose,
|
||||
config: Optional[dict] = None,
|
||||
) -> None:
|
||||
subject = _SUBJECTS[purpose]
|
||||
headline = _HEADLINES[purpose]
|
||||
text_body = (
|
||||
f"{headline}\n\n"
|
||||
f"Your verification code: {code}\n"
|
||||
"This code expires in 10 minutes. If you did not request it, ignore this email.\n"
|
||||
)
|
||||
html_body = (
|
||||
f"<p>{headline}</p>"
|
||||
f"<p style=\"font-size:24px;letter-spacing:4px;font-family:monospace\"><b>{code}</b></p>"
|
||||
"<p>This code expires in 10 minutes. If you did not request it, ignore this email.</p>"
|
||||
)
|
||||
await send_email(
|
||||
db,
|
||||
to=to,
|
||||
subject=subject,
|
||||
text_body=text_body,
|
||||
html_body=html_body,
|
||||
config=config,
|
||||
)
|
||||
57
backend/app/services/location/__init__.py
Normal file
57
backend/app/services/location/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Shared location-resolution pipeline.
|
||||
|
||||
A reusable abstraction for "given a record, decide its lat/lon" — used by
|
||||
compute centers, BGP collectors, BGP events, and any future entity that needs
|
||||
location estimation.
|
||||
|
||||
Each domain wires its own :class:`LocationPipeline` from a sequence of
|
||||
:class:`LocationResolver` instances. Future algorithms (peeringdb, IXP tables,
|
||||
user-confirmed coordinates, …) plug in by implementing the protocol — no
|
||||
changes needed to consumers.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolutionDiagnostic,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
)
|
||||
from .pipeline import LocationPipeline, LocationResolver
|
||||
from .resolvers.inherit import InheritFromAnotherEntityResolver
|
||||
from .resolvers.nominatim import (
|
||||
NominatimResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
interpret_geocode_result,
|
||||
)
|
||||
from .resolvers.registry import RegistryResolver, default_score_alias_match
|
||||
from .resolvers.source_coordinates import SourceCoordinatesResolver
|
||||
from .text import (
|
||||
city_key,
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LocationCandidate",
|
||||
"LocationPipeline",
|
||||
"LocationQuery",
|
||||
"LocationResolver",
|
||||
"ResolutionDiagnostic",
|
||||
"ResolutionResult",
|
||||
"ResolverOutput",
|
||||
"InheritFromAnotherEntityResolver",
|
||||
"NominatimResolver",
|
||||
"RegistryResolver",
|
||||
"SourceCoordinatesResolver",
|
||||
"build_default_nominatim_geocoder",
|
||||
"city_key",
|
||||
"coerce_str",
|
||||
"default_score_alias_match",
|
||||
"interpret_geocode_result",
|
||||
"normalize_country_text",
|
||||
"normalize_text",
|
||||
"parse_float",
|
||||
]
|
||||
1064
backend/app/services/location/llm_fallback.py
Normal file
1064
backend/app/services/location/llm_fallback.py
Normal file
File diff suppressed because it is too large
Load Diff
128
backend/app/services/location/models.py
Normal file
128
backend/app/services/location/models.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Domain-neutral data structures for the location pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
# Renderable precision tiers, ordered from most precise to least.
|
||||
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationQuery:
|
||||
"""Domain-neutral input for the resolution pipeline.
|
||||
|
||||
``name`` and ``aliases`` are matched against registry alias indexes;
|
||||
``city`` / ``country`` / ``region`` provide geographic context for both
|
||||
registry lookups and Nominatim queries; ``source_latitude`` /
|
||||
``source_longitude`` short-circuit when the record already carries
|
||||
coordinates; ``extra`` carries domain-specific fields (operator, site,
|
||||
organization, asn, peer_ip, …) that resolvers can opt into.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
city: str | None = None
|
||||
country: str | None = None
|
||||
region: str | None = None
|
||||
source_latitude: float | None = None
|
||||
source_longitude: float | None = None
|
||||
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationCandidate:
|
||||
"""A resolved location candidate produced by a resolver."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
display_name: str
|
||||
precision: str # "precise" | "site" | "city" | (rejected: country/unknown)
|
||||
confidence: float
|
||||
query: str
|
||||
source: str
|
||||
source_note: str | None
|
||||
matched_fields: tuple[str, ...]
|
||||
needs_confirmation: bool
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
matched_location_name: str | None = None
|
||||
location_verified_at: str | None = None
|
||||
suggested_registry_entry: dict[str, Any] | None = None
|
||||
raw_payload: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"display_name": self.display_name,
|
||||
"precision": self.precision,
|
||||
"confidence": self.confidence,
|
||||
"query": self.query,
|
||||
"source": self.source,
|
||||
"source_note": self.source_note,
|
||||
"matched_fields": list(self.matched_fields),
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"city": self.city,
|
||||
"region": self.region,
|
||||
"country": self.country,
|
||||
"matched_location_name": self.matched_location_name,
|
||||
"location_verified_at": self.location_verified_at,
|
||||
"suggested_registry_entry": self.suggested_registry_entry,
|
||||
"raw_payload": self.raw_payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolverOutput:
|
||||
"""What a single resolver returns from one ``resolve()`` call."""
|
||||
|
||||
candidates: tuple[LocationCandidate, ...] = ()
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionDiagnostic:
|
||||
"""Why we could not resolve, plus what we tried."""
|
||||
|
||||
failure_reason: str
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
record_id: int | None = None
|
||||
source: str | None = None
|
||||
source_id: str | None = None
|
||||
name: str | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
site: str | None = None
|
||||
operator: str | None = None
|
||||
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"failure_reason": self.failure_reason,
|
||||
"attempted_queries": list(self.attempted_queries),
|
||||
"record_id": self.record_id,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"country": self.country,
|
||||
"city": self.city,
|
||||
"site": self.site,
|
||||
"operator": self.operator,
|
||||
**({"extra": dict(self.extra)} if self.extra else {}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
"""Pipeline output: best candidate (if any) + diagnostic on miss."""
|
||||
|
||||
location: LocationCandidate | None
|
||||
diagnostic: ResolutionDiagnostic | None
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return bool(self.location)
|
||||
126
backend/app/services/location/pipeline.py
Normal file
126
backend/app/services/location/pipeline.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Pipeline that runs a sequence of :class:`LocationResolver` instances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from .models import (
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolutionDiagnostic,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
)
|
||||
|
||||
|
||||
class LocationResolver(Protocol):
|
||||
"""Pluggable location resolution step.
|
||||
|
||||
Implementations: ``SourceCoordinatesResolver``, ``RegistryResolver``,
|
||||
``NominatimResolver``, ``InheritFromAnotherEntityResolver`` — see the
|
||||
``resolvers`` subpackage. New algorithms (peeringdb / IXP / user-confirmed
|
||||
coordinates) plug in by implementing this protocol; the pipeline does not
|
||||
care how candidates are produced.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||
|
||||
|
||||
def default_candidate_sort_key(
|
||||
candidate: LocationCandidate,
|
||||
) -> tuple[int, int, float]:
|
||||
precision_rank = {"precise": 0, "site": 1, "city": 2}.get(
|
||||
candidate.precision, 9
|
||||
)
|
||||
source_rank = {
|
||||
"source_coordinates": 0,
|
||||
"stored_compute_center_location": 1,
|
||||
"stored_collector_location": 1,
|
||||
"ror_organization_registry": 2,
|
||||
"inherited": 3,
|
||||
"nominatim_online_geocode": 4,
|
||||
"local_registry": 8,
|
||||
"local_registry_city": 9,
|
||||
}.get(candidate.source, 9)
|
||||
return (source_rank, precision_rank, -float(candidate.confidence or 0))
|
||||
|
||||
|
||||
class LocationPipeline:
|
||||
"""Orchestrate a sequence of resolvers.
|
||||
|
||||
``collect_candidates`` runs every resolver and returns *all* deduped
|
||||
candidates plus the queries each resolver attempted (useful for
|
||||
user-facing "why didn't this work?" diagnostics).
|
||||
|
||||
``resolve_best`` returns the top candidate per
|
||||
:func:`default_candidate_sort_key` (or a custom sort).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolvers: Sequence[LocationResolver],
|
||||
*,
|
||||
sort_key=default_candidate_sort_key,
|
||||
failure_reason: str = (
|
||||
"Could not resolve to renderable coordinates from any configured resolver."
|
||||
),
|
||||
) -> None:
|
||||
self._resolvers = list(resolvers)
|
||||
self._sort_key = sort_key
|
||||
self._failure_reason = failure_reason
|
||||
|
||||
@property
|
||||
def resolvers(self) -> tuple[LocationResolver, ...]:
|
||||
return tuple(self._resolvers)
|
||||
|
||||
def collect_candidates(
|
||||
self, query: LocationQuery
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
candidates: list[LocationCandidate] = []
|
||||
attempted: list[str] = []
|
||||
seen_keys: set[tuple[str, str, str]] = set()
|
||||
|
||||
for resolver in self._resolvers:
|
||||
output = resolver.resolve(query)
|
||||
for q in output.attempted_queries:
|
||||
if q and q not in attempted:
|
||||
attempted.append(q)
|
||||
for candidate in output.candidates:
|
||||
key = (
|
||||
candidate.source,
|
||||
f"{candidate.latitude:.4f}",
|
||||
f"{candidate.longitude:.4f}",
|
||||
)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
candidates.append(candidate)
|
||||
|
||||
candidates.sort(key=self._sort_key)
|
||||
return candidates, attempted
|
||||
|
||||
def resolve_best(self, query: LocationQuery) -> ResolutionResult:
|
||||
candidates, attempted = self.collect_candidates(query)
|
||||
if candidates:
|
||||
return ResolutionResult(
|
||||
location=candidates[0],
|
||||
diagnostic=None,
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
return ResolutionResult(
|
||||
location=None,
|
||||
diagnostic=ResolutionDiagnostic(
|
||||
failure_reason=self._failure_reason,
|
||||
attempted_queries=tuple(attempted),
|
||||
name=query.name,
|
||||
country=query.country,
|
||||
city=query.city,
|
||||
site=str(query.extra.get("site")) if query.extra.get("site") else None,
|
||||
operator=str(query.extra.get("operator"))
|
||||
if query.extra.get("operator")
|
||||
else None,
|
||||
),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
20
backend/app/services/location/resolvers/__init__.py
Normal file
20
backend/app/services/location/resolvers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Built-in resolver implementations."""
|
||||
|
||||
from .inherit import InheritFromAnotherEntityResolver
|
||||
from .nominatim import (
|
||||
NominatimResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
interpret_geocode_result,
|
||||
)
|
||||
from .registry import RegistryResolver, default_score_alias_match
|
||||
from .source_coordinates import SourceCoordinatesResolver
|
||||
|
||||
__all__ = [
|
||||
"InheritFromAnotherEntityResolver",
|
||||
"NominatimResolver",
|
||||
"RegistryResolver",
|
||||
"SourceCoordinatesResolver",
|
||||
"build_default_nominatim_geocoder",
|
||||
"default_score_alias_match",
|
||||
"interpret_geocode_result",
|
||||
]
|
||||
31
backend/app/services/location/resolvers/inherit.py
Normal file
31
backend/app/services/location/resolvers/inherit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Resolver that inherits a candidate from another entity's resolution.
|
||||
|
||||
Used by BGP events to pick up the location of their owning collector. The
|
||||
``source_lookup`` callable is the only domain coupling — it receives the
|
||||
incoming :class:`LocationQuery` and returns either an already-resolved
|
||||
:class:`LocationCandidate` (typically by querying another pipeline) or
|
||||
``None`` to signal "no parent location available".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
|
||||
|
||||
class InheritFromAnotherEntityResolver:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source_lookup: Callable[[LocationQuery], LocationCandidate | None],
|
||||
name: str = "inherited",
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._lookup = source_lookup
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
result = self._lookup(query)
|
||||
if result is None:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(candidates=(result,))
|
||||
292
backend/app/services/location/resolvers/nominatim.py
Normal file
292
backend/app/services/location/resolvers/nominatim.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Nominatim-backed online geocoder.
|
||||
|
||||
The actual HTTP call is encapsulated in :func:`build_default_nominatim_geocoder`
|
||||
which returns an ``lru_cache``-wrapped function. Domain modules typically:
|
||||
|
||||
1. Build a default geocoder via :func:`build_default_nominatim_geocoder`.
|
||||
2. Re-export it under a stable module-level name (e.g. ``_geocode_online``).
|
||||
3. Pass a *late-binding lambda* (``lambda q: _geocode_online(q)``) to
|
||||
:class:`NominatimResolver`.
|
||||
|
||||
This ensures tests that ``monkeypatch.setattr(module, "_geocode_online", ...)``
|
||||
can swap the geocoder behavior without touching pipeline construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
from ..text import (
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search"
|
||||
DEFAULT_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||
DEFAULT_MIN_INTERVAL_SECONDS = 1.1
|
||||
DEFAULT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
def build_default_nominatim_geocoder(
|
||||
*,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
cache_size: int = 512,
|
||||
) -> Callable[[str], dict[str, Any] | None]:
|
||||
"""Return a cached, rate-limited Nominatim geocoder."""
|
||||
|
||||
last_request_at = [0.0]
|
||||
|
||||
@lru_cache(maxsize=cache_size)
|
||||
def geocode(query: str) -> dict[str, Any] | None:
|
||||
if not query:
|
||||
return None
|
||||
elapsed = time.monotonic() - last_request_at[0]
|
||||
if elapsed < min_interval_seconds:
|
||||
time.sleep(min_interval_seconds - elapsed)
|
||||
last_request_at[0] = time.monotonic()
|
||||
response = httpx.get(
|
||||
NOMINATIM_SEARCH_URL,
|
||||
params={
|
||||
"q": query,
|
||||
"format": "jsonv2",
|
||||
"limit": 1,
|
||||
"addressdetails": 1,
|
||||
},
|
||||
headers={"User-Agent": user_agent},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return None
|
||||
result = payload[0]
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
return result
|
||||
|
||||
return geocode
|
||||
|
||||
|
||||
_DEFAULT_SITE_CATEGORIES = frozenset(
|
||||
{
|
||||
"amenity",
|
||||
"office",
|
||||
"building",
|
||||
"industrial",
|
||||
"research",
|
||||
"university",
|
||||
"education",
|
||||
"tourism",
|
||||
"shop",
|
||||
"man_made",
|
||||
"campus",
|
||||
"research_institute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def interpret_geocode_result(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
matched_fields: tuple[str, ...],
|
||||
context_country: str | None,
|
||||
site_categories: frozenset[str] = _DEFAULT_SITE_CATEGORIES,
|
||||
site_promoting_match_fields: frozenset[str] = frozenset(
|
||||
{"site", "operator", "name"}
|
||||
),
|
||||
) -> tuple[float, float, dict[str, Any], str] | None:
|
||||
"""Validate a Nominatim raw result. Returns (lat, lon, address, classification)."""
|
||||
latitude = parse_float(result.get("lat"))
|
||||
longitude = parse_float(result.get("lon"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return None
|
||||
|
||||
address = result.get("address") if isinstance(result.get("address"), dict) else {}
|
||||
if not isinstance(address, dict):
|
||||
address = {}
|
||||
|
||||
has_city_level = bool(
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or address.get("hamlet")
|
||||
or address.get("suburb")
|
||||
)
|
||||
osm_class = str(result.get("class") or "").lower()
|
||||
osm_type = str(result.get("type") or "").lower()
|
||||
is_site_like = osm_class in site_categories or osm_type in site_categories
|
||||
if not has_city_level and not is_site_like:
|
||||
return None
|
||||
|
||||
if context_country:
|
||||
normalized_context = normalize_text(normalize_country_text(context_country))
|
||||
normalized_result = normalize_text(
|
||||
normalize_country_text(address.get("country"))
|
||||
)
|
||||
if (
|
||||
normalized_context
|
||||
and normalized_result
|
||||
and normalized_context != normalized_result
|
||||
):
|
||||
return None
|
||||
|
||||
classification = (
|
||||
"site"
|
||||
if (
|
||||
is_site_like
|
||||
and has_city_level
|
||||
and any(field in site_promoting_match_fields for field in matched_fields)
|
||||
)
|
||||
else "city"
|
||||
)
|
||||
return float(latitude), float(longitude), address, classification
|
||||
|
||||
|
||||
def _candidate_from_geocode(
|
||||
*,
|
||||
query: LocationQuery,
|
||||
geocode_query: str,
|
||||
matched_fields: tuple[str, ...],
|
||||
raw_result: dict[str, Any],
|
||||
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None],
|
||||
source: str,
|
||||
site_confidence: float,
|
||||
city_confidence: float,
|
||||
) -> LocationCandidate | None:
|
||||
interpreted = interpret(
|
||||
raw_result,
|
||||
matched_fields=matched_fields,
|
||||
context_country=query.country,
|
||||
)
|
||||
if not interpreted:
|
||||
return None
|
||||
latitude, longitude, address, classification = interpreted
|
||||
city = (
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or query.city
|
||||
or None
|
||||
)
|
||||
region = address.get("state") or address.get("region")
|
||||
country = address.get("country") or query.country or None
|
||||
display_name = raw_result.get("display_name") or geocode_query
|
||||
confidence = city_confidence if classification == "city" else site_confidence
|
||||
|
||||
extra = query.extra or {}
|
||||
suggested_registry_entry = {
|
||||
"canonical_name": (
|
||||
(query.aliases[0] if query.aliases else None)
|
||||
or query.name
|
||||
or display_name
|
||||
),
|
||||
"aliases": list(
|
||||
{
|
||||
value
|
||||
for value in [
|
||||
query.name,
|
||||
*query.aliases,
|
||||
coerce_str(extra.get("operator")),
|
||||
coerce_str(extra.get("site")),
|
||||
]
|
||||
if value
|
||||
}
|
||||
),
|
||||
"operator": coerce_str(extra.get("operator")) or None,
|
||||
"site": coerce_str(extra.get("site"))
|
||||
or coerce_str(extra.get("organization"))
|
||||
or None,
|
||||
"country": country,
|
||||
"city": city,
|
||||
"region": region,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"precision": classification,
|
||||
"confidence": confidence,
|
||||
"source_note": (
|
||||
f"Resolved via Nominatim query '{geocode_query}' → {display_name}"
|
||||
),
|
||||
}
|
||||
return LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=display_name,
|
||||
precision=classification,
|
||||
confidence=confidence,
|
||||
query=geocode_query,
|
||||
source=source,
|
||||
source_note=f"Nominatim search result: {display_name}",
|
||||
matched_fields=matched_fields,
|
||||
needs_confirmation=True,
|
||||
city=city,
|
||||
region=region,
|
||||
country=country,
|
||||
matched_location_name=display_name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=suggested_registry_entry,
|
||||
)
|
||||
|
||||
|
||||
class NominatimResolver:
|
||||
"""Run a domain-specific query plan against Nominatim."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query_plan_builder: Callable[
|
||||
[LocationQuery], list[tuple[str, tuple[str, ...]]]
|
||||
],
|
||||
geocoder: Callable[[str], dict[str, Any] | None],
|
||||
name: str = "nominatim_online_geocode",
|
||||
site_confidence: float = 0.72,
|
||||
city_confidence: float = 0.62,
|
||||
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None] = (
|
||||
interpret_geocode_result
|
||||
),
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._query_plan_builder = query_plan_builder
|
||||
self._geocoder = geocoder
|
||||
self._site_confidence = site_confidence
|
||||
self._city_confidence = city_confidence
|
||||
self._interpret = interpret
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
plan = self._query_plan_builder(query)
|
||||
candidates: list[LocationCandidate] = []
|
||||
attempted: list[str] = []
|
||||
for geocode_query, matched_fields in plan:
|
||||
attempted.append(geocode_query)
|
||||
try:
|
||||
raw_result = self._geocoder(geocode_query)
|
||||
except Exception:
|
||||
continue
|
||||
if not raw_result:
|
||||
continue
|
||||
candidate = _candidate_from_geocode(
|
||||
query=query,
|
||||
geocode_query=geocode_query,
|
||||
matched_fields=matched_fields,
|
||||
raw_result=raw_result,
|
||||
interpret=self._interpret,
|
||||
source=self.name,
|
||||
site_confidence=self._site_confidence,
|
||||
city_confidence=self._city_confidence,
|
||||
)
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
return ResolverOutput(
|
||||
candidates=tuple(candidates),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
323
backend/app/services/location/resolvers/registry.py
Normal file
323
backend/app/services/location/resolvers/registry.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Resolver that matches a query against a local JSON registry.
|
||||
|
||||
Registry schema (a single JSON file):
|
||||
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "...",
|
||||
"aliases": ["...", "..."],
|
||||
"operator": "...",
|
||||
"site": "...",
|
||||
"city": "...",
|
||||
"country": "...",
|
||||
"region": "...",
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"precision": "precise" | "site" | "city",
|
||||
"confidence": 0.0,
|
||||
"verification_status": "verified",
|
||||
"source_note": "...",
|
||||
"verified_at": "YYYY-MM-DD"
|
||||
}
|
||||
],
|
||||
"city_fallbacks": [ {city, country, latitude, longitude, ...} ]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from ..models import (
|
||||
RENDERABLE_PRECISIONS,
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolverOutput,
|
||||
)
|
||||
from ..text import (
|
||||
city_key,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
# Field-priority weights when scoring "this query field text contains this
|
||||
# alias text". Tuned to match the legacy compute-center ordering — name beats
|
||||
# site beats operator beats city — which generalizes well to other domains.
|
||||
_DEFAULT_FIELD_PRIORITY = {
|
||||
"name": 8,
|
||||
"site": 6,
|
||||
"operator": 5,
|
||||
"city": 3,
|
||||
}
|
||||
|
||||
|
||||
def default_score_alias_match(
|
||||
alias_field: str, record_field: str, alias_text: str
|
||||
) -> int:
|
||||
score = max(0, len(alias_text))
|
||||
score += _DEFAULT_FIELD_PRIORITY.get(alias_field, 1)
|
||||
if alias_field == record_field:
|
||||
score += 4
|
||||
if alias_field == "name" and record_field in {"name", "name_short", "alias"}:
|
||||
score += 6
|
||||
if alias_field == "site" and record_field in {"site", "organization"}:
|
||||
score += 4
|
||||
if alias_field == "operator" and record_field in {"operator", "organization"}:
|
||||
score += 4
|
||||
return score
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _load_registry_file(path: str) -> dict[str, Any]:
|
||||
with Path(path).open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _build_alias_index(
|
||||
path: str,
|
||||
) -> tuple[tuple[dict[str, Any], tuple[tuple[str, str], ...]], ...]:
|
||||
index: list[tuple[dict[str, Any], tuple[tuple[str, str], ...]]] = []
|
||||
for entry in _load_registry_file(path).get("locations", []):
|
||||
aliases: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for alias in [entry.get("canonical_name"), *(entry.get("aliases") or [])]:
|
||||
normalized = normalize_text(alias)
|
||||
if normalized and normalized not in seen:
|
||||
aliases.append(("name", normalized))
|
||||
seen.add(normalized)
|
||||
for field_name in ("operator", "site", "city"):
|
||||
value = entry.get(field_name)
|
||||
normalized = normalize_text(value)
|
||||
if normalized and normalized not in seen:
|
||||
aliases.append((field_name, normalized))
|
||||
seen.add(normalized)
|
||||
index.append((entry, tuple(aliases)))
|
||||
return tuple(index)
|
||||
|
||||
|
||||
def _query_corpus(query: LocationQuery) -> dict[str, str]:
|
||||
"""Map a query into normalized strings keyed by source field."""
|
||||
fields: dict[str, str] = {
|
||||
"name": query.name or "",
|
||||
"city": query.city or "",
|
||||
"country": query.country or "",
|
||||
}
|
||||
for alias in query.aliases:
|
||||
if alias and alias != query.name:
|
||||
fields["name_short"] = alias
|
||||
break
|
||||
extra = query.extra or {}
|
||||
for key in ("site", "operator", "organization"):
|
||||
value = extra.get(key)
|
||||
if value:
|
||||
fields[key] = str(value)
|
||||
return {key: normalize_text(value) for key, value in fields.items() if value}
|
||||
|
||||
|
||||
def _country_compatible(entry: dict[str, Any], query: LocationQuery) -> bool:
|
||||
record_country = normalize_country_text(query.country)
|
||||
entry_country = normalize_country_text(entry.get("country"))
|
||||
if not record_country or not entry_country:
|
||||
return True
|
||||
return normalize_text(record_country) == normalize_text(entry_country)
|
||||
|
||||
|
||||
def _normalized_alias_matches(alias_normalized: str, record_text: str) -> bool:
|
||||
alias_tokens = alias_normalized.split()
|
||||
record_tokens = record_text.split()
|
||||
if not alias_tokens or not record_tokens:
|
||||
return False
|
||||
if len(alias_tokens) == 1:
|
||||
return alias_tokens[0] in record_tokens
|
||||
window_size = len(alias_tokens)
|
||||
return any(
|
||||
record_tokens[index : index + window_size] == alias_tokens
|
||||
for index in range(0, len(record_tokens) - window_size + 1)
|
||||
)
|
||||
|
||||
|
||||
def _entry_to_candidate(
|
||||
entry: dict[str, Any],
|
||||
*,
|
||||
matched_alias: str,
|
||||
matched_fields: Iterable[str],
|
||||
source: str,
|
||||
score_explainer: str,
|
||||
confidence_floor: float,
|
||||
) -> LocationCandidate:
|
||||
canonical_name = entry.get("canonical_name") or matched_alias
|
||||
# Registry entries are treated as candidates unless explicitly verified.
|
||||
# This prevents migrated hard-coded hints from appearing as factual
|
||||
# location evidence.
|
||||
is_verified = entry.get("verification_status") == "verified"
|
||||
precision = entry.get("precision") or "city"
|
||||
if precision not in RENDERABLE_PRECISIONS:
|
||||
precision = "city"
|
||||
fields_summary = ", ".join(sorted(set(matched_fields))) or "name"
|
||||
confidence_value = parse_float(entry.get("confidence"))
|
||||
confidence = (
|
||||
float(confidence_value)
|
||||
if confidence_value is not None
|
||||
else confidence_floor
|
||||
)
|
||||
return LocationCandidate(
|
||||
latitude=float(parse_float(entry.get("latitude")) or 0.0),
|
||||
longitude=float(parse_float(entry.get("longitude")) or 0.0),
|
||||
display_name=canonical_name,
|
||||
precision=precision,
|
||||
confidence=confidence,
|
||||
query=f"local_registry::{matched_alias or canonical_name}",
|
||||
source=source,
|
||||
source_note=entry.get("source_note")
|
||||
or f"{score_explainer}: matched {fields_summary}",
|
||||
matched_fields=tuple(sorted(set(matched_fields))) or ("name",),
|
||||
needs_confirmation=bool(entry.get("needs_confirmation")) or not is_verified,
|
||||
city=entry.get("city"),
|
||||
region=entry.get("region"),
|
||||
country=entry.get("country"),
|
||||
matched_location_name=canonical_name,
|
||||
location_verified_at=entry.get("verified_at") if is_verified else None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
|
||||
|
||||
class RegistryResolver:
|
||||
"""Match a query against a JSON registry (plus its city_fallbacks table)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry_path: Path | str,
|
||||
name: str = "local_registry",
|
||||
city_fallback_source: str = "local_registry_city",
|
||||
city_fallback_confidence_default: float = 0.65,
|
||||
confidence_default: float = 0.85,
|
||||
score_alias_match: Callable[[str, str, str], int] = default_score_alias_match,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._registry_path = str(Path(registry_path))
|
||||
self._city_fallback_source = city_fallback_source
|
||||
self._city_fallback_confidence_default = city_fallback_confidence_default
|
||||
self._confidence_default = confidence_default
|
||||
self._score = score_alias_match
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Drop the cached registry — useful when the JSON file is edited."""
|
||||
_load_registry_file.cache_clear()
|
||||
_build_alias_index.cache_clear()
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
candidates: list[LocationCandidate] = []
|
||||
candidates.extend(self._registry_candidates(query))
|
||||
city_candidate = self._city_fallback_candidate(query)
|
||||
if city_candidate is not None:
|
||||
candidates.append(city_candidate)
|
||||
return ResolverOutput(candidates=tuple(candidates))
|
||||
|
||||
# ── internals ──────────────────────────────────────────────
|
||||
|
||||
def _registry_candidates(
|
||||
self, query: LocationQuery
|
||||
) -> list[LocationCandidate]:
|
||||
corpus = _query_corpus(query)
|
||||
if not corpus:
|
||||
return []
|
||||
|
||||
# When the query carries a name (a record-specific identifier), require
|
||||
# at least one alias match against a name-class field — otherwise a
|
||||
# generic shared field like operator="RIPE NCC" would promote every
|
||||
# registry entry that lists that operator, regardless of whether the
|
||||
# name matches.
|
||||
query_has_name = bool(corpus.get("name") or corpus.get("name_short"))
|
||||
|
||||
results: list[LocationCandidate] = []
|
||||
for entry, aliases in _build_alias_index(self._registry_path):
|
||||
best_alias = ""
|
||||
best_score = 0
|
||||
matched_fields: list[str] = []
|
||||
matched_via_name_alias = False
|
||||
for alias_field, alias_normalized in aliases:
|
||||
for record_field, record_text in corpus.items():
|
||||
if not _normalized_alias_matches(alias_normalized, record_text):
|
||||
continue
|
||||
score = self._score(
|
||||
alias_field, record_field, alias_normalized
|
||||
)
|
||||
if score > best_score or (
|
||||
score == best_score
|
||||
and len(alias_normalized) > len(best_alias)
|
||||
):
|
||||
best_score = score
|
||||
best_alias = alias_normalized
|
||||
if record_field not in matched_fields:
|
||||
matched_fields.append(record_field)
|
||||
if alias_field == "name" and record_field in {"name", "name_short"}:
|
||||
matched_via_name_alias = True
|
||||
if not matched_fields or best_score <= 0:
|
||||
continue
|
||||
if query_has_name and not matched_via_name_alias:
|
||||
continue
|
||||
if not _country_compatible(entry, query):
|
||||
continue
|
||||
results.append(
|
||||
_entry_to_candidate(
|
||||
entry,
|
||||
matched_alias=best_alias,
|
||||
matched_fields=matched_fields,
|
||||
source=self.name,
|
||||
score_explainer="Registry alias match",
|
||||
confidence_floor=self._confidence_default,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def _city_fallback_candidate(
|
||||
self, query: LocationQuery
|
||||
) -> LocationCandidate | None:
|
||||
country = normalize_country_text(query.country)
|
||||
city = city_key(query.city)
|
||||
if not country or not city:
|
||||
return None
|
||||
|
||||
for fallback in _load_registry_file(self._registry_path).get(
|
||||
"city_fallbacks", []
|
||||
):
|
||||
fallback_country = normalize_country_text(fallback.get("country"))
|
||||
fallback_city = city_key(fallback.get("city"))
|
||||
if fallback_country != country or fallback_city != city:
|
||||
continue
|
||||
confidence_value = parse_float(fallback.get("confidence"))
|
||||
confidence = (
|
||||
float(confidence_value)
|
||||
if confidence_value is not None
|
||||
else self._city_fallback_confidence_default
|
||||
)
|
||||
return LocationCandidate(
|
||||
latitude=float(parse_float(fallback.get("latitude")) or 0.0),
|
||||
longitude=float(parse_float(fallback.get("longitude")) or 0.0),
|
||||
display_name=fallback.get("city") or "",
|
||||
precision="city",
|
||||
confidence=confidence,
|
||||
query=(
|
||||
f"city_fallback::{fallback.get('city')}, "
|
||||
f"{fallback.get('country')}"
|
||||
),
|
||||
source=self._city_fallback_source,
|
||||
source_note=fallback.get("source_note")
|
||||
or f"City fallback for {fallback.get('city')}, {fallback.get('country')}",
|
||||
matched_fields=("city", "country"),
|
||||
needs_confirmation=False,
|
||||
city=fallback.get("city"),
|
||||
region=fallback.get("region"),
|
||||
country=fallback.get("country"),
|
||||
matched_location_name=fallback.get("city"),
|
||||
location_verified_at=fallback.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Resolver that consumes lat/lon already present on the source record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
from ..text import normalize_country_text
|
||||
|
||||
|
||||
class SourceCoordinatesResolver:
|
||||
"""Pass-through for records that already carry valid coordinates."""
|
||||
|
||||
name = "source_coordinates"
|
||||
|
||||
def __init__(self, *, source: str = "source_coordinates") -> None:
|
||||
self._source = source
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
lat = query.source_latitude
|
||||
lon = query.source_longitude
|
||||
if lat in (None, 0.0) or lon in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
|
||||
country = normalize_country_text(query.country) or query.country
|
||||
candidate = LocationCandidate(
|
||||
latitude=float(lat),
|
||||
longitude=float(lon),
|
||||
display_name=query.name or "",
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
query="source_coordinates",
|
||||
source=self._source,
|
||||
source_note="Source record provided valid coordinates.",
|
||||
matched_fields=("source_coordinates",),
|
||||
needs_confirmation=False,
|
||||
city=query.city,
|
||||
region=query.region,
|
||||
country=country,
|
||||
matched_location_name=query.name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
return ResolverOutput(candidates=(candidate,))
|
||||
41
backend/app/services/location/text.py
Normal file
41
backend/app/services/location/text.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Text-normalization helpers shared by every resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.core.countries import normalize_country
|
||||
|
||||
|
||||
def parse_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_str(value: Any) -> str:
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_text(value: Any) -> str:
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
normalized = str(value).casefold()
|
||||
normalized = re.sub(r"[^a-z0-9一-鿿]+", " ", normalized)
|
||||
return re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
|
||||
def normalize_country_text(value: Any) -> str:
|
||||
normalized = normalize_country(value)
|
||||
return normalized or coerce_str(value)
|
||||
|
||||
|
||||
def city_key(city: Any) -> str:
|
||||
text = coerce_str(city).split(",", 1)[0]
|
||||
return normalize_text(text)
|
||||
100
backend/app/services/otp.py
Normal file
100
backend/app/services/otp.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""One-time verification codes backed by Redis.
|
||||
|
||||
Reusable primitive for register/verify-email/reset-password (and any future 2FA or
|
||||
phone-number verification). Codes are bcrypt-hashed before storage so a Redis dump
|
||||
does not leak active codes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
import bcrypt
|
||||
|
||||
from app.core.security import redis_client
|
||||
|
||||
OtpPurpose = Literal["register", "verify_email", "reset_password"]
|
||||
|
||||
CODE_TTL_SECONDS = 600 # 10 minutes
|
||||
RESEND_COOLDOWN_SECONDS = 60
|
||||
MAX_ATTEMPTS = 5
|
||||
CODE_LENGTH = 6
|
||||
|
||||
|
||||
class OtpError(Exception):
|
||||
code: str = "OTP_ERROR"
|
||||
|
||||
|
||||
class OtpResendRateLimited(OtpError):
|
||||
code = "OTP_RESEND_RATE_LIMITED"
|
||||
|
||||
def __init__(self, retry_after_seconds: int) -> None:
|
||||
super().__init__(f"Resend allowed in {retry_after_seconds}s")
|
||||
self.retry_after_seconds = retry_after_seconds
|
||||
|
||||
|
||||
class OtpInvalid(OtpError):
|
||||
code = "OTP_INVALID"
|
||||
|
||||
|
||||
class OtpExpired(OtpError):
|
||||
code = "OTP_EXPIRED"
|
||||
|
||||
|
||||
class OtpAttemptsExceeded(OtpError):
|
||||
code = "OTP_ATTEMPTS_EXCEEDED"
|
||||
|
||||
|
||||
def _code_key(email: str, purpose: OtpPurpose) -> str:
|
||||
return f"otp:{purpose}:{email.lower()}"
|
||||
|
||||
|
||||
def _rate_key(email: str, purpose: OtpPurpose) -> str:
|
||||
return f"otp_rate:{purpose}:{email.lower()}"
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
# secrets.randbelow gives uniform 0..10**CODE_LENGTH-1 without modulo bias
|
||||
return f"{secrets.randbelow(10 ** CODE_LENGTH):0{CODE_LENGTH}d}"
|
||||
|
||||
|
||||
def check_resend_allowed(email: str, purpose: OtpPurpose) -> None:
|
||||
ttl = redis_client.ttl(_rate_key(email, purpose))
|
||||
if ttl and ttl > 0:
|
||||
raise OtpResendRateLimited(ttl)
|
||||
|
||||
|
||||
def issue_code(email: str, purpose: OtpPurpose) -> str:
|
||||
"""Generate a new code, persist its hash, and start the resend cooldown.
|
||||
|
||||
Caller is responsible for delivering the returned plaintext (e.g. via email).
|
||||
Any pre-existing code for the same (purpose, email) is overwritten.
|
||||
"""
|
||||
check_resend_allowed(email, purpose)
|
||||
code = _generate_code()
|
||||
hashed = bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode()
|
||||
payload = json.dumps({"hash": hashed, "attempts": 0})
|
||||
redis_client.set(_code_key(email, purpose), payload, ex=CODE_TTL_SECONDS)
|
||||
redis_client.set(_rate_key(email, purpose), "1", ex=RESEND_COOLDOWN_SECONDS)
|
||||
return code
|
||||
|
||||
|
||||
def verify_code(email: str, purpose: OtpPurpose, code: str) -> None:
|
||||
"""Validate and consume a code. Raises subclasses of OtpError on failure."""
|
||||
key = _code_key(email, purpose)
|
||||
raw = redis_client.get(key)
|
||||
if raw is None:
|
||||
raise OtpExpired("Code expired or never issued")
|
||||
record = json.loads(raw)
|
||||
attempts = int(record.get("attempts", 0))
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
redis_client.delete(key)
|
||||
raise OtpAttemptsExceeded("Too many invalid attempts")
|
||||
if not bcrypt.checkpw(code.encode(), record["hash"].encode()):
|
||||
record["attempts"] = attempts + 1
|
||||
ttl = redis_client.ttl(key)
|
||||
redis_client.set(key, json.dumps(record), ex=max(ttl, 1))
|
||||
raise OtpInvalid("Incorrect code")
|
||||
redis_client.delete(key)
|
||||
@@ -33,6 +33,7 @@ from app.services.playground_session_store import upsert_playground_session
|
||||
STREAM_CHUNK_SIZE = 24
|
||||
STREAM_INTERVAL_SECONDS = 0.08
|
||||
THINKING_PREVIEW_SECONDS = 2.6
|
||||
ORPHANED_RUN_MESSAGE = "后台生成任务已中断,请点击上一条用户消息的重试按钮重新生成。"
|
||||
|
||||
|
||||
class _ActiveRun:
|
||||
@@ -179,6 +180,7 @@ async def _build_thread_response(
|
||||
session: PlaygroundSession,
|
||||
) -> PlaygroundThreadResponse:
|
||||
messages = await _list_visible_messages(db, session_id=session.id)
|
||||
messages = await _reconcile_orphaned_active_messages(db, messages)
|
||||
id_map = {item.id: item.public_id for item in messages}
|
||||
return PlaygroundThreadResponse(
|
||||
session=session_to_response(session),
|
||||
@@ -186,6 +188,30 @@ async def _build_thread_response(
|
||||
)
|
||||
|
||||
|
||||
async def _reconcile_orphaned_active_messages(
|
||||
db: AsyncSession,
|
||||
messages: list[PlaygroundMessage],
|
||||
) -> list[PlaygroundMessage]:
|
||||
changed = False
|
||||
for item in messages:
|
||||
if item.status not in {"pending", "thinking", "answering"}:
|
||||
continue
|
||||
if item.public_id in _ACTIVE_RUNS:
|
||||
continue
|
||||
item.status = "error"
|
||||
item.content = item.content or ORPHANED_RUN_MESSAGE
|
||||
orphan_meta = "错误: 后台任务已中断"
|
||||
if orphan_meta not in (item.meta or []):
|
||||
item.meta = [*(item.meta or []), orphan_meta]
|
||||
changed = True
|
||||
if changed:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
for item in messages:
|
||||
await db.refresh(item)
|
||||
return messages
|
||||
|
||||
|
||||
async def get_thread(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -550,6 +576,15 @@ def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_u
|
||||
return history[-8:]
|
||||
|
||||
|
||||
def _format_run_exception(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
return str(detail)
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
async def _run_assistant_message(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -680,13 +715,18 @@ async def _run_assistant_message(
|
||||
await db.commit()
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_message = _format_run_exception(exc)
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
|
||||
message = result.scalar_one_or_none()
|
||||
if message is not None:
|
||||
message.status = "error"
|
||||
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
|
||||
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
|
||||
message.content = message.content or f"分析失败:{error_message}"
|
||||
message.meta = [
|
||||
*(message.meta or []),
|
||||
f"Request ID: {request_id}",
|
||||
f"错误: {error_message}",
|
||||
]
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
finally:
|
||||
|
||||
198
backend/app/services/vessel_aggregation_strategy.py
Normal file
198
backend/app/services/vessel_aggregation_strategy.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Persistence + validation for the v4 vessel_ais aggregation strategy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
VESSEL_AGGREGATION_STRATEGY_CATEGORY = "vessel_aggregation_strategy"
|
||||
|
||||
DYNAMIC_FIELDS: tuple[str, ...] = ("lat", "lon", "sog", "cog", "heading", "nav_status")
|
||||
STATIC_FIELDS: tuple[str, ...] = (
|
||||
"name",
|
||||
"callsign",
|
||||
"imo",
|
||||
"flag",
|
||||
"vessel_type",
|
||||
"vessel_type_name",
|
||||
"length",
|
||||
"width",
|
||||
"draught",
|
||||
)
|
||||
ALLOWED_FIELDS: frozenset[str] = frozenset(DYNAMIC_FIELDS + STATIC_FIELDS)
|
||||
ALLOWED_DYNAMIC_MODES: frozenset[str] = frozenset({"newest"})
|
||||
ALLOWED_STATIC_MODES: frozenset[str] = frozenset({"source_priority", "non_empty", "newest", "locked"})
|
||||
ALLOWED_LOCKED_DYNAMIC_MODES: frozenset[str] = frozenset({"newest", "source_priority", "locked"})
|
||||
|
||||
|
||||
DEFAULT_STRATEGY: dict[str, Any] = {
|
||||
"version": 1,
|
||||
"vessel_ais": {
|
||||
"source_priority": ["aisstream_vessels", "barentswatch_vessels"],
|
||||
"field_rules": {},
|
||||
"freshness": {
|
||||
"realtime_stream_seconds": 900,
|
||||
"polling_seconds": 3600,
|
||||
},
|
||||
"allow_dynamic_lock": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class StrategyValidationError(ValueError):
|
||||
"""Raised when a saved strategy payload is malformed."""
|
||||
|
||||
|
||||
def _coerce_str_list(value: Any, *, label: str) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise StrategyValidationError(f"{label} must be a list of source names")
|
||||
out: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
raise StrategyValidationError(f"{label} entries must be non-empty strings")
|
||||
out.append(item.strip())
|
||||
return out
|
||||
|
||||
|
||||
def validate_strategy(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and normalize a strategy payload. Raise StrategyValidationError on issues."""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise StrategyValidationError("strategy payload must be an object")
|
||||
|
||||
vessel_ais = payload.get("vessel_ais")
|
||||
if not isinstance(vessel_ais, dict):
|
||||
raise StrategyValidationError("strategy.vessel_ais is required and must be an object")
|
||||
|
||||
allow_dynamic_lock = bool(vessel_ais.get("allow_dynamic_lock", False))
|
||||
source_priority = _coerce_str_list(
|
||||
vessel_ais.get("source_priority"),
|
||||
label="vessel_ais.source_priority",
|
||||
)
|
||||
|
||||
raw_rules = vessel_ais.get("field_rules") or {}
|
||||
if not isinstance(raw_rules, dict):
|
||||
raise StrategyValidationError("vessel_ais.field_rules must be an object")
|
||||
field_rules: dict[str, dict[str, Any]] = {}
|
||||
for field, rule in raw_rules.items():
|
||||
if field not in ALLOWED_FIELDS:
|
||||
raise StrategyValidationError(f"unknown vessel_ais field: {field}")
|
||||
if not isinstance(rule, dict):
|
||||
raise StrategyValidationError(f"field_rules.{field} must be an object")
|
||||
mode = str(rule.get("mode") or "").strip()
|
||||
if not mode:
|
||||
raise StrategyValidationError(f"field_rules.{field}.mode is required")
|
||||
is_dynamic = field in DYNAMIC_FIELDS
|
||||
if is_dynamic:
|
||||
allowed_modes = ALLOWED_LOCKED_DYNAMIC_MODES if allow_dynamic_lock else ALLOWED_DYNAMIC_MODES
|
||||
if mode not in allowed_modes:
|
||||
if not allow_dynamic_lock:
|
||||
raise StrategyValidationError(
|
||||
f"field_rules.{field}.mode='{mode}' requires allow_dynamic_lock=true"
|
||||
)
|
||||
raise StrategyValidationError(
|
||||
f"field_rules.{field}.mode must be one of {sorted(allowed_modes)}"
|
||||
)
|
||||
else:
|
||||
if mode not in ALLOWED_STATIC_MODES:
|
||||
raise StrategyValidationError(
|
||||
f"field_rules.{field}.mode must be one of {sorted(ALLOWED_STATIC_MODES)}"
|
||||
)
|
||||
normalized_rule: dict[str, Any] = {"mode": mode}
|
||||
rule_priority = rule.get("source_priority")
|
||||
if rule_priority is not None:
|
||||
normalized_rule["source_priority"] = _coerce_str_list(
|
||||
rule_priority,
|
||||
label=f"field_rules.{field}.source_priority",
|
||||
)
|
||||
if mode == "locked":
|
||||
locked_source = rule.get("locked_source")
|
||||
if not isinstance(locked_source, str) or not locked_source.strip():
|
||||
raise StrategyValidationError(
|
||||
f"field_rules.{field}.locked_source must be a non-empty string when mode=locked"
|
||||
)
|
||||
normalized_rule["locked_source"] = locked_source.strip()
|
||||
field_rules[field] = normalized_rule
|
||||
|
||||
raw_freshness = vessel_ais.get("freshness") or {}
|
||||
if not isinstance(raw_freshness, dict):
|
||||
raise StrategyValidationError("vessel_ais.freshness must be an object")
|
||||
freshness: dict[str, int] = {}
|
||||
for key in ("realtime_stream_seconds", "polling_seconds"):
|
||||
value = raw_freshness.get(key, DEFAULT_STRATEGY["vessel_ais"]["freshness"][key])
|
||||
try:
|
||||
seconds = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise StrategyValidationError(f"freshness.{key} must be an integer") from exc
|
||||
if seconds < 0:
|
||||
raise StrategyValidationError(f"freshness.{key} must be non-negative")
|
||||
freshness[key] = seconds
|
||||
|
||||
return {
|
||||
"version": int(payload.get("version") or 0) + 1,
|
||||
"vessel_ais": {
|
||||
"source_priority": source_priority,
|
||||
"field_rules": field_rules,
|
||||
"freshness": freshness,
|
||||
"allow_dynamic_lock": allow_dynamic_lock,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _select_setting(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == VESSEL_AGGREGATION_STRATEGY_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _current_version(setting: SystemSetting | None) -> int:
|
||||
if setting is None:
|
||||
return 0
|
||||
payload = setting.payload or {}
|
||||
return int(payload.get("version") or 0)
|
||||
|
||||
|
||||
async def load_strategy(db: AsyncSession) -> dict[str, Any]:
|
||||
setting = await _select_setting(db)
|
||||
if setting is None or not isinstance(setting.payload, dict):
|
||||
return DEFAULT_STRATEGY
|
||||
payload = setting.payload
|
||||
if "vessel_ais" not in payload:
|
||||
return DEFAULT_STRATEGY
|
||||
return payload
|
||||
|
||||
|
||||
async def save_strategy(db: AsyncSession, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate + persist; bumps version automatically."""
|
||||
|
||||
existing = await _select_setting(db)
|
||||
incoming = dict(payload)
|
||||
incoming.setdefault("version", _current_version(existing))
|
||||
validated = validate_strategy(incoming)
|
||||
|
||||
if existing is None:
|
||||
existing = SystemSetting(category=VESSEL_AGGREGATION_STRATEGY_CATEGORY, payload=validated)
|
||||
db.add(existing)
|
||||
else:
|
||||
existing.payload = validated
|
||||
await db.commit()
|
||||
return validated
|
||||
|
||||
|
||||
async def reset_strategy(db: AsyncSession) -> dict[str, Any]:
|
||||
existing = await _select_setting(db)
|
||||
payload = {**DEFAULT_STRATEGY, "version": _current_version(existing) + 1}
|
||||
if existing is None:
|
||||
existing = SystemSetting(category=VESSEL_AGGREGATION_STRATEGY_CATEGORY, payload=payload)
|
||||
db.add(existing)
|
||||
else:
|
||||
existing.payload = payload
|
||||
await db.commit()
|
||||
return payload
|
||||
@@ -1,17 +1,27 @@
|
||||
"""AIS raw observation and aggregation support for vessel collectors."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
|
||||
from app.services.vessel_aggregation_strategy import (
|
||||
DEFAULT_STRATEGY,
|
||||
load_strategy,
|
||||
)
|
||||
from app.services.vessel_types import normalize_vessel_type_name
|
||||
|
||||
VESSEL_AIS_SCHEMA = "vessel_ais"
|
||||
DEFAULT_AGGREGATION_WINDOW_HOURS = 24
|
||||
DEFAULT_SNAPSHOT_WINDOW_MINUTES = 60
|
||||
MAX_SNAPSHOT_LIMIT = 5000
|
||||
MAX_SNAPSHOT_CANDIDATE_MULTIPLIER = 20
|
||||
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS = 100_000
|
||||
BARENTSWATCH_DELIVERY_MODE = "polling"
|
||||
BARENTSWATCH_TRANSPORT = "http"
|
||||
AISSTREAM_DELIVERY_MODE = "realtime_stream"
|
||||
@@ -171,13 +181,43 @@ def _is_future_observation(observation: AISRawObservation, now: datetime) -> boo
|
||||
return observation.observed_at > now
|
||||
|
||||
|
||||
def _strategy_source_rank(
|
||||
source: str,
|
||||
strategy: dict[str, Any],
|
||||
) -> int:
|
||||
priority = (strategy.get("vessel_ais") or {}).get("source_priority") or []
|
||||
if source in priority:
|
||||
return len(priority) - priority.index(source)
|
||||
return 0
|
||||
|
||||
|
||||
def _is_stream_stale(
|
||||
observation: AISRawObservation,
|
||||
*,
|
||||
now: datetime,
|
||||
strategy: dict[str, Any],
|
||||
) -> bool:
|
||||
delivery_mode = str(observation.delivery_mode or "")
|
||||
freshness = (strategy.get("vessel_ais") or {}).get("freshness") or {}
|
||||
if delivery_mode == "realtime_stream":
|
||||
window = int(freshness.get("realtime_stream_seconds", 0) or 0)
|
||||
else:
|
||||
window = int(freshness.get("polling_seconds", 0) or 0)
|
||||
if window <= 0:
|
||||
return False
|
||||
return (now - observation.observed_at).total_seconds() > window
|
||||
|
||||
|
||||
def _select_position_observation(
|
||||
observations: list[AISRawObservation],
|
||||
*,
|
||||
now: datetime,
|
||||
strategy: dict[str, Any] | None = None,
|
||||
) -> tuple[AISRawObservation | None, list[str]]:
|
||||
strategy = strategy or DEFAULT_STRATEGY
|
||||
rejected_flags: list[str] = []
|
||||
candidates = []
|
||||
fresh_candidates: list[AISRawObservation] = []
|
||||
stale_candidates: list[AISRawObservation] = []
|
||||
for observation in observations:
|
||||
payload = observation.normalized_payload or {}
|
||||
if not _has_valid_position(payload):
|
||||
@@ -186,8 +226,13 @@ def _select_position_observation(
|
||||
if _is_future_observation(observation, now):
|
||||
rejected_flags.append("future_timestamp")
|
||||
continue
|
||||
candidates.append(observation)
|
||||
if _is_stream_stale(observation, now=now, strategy=strategy):
|
||||
stale_candidates.append(observation)
|
||||
rejected_flags.append("freshness_fallback")
|
||||
continue
|
||||
fresh_candidates.append(observation)
|
||||
|
||||
candidates = fresh_candidates or stale_candidates
|
||||
if not candidates:
|
||||
return None, sorted(set(rejected_flags))
|
||||
|
||||
@@ -195,6 +240,7 @@ def _select_position_observation(
|
||||
key=lambda item: (
|
||||
item.observed_at,
|
||||
_delivery_priority(item),
|
||||
_strategy_source_rank(item.source, strategy),
|
||||
item.collected_at,
|
||||
item.id or 0,
|
||||
),
|
||||
@@ -206,7 +252,9 @@ def _select_position_observation(
|
||||
def _select_static_field(
|
||||
observations: list[AISRawObservation],
|
||||
field: str,
|
||||
strategy: dict[str, Any] | None = None,
|
||||
) -> tuple[Any, str | None, str | None]:
|
||||
strategy = strategy or DEFAULT_STRATEGY
|
||||
candidates = []
|
||||
for observation in observations:
|
||||
value = _payload_value(observation.normalized_payload or {}, field)
|
||||
@@ -219,6 +267,39 @@ def _select_static_field(
|
||||
if not candidates:
|
||||
return None, None, None
|
||||
|
||||
field_rules = (strategy.get("vessel_ais") or {}).get("field_rules") or {}
|
||||
rule = field_rules.get(field) or {"mode": "source_priority"}
|
||||
mode = rule.get("mode")
|
||||
|
||||
if mode == "locked":
|
||||
locked_source = rule.get("locked_source")
|
||||
for observation, value in candidates:
|
||||
if observation.source == locked_source:
|
||||
return value, observation.source, "locked"
|
||||
|
||||
if mode in ("source_priority", "locked"):
|
||||
priority = rule.get("source_priority") or (strategy.get("vessel_ais") or {}).get("source_priority") or []
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda item: (
|
||||
priority.index(item[0].source) if item[0].source in priority else len(priority) + 1,
|
||||
-_delivery_priority(item[0]),
|
||||
-(item[0].observed_at.timestamp() if item[0].observed_at else 0),
|
||||
),
|
||||
)
|
||||
observation, value = ranked[0]
|
||||
return value, observation.source, "source_priority"
|
||||
|
||||
if mode == "newest":
|
||||
ranked = sorted(
|
||||
candidates,
|
||||
key=lambda item: (item[0].observed_at, _delivery_priority(item[0]), item[0].id or 0),
|
||||
reverse=True,
|
||||
)
|
||||
observation, value = ranked[0]
|
||||
return value, observation.source, "newest_observation"
|
||||
|
||||
# default / non_empty: prefer delivery mode priority, then newest
|
||||
candidates.sort(
|
||||
key=lambda item: (
|
||||
_delivery_priority(item[0]),
|
||||
@@ -261,8 +342,12 @@ def _build_aggregated_vessel(
|
||||
observations: list[AISRawObservation],
|
||||
*,
|
||||
now: datetime,
|
||||
strategy: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
position_observation, rejected_flags = _select_position_observation(observations, now=now)
|
||||
strategy = strategy or DEFAULT_STRATEGY
|
||||
position_observation, rejected_flags = _select_position_observation(
|
||||
observations, now=now, strategy=strategy
|
||||
)
|
||||
if position_observation is None:
|
||||
return None
|
||||
|
||||
@@ -279,6 +364,7 @@ def _build_aggregated_vessel(
|
||||
"quality_flags": sorted(
|
||||
set((position_observation.quality_flags or []) + rejected_flags)
|
||||
),
|
||||
"aggregation_strategy_version": int(strategy.get("version") or 0),
|
||||
}
|
||||
|
||||
for field in DYNAMIC_FIELDS:
|
||||
@@ -289,7 +375,9 @@ def _build_aggregated_vessel(
|
||||
result["selected_reasons"][field] = "newest_observation"
|
||||
|
||||
for field in CONFLICT_FIELDS:
|
||||
selected_value, selected_source, reason = _select_static_field(observations, field)
|
||||
selected_value, selected_source, reason = _select_static_field(
|
||||
observations, field, strategy=strategy
|
||||
)
|
||||
if selected_value is None:
|
||||
continue
|
||||
result[field] = selected_value
|
||||
@@ -408,12 +496,16 @@ async def aggregate_vessel_observations(
|
||||
db: AsyncSession,
|
||||
observations: Iterable[AISRawObservation],
|
||||
*,
|
||||
write_conflicts: bool = True,
|
||||
write_conflicts: bool = False,
|
||||
strategy: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
strategy = strategy if strategy is not None else await _safe_load_strategy(db)
|
||||
now = datetime.now(UTC)
|
||||
vessels = []
|
||||
for entity_key, entity_observations in _group_observations(observations).items():
|
||||
aggregated = _build_aggregated_vessel(entity_key, entity_observations, now=now)
|
||||
aggregated = _build_aggregated_vessel(
|
||||
entity_key, entity_observations, now=now, strategy=strategy
|
||||
)
|
||||
if aggregated is None:
|
||||
continue
|
||||
if write_conflicts:
|
||||
@@ -431,15 +523,28 @@ async def aggregate_vessel_observations(
|
||||
return vessels
|
||||
|
||||
|
||||
async def _safe_load_strategy(db: AsyncSession) -> dict[str, Any]:
|
||||
"""Tolerate fake test sessions where load_strategy may misbehave."""
|
||||
try:
|
||||
return await load_strategy(db)
|
||||
except Exception:
|
||||
return DEFAULT_STRATEGY
|
||||
|
||||
|
||||
async def get_aggregated_vessels(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float] | None = None,
|
||||
limit: int | None = None,
|
||||
observed_since: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
observed_since = observed_since or (
|
||||
datetime.now(UTC) - timedelta(hours=DEFAULT_AGGREGATION_WINDOW_HOURS)
|
||||
)
|
||||
stmt = (
|
||||
select(AISRawObservation)
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.where(AISRawObservation.observed_at >= observed_since)
|
||||
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
|
||||
)
|
||||
if limit and limit > 0:
|
||||
@@ -464,6 +569,46 @@ async def get_aggregated_vessels(
|
||||
return vessels
|
||||
|
||||
|
||||
async def get_aggregated_vessels_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float],
|
||||
limit: int = 1000,
|
||||
observed_since: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return a bounded viewport snapshot without loading the global AIS window."""
|
||||
|
||||
observed_since = observed_since or (
|
||||
datetime.now(UTC) - timedelta(minutes=DEFAULT_SNAPSHOT_WINDOW_MINUTES)
|
||||
)
|
||||
safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT)
|
||||
candidate_limit = min(
|
||||
max(safe_limit * MAX_SNAPSHOT_CANDIDATE_MULTIPLIER, safe_limit),
|
||||
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS,
|
||||
)
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
payload_lon = AISRawObservation.normalized_payload["lon"].as_string().cast(Float)
|
||||
payload_lat = AISRawObservation.normalized_payload["lat"].as_string().cast(Float)
|
||||
|
||||
stmt = (
|
||||
select(AISRawObservation)
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.where(AISRawObservation.observed_at >= observed_since)
|
||||
.where(payload_lon >= lon_min)
|
||||
.where(payload_lon <= lon_max)
|
||||
.where(payload_lat >= lat_min)
|
||||
.where(payload_lat <= lat_max)
|
||||
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
|
||||
.limit(candidate_limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
if not hasattr(result, "scalars"):
|
||||
return []
|
||||
vessels = await aggregate_vessel_observations(db, result.scalars().all())
|
||||
return vessels[:safe_limit]
|
||||
|
||||
|
||||
async def get_aggregated_vessel(db: AsyncSession, mmsi: int) -> dict[str, Any] | None:
|
||||
observations = await get_vessel_raw_observations(db, mmsi, limit=1000)
|
||||
vessels = await aggregate_vessel_observations(db, observations)
|
||||
@@ -545,6 +690,30 @@ async def update_ais_source_health(
|
||||
return health
|
||||
|
||||
|
||||
async def count_unique_raw_vessel_mmsi(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
observed_since: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count unique raw vessel MMSI values for HUD counts; never aggregates."""
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
unique_mmsi_stmt = (
|
||||
select(AISRawObservation.entity_key)
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.distinct()
|
||||
)
|
||||
if observed_since is not None:
|
||||
unique_mmsi_stmt = unique_mmsi_stmt.where(
|
||||
AISRawObservation.observed_at >= observed_since,
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(sa_func.count()).select_from(unique_mmsi_stmt.subquery()),
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def get_vessel_raw_observations(
|
||||
db: AsyncSession,
|
||||
mmsi: int,
|
||||
|
||||
109
backend/app/services/vessel_enrichment.py
Normal file
109
backend/app/services/vessel_enrichment.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""v5 vessel enrichment service.
|
||||
|
||||
Read-only side: `get_vessel_enrichment_bundle` is the only path the
|
||||
aggregation/detail endpoints use. It never reaches out to third parties; it
|
||||
just returns whatever the upsert side has already cached. Expired rows are
|
||||
filtered out so old data never leaks back into the live UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vessel_enrichment import VesselMediaEnrichment, VesselProfileEnrichment
|
||||
|
||||
|
||||
def _coerce_datetime(value: Any) -> datetime | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
if isinstance(value, (int, float)):
|
||||
ts = float(value)
|
||||
if ts > 10_000_000_000:
|
||||
ts /= 1000
|
||||
return datetime.fromtimestamp(ts, UTC)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _build_payload(record, *, now: datetime) -> dict[str, Any] | None:
|
||||
if record is None:
|
||||
return None
|
||||
expires_at = record.expires_at
|
||||
if isinstance(expires_at, datetime):
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at < now:
|
||||
return None
|
||||
return record.to_dict()
|
||||
|
||||
|
||||
async def get_vessel_enrichment_bundle(db: AsyncSession, mmsi: int) -> dict[str, Any]:
|
||||
now = datetime.now(UTC)
|
||||
profile = await db.get(VesselProfileEnrichment, mmsi)
|
||||
media = await db.get(VesselMediaEnrichment, mmsi)
|
||||
return {
|
||||
"mmsi": mmsi,
|
||||
"profile": _build_payload(profile, now=now),
|
||||
"media": _build_payload(media, now=now),
|
||||
}
|
||||
|
||||
|
||||
async def upsert_vessel_profile_enrichment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mmsi: int,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
record = await db.get(VesselProfileEnrichment, mmsi)
|
||||
if record is None:
|
||||
record = VesselProfileEnrichment(mmsi=mmsi)
|
||||
db.add(record)
|
||||
return _apply_upsert(record, payload)
|
||||
|
||||
|
||||
async def upsert_vessel_media_enrichment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
mmsi: int,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
record = await db.get(VesselMediaEnrichment, mmsi)
|
||||
if record is None:
|
||||
record = VesselMediaEnrichment(mmsi=mmsi)
|
||||
db.add(record)
|
||||
return _apply_upsert(record, payload)
|
||||
|
||||
|
||||
def _apply_upsert(record, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("enrichment payload must be an object")
|
||||
body = payload.get("payload")
|
||||
if body is not None and not isinstance(body, dict):
|
||||
raise ValueError("payload.payload must be an object")
|
||||
if body is not None:
|
||||
record.payload = body
|
||||
if "source" in payload and isinstance(payload["source"], str) and payload["source"].strip():
|
||||
record.source = payload["source"].strip()
|
||||
fetched_at = _coerce_datetime(payload.get("fetched_at"))
|
||||
record.fetched_at = fetched_at or datetime.now(UTC)
|
||||
record.expires_at = _coerce_datetime(payload.get("expires_at"))
|
||||
confidence = payload.get("confidence")
|
||||
if confidence is not None:
|
||||
try:
|
||||
record.confidence = float(confidence)
|
||||
except (TypeError, ValueError):
|
||||
record.confidence = None
|
||||
if "reference_url" in payload:
|
||||
ref = payload.get("reference_url")
|
||||
record.reference_url = str(ref) if ref else None
|
||||
return record.to_dict()
|
||||
@@ -17,15 +17,16 @@ async def create_admin():
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
print(f"用户 linkong 已存在,更新密码...")
|
||||
existing_user.set_password("LK12345678")
|
||||
print("用户 linkong 已存在,更新密码...")
|
||||
existing_user.set_password("12345678")
|
||||
existing_user.role = "super_admin"
|
||||
existing_user.email = "linkong@planet.local"
|
||||
else:
|
||||
print("创建管理员用户...")
|
||||
user = User(
|
||||
username="linkong",
|
||||
email="linkong@example.com",
|
||||
password_hash=get_password_hash("LK12345678"),
|
||||
email="linkong@planet.local",
|
||||
password_hash=get_password_hash("12345678"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -6,29 +6,58 @@ import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import engine, async_session_factory
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_admin():
|
||||
from sqlalchemy import text
|
||||
|
||||
async with async_session_factory() as session:
|
||||
result = await session.execute(text("SELECT id FROM users WHERE username = 'admin'"))
|
||||
if result.fetchone():
|
||||
print("Admin user already exists")
|
||||
created = []
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username=default_user["username"],
|
||||
email=default_user["email"],
|
||||
password_hash=get_password_hash(default_user["password"]),
|
||||
role=default_user["role"],
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
created.append(default_user)
|
||||
|
||||
await session.commit()
|
||||
if not created:
|
||||
print("Default login users already exist")
|
||||
return
|
||||
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@planet.local",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
print("Admin user created: admin / admin123")
|
||||
for default_user in created:
|
||||
print(
|
||||
f"Default login user created: {default_user['username']} / {default_user['password']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,8 +12,20 @@ from sqlalchemy.orm import sessionmaker
|
||||
import bcrypt
|
||||
|
||||
|
||||
# Generate proper bcrypt hash
|
||||
ADMIN_PASSWORD_HASH = bcrypt.hashpw("admin123".encode(), bcrypt.gensalt()).decode()
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_admin():
|
||||
@@ -22,23 +34,42 @@ async def create_admin():
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = 'admin'")
|
||||
)
|
||||
if result.fetchone():
|
||||
print("Admin user already exists")
|
||||
created = []
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
password_hash = bcrypt.hashpw(
|
||||
default_user["password"].encode(), bcrypt.gensalt()
|
||||
).decode()
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password, :role, true, NOW(), NOW())
|
||||
"""),
|
||||
{
|
||||
"username": default_user["username"],
|
||||
"email": default_user["email"],
|
||||
"password": password_hash,
|
||||
"role": default_user["role"],
|
||||
},
|
||||
)
|
||||
created.append((default_user, password_hash))
|
||||
|
||||
await session.commit()
|
||||
if not created:
|
||||
print("Default login users already exist")
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES ('admin', 'admin@planet.local', :password, 'super_admin', true, NOW(), NOW())
|
||||
"""),
|
||||
{"password": ADMIN_PASSWORD_HASH},
|
||||
)
|
||||
await session.commit()
|
||||
print(f"Admin user created: admin / admin123")
|
||||
print(f"Hash: {ADMIN_PASSWORD_HASH}")
|
||||
for default_user, password_hash in created:
|
||||
print(
|
||||
f"Default login user created: {default_user['username']} / {default_user['password']}"
|
||||
)
|
||||
print(f"Hash: {password_hash}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,10 +2,45 @@
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bgp_collector_location_cache():
|
||||
"""Mirror app startup seeding for tests that call sync BGP helpers."""
|
||||
from app.services.bgp_collector_locations import (
|
||||
SEED_PATH,
|
||||
set_bgp_collector_location_cache,
|
||||
)
|
||||
|
||||
payload = json.loads(SEED_PATH.read_text(encoding="utf-8"))
|
||||
cache = {}
|
||||
for entry in payload.get("locations", []):
|
||||
collector_id = next(
|
||||
alias for alias in entry.get("aliases", []) if str(alias).startswith("rrc")
|
||||
)
|
||||
cache[collector_id] = {
|
||||
"city": entry.get("city"),
|
||||
"country": entry.get("country"),
|
||||
"latitude": entry.get("latitude"),
|
||||
"longitude": entry.get("longitude"),
|
||||
"precision": entry.get("precision") or "city",
|
||||
"source": "legacy_seed",
|
||||
"needs_confirmation": True,
|
||||
"matched_location_name": entry.get("site") or collector_id,
|
||||
"verified_at": None,
|
||||
"confidence": entry.get("confidence"),
|
||||
"operator": entry.get("operator"),
|
||||
"site": entry.get("site"),
|
||||
"verification_status": "unverified",
|
||||
"source_note": entry.get("source_note"),
|
||||
}
|
||||
set_bgp_collector_location_cache(cache)
|
||||
yield
|
||||
set_bgp_collector_location_cache({})
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for BGP observability helpers."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -54,11 +55,34 @@ class _FakeResult:
|
||||
def scalars(self):
|
||||
return _FakeScalarResult(self._rows)
|
||||
|
||||
def all(self):
|
||||
if self._rows and all(isinstance(row, BGPObservation) for row in self._rows):
|
||||
return [
|
||||
(row.prefix, row.origin_asn, row.collector, row.collector_geo)
|
||||
for row in self._rows
|
||||
]
|
||||
return self._rows
|
||||
|
||||
def scalar(self):
|
||||
if not self._rows:
|
||||
return 0
|
||||
first = self._rows[0]
|
||||
if isinstance(first, (int, float, str)):
|
||||
return first
|
||||
if isinstance(first, tuple) and len(first) == 1:
|
||||
return first[0]
|
||||
return len(self._rows)
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
if not self._rows:
|
||||
return None
|
||||
first = self._rows[0]
|
||||
if isinstance(first, CollectedData):
|
||||
return {"extra_data": first.extra_data}
|
||||
return first
|
||||
|
||||
|
||||
class _FakeAsyncSession:
|
||||
@@ -988,7 +1012,7 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
data_type="cable",
|
||||
extra_data={"cable_id": 20},
|
||||
)
|
||||
db = _FakeAsyncSession([[landing], [relation], [cable]])
|
||||
db = _FakeAsyncSession([[landing, relation, cable]])
|
||||
|
||||
result = await infer_related_infrastructure(
|
||||
db,
|
||||
@@ -1012,27 +1036,37 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_bgp_collector_coverage_summarizes_observations():
|
||||
now = datetime.now(UTC)
|
||||
obs_one = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
aggregate = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
observation_count=2,
|
||||
prefix_count=2,
|
||||
origin_asn_count=2,
|
||||
peer_asn_count=2,
|
||||
recent_15m_observation_count=2,
|
||||
recent_24h_observation_count=2,
|
||||
recent_7d_observation_count=2,
|
||||
recent_15m_prefix_count=2,
|
||||
recent_24h_prefix_count=2,
|
||||
recent_7d_prefix_count=2,
|
||||
latest_observed_at=now + timedelta(minutes=5),
|
||||
)
|
||||
latest = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
latest_event_type="withdrawal",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
top_event = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
prefix="203.0.113.0/24",
|
||||
origin_asn=64496,
|
||||
peer_asn=3333,
|
||||
event_type="announcement",
|
||||
observed_at=now,
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
count=1,
|
||||
)
|
||||
obs_two = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
scope = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
prefix="198.51.100.0/24",
|
||||
origin_asn=64497,
|
||||
peer_asn=3334,
|
||||
event_type="withdrawal",
|
||||
observed_at=now + timedelta(minutes=5),
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
db = _FakeAsyncSession([[obs_one, obs_two]])
|
||||
db = _FakeAsyncSession([[aggregate], [latest], [top_event], [scope]])
|
||||
|
||||
coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
||||
|
||||
@@ -1363,18 +1397,39 @@ async def test_bgp_event_summary_api_returns_aggregates():
|
||||
@pytest.mark.asyncio
|
||||
async def test_bgp_collectors_api_returns_coverage():
|
||||
now = datetime.now(UTC)
|
||||
observation = BGPObservation(
|
||||
id=1,
|
||||
source="ris_live_bgp",
|
||||
aggregate = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
peer_asn=3333,
|
||||
prefix="203.0.113.0/24",
|
||||
event_type="announcement",
|
||||
origin_asn=64496,
|
||||
observed_at=now,
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
observation_count=1,
|
||||
prefix_count=1,
|
||||
origin_asn_count=1,
|
||||
peer_asn_count=1,
|
||||
recent_15m_observation_count=1,
|
||||
recent_24h_observation_count=1,
|
||||
recent_7d_observation_count=1,
|
||||
recent_15m_prefix_count=1,
|
||||
recent_24h_prefix_count=1,
|
||||
recent_7d_prefix_count=1,
|
||||
latest_observed_at=now,
|
||||
)
|
||||
latest = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
latest_event_type="announcement",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
top_event = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
event_type="announcement",
|
||||
count=1,
|
||||
)
|
||||
scope = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
db = _FakeAsyncSession(
|
||||
[[aggregate], [latest], [top_event], [scope], [aggregate], [latest], [top_event], [scope]]
|
||||
)
|
||||
db = _FakeAsyncSession([[observation], [observation]])
|
||||
client = await _bgp_test_client(db)
|
||||
|
||||
try:
|
||||
|
||||
222
backend/tests/test_bgp_collector_locations.py
Normal file
222
backend/tests/test_bgp_collector_locations.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Tests for the BGP collector + event location services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import bgp as bgp_api
|
||||
from app.services import bgp_collector_locations
|
||||
from app.services.location.llm_fallback import LocationLLMFallbackResult
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
collect_bgp_collector_location_candidates,
|
||||
iter_known_collector_names,
|
||||
resolve_bgp_collector_location,
|
||||
)
|
||||
from app.services.bgp_event_locations import (
|
||||
resolve_bgp_event_geo_dict,
|
||||
resolve_bgp_event_location,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_dict_view_preserves_backward_compatible_keys():
|
||||
rrc00 = RIPE_RIS_COLLECTOR_COORDS["rrc00"]
|
||||
assert rrc00["city"] == "Amsterdam"
|
||||
assert rrc00["country"] == "Netherlands"
|
||||
assert rrc00["latitude"] == pytest.approx(52.3676)
|
||||
assert rrc00["longitude"] == pytest.approx(4.9041)
|
||||
# New richer fields layered on top.
|
||||
assert rrc00["precision"] == "city"
|
||||
assert rrc00["source"] == "legacy_seed"
|
||||
assert rrc00["needs_confirmation"] is True
|
||||
|
||||
|
||||
def test_every_legacy_collector_present():
|
||||
expected = {
|
||||
"rrc00", "rrc01", "rrc03", "rrc04", "rrc05", "rrc06", "rrc07",
|
||||
"rrc10", "rrc11", "rrc12", "rrc13", "rrc14", "rrc15", "rrc16",
|
||||
"rrc18", "rrc19", "rrc20", "rrc21", "rrc22", "rrc23", "rrc24",
|
||||
"rrc25", "rrc26",
|
||||
}
|
||||
assert set(iter_known_collector_names()) == expected
|
||||
|
||||
|
||||
def test_resolve_bgp_collector_returns_stored_location():
|
||||
result = resolve_bgp_collector_location("rrc12")
|
||||
assert result.location is not None
|
||||
assert result.location.city == "Frankfurt"
|
||||
assert result.location.country == "Germany"
|
||||
assert result.location.precision == "city"
|
||||
assert result.location.source == "legacy_seed"
|
||||
assert result.location.needs_confirmation is True
|
||||
|
||||
|
||||
def test_resolve_unknown_bgp_collector_returns_diagnostic(monkeypatch):
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", lambda q: None)
|
||||
result = resolve_bgp_collector_location("rrc-doesnotexist")
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.failure_reason
|
||||
|
||||
|
||||
def test_collect_bgp_collector_candidates_uses_stored_context_without_registry(monkeypatch):
|
||||
bgp_collector_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
assert "CIXP" in query or "Geneva" in query
|
||||
return {
|
||||
"lat": "46.2044",
|
||||
"lon": "6.1432",
|
||||
"display_name": "Geneva, Switzerland",
|
||||
"address": {"city": "Geneva", "country": "Switzerland"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||
collector="rrc04",
|
||||
)
|
||||
assert attempted, "stored context should feed online query attempts"
|
||||
assert candidates, "online geocoding should produce at least one candidate"
|
||||
best = candidates[0]
|
||||
assert best.source == "nominatim_online_geocode"
|
||||
assert best.needs_confirmation is True
|
||||
assert all(candidate.source != "local_registry" for candidate in candidates)
|
||||
|
||||
|
||||
def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(monkeypatch):
|
||||
bgp_collector_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
if "Lyon" not in query and "France-IX" not in query and "FR-IX" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "45.764",
|
||||
"lon": "4.8357",
|
||||
"display_name": "Lyon, Auvergne-Rhône-Alpes, France",
|
||||
"address": {"city": "Lyon", "country": "France"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||
collector="rrc-mystery",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
assert attempted, "Nominatim plan should run"
|
||||
online = [c for c in candidates if c.source == "nominatim_online_geocode"]
|
||||
assert online, "online resolver must produce a candidate when registry misses"
|
||||
assert online[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
llm_candidate = bgp_collector_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon, France",
|
||||
precision="city",
|
||||
confidence=0.74,
|
||||
query="llm_factcheck:bgp_collector:rrc-mystery",
|
||||
source="llm_location_factcheck",
|
||||
source_note="LLM location factcheck fallback",
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"get_bgp_collector_location_dict",
|
||||
lambda _collector: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"collect_bgp_collector_location_candidates",
|
||||
lambda **_kwargs: ([], ["Lyon, France"]),
|
||||
)
|
||||
|
||||
from app.services.location.llm_fallback import LocationSearchEvidenceResult
|
||||
|
||||
async def _search_evidence(**_kwargs):
|
||||
return LocationSearchEvidenceResult(
|
||||
evidence=[
|
||||
{
|
||||
"title": "RRC source",
|
||||
"url": "https://example.test/rrc",
|
||||
"snippet": "rrc-mystery is in Lyon.",
|
||||
}
|
||||
],
|
||||
attempted_queries=["web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city"],
|
||||
)
|
||||
|
||||
async def _fallback(**_kwargs):
|
||||
return LocationLLMFallbackResult(
|
||||
candidates=[llm_candidate],
|
||||
attempted_queries=["llm_factcheck:bgp_collector:rrc-mystery"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "get_web_search_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "collect_location_search_evidence", _search_evidence)
|
||||
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||
|
||||
response = await bgp_api.collect_bgp_collector_location(
|
||||
"rrc-mystery",
|
||||
bgp_api.CollectBGPCollectorLocationRequest(city="Lyon", country="France"),
|
||||
current_user=object(),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "llm_location_factcheck"
|
||||
assert response["best_candidate"]["needs_confirmation"] is True
|
||||
assert response["attempted_queries"] == [
|
||||
"Lyon, France",
|
||||
"web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city",
|
||||
"llm_factcheck:bgp_collector:rrc-mystery",
|
||||
]
|
||||
|
||||
|
||||
# ── BGP event resolver ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_event_resolver_inherits_from_owning_collector():
|
||||
geo = resolve_bgp_event_geo_dict("rrc25")
|
||||
assert geo["city"] == "Amsterdam"
|
||||
assert geo["country"] == "Netherlands"
|
||||
assert geo["source"] == "inherited_from_collector"
|
||||
assert geo["precision"] == "city"
|
||||
|
||||
|
||||
def test_event_resolver_does_not_match_unrelated_collectors():
|
||||
"""Regression: passing operator=RIPE NCC must NOT make every collector match."""
|
||||
rrc12 = resolve_bgp_event_geo_dict("rrc12")
|
||||
rrc25 = resolve_bgp_event_geo_dict("rrc25")
|
||||
assert rrc12["city"] == "Frankfurt"
|
||||
assert rrc25["city"] == "Amsterdam"
|
||||
assert rrc12["latitude"] != rrc25["latitude"]
|
||||
|
||||
|
||||
def test_event_resolver_uses_source_coordinates_when_present():
|
||||
geo = resolve_bgp_event_geo_dict(
|
||||
"rrc12",
|
||||
source_latitude=12.34,
|
||||
source_longitude=56.78,
|
||||
)
|
||||
assert geo["latitude"] == pytest.approx(12.34)
|
||||
assert geo["longitude"] == pytest.approx(56.78)
|
||||
assert geo["precision"] == "precise"
|
||||
assert geo["source"] == "source_coordinates"
|
||||
|
||||
|
||||
def test_event_resolver_returns_empty_for_unknown_collector_without_source_coords():
|
||||
geo = resolve_bgp_event_geo_dict("rrc-doesnotexist")
|
||||
assert geo == {}
|
||||
|
||||
|
||||
def test_event_resolver_full_result_carries_diagnostic_on_miss():
|
||||
result = resolve_bgp_event_location(collector="rrc-doesnotexist")
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
149
backend/tests/test_custom_datasource_runtime_live.py
Normal file
149
backend/tests/test_custom_datasource_runtime_live.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""End-to-end integration test for the custom WebSocket datasource runner.
|
||||
|
||||
Boots an in-process WebSocket server that mimics the bun mock AIS server
|
||||
(`scripts/mock-ais-ws-server.ts`) and runs the real
|
||||
`run_mapped_websocket_config` against it. Catches regressions where the
|
||||
runner stops connecting, fails to extract the configured message path,
|
||||
or quietly drops mapped records before broadcasting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import websockets
|
||||
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.services import custom_datasource_runtime
|
||||
from app.services.custom_datasource_runtime import run_mapped_websocket_config
|
||||
|
||||
|
||||
def _make_payload(seq: int) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"type": "vessel",
|
||||
"sequence": seq,
|
||||
"data": {
|
||||
"mmsi": str(999_000_000 + seq),
|
||||
"name": f"MOCK VESSEL {seq:03d}",
|
||||
"lat": 36.20 + seq * 0.001,
|
||||
"lon": 14.20 + seq * 0.001,
|
||||
"sog": 12.0,
|
||||
"cog": 90.0,
|
||||
"heading": 90,
|
||||
"vessel_type": 70,
|
||||
"vessel_type_name": "Cargo",
|
||||
"received_at": datetime.now(UTC).isoformat(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _mock_ais_server(emit_count: int):
|
||||
received_subscribe: list[str] = []
|
||||
|
||||
async def handler(ws):
|
||||
try:
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.recv(), timeout=0.5)
|
||||
received_subscribe.append(msg)
|
||||
except (asyncio.TimeoutError, websockets.ConnectionClosed):
|
||||
pass
|
||||
for seq in range(1, emit_count + 1):
|
||||
await ws.send(_make_payload(seq))
|
||||
await asyncio.sleep(0.01)
|
||||
# keep the socket open briefly so the runner observes the messages
|
||||
await asyncio.sleep(0.05)
|
||||
except websockets.ConnectionClosed:
|
||||
return
|
||||
|
||||
async with websockets.serve(handler, "127.0.0.1", 0) as server:
|
||||
port = next(iter(server.sockets)).getsockname()[1]
|
||||
yield port, received_subscribe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_runner_streams_from_live_mock(monkeypatch):
|
||||
mapping = SimpleNamespace(
|
||||
id=11,
|
||||
version=3,
|
||||
target_schema="vessel_ais",
|
||||
mapping_json={
|
||||
"source": {"items_path": "$"},
|
||||
"fields": {
|
||||
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||
"lat": {"path": "$.lat", "type": "float"},
|
||||
"lon": {"path": "$.lon", "type": "float"},
|
||||
"name": {"path": "$.name", "type": "string"},
|
||||
"vessel_type": {"path": "$.vessel_type", "type": "integer", "default": None},
|
||||
"vessel_type_name": {"path": "$.vessel_type_name", "type": "string", "default": None},
|
||||
"sog": {"path": "$.sog", "type": "float", "default": None},
|
||||
"cog": {"path": "$.cog", "type": "float", "default": None},
|
||||
"heading": {"path": "$.heading", "type": "integer", "default": None},
|
||||
"received_at": {"path": "$.received_at", "type": "datetime"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
class FakeResult:
|
||||
def scalar_one_or_none(self):
|
||||
return mapping
|
||||
|
||||
class FakeDB:
|
||||
async def execute(self, _stmt):
|
||||
return FakeResult()
|
||||
|
||||
persist = AsyncMock(return_value=1)
|
||||
monkeypatch.setattr(custom_datasource_runtime, "persist_mapped_records", persist)
|
||||
|
||||
async with _mock_ais_server(emit_count=3) as (port, received_subscribe):
|
||||
result = await run_mapped_websocket_config(
|
||||
FakeDB(),
|
||||
DataSourceConfig(
|
||||
id=99,
|
||||
name="mock_ais_ws",
|
||||
source_type="websocket",
|
||||
endpoint=f"ws://127.0.0.1:{port}",
|
||||
auth_type="none",
|
||||
headers={},
|
||||
config={
|
||||
"ws_message_path": "$.data",
|
||||
"ws_subscribe_message": {
|
||||
"type": "subscribe",
|
||||
"anchor": {"lat": 36.2, "lon": 14.2},
|
||||
"spread_km": 50,
|
||||
"rate_hz": 1,
|
||||
},
|
||||
"debug_max_messages": 2,
|
||||
"delivery_mode": "realtime_stream",
|
||||
"ws_reconnect": False,
|
||||
},
|
||||
),
|
||||
use_config_debug_max_messages=True,
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["messages_seen"] == 2
|
||||
assert result["written_count"] == 2
|
||||
assert result["mapped_count"] == 2
|
||||
assert result["target_schema"] == "vessel_ais"
|
||||
# subscribe message must reach the server unchanged
|
||||
assert received_subscribe, "runner did not forward ws_subscribe_message"
|
||||
parsed = json.loads(received_subscribe[0])
|
||||
assert parsed["type"] == "subscribe"
|
||||
assert parsed["anchor"] == {"lat": 36.2, "lon": 14.2}
|
||||
assert parsed["rate_hz"] == 1
|
||||
# mapped records carry the real MMSIs from the mock stream
|
||||
persisted_records = []
|
||||
for call in persist.await_args_list:
|
||||
persisted_records.extend(call.kwargs["records"])
|
||||
assert {record["mmsi"] for record in persisted_records} == {999_000_001, 999_000_002}
|
||||
assert all(record["vessel_type"] == 70 for record in persisted_records)
|
||||
assert all(record["vessel_type_name"] == "Cargo" for record in persisted_records)
|
||||
@@ -1,13 +1,18 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1.datasource_config import get_ai_provider_client
|
||||
from app.core.websocket import broadcaster as broadcaster_module
|
||||
from app.core.security import get_current_user
|
||||
from app.core.target_schema_registry import get_target_schema, list_target_schemas
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.services import custom_datasource_runtime
|
||||
from app.services.custom_datasource_runtime import run_mapped_websocket_config
|
||||
from app.services.datasource_mapping import execute_mapping, persist_mapped_records, redact_for_llm
|
||||
|
||||
|
||||
@@ -106,6 +111,130 @@ async def test_persist_mapped_records_writes_generic_records():
|
||||
assert db.added[0].extra_data["mapping_version"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_mapped_vessel_records_writes_raw_and_broadcasts(monkeypatch):
|
||||
record_observation = AsyncMock(return_value=object())
|
||||
update_health = AsyncMock()
|
||||
broadcast_custom = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.vessel_ais_aggregation.record_vessel_ais_observation",
|
||||
record_observation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.vessel_ais_aggregation.update_ais_source_health",
|
||||
update_health,
|
||||
)
|
||||
monkeypatch.setattr(broadcaster_module, "broadcast_custom", broadcast_custom)
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.committed = False
|
||||
|
||||
async def commit(self):
|
||||
self.committed = True
|
||||
|
||||
db = FakeDB()
|
||||
|
||||
count = await persist_mapped_records(
|
||||
db,
|
||||
datasource_name="mock_ais_ws",
|
||||
datasource_config_id=42,
|
||||
target_schema="vessel_ais",
|
||||
records=[
|
||||
{
|
||||
"mmsi": 999000001,
|
||||
"lat": 31.2,
|
||||
"lon": 121.4,
|
||||
"name": "MOCK VESSEL 001",
|
||||
"received_at": "2026-05-01T00:00:00Z",
|
||||
}
|
||||
],
|
||||
mapping_version=1,
|
||||
delivery_mode="realtime_stream",
|
||||
transport="websocket",
|
||||
)
|
||||
|
||||
assert count == 1
|
||||
assert db.committed is True
|
||||
record_observation.assert_awaited_once()
|
||||
assert record_observation.await_args.kwargs["source"] == "mock_ais_ws"
|
||||
assert record_observation.await_args.kwargs["delivery_mode"] == "realtime_stream"
|
||||
assert record_observation.await_args.kwargs["transport"] == "websocket"
|
||||
update_health.assert_awaited_once()
|
||||
broadcast_custom.assert_awaited_once()
|
||||
assert broadcast_custom.await_args.args[0] == "vessels"
|
||||
assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "999000001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_websocket_runner_maps_and_persists_vessel_records(monkeypatch):
|
||||
mapping = SimpleNamespace(
|
||||
id=7,
|
||||
version=2,
|
||||
target_schema="vessel_ais",
|
||||
mapping_json={
|
||||
"source": {"items_path": "$"},
|
||||
"fields": {
|
||||
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||
"lat": {"path": "$.lat", "type": "float"},
|
||||
"lon": {"path": "$.lon", "type": "float"},
|
||||
"name": {"path": "$.name", "type": "string"},
|
||||
"received_at": {"path": "$.received_at", "type": "datetime"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
class FakeResult:
|
||||
def scalar_one_or_none(self):
|
||||
return mapping
|
||||
|
||||
class FakeDB:
|
||||
async def execute(self, _stmt):
|
||||
return FakeResult()
|
||||
|
||||
class FakeWebSocket:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def send(self, _message):
|
||||
return None
|
||||
|
||||
async def recv(self):
|
||||
return (
|
||||
'{"type":"vessel","data":{"mmsi":"999000001","name":"MOCK VESSEL 001",'
|
||||
'"lat":31.2,"lon":121.4,"received_at":"2026-05-01T00:00:00Z"}}'
|
||||
)
|
||||
|
||||
persist = AsyncMock(return_value=1)
|
||||
monkeypatch.setattr(custom_datasource_runtime, "_connect_websocket", AsyncMock(return_value=FakeWebSocket()))
|
||||
monkeypatch.setattr(custom_datasource_runtime, "persist_mapped_records", persist)
|
||||
|
||||
result = await run_mapped_websocket_config(
|
||||
FakeDB(),
|
||||
DataSourceConfig(
|
||||
id=42,
|
||||
name="mock_ais_ws",
|
||||
source_type="websocket",
|
||||
endpoint="ws://localhost:8787/ais",
|
||||
auth_type="none",
|
||||
headers={},
|
||||
config={"ws_message_path": "$.data", "debug_max_messages": 1},
|
||||
),
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["messages_seen"] == 1
|
||||
assert result["written_count"] == 1
|
||||
persist.assert_awaited_once()
|
||||
assert persist.await_args.kwargs["datasource_name"] == "mock_ais_ws"
|
||||
assert persist.await_args.kwargs["records"][0]["mmsi"] == 999000001
|
||||
assert persist.await_args.kwargs["delivery_mode"] == "realtime_stream"
|
||||
assert persist.await_args.kwargs["transport"] == "websocket"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mapping_preview_api_uses_deterministic_engine():
|
||||
def override_get_current_user():
|
||||
|
||||
86
backend/tests/test_datasources_batch.py
Normal file
86
backend/tests/test_datasources_batch.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import datasources as datasources_api
|
||||
from app.models.datasource import DataSource
|
||||
|
||||
|
||||
def make_datasource(
|
||||
datasource_id: int,
|
||||
source: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
module: str = "L4",
|
||||
is_active: bool = True,
|
||||
last_status: str | None = None,
|
||||
last_run_at: datetime | None = None,
|
||||
frequency_minutes: int = 60,
|
||||
) -> DataSource:
|
||||
return DataSource(
|
||||
id=datasource_id,
|
||||
name=name or source,
|
||||
source=source,
|
||||
module=module,
|
||||
priority="P1",
|
||||
frequency_minutes=frequency_minutes,
|
||||
collector_class=source,
|
||||
is_active=is_active,
|
||||
last_status=last_status,
|
||||
last_run_at=last_run_at,
|
||||
)
|
||||
|
||||
|
||||
def test_datasource_product_key_groups_domain_specific_sources():
|
||||
assert datasources_api.datasource_product_key(make_datasource(1, "aisstream_vessels")) == "vessels"
|
||||
assert datasources_api.datasource_product_key(make_datasource(2, "telegeography_cables")) == "cables"
|
||||
assert datasources_api.datasource_product_key(make_datasource(3, "celestrak_tle")) == "satellites"
|
||||
assert datasources_api.datasource_product_key(make_datasource(4, "ris_live_bgp")) == "bgp"
|
||||
|
||||
|
||||
def test_filter_datasources_by_product_status_and_collected_state():
|
||||
vessels = make_datasource(1, "aisstream_vessels", last_status="success")
|
||||
cables = make_datasource(2, "telegeography_cables", last_status="failed")
|
||||
filtered = datasources_api._filter_datasources_in_memory(
|
||||
[vessels, cables],
|
||||
running_tasks={},
|
||||
record_counts={"aisstream_vessels": 12, "telegeography_cables": 0},
|
||||
product="vessels",
|
||||
run_status="success",
|
||||
collected=True,
|
||||
)
|
||||
|
||||
assert filtered == [vessels]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
disabled = make_datasource(1, "aisstream_vessels", is_active=False)
|
||||
not_due = make_datasource(2, "telegeography_cables", last_run_at=now, frequency_minutes=120)
|
||||
due = make_datasource(3, "ris_live_bgp", last_run_at=now - timedelta(hours=2))
|
||||
triggered_sources: list[str] = []
|
||||
|
||||
async def fake_running_tasks(_db, _ids):
|
||||
return {}
|
||||
|
||||
async def fake_latest_task_ids(_db, _ids):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_running_tasks", fake_running_tasks)
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_task_ids", fake_latest_task_ids)
|
||||
monkeypatch.setattr(
|
||||
datasources_api,
|
||||
"run_collector_now",
|
||||
lambda source: triggered_sources.append(source) or True,
|
||||
)
|
||||
|
||||
result = await datasources_api._trigger_datasource_batch(
|
||||
object(),
|
||||
[disabled, not_due, due],
|
||||
force=False,
|
||||
)
|
||||
|
||||
assert [item["source"] for item in result["triggered"]] == ["ris_live_bgp"]
|
||||
assert {item["reason"] for item in result["skipped"]} == {"disabled", "within_frequency_window"}
|
||||
assert triggered_sources == ["ris_live_bgp"]
|
||||
116
backend/tests/test_docs_gatekeeper.py
Normal file
116
backend/tests/test_docs_gatekeeper.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Docs Gatekeeper API tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1 import docs as docs_api
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def make_user(role: str = "viewer", groups: list[str] | None = None) -> User:
|
||||
user = User(
|
||||
id=1,
|
||||
username="docs-user",
|
||||
email="docs@example.com",
|
||||
password_hash="x",
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
user.gatekeeper_groups = groups or []
|
||||
return user
|
||||
|
||||
|
||||
async def get_json(path: str, user: User | None = None):
|
||||
if user is not None:
|
||||
async def override_user():
|
||||
return user
|
||||
|
||||
app.dependency_overrides[docs_api.get_optional_current_user] = override_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_catalog_only_for_anonymous_user():
|
||||
response = await get_json("/api/v1/docs/catalog")
|
||||
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
assert {item["access"] for item in items} == {"public"}
|
||||
assert {item["slug"] for item in items if item["lang"] == "zh"} == {
|
||||
"overview",
|
||||
"quickstart",
|
||||
"manual",
|
||||
"faq",
|
||||
"location-pipeline-user",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_read_public_doc():
|
||||
response = await get_json("/api/v1/docs/zh/quickstart")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access"] == "public"
|
||||
assert "快速开始" in response.json()["markdown"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_protected_doc_requires_authentication():
|
||||
response = await get_json("/api/v1/docs/zh/backend-collectors")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_without_group_cannot_read_developer_doc():
|
||||
response = await get_json(
|
||||
"/api/v1/docs/zh/backend-collectors",
|
||||
make_user(role="viewer"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_developer_group_can_read_developer_but_not_admin_doc():
|
||||
user = make_user(role="viewer", groups=["docs_developer"])
|
||||
|
||||
developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user)
|
||||
admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user)
|
||||
|
||||
assert developer_response.status_code == 200
|
||||
assert developer_response.json()["access"] == "docs_developer"
|
||||
assert admin_response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_and_super_admin_can_read_admin_docs():
|
||||
admin_response = await get_json(
|
||||
"/api/v1/docs/zh/backend-system-service-control",
|
||||
make_user(role="admin"),
|
||||
)
|
||||
super_admin_response = await get_json(
|
||||
"/api/v1/docs/zh/backend-system-service-control",
|
||||
make_user(role="super_admin"),
|
||||
)
|
||||
|
||||
assert admin_response.status_code == 200
|
||||
assert super_admin_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_language_slug_and_path_traversal_do_not_read_files():
|
||||
bad_lang = await get_json("/api/v1/docs/fr/quickstart")
|
||||
bad_slug = await get_json("/api/v1/docs/zh/not-a-doc")
|
||||
traversal = await get_json("/api/v1/docs/zh/..%2Fmanual")
|
||||
|
||||
assert bad_lang.status_code == 404
|
||||
assert bad_slug.status_code == 404
|
||||
assert traversal.status_code == 404
|
||||
44
backend/tests/test_layers.py
Normal file
44
backend/tests/test_layers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1 import layers
|
||||
|
||||
|
||||
def test_layer_guard_requires_bbox():
|
||||
try:
|
||||
layers._parse_layer_bbox("")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("Expected missing bbox to fail")
|
||||
|
||||
|
||||
def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit():
|
||||
geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [121.0, 31.0]},
|
||||
"properties": {"id": "inside"},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [10.0, 10.0]},
|
||||
"properties": {"id": "outside"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = layers._guard_geojson_layer(
|
||||
geojson,
|
||||
bbox=(120.0, 30.0, 122.0, 32.0),
|
||||
zoom=2,
|
||||
limit=6000,
|
||||
)
|
||||
|
||||
assert result["returned_count"] == 1
|
||||
assert result["visible_count"] == 1
|
||||
assert result["features"][0]["properties"]["id"] == "inside"
|
||||
assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT
|
||||
assert result["diagnostics"]["limit_clamped"] is True
|
||||
assert result["diagnostics"]["degraded"] is True
|
||||
957
backend/tests/test_location_pipeline.py
Normal file
957
backend/tests/test_location_pipeline.py
Normal file
@@ -0,0 +1,957 @@
|
||||
"""Tests for the shared location resolution pipeline.
|
||||
|
||||
Validates the abstraction itself: the protocol contract, the orchestrator,
|
||||
each built-in resolver, and the pluggability promise (a custom resolver can
|
||||
be slotted in without touching consumers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.location import (
|
||||
InheritFromAnotherEntityResolver,
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
RegistryResolver,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
)
|
||||
from app.schemas.ai import SituationalAnalysisResponse
|
||||
import app.services.location.llm_fallback as llm_fallback
|
||||
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
|
||||
|
||||
|
||||
# ── Test fixtures ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_registry(tmp_path: Path) -> Path:
|
||||
payload = {
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "Test Site Alpha",
|
||||
"aliases": ["alpha", "alpha-one", "Acme HQ"],
|
||||
"operator": "Acme Networks",
|
||||
"site": "Acme HQ",
|
||||
"city": "Lyon",
|
||||
"country": "France",
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "site",
|
||||
"confidence": 0.92,
|
||||
"source_note": "Test fixture",
|
||||
"verified_at": "2026-05-08",
|
||||
},
|
||||
{
|
||||
"canonical_name": "Test Site Bravo",
|
||||
"aliases": ["bravo"],
|
||||
"operator": "Acme Networks",
|
||||
"site": "Bravo POP",
|
||||
"city": "Berlin",
|
||||
"country": "Germany",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
],
|
||||
"city_fallbacks": [
|
||||
{
|
||||
"city": "Bhutan-Capital",
|
||||
"country": "Bhutan",
|
||||
"latitude": 27.4728,
|
||||
"longitude": 89.639,
|
||||
"precision": "city",
|
||||
"confidence": 0.5,
|
||||
}
|
||||
],
|
||||
}
|
||||
path = tmp_path / "registry.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ── SourceCoordinatesResolver ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_passes_through_valid_coordinates():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
query = LocationQuery(
|
||||
name="Acme HQ",
|
||||
source_latitude=45.0,
|
||||
source_longitude=4.0,
|
||||
country="France",
|
||||
)
|
||||
output = resolver.resolve(query)
|
||||
assert len(output.candidates) == 1
|
||||
candidate = output.candidates[0]
|
||||
assert candidate.latitude == 45.0
|
||||
assert candidate.longitude == 4.0
|
||||
assert candidate.precision == "precise"
|
||||
assert candidate.source == "source_coordinates"
|
||||
assert candidate.needs_confirmation is False
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_skips_zero_coordinates():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="X", source_latitude=0.0, source_longitude=0.0)
|
||||
)
|
||||
assert output.candidates == ()
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_skips_when_missing():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
|
||||
|
||||
# ── RegistryResolver ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_registry_resolver_matches_alias(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="alpha", country="France")
|
||||
)
|
||||
candidates = list(output.candidates)
|
||||
assert candidates, "should match registry entry"
|
||||
assert any(c.matched_location_name == "Test Site Alpha" for c in candidates)
|
||||
alpha = next(c for c in candidates if c.matched_location_name == "Test Site Alpha")
|
||||
assert alpha.precision == "site"
|
||||
assert alpha.confidence == pytest.approx(0.92)
|
||||
assert alpha.needs_confirmation is True
|
||||
assert alpha.location_verified_at is None
|
||||
|
||||
|
||||
def test_registry_resolver_filters_country_mismatch(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
# alpha is in France; query says Spain → should reject
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="alpha", country="Spain")
|
||||
)
|
||||
assert all(
|
||||
c.matched_location_name != "Test Site Alpha" for c in output.candidates
|
||||
)
|
||||
|
||||
|
||||
def test_registry_resolver_emits_city_fallback_candidate(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(city="Bhutan-Capital", country="Bhutan")
|
||||
)
|
||||
candidates = list(output.candidates)
|
||||
assert candidates, "city fallback should fire"
|
||||
assert any(c.source == "local_registry_city" for c in candidates)
|
||||
|
||||
|
||||
# ── NominatimResolver ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nominatim_resolver_calls_geocoder_with_plan_queries():
|
||||
calls = []
|
||||
|
||||
def fake_geocoder(query: str):
|
||||
calls.append(query)
|
||||
return {
|
||||
"lat": "12.34",
|
||||
"lon": "56.78",
|
||||
"display_name": "Test City, Country",
|
||||
"address": {"city": "Test City", "country": "Country"},
|
||||
}
|
||||
|
||||
def plan(query: LocationQuery):
|
||||
return [
|
||||
("primary query", ("name",)),
|
||||
("secondary query", ("city",)),
|
||||
]
|
||||
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=plan,
|
||||
geocoder=fake_geocoder,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X", country="Country"))
|
||||
assert calls == ["primary query", "secondary query"]
|
||||
assert output.attempted_queries == ("primary query", "secondary query")
|
||||
assert len(output.candidates) == 2
|
||||
assert all(c.precision == "city" for c in output.candidates)
|
||||
assert all(c.needs_confirmation for c in output.candidates)
|
||||
|
||||
|
||||
def test_nominatim_resolver_skips_when_geocoder_returns_none():
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=lambda q: [("only", ("name",))],
|
||||
geocoder=lambda q: None,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
assert output.attempted_queries == ("only",)
|
||||
|
||||
|
||||
def test_nominatim_resolver_swallows_exceptions_per_query():
|
||||
def boom(query):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=lambda q: [("a", ()), ("b", ())],
|
||||
geocoder=boom,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
assert output.attempted_queries == ("a", "b")
|
||||
|
||||
|
||||
# ── InheritFromAnotherEntityResolver ────────────────────────────────
|
||||
|
||||
|
||||
def test_inherit_resolver_returns_provided_candidate():
|
||||
sentinel = LocationCandidate(
|
||||
latitude=10.0,
|
||||
longitude=20.0,
|
||||
display_name="Inherited",
|
||||
precision="city",
|
||||
confidence=0.7,
|
||||
query="inherit::test",
|
||||
source="inherited",
|
||||
source_note=None,
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
resolver = InheritFromAnotherEntityResolver(
|
||||
source_lookup=lambda q: sentinel
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == (sentinel,)
|
||||
|
||||
|
||||
def test_inherit_resolver_skips_when_lookup_returns_none():
|
||||
resolver = InheritFromAnotherEntityResolver(source_lookup=lambda q: None)
|
||||
assert resolver.resolve(LocationQuery(name="X")).candidates == ()
|
||||
|
||||
|
||||
# ── LocationPipeline orchestration ──────────────────────────────────
|
||||
|
||||
|
||||
def test_pipeline_aggregates_candidates_across_resolvers(tmp_registry):
|
||||
pipeline = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
RegistryResolver(registry_path=tmp_registry),
|
||||
NominatimResolver(
|
||||
query_plan_builder=lambda q: [("nominatim attempt", ("name",))],
|
||||
geocoder=lambda q: {
|
||||
"lat": "1.0",
|
||||
"lon": "2.0",
|
||||
"display_name": "Online City",
|
||||
"address": {"city": "Online City", "country": "France"},
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
pipeline.resolvers[1].reload()
|
||||
candidates, attempted = pipeline.collect_candidates(
|
||||
LocationQuery(
|
||||
name="alpha",
|
||||
country="France",
|
||||
source_latitude=44.0,
|
||||
source_longitude=5.0,
|
||||
)
|
||||
)
|
||||
sources = {c.source for c in candidates}
|
||||
assert "source_coordinates" in sources
|
||||
assert "local_registry" in sources
|
||||
assert "nominatim_online_geocode" in sources
|
||||
assert "nominatim attempt" in attempted
|
||||
|
||||
|
||||
def test_pipeline_dedupes_by_source_and_coordinates():
|
||||
same = LocationCandidate(
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
display_name="dup",
|
||||
precision="city",
|
||||
confidence=0.5,
|
||||
query="x",
|
||||
source="dup_source",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
class _DupResolver:
|
||||
name = "dup_source"
|
||||
|
||||
def resolve(self, query):
|
||||
return ResolverOutput(candidates=(same, same))
|
||||
|
||||
pipeline = LocationPipeline([_DupResolver()])
|
||||
candidates, _ = pipeline.collect_candidates(LocationQuery(name="X"))
|
||||
assert len(candidates) == 1
|
||||
|
||||
|
||||
def test_registry_short_aliases_do_not_match_inside_larger_tokens(tmp_path: Path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "Aurora",
|
||||
"aliases": ["Aurora", "ANL"],
|
||||
"site": "DOE/SC/Argonne National Laboratory",
|
||||
"country": "United States",
|
||||
"city": "Lemont",
|
||||
"latitude": 41.713,
|
||||
"longitude": -87.982,
|
||||
"precision": "site",
|
||||
},
|
||||
{
|
||||
"canonical_name": "Venado",
|
||||
"aliases": ["Venado"],
|
||||
"site": "DOE/NNSA/LANL",
|
||||
"country": "United States",
|
||||
"city": "Los Alamos",
|
||||
"latitude": 35.8443,
|
||||
"longitude": -106.2872,
|
||||
"precision": "site",
|
||||
},
|
||||
],
|
||||
"city_fallbacks": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
resolver = RegistryResolver(registry_path=registry_path)
|
||||
resolver.reload()
|
||||
|
||||
output = resolver.resolve(
|
||||
LocationQuery(
|
||||
name="Venado",
|
||||
country="United States",
|
||||
extra={"site": "DOE/NNSA/LANL"},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(output.candidates) == 1
|
||||
assert output.candidates[0].matched_location_name == "Venado"
|
||||
|
||||
|
||||
def test_pipeline_resolve_best_returns_highest_priority():
|
||||
online = LocationCandidate(
|
||||
latitude=10.0,
|
||||
longitude=20.0,
|
||||
display_name="online",
|
||||
precision="city",
|
||||
confidence=0.9,
|
||||
query="x",
|
||||
source="nominatim_online_geocode",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=True,
|
||||
)
|
||||
source = LocationCandidate(
|
||||
latitude=11.0,
|
||||
longitude=21.0,
|
||||
display_name="src",
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
query="x",
|
||||
source="source_coordinates",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
class _StubResolver:
|
||||
def __init__(self, c, name):
|
||||
self._c = c
|
||||
self.name = name
|
||||
|
||||
def resolve(self, query):
|
||||
return ResolverOutput(candidates=(self._c,))
|
||||
|
||||
pipeline = LocationPipeline(
|
||||
[
|
||||
_StubResolver(online, "online"),
|
||||
_StubResolver(source, "src"),
|
||||
]
|
||||
)
|
||||
result = pipeline.resolve_best(LocationQuery(name="X"))
|
||||
assert result.location is source, "source_coordinates should beat nominatim"
|
||||
|
||||
|
||||
def test_pipeline_returns_diagnostic_when_nothing_resolves():
|
||||
pipeline = LocationPipeline([SourceCoordinatesResolver()])
|
||||
result = pipeline.resolve_best(LocationQuery(name="X", country="Bhutan"))
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.country == "Bhutan"
|
||||
|
||||
|
||||
def test_pluggability_custom_resolver_works_without_changing_pipeline():
|
||||
"""Validates the abstraction promise: a new algorithm = a new class."""
|
||||
|
||||
class _PeeringDBStubResolver:
|
||||
name = "fake_peeringdb"
|
||||
|
||||
def resolve(self, query):
|
||||
asn = (query.extra or {}).get("asn")
|
||||
if asn != 174:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
display_name="Cogent HQ",
|
||||
precision="site",
|
||||
confidence=0.8,
|
||||
query=f"peeringdb::{asn}",
|
||||
source="peeringdb_stub",
|
||||
source_note="Stub for testing",
|
||||
matched_fields=("asn",),
|
||||
needs_confirmation=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
pipeline = LocationPipeline([_PeeringDBStubResolver()])
|
||||
candidates, _ = pipeline.collect_candidates(
|
||||
LocationQuery(name="X", extra={"asn": 174})
|
||||
)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].source == "peeringdb_stub"
|
||||
|
||||
|
||||
# ── LLM fallback helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeAIProviderClient:
|
||||
def __init__(self, content: str | list[str]):
|
||||
self.contents = content if isinstance(content, list) else [content]
|
||||
self.calls = 0
|
||||
|
||||
async def analyze(self, payload, request_id=None):
|
||||
self.calls += 1
|
||||
content = self.contents[min(self.calls - 1, len(self.contents) - 1)]
|
||||
return SituationalAnalysisResponse(
|
||||
provider="test",
|
||||
model="test-model",
|
||||
content=content,
|
||||
raw_response={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_returns_candidate_from_strict_json():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "city",
|
||||
"confidence": 0.74,
|
||||
"city": "Lyon",
|
||||
"region": "Auvergne-Rhone-Alpes",
|
||||
"country": "France",
|
||||
"matched_location_name": "Lyon, France",
|
||||
"evidence": ["operator and city point to Lyon"],
|
||||
"reasoning_summary": "Best supported city-level match.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(
|
||||
name="Mystery GPU Cluster",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
extra={"operator": "Mystery Operator"},
|
||||
),
|
||||
entity_type="compute_center",
|
||||
attempted_queries=("Mystery Operator, Lyon, France",),
|
||||
)
|
||||
|
||||
assert client.calls == 1
|
||||
assert result.failure_reason is None
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Mystery GPU Cluster"]
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.source == "llm_location_factcheck"
|
||||
assert candidate.needs_confirmation is True
|
||||
assert candidate.precision == "city"
|
||||
assert candidate.city == "Lyon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_common_precision_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"candidate": {
|
||||
"latitude": 43.2389,
|
||||
"longitude": 76.8897,
|
||||
"precision": "city-level",
|
||||
"confidence": "0.68",
|
||||
"city": "Almaty",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Almaty, Kazakhstan",
|
||||
"evidence": ["NITEC context points to Almaty"],
|
||||
"reasoning_summary": "City-level fallback.",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].precision == "city"
|
||||
assert result.candidates[0].confidence >= 0.55
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_lat_lng_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"lat": 51.1694,
|
||||
"lng": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].latitude == pytest.approx(51.1694)
|
||||
assert result.candidates[0].longitude == pytest.approx(71.4491)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_when_coordinates_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "51.1694",
|
||||
"lon": "71.4491",
|
||||
"display_name": "Astana, Kazakhstan",
|
||||
"address": {"city": "Astana", "country": "Kazakhstan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.latitude == pytest.approx(51.1694)
|
||||
assert candidate.longitude == pytest.approx(71.4491)
|
||||
assert "Nominatim city fallback" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_matched_location_without_city(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if "Falun" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Dalarna County, Sweden",
|
||||
"address": {"city": "Falun", "state": "Dalarna County", "country": "Sweden"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.64,
|
||||
"country": "Sweden",
|
||||
"matched_location_name": "Falun, Sweden",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Credible public source",
|
||||
"source_type": "news",
|
||||
"entity_match": True,
|
||||
"text": "DeepL Mercury supercomputer is located in Falun.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Falun"
|
||||
assert candidate.country == "瑞典"
|
||||
assert candidate.latitude == pytest.approx(60.6065)
|
||||
assert candidate.longitude == pytest.approx(15.6355)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_repairs_non_json_answer(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"TAIPEI-1 appears to be located in Taipei, Taiwan, based on NVIDIA context.",
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 appears to be located in Taipei.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "City-level location extracted from prose.",
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Taipei"
|
||||
assert result.candidates[0].source == "llm_location_factcheck"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.43,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 points to Taipei city-level placement.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Weak city-level evidence, but the entity name and geography align.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.confidence >= 0.55
|
||||
breakdown = candidate.suggested_registry_entry["llm_score_breakdown"]
|
||||
assert breakdown["weak_evidence_penalty"] <= 0.15
|
||||
assert breakdown["conflict_penalty"] == 0
|
||||
assert breakdown["name_location_hint"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unparseable(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if query != "Taipei, 中国(台湾)":
|
||||
return None
|
||||
return {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(["not a location answer", "still not json"])
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="中国(台湾)"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.latitude == pytest.approx(25.033)
|
||||
assert candidate.longitude == pytest.approx(121.5654)
|
||||
assert "Entity name city hint" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_extracts_city_from_non_json_when_repair_fails(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Sweden",
|
||||
"address": {"city": "Falun", "country": "Sweden"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"DeepL Mercury 超級電腦位於瑞典的 法倫 (Falun)。",
|
||||
"still not json",
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Falun"
|
||||
assert result.candidates[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_combines_model_score_with_evidence_score():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Kazakhstan National Supercomputing Center",
|
||||
"url": "https://example.test/alem-cloud",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is located in Astana.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Evidence supports city-level location but not exact facility coordinates.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Astana"
|
||||
assert candidate.confidence >= 0.55
|
||||
assert candidate.suggested_registry_entry["llm_model_confidence"] == pytest.approx(0.38)
|
||||
assert candidate.suggested_registry_entry["llm_combined_confidence"] == pytest.approx(
|
||||
candidate.confidence
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_low_combined_score():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": ["some page mentions Kazakhstan"],
|
||||
"reasoning_summary": "Weak and ambiguous city evidence.",
|
||||
"ambiguity": "weak city evidence",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "combined evidence score" in result.failure_reason
|
||||
assert "below minimum 0.55" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_explicit_conflicts():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 25.033,
|
||||
"longitude": 121.5654,
|
||||
"precision": "city",
|
||||
"confidence": 0.70,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Conflicting source",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"has_conflict": True,
|
||||
"text": "One source says Taipei, another contradicts it.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Conflicting evidence prevents confirmation.",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "conflict=" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"not json",
|
||||
json.dumps({"latitude": 0, "longitude": 0, "precision": "city", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "country", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "city", "confidence": 0.2}),
|
||||
],
|
||||
)
|
||||
async def test_llm_location_fallback_rejects_unsafe_outputs(content):
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(content),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert result.failure_reason
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Unsafe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_failure_explains_rejection_reason():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps({
|
||||
"latitude": 45,
|
||||
"longitude": 4,
|
||||
"precision": "region",
|
||||
"confidence": 0.9,
|
||||
})
|
||||
),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "precision" in result.failure_reason
|
||||
assert "region" in result.failure_reason
|
||||
242
backend/tests/test_motion_agent.py
Normal file
242
backend/tests/test_motion_agent.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from motion_agent.cameras import (
|
||||
MotionAgentCameraError,
|
||||
MotionAgentDependencyError,
|
||||
UrlCameraInput,
|
||||
UrlCameraSpec,
|
||||
UsbCameraInput,
|
||||
UsbCameraSpec,
|
||||
)
|
||||
import motion_agent.cameras as motion_cameras
|
||||
from motion_agent.config import MotionAgentConfig
|
||||
from motion_agent.events import GestureEvent, HeartbeatEvent, SkeletonEvent, SkeletonJoint
|
||||
from motion_agent.recognizer import GestureObservation
|
||||
from motion_agent.server import MotionAgentServer
|
||||
from motion_agent.state import GestureStateMachine
|
||||
from motion_agent import cli as motion_cli
|
||||
|
||||
|
||||
def test_gesture_event_serializes_stable_protocol_fields():
|
||||
event = GestureEvent(
|
||||
gesture="rotate_left",
|
||||
confidence=0.91,
|
||||
intensity=0.75,
|
||||
timestamp_ms=1000,
|
||||
seq=7,
|
||||
mode="single",
|
||||
)
|
||||
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "gesture"
|
||||
assert payload["gesture"] == "rotate_left"
|
||||
assert payload["phase"] == "discrete"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["intensity"] == 0.75
|
||||
assert payload["timestamp_ms"] == 1000
|
||||
assert payload["seq"] == 7
|
||||
assert payload["source"] == "motion-agent"
|
||||
assert payload["mode"] == "single"
|
||||
assert payload["payload"] == {}
|
||||
|
||||
|
||||
def test_state_machine_ignores_low_confidence_observations():
|
||||
state = GestureStateMachine(confidence_threshold=0.8, cooldown_ms=400)
|
||||
|
||||
event = state.accept(
|
||||
GestureObservation(
|
||||
gesture="confirm",
|
||||
confidence=0.79,
|
||||
intensity=1,
|
||||
timestamp_ms=1000,
|
||||
)
|
||||
)
|
||||
|
||||
assert event is None
|
||||
|
||||
|
||||
def test_state_machine_applies_per_gesture_cooldown():
|
||||
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=400)
|
||||
|
||||
first = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
||||
)
|
||||
repeated = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1200)
|
||||
)
|
||||
later = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1500)
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.seq == 1
|
||||
assert repeated is None
|
||||
assert later is not None
|
||||
assert later.seq == 2
|
||||
|
||||
|
||||
def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
status = json.loads(server.status_event().to_json())
|
||||
heartbeat = json.loads(HeartbeatEvent(timestamp_ms=123).to_json())
|
||||
|
||||
assert status["type"] == "status"
|
||||
assert status["camera_count"] == 1
|
||||
assert status["active_camera_ids"] == ["dry-run:null-camera"]
|
||||
assert status["recognizer"] == "dry-run"
|
||||
assert heartbeat == {
|
||||
"timestamp_ms": 123,
|
||||
"source": "motion-agent",
|
||||
"type": "heartbeat",
|
||||
}
|
||||
|
||||
|
||||
def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
event = SkeletonEvent(
|
||||
joints=[SkeletonJoint("left_wrist", 0.42, 0.61, 0.98)],
|
||||
bones=[("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist")],
|
||||
matched_gesture="rotate_left",
|
||||
confidence=0.91,
|
||||
camera_id="usb:0",
|
||||
timestamp_ms=1000,
|
||||
mode="single",
|
||||
)
|
||||
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "skeleton"
|
||||
assert payload["matched_gesture"] == "rotate_left"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["camera_id"] == "usb:0"
|
||||
assert payload["joints"] == [
|
||||
{"id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98}
|
||||
]
|
||||
assert payload["bones"] == [["left_shoulder", "left_elbow"], ["left_elbow", "left_wrist"]]
|
||||
assert "image" not in payload
|
||||
assert "frame" not in payload
|
||||
|
||||
|
||||
def test_dry_run_recognizer_produces_debug_skeleton():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
skeleton = server.recognizer.debug_skeleton(
|
||||
None,
|
||||
camera_id="dry-run:null-camera",
|
||||
mode="single",
|
||||
)
|
||||
|
||||
assert skeleton is not None
|
||||
assert skeleton.type == "skeleton"
|
||||
assert skeleton.camera_id == "dry-run:null-camera"
|
||||
assert skeleton.joints
|
||||
assert skeleton.bones
|
||||
|
||||
|
||||
class ServerRecognizerStub:
|
||||
name = "stub"
|
||||
|
||||
def recognize(self, frame):
|
||||
_ = frame
|
||||
return None
|
||||
|
||||
def debug_skeleton(self, frame, **kwargs):
|
||||
_ = frame, kwargs
|
||||
return None
|
||||
|
||||
|
||||
def test_motion_server_prefers_camera_urls_over_usb_indexes():
|
||||
server = MotionAgentServer(
|
||||
MotionAgentConfig(
|
||||
dry_run=False,
|
||||
camera_indexes=(0,),
|
||||
camera_urls=("rtsp://camera.example/live", "http://camera.example/video"),
|
||||
),
|
||||
recognizer=ServerRecognizerStub(),
|
||||
)
|
||||
|
||||
assert [camera.camera_id for camera in server.cameras] == ["url:0", "url:1"]
|
||||
assert all(isinstance(camera, UrlCameraInput) for camera in server.cameras)
|
||||
|
||||
|
||||
def test_usb_camera_reports_missing_opencv_as_readable_dependency_error(monkeypatch):
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
original_exists = motion_cameras.Path.exists
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "cv2":
|
||||
raise ImportError("cv2 missing")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
monkeypatch.setattr(
|
||||
motion_cameras.Path,
|
||||
"exists",
|
||||
lambda self: True if str(self) in {"/dev", "/dev/video0"} else original_exists(self),
|
||||
)
|
||||
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
||||
|
||||
with pytest.raises(MotionAgentDependencyError, match="Add opencv-python with uv"):
|
||||
camera.open()
|
||||
|
||||
|
||||
def test_usb_camera_reports_missing_device_before_opencv_noise(monkeypatch):
|
||||
original_exists = motion_cameras.Path.exists
|
||||
|
||||
monkeypatch.setattr(
|
||||
motion_cameras.Path,
|
||||
"exists",
|
||||
lambda self: True if str(self) == "/dev" else False if str(self) == "/dev/video0" else original_exists(self),
|
||||
)
|
||||
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
||||
|
||||
with pytest.raises(MotionAgentCameraError, match="/dev/video0"):
|
||||
camera.open()
|
||||
|
||||
|
||||
def test_url_camera_reports_unreachable_stream(monkeypatch):
|
||||
class BrokenCapture:
|
||||
def __init__(self, _url):
|
||||
pass
|
||||
|
||||
def isOpened(self):
|
||||
return False
|
||||
|
||||
class Cv2Stub:
|
||||
VideoCapture = BrokenCapture
|
||||
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "cv2":
|
||||
return Cv2Stub()
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
camera = UrlCameraInput(UrlCameraSpec(url="rtsp://camera.example/live"))
|
||||
|
||||
with pytest.raises(MotionAgentCameraError, match="Unable to open camera URL"):
|
||||
camera.open()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_cli_reports_dependency_error_without_traceback(monkeypatch, capsys):
|
||||
class BrokenServer:
|
||||
def __init__(self, _config):
|
||||
raise MotionAgentDependencyError("missing cv stack")
|
||||
|
||||
monkeypatch.setattr(motion_cli, "MotionAgentServer", BrokenServer)
|
||||
|
||||
exit_code = await motion_cli.async_main([])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 2
|
||||
assert "Motion agent failed: missing cv stack" in captured.err
|
||||
assert "Traceback" not in captured.err
|
||||
106
backend/tests/test_otp_service.py
Normal file
106
backend/tests/test_otp_service.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Unit tests for app.services.otp using an in-memory Redis fake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import otp
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal subset of redis-py used by services.otp."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, tuple[Any, float | None]] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return True
|
||||
_, expires = item
|
||||
if expires is not None and expires <= time.time():
|
||||
self._store.pop(key, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: Any, ex: int | None = None) -> None:
|
||||
expires = time.time() + ex if ex else None
|
||||
self._store[key] = (value, expires)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
if self._expired(key):
|
||||
return None
|
||||
return self._store[key][0]
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
if self._expired(key):
|
||||
return -2
|
||||
_, expires = self._store[key]
|
||||
if expires is None:
|
||||
return -1
|
||||
return max(int(expires - time.time()), 0)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._store.pop(key, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis(monkeypatch):
|
||||
fake = FakeRedis()
|
||||
monkeypatch.setattr(otp, "redis_client", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_issue_code_returns_six_digits(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
assert len(code) == 6
|
||||
assert code.isdigit()
|
||||
|
||||
|
||||
def test_verify_code_succeeds_and_consumes(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
with pytest.raises(otp.OtpExpired):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_verify_code_rejects_wrong_code(fake_redis):
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
with pytest.raises(otp.OtpInvalid):
|
||||
otp.verify_code("alice@example.com", "register", "000000")
|
||||
|
||||
|
||||
def test_verify_code_locks_after_max_attempts(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
for _ in range(otp.MAX_ATTEMPTS):
|
||||
with pytest.raises(otp.OtpInvalid):
|
||||
otp.verify_code("alice@example.com", "register", "000000")
|
||||
# After max attempts the next call should raise OtpAttemptsExceeded and clear the code.
|
||||
with pytest.raises(otp.OtpAttemptsExceeded):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
with pytest.raises(otp.OtpExpired):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_issue_code_enforces_resend_cooldown(fake_redis):
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
with pytest.raises(otp.OtpResendRateLimited) as excinfo:
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
assert excinfo.value.retry_after_seconds > 0
|
||||
|
||||
|
||||
def test_issue_code_emails_are_case_insensitive(fake_redis):
|
||||
code = otp.issue_code("Alice@Example.com", "register")
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_purposes_are_isolated(fake_redis):
|
||||
register_code = otp.issue_code("alice@example.com", "register")
|
||||
reset_code = otp.issue_code("alice@example.com", "reset_password")
|
||||
assert register_code != reset_code
|
||||
otp.verify_code("alice@example.com", "register", register_code)
|
||||
# Reset code should still be valid after consuming the register code.
|
||||
otp.verify_code("alice@example.com", "reset_password", reset_code)
|
||||
209
backend/tests/test_settings_ai_provider.py
Normal file
209
backend/tests/test_settings_ai_provider.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import settings as settings_api
|
||||
from app.api.v1.settings import (
|
||||
AIProviderIntegrationUpdate,
|
||||
OCRIntegrationUpdate,
|
||||
_build_ai_provider_payload,
|
||||
_build_ocr_payload,
|
||||
_mask_secret,
|
||||
_normalize_ai_provider_payload,
|
||||
_normalize_ocr_payload,
|
||||
_resolve_provider_api_key,
|
||||
get_runtime_ai_provider_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
monkeypatch.setattr(settings_api, "AI_PROVIDER_ENV_FILE", env_file)
|
||||
return env_file
|
||||
|
||||
|
||||
def test_legacy_ai_provider_payload_maps_to_provider_config():
|
||||
payload = _normalize_ai_provider_payload(
|
||||
{
|
||||
"provider": "openai",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.example/v1",
|
||||
"model": "gpt-test",
|
||||
"api_key": "old-openai-key",
|
||||
"max_tokens": 2048,
|
||||
"anthropic_version": "2023-06-01",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["default_provider"] == "openai"
|
||||
assert payload["providers"]["openai"]["api_key"] == "old-openai-key"
|
||||
assert payload["providers"]["openai"]["model"] == "gpt-test"
|
||||
assert payload["providers"]["openai"]["base_url"] == "https://api.openai.example/v1"
|
||||
|
||||
|
||||
def test_provider_key_prefers_specific_env_file_key(isolated_ai_provider_env_file):
|
||||
isolated_ai_provider_env_file.write_text(
|
||||
"OPENAI_API_KEY=openai-env-file-key\nAI_API_KEY=generic-env-file-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
||||
|
||||
assert value == "openai-env-file-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_file):
|
||||
isolated_ai_provider_env_file.write_text(
|
||||
"AI_API_KEY=generic-env-file-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
||||
|
||||
assert value == "generic-env-file-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_mask_secret_without_prefix_is_fully_masked():
|
||||
assert _mask_secret("plainsecret")["preview"] == "***********"
|
||||
assert _mask_secret("sk-prefixed")["preview"] == "sk-********"
|
||||
|
||||
|
||||
def test_build_payload_updates_only_selected_provider_key():
|
||||
current = {
|
||||
"ai_provider": {
|
||||
"default_provider": "openai",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-old",
|
||||
"api_key": "openai-old-key",
|
||||
"max_tokens": 4096,
|
||||
"anthropic_version": "2023-06-01",
|
||||
},
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"api_key": "minimax-old-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = AIProviderIntegrationUpdate(
|
||||
provider="openai",
|
||||
provider_api="openai-completions",
|
||||
base_url="https://api.openai.com/v1",
|
||||
model="gpt-new",
|
||||
api_key="openai-new-key",
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
payload = _build_ai_provider_payload(current, update)
|
||||
|
||||
assert payload["default_provider"] == "openai"
|
||||
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
|
||||
assert payload["providers"]["openai"]["model"] == "gpt-new"
|
||||
assert payload["providers"]["minimax"]["api_key"] == "minimax-old-key"
|
||||
|
||||
|
||||
def test_build_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"ai_provider": {
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-old-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = AIProviderIntegrationUpdate(
|
||||
provider="openai",
|
||||
provider_api="openai-completions",
|
||||
base_url="https://api.openai.com/v1",
|
||||
model="gpt-test",
|
||||
api_key="sk-*********",
|
||||
)
|
||||
|
||||
payload = _build_ai_provider_payload(current, update)
|
||||
|
||||
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
|
||||
|
||||
|
||||
def test_normalize_ocr_payload_adds_defaults():
|
||||
payload = _normalize_ocr_payload({})
|
||||
|
||||
assert payload["enabled"] is False
|
||||
assert payload["provider"] == "paddleocr"
|
||||
assert payload["languages"] == ["zh", "en"]
|
||||
assert payload["output_format"] == "markdown"
|
||||
|
||||
|
||||
def test_build_ocr_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"ocr": {
|
||||
"enabled": True,
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:8020",
|
||||
"api_key": "ocr-old-secret",
|
||||
}
|
||||
}
|
||||
update = OCRIntegrationUpdate(
|
||||
enabled=True,
|
||||
provider="custom",
|
||||
base_url="http://localhost:8020",
|
||||
api_key="**************",
|
||||
model="ocr-model",
|
||||
languages=["zh", "en"],
|
||||
timeout_seconds=45,
|
||||
max_file_size_mb=50,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
payload = _build_ocr_payload(current, update)
|
||||
|
||||
assert payload["api_key"] == "ocr-old-secret"
|
||||
assert payload["model"] == "ocr-model"
|
||||
assert payload["output_format"] == "json"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
|
||||
record = SimpleNamespace(
|
||||
payload={
|
||||
"ai_provider": {
|
||||
"default_provider": "minimax",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"api_key": "openai-key",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"api_key": "minimax-key",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://api.minimaxi.com/anthropic",
|
||||
"model": "MiniMax-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def fake_get_setting_record(_db, category):
|
||||
assert category == "external_integrations"
|
||||
return record
|
||||
|
||||
monkeypatch.setattr(settings_api, "get_setting_record", fake_get_setting_record)
|
||||
|
||||
runtime_config = await get_runtime_ai_provider_config(object())
|
||||
|
||||
assert runtime_config["llm_config"]["provider"] == "minimax"
|
||||
assert runtime_config["llm_config"]["api_key"] == "minimax-key"
|
||||
assert runtime_config["llm_config"]["model"] == "MiniMax-test"
|
||||
86
backend/tests/test_settings_smtp.py
Normal file
86
backend/tests/test_settings_smtp.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Unit tests for SMTP settings helpers in app.api.v1.settings."""
|
||||
|
||||
from app.api.v1.settings import (
|
||||
SMTPSettingsUpdate,
|
||||
_build_smtp_payload,
|
||||
_serialize_smtp_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_masks_password_and_reports_configured():
|
||||
serialized = _serialize_smtp_payload(
|
||||
{
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "noreply@example.com",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"from_name": "Planet",
|
||||
"use_tls": False,
|
||||
"use_starttls": True,
|
||||
"timeout_seconds": 20,
|
||||
}
|
||||
)
|
||||
assert serialized["configured"] is True
|
||||
assert serialized["password"]["configured"] is True
|
||||
assert "secret" not in serialized["password"]["preview"]
|
||||
|
||||
|
||||
def test_serialize_marks_unconfigured_when_host_missing():
|
||||
serialized = _serialize_smtp_payload(
|
||||
{
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"from_address": "",
|
||||
}
|
||||
)
|
||||
assert serialized["configured"] is False
|
||||
assert serialized["password"]["configured"] is False
|
||||
|
||||
|
||||
def test_build_payload_preserves_password_when_placeholder_submitted():
|
||||
current = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "noreply@example.com",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"from_name": "Planet",
|
||||
"use_tls": False,
|
||||
"use_starttls": True,
|
||||
"timeout_seconds": 20,
|
||||
}
|
||||
preview = _serialize_smtp_payload(current)["password"]["preview"]
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="noreply@example.com",
|
||||
password=preview,
|
||||
from_address="noreply@example.com",
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == "super-secret"
|
||||
|
||||
|
||||
def test_build_payload_replaces_password_when_new_value_submitted():
|
||||
current = {"password": "old", "host": "", "port": 587, "from_address": ""}
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
password="new-secret",
|
||||
from_address="noreply@example.com",
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == "new-secret"
|
||||
|
||||
|
||||
def test_build_payload_clears_password_when_requested():
|
||||
current = {"password": "old"}
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
from_address="noreply@example.com",
|
||||
clear_password=True,
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == ""
|
||||
161
backend/tests/test_vessel_aggregation_strategy.py
Normal file
161
backend/tests/test_vessel_aggregation_strategy.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Tests for the v4 vessel_ais aggregation strategy."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.vessel import AISRawObservation
|
||||
from app.services.vessel_aggregation_strategy import (
|
||||
DEFAULT_STRATEGY,
|
||||
StrategyValidationError,
|
||||
validate_strategy,
|
||||
)
|
||||
from app.services.vessel_ais_aggregation import aggregate_vessel_observations
|
||||
|
||||
|
||||
def _obs(*, source: str, mmsi: int, observed_at: datetime, **payload) -> AISRawObservation:
|
||||
payload = {"mmsi": mmsi, "lat": 50.0, "lon": 10.0, **payload}
|
||||
delivery_mode = "realtime_stream" if source == "aisstream_vessels" else "polling"
|
||||
transport = "websocket" if source == "aisstream_vessels" else "http"
|
||||
return AISRawObservation(
|
||||
target_schema="vessel_ais",
|
||||
source=source,
|
||||
entity_key=str(mmsi),
|
||||
delivery_mode=delivery_mode,
|
||||
transport=transport,
|
||||
message_type="PositionReport",
|
||||
observation_hash=f"{source}:{mmsi}:{observed_at.isoformat()}",
|
||||
observed_at=observed_at,
|
||||
collected_at=observed_at,
|
||||
normalized_payload=payload,
|
||||
raw_payload=payload,
|
||||
quality_flags=[],
|
||||
)
|
||||
|
||||
|
||||
def test_validate_rejects_unknown_field():
|
||||
with pytest.raises(StrategyValidationError, match="unknown vessel_ais field"):
|
||||
validate_strategy({"vessel_ais": {"field_rules": {"definitely_not_a_field": {"mode": "newest"}}}})
|
||||
|
||||
|
||||
def test_validate_rejects_dynamic_lock_without_flag():
|
||||
with pytest.raises(StrategyValidationError, match="allow_dynamic_lock"):
|
||||
validate_strategy(
|
||||
{
|
||||
"vessel_ais": {
|
||||
"field_rules": {"lat": {"mode": "source_priority"}},
|
||||
"allow_dynamic_lock": False,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_allows_dynamic_lock_with_flag():
|
||||
normalized = validate_strategy(
|
||||
{
|
||||
"version": 0,
|
||||
"vessel_ais": {
|
||||
"field_rules": {"lat": {"mode": "source_priority", "source_priority": ["barentswatch_vessels"]}},
|
||||
"allow_dynamic_lock": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert normalized["vessel_ais"]["field_rules"]["lat"]["mode"] == "source_priority"
|
||||
assert normalized["version"] == 1
|
||||
|
||||
|
||||
def test_validate_increments_version():
|
||||
first = validate_strategy({"version": 5, "vessel_ais": {}})
|
||||
assert first["version"] == 6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strategy_field_rule_promotes_specific_source(monkeypatch):
|
||||
now = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
obs_a = _obs(
|
||||
source="aisstream_vessels",
|
||||
mmsi=257123000,
|
||||
observed_at=now,
|
||||
name="AISSTREAM ONE",
|
||||
vessel_type_name="Cargo",
|
||||
)
|
||||
obs_b = _obs(
|
||||
source="barentswatch_vessels",
|
||||
mmsi=257123000,
|
||||
observed_at=now - timedelta(seconds=1),
|
||||
name="BARENTSWATCH ONE",
|
||||
vessel_type_name="Cargo",
|
||||
)
|
||||
|
||||
strategy = {
|
||||
"version": 7,
|
||||
"vessel_ais": {
|
||||
"source_priority": [],
|
||||
"field_rules": {
|
||||
"name": {"mode": "source_priority", "source_priority": ["barentswatch_vessels", "aisstream_vessels"]},
|
||||
},
|
||||
"freshness": {"realtime_stream_seconds": 0, "polling_seconds": 0},
|
||||
"allow_dynamic_lock": False,
|
||||
},
|
||||
}
|
||||
|
||||
db = AsyncMock()
|
||||
vessels = await aggregate_vessel_observations(
|
||||
db,
|
||||
[obs_a, obs_b],
|
||||
write_conflicts=False,
|
||||
strategy=strategy,
|
||||
)
|
||||
assert len(vessels) == 1
|
||||
vessel = vessels[0]
|
||||
assert vessel["name"] == "BARENTSWATCH ONE"
|
||||
assert vessel["field_sources"]["name"] == "barentswatch_vessels"
|
||||
assert vessel["selected_reasons"]["name"] == "source_priority"
|
||||
assert vessel["aggregation_strategy_version"] == 7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strategy_freshness_falls_back_to_polling_when_realtime_stale():
|
||||
now = datetime(2026, 5, 4, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
stale_realtime = _obs(
|
||||
source="aisstream_vessels",
|
||||
mmsi=257123000,
|
||||
observed_at=now - timedelta(hours=1),
|
||||
lat=58.0,
|
||||
lon=10.0,
|
||||
)
|
||||
fresh_polling = _obs(
|
||||
source="barentswatch_vessels",
|
||||
mmsi=257123000,
|
||||
observed_at=now - timedelta(seconds=30),
|
||||
lat=60.0,
|
||||
lon=11.0,
|
||||
)
|
||||
|
||||
strategy = {
|
||||
"version": 1,
|
||||
"vessel_ais": {
|
||||
"source_priority": ["aisstream_vessels", "barentswatch_vessels"],
|
||||
"field_rules": {},
|
||||
"freshness": {"realtime_stream_seconds": 900, "polling_seconds": 7200},
|
||||
"allow_dynamic_lock": False,
|
||||
},
|
||||
}
|
||||
|
||||
db = AsyncMock()
|
||||
vessels = await aggregate_vessel_observations(
|
||||
db,
|
||||
[stale_realtime, fresh_polling],
|
||||
write_conflicts=False,
|
||||
strategy=strategy,
|
||||
)
|
||||
assert vessels[0]["field_sources"]["lat"] == "barentswatch_vessels"
|
||||
assert vessels[0]["lat"] == 60.0
|
||||
|
||||
|
||||
def test_default_strategy_is_stable():
|
||||
assert DEFAULT_STRATEGY["vessel_ais"]["allow_dynamic_lock"] is False
|
||||
assert "freshness" in DEFAULT_STRATEGY["vessel_ais"]
|
||||
155
backend/tests/test_vessel_enrichment.py
Normal file
155
backend/tests/test_vessel_enrichment.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Tests for v5 enrichment + conflict promote-to-rule."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation
|
||||
from app.models.vessel_enrichment import VesselMediaEnrichment, VesselProfileEnrichment
|
||||
from app.services.vessel_ais_aggregation import aggregate_vessel_observations
|
||||
from app.services.vessel_enrichment import (
|
||||
_apply_upsert,
|
||||
get_vessel_enrichment_bundle,
|
||||
)
|
||||
|
||||
|
||||
class _StoreSession:
|
||||
"""Minimal AsyncSession stand-in that tracks mmsi-keyed enrichment + a strategy."""
|
||||
|
||||
def __init__(self, *, profile=None, media=None, conflicts=None):
|
||||
self.profile = profile
|
||||
self.media = media
|
||||
self.conflicts = list(conflicts or [])
|
||||
self.added: list = []
|
||||
self.committed = False
|
||||
|
||||
async def get(self, model, key):
|
||||
if model is VesselProfileEnrichment:
|
||||
return self.profile if self.profile and self.profile.mmsi == key else None
|
||||
if model is VesselMediaEnrichment:
|
||||
return self.media if self.media and self.media.mmsi == key else None
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrichment_bundle_filters_expired_records():
|
||||
now = datetime.now(timezone.utc)
|
||||
fresh = VesselProfileEnrichment(
|
||||
mmsi=257123000,
|
||||
source="local_cache",
|
||||
payload={"vessel_subtype": "Container"},
|
||||
fetched_at=now - timedelta(hours=1),
|
||||
expires_at=now + timedelta(days=7),
|
||||
confidence=0.9,
|
||||
)
|
||||
expired_media = VesselMediaEnrichment(
|
||||
mmsi=257123000,
|
||||
source="vesselfinder",
|
||||
payload={"images": ["https://example.com/a.jpg"]},
|
||||
fetched_at=now - timedelta(days=30),
|
||||
expires_at=now - timedelta(days=1),
|
||||
)
|
||||
db = _StoreSession(profile=fresh, media=expired_media)
|
||||
|
||||
bundle = await get_vessel_enrichment_bundle(db, 257123000)
|
||||
|
||||
assert bundle["profile"]["payload"]["vessel_subtype"] == "Container"
|
||||
assert bundle["media"] is None
|
||||
|
||||
|
||||
def test_apply_upsert_preserves_payload_and_metadata():
|
||||
record = VesselProfileEnrichment(mmsi=257123000)
|
||||
out = _apply_upsert(
|
||||
record,
|
||||
{
|
||||
"source": "vesselfinder",
|
||||
"payload": {"vessel_subtype": "Container", "operator": "Maersk"},
|
||||
"expires_at": "2026-12-31T00:00:00Z",
|
||||
"confidence": 0.85,
|
||||
"reference_url": "https://www.vesselfinder.com/vessels/257123000",
|
||||
},
|
||||
)
|
||||
assert out["payload"]["operator"] == "Maersk"
|
||||
assert out["confidence"] == 0.85
|
||||
assert record.reference_url == "https://www.vesselfinder.com/vessels/257123000"
|
||||
assert record.expires_at is not None
|
||||
assert record.expires_at.year == 2026
|
||||
|
||||
|
||||
def _obs(*, source: str, mmsi: int, observed_at, **payload) -> AISRawObservation:
|
||||
payload = {"mmsi": mmsi, "lat": 60.0, "lon": 5.0, **payload}
|
||||
delivery_mode = "realtime_stream" if source == "aisstream_vessels" else "polling"
|
||||
transport = "websocket" if source == "aisstream_vessels" else "http"
|
||||
return AISRawObservation(
|
||||
target_schema="vessel_ais",
|
||||
source=source,
|
||||
entity_key=str(mmsi),
|
||||
delivery_mode=delivery_mode,
|
||||
transport=transport,
|
||||
message_type="PositionReport",
|
||||
observation_hash=f"{source}:{mmsi}:{observed_at.isoformat()}",
|
||||
observed_at=observed_at,
|
||||
collected_at=observed_at,
|
||||
normalized_payload=payload,
|
||||
raw_payload=payload,
|
||||
quality_flags=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_promoted_rule_wins_during_aggregation():
|
||||
"""Simulate the strategy that conflict-promote-to-rule writes."""
|
||||
now = datetime.now(timezone.utc)
|
||||
obs_a = _obs(
|
||||
source="aisstream_vessels",
|
||||
mmsi=257111000,
|
||||
observed_at=now,
|
||||
name="STREAM NAME",
|
||||
vessel_type_name="Cargo",
|
||||
)
|
||||
obs_b = _obs(
|
||||
source="barentswatch_vessels",
|
||||
mmsi=257111000,
|
||||
observed_at=now - timedelta(seconds=1),
|
||||
name="REST NAME",
|
||||
vessel_type_name="Cargo",
|
||||
)
|
||||
promoted_strategy = {
|
||||
"version": 99,
|
||||
"vessel_ais": {
|
||||
"source_priority": [],
|
||||
"field_rules": {
|
||||
"name": {"mode": "source_priority", "source_priority": ["barentswatch_vessels"]}
|
||||
},
|
||||
"freshness": {"realtime_stream_seconds": 0, "polling_seconds": 0},
|
||||
"allow_dynamic_lock": False,
|
||||
},
|
||||
}
|
||||
|
||||
db = AsyncMock()
|
||||
vessels = await aggregate_vessel_observations(
|
||||
db,
|
||||
[obs_a, obs_b],
|
||||
write_conflicts=False,
|
||||
strategy=promoted_strategy,
|
||||
)
|
||||
assert vessels[0]["name"] == "REST NAME"
|
||||
assert vessels[0]["selected_reasons"]["name"] == "source_priority"
|
||||
assert vessels[0]["aggregation_strategy_version"] == 99
|
||||
|
||||
|
||||
def test_conflict_record_holds_selected_source():
|
||||
"""Sanity: the promote-to-rule API reads selected_source from this column."""
|
||||
record = AISConflictRecord(
|
||||
target_schema="vessel_ais",
|
||||
entity_key="257111000",
|
||||
field="name",
|
||||
candidates={"a": "X", "b": "Y"},
|
||||
selected_source="barentswatch_vessels",
|
||||
selected_value="Y",
|
||||
selected_reason="delivery_mode_priority",
|
||||
)
|
||||
serialized = record.to_dict()
|
||||
assert serialized["selected_source"] == "barentswatch_vessels"
|
||||
assert serialized["field"] == "name"
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1 import visualization
|
||||
from app.api.v1.visualization import convert_vessels_to_geojson
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
@@ -200,11 +201,12 @@ async def test_aggregate_vessel_observations_prefers_realtime_and_records_confli
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_collector_writes_raw_observations_without_changing_position_save(monkeypatch):
|
||||
async def test_vessel_collector_writes_raw_observations_only(monkeypatch):
|
||||
collector = VesselAISCollector()
|
||||
collector.update_progress = AsyncMock()
|
||||
record_observation = AsyncMock()
|
||||
update_health = AsyncMock()
|
||||
broadcast_custom = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.vessel_ais.record_vessel_ais_observation",
|
||||
record_observation,
|
||||
@@ -213,6 +215,10 @@ async def test_vessel_collector_writes_raw_observations_without_changing_positio
|
||||
"app.services.collectors.vessel_ais.update_ais_source_health",
|
||||
update_health,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.vessel_ais.broadcaster.broadcast_custom",
|
||||
broadcast_custom,
|
||||
)
|
||||
|
||||
class _Session:
|
||||
def __init__(self):
|
||||
@@ -249,12 +255,17 @@ async def test_vessel_collector_writes_raw_observations_without_changing_positio
|
||||
|
||||
assert saved == 1
|
||||
assert db.committed is True
|
||||
assert any(isinstance(item, VesselStatic) for item in db.added)
|
||||
assert any(isinstance(item, VesselPosition) for item in db.added)
|
||||
# BarentsWatch must funnel through the unified AIS pipeline only — no legacy writes.
|
||||
assert not any(isinstance(item, VesselStatic) for item in db.added)
|
||||
assert not any(isinstance(item, VesselPosition) for item in db.added)
|
||||
record_observation.assert_awaited_once()
|
||||
assert record_observation.await_args.kwargs["source"] == "barentswatch_vessels"
|
||||
assert record_observation.await_args.kwargs["normalized_payload"]["mmsi"] == 257123000
|
||||
update_health.assert_awaited_once()
|
||||
broadcast_custom.assert_awaited_once()
|
||||
assert broadcast_custom.await_args.args[0] == "vessels"
|
||||
assert broadcast_custom.await_args.args[1]["action"] == "upsert"
|
||||
assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "257123000"
|
||||
|
||||
|
||||
def test_aisstream_collector_normalizes_position_report():
|
||||
@@ -365,6 +376,48 @@ async def test_aisstream_collector_writes_only_raw_observations(monkeypatch):
|
||||
update_health.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aisstream_stream_record_broadcasts_vessel_delta(monkeypatch):
|
||||
collector = AISStreamCollector()
|
||||
record_observation = AsyncMock(return_value=object())
|
||||
update_health = AsyncMock()
|
||||
broadcast_custom = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.aisstream.record_vessel_ais_observation",
|
||||
record_observation,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.aisstream.update_ais_source_health",
|
||||
update_health,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.collectors.aisstream.broadcaster.broadcast_custom",
|
||||
broadcast_custom,
|
||||
)
|
||||
|
||||
class _Session:
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
created = await collector._save_stream_record(
|
||||
_Session(),
|
||||
{
|
||||
"mmsi": 257123000,
|
||||
"lat": 59.91,
|
||||
"lon": 10.73,
|
||||
"cog": 214,
|
||||
"received_at": datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc),
|
||||
},
|
||||
)
|
||||
|
||||
assert created is True
|
||||
record_observation.assert_awaited_once()
|
||||
broadcast_custom.assert_awaited_once()
|
||||
assert broadcast_custom.await_args.args[0] == "vessels"
|
||||
assert broadcast_custom.await_args.args[1]["action"] == "upsert"
|
||||
assert broadcast_custom.await_args.args[1]["vessels"][0]["mmsi_display"] == "257123000"
|
||||
|
||||
|
||||
def test_barentswatch_reads_credentials_from_zshrc(tmp_path):
|
||||
zshrc = tmp_path / ".zshrc"
|
||||
zshrc.write_text(
|
||||
@@ -436,23 +489,149 @@ def test_convert_vessels_to_geojson():
|
||||
assert payload["features"][0]["properties"]["vessel_type_name"] == "Cargo"
|
||||
|
||||
|
||||
def test_convert_vessels_to_geojson_dedupes_mmsi_rows():
|
||||
first = VesselPosition(
|
||||
mmsi=257123000,
|
||||
lat=59.91,
|
||||
lon=10.73,
|
||||
received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
duplicate = VesselPosition(
|
||||
mmsi=257123000,
|
||||
lat=60.01,
|
||||
lon=10.83,
|
||||
received_at=datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
other = VesselPosition(
|
||||
mmsi=257456000,
|
||||
lat=60.3,
|
||||
lon=5.3,
|
||||
received_at=datetime(2026, 4, 28, 0, 59, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
payload = convert_vessels_to_geojson(
|
||||
[
|
||||
(first, VesselStatic(mmsi=257123000, name="OSLO TRADER")),
|
||||
(duplicate, VesselStatic(mmsi=257123000, name="OSLO TRADER DUP")),
|
||||
(other, VesselStatic(mmsi=257456000, name="BERGEN FERRY")),
|
||||
]
|
||||
)
|
||||
|
||||
mmsis = [feature["properties"]["mmsi"] for feature in payload["features"]]
|
||||
assert mmsis == [257123000, 257456000]
|
||||
assert payload["features"][0]["geometry"]["coordinates"] == [10.73, 59.91]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
||||
async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
rows = [
|
||||
(
|
||||
VesselPosition(mmsi=1, lat=59.9, lon=10.7, received_at=now),
|
||||
VesselStatic(mmsi=1, name="Cargo Ship", vessel_type=70, vessel_type_name="Cargo"),
|
||||
monkeypatch.setattr(
|
||||
visualization,
|
||||
"get_aggregated_vessels_snapshot",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.9,
|
||||
"lon": 10.7,
|
||||
"received_at": now,
|
||||
"name": "Cargo Ship",
|
||||
"vessel_type": 70,
|
||||
"vessel_type_name": "Cargo",
|
||||
},
|
||||
{
|
||||
"mmsi": 2,
|
||||
"lat": 60.3,
|
||||
"lon": 5.3,
|
||||
"received_at": now - timedelta(minutes=1),
|
||||
"name": "Passenger Ship",
|
||||
"vessel_type": 60,
|
||||
"vessel_type_name": "Passenger",
|
||||
},
|
||||
]
|
||||
),
|
||||
(
|
||||
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now - timedelta(minutes=1)),
|
||||
VesselStatic(mmsi=2, name="Passenger Ship", vessel_type=60, vessel_type_name="Passenger"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def override_get_db():
|
||||
yield object()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/vessels/snapshot",
|
||||
params={"bbox": "0,50,20,70", "zoom": 12, "type": "cargo", "limit": 1000},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["features"][0]["properties"]["name"] == "Cargo Ship"
|
||||
assert data["stats"]["by_type"]["Cargo"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_vessels_geojson_endpoint_is_gone():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/vessels")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert "/api/v1/vessels/snapshot" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_snapshot_requires_bbox():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/vessels/snapshot", params={"zoom": 12})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "bbox is required"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
captured = {}
|
||||
|
||||
async def fake_get_aggregated_vessels_snapshot(db, *, bbox, limit, observed_since):
|
||||
captured["bbox"] = bbox
|
||||
captured["limit"] = limit
|
||||
captured["observed_since"] = observed_since
|
||||
return [
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.9,
|
||||
"lon": 10.7,
|
||||
"received_at": now,
|
||||
"name": "Cargo Ship",
|
||||
"vessel_type": 70,
|
||||
"vessel_type_name": "Cargo",
|
||||
},
|
||||
{
|
||||
"mmsi": 2,
|
||||
"lat": 60.3,
|
||||
"lon": 5.3,
|
||||
"received_at": now,
|
||||
"name": "Passenger Ship",
|
||||
"vessel_type": 60,
|
||||
"vessel_type_name": "Passenger",
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
visualization,
|
||||
"get_aggregated_vessels_snapshot",
|
||||
fake_get_aggregated_vessels_snapshot,
|
||||
)
|
||||
|
||||
class _Result:
|
||||
def all(self):
|
||||
return rows
|
||||
return []
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
@@ -466,14 +645,88 @@ async def test_vessels_geojson_endpoint_filters_type_and_bbox():
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/visualization/geo/vessels",
|
||||
params={"bbox": "0,50,20,70", "type": "cargo", "limit": 0},
|
||||
"/api/v1/vessels/snapshot",
|
||||
params={
|
||||
"bbox": "10,59,11,60",
|
||||
"zoom": 12,
|
||||
"type": "cargo",
|
||||
"limit": 5000,
|
||||
"since_minutes": 30,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["features"][0]["properties"]["name"] == "Cargo Ship"
|
||||
assert data["stats"]["by_type"]["Cargo"] == 1
|
||||
assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0)
|
||||
assert captured["limit"] == 5000
|
||||
assert data["diagnostics"]["bbox_applied"] is True
|
||||
assert data["diagnostics"]["legacy_feature_count"] == 0
|
||||
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 0
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_vessels_geojson_rejects_even_with_bbox():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/visualization/geo/vessels",
|
||||
params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_name_fallbacks_reports_mmsi_display_names(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(
|
||||
visualization,
|
||||
"get_aggregated_vessels",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"mmsi": 257123000,
|
||||
"lat": 59.9,
|
||||
"lon": 10.7,
|
||||
"received_at": now,
|
||||
"name": "MMSI 257123000",
|
||||
"vessel_type_name": "Other",
|
||||
"source_summary": {
|
||||
"aisstream_vessels": {
|
||||
"latest_observed_at": now,
|
||||
"message_types": ["PositionReport"],
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
class _Result:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _Result()
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/vessels/name-fallbacks")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["count"] == 1
|
||||
assert data["items"][0]["mmsi"] == "257123000"
|
||||
assert data["items"][0]["message_types"] == ["PositionReport"]
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1.visualization import convert_compute_centers_to_geojson
|
||||
from app.api.v1 import visualization as visualization_api
|
||||
from app.api.v1.visualization import (
|
||||
CollectComputeCenterLocationRequest,
|
||||
convert_compute_centers_to_geojson,
|
||||
)
|
||||
import app.services.compute_center_locations as compute_center_locations
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.models.collected_data import CollectedData
|
||||
@@ -89,6 +95,8 @@ def test_convert_compute_centers_to_geojson_unifies_sources():
|
||||
assert supercomputer_feature["properties"]["operator"] == "ORNL"
|
||||
assert supercomputer_feature["properties"]["location_precision"] == "precise"
|
||||
assert supercomputer_feature["properties"]["is_estimated"] is False
|
||||
assert supercomputer_feature["properties"]["location_source"] == "source_coordinates"
|
||||
assert supercomputer_feature["properties"]["location_confidence"] == 1.0
|
||||
|
||||
gpu_feature = payload["features"][1]
|
||||
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
|
||||
@@ -98,8 +106,110 @@ def test_convert_compute_centers_to_geojson_unifies_sources():
|
||||
assert gpu_feature["properties"]["location_precision"] == "precise"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
||||
hinted_record = _build_record(
|
||||
def test_convert_compute_centers_to_geojson_accepts_source_coordinate_aliases():
|
||||
record = _build_record(
|
||||
record_id=3,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Alias Coordinates",
|
||||
country="United States",
|
||||
city="New York",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"latitude": "",
|
||||
"longitude": "",
|
||||
"location": {
|
||||
"lat": 40.7128,
|
||||
"lng": -74.0060,
|
||||
},
|
||||
"value": "1200",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
feature = payload["features"][0]
|
||||
assert feature["geometry"]["coordinates"] == [-74.006, 40.7128]
|
||||
assert feature["properties"]["location_source"] == "source_coordinates"
|
||||
|
||||
|
||||
def test_compute_center_source_coordinates_win_over_stored_location():
|
||||
compute_center_locations.set_compute_center_location_cache({
|
||||
"top500:top500-31": {
|
||||
"source": "top500",
|
||||
"source_id": "top500-31",
|
||||
"name": "Stored Wrong",
|
||||
"latitude": 1.0,
|
||||
"longitude": 2.0,
|
||||
"precision": "city",
|
||||
"confidence": 0.5,
|
||||
"needs_confirmation": True,
|
||||
}
|
||||
})
|
||||
record = _build_record(
|
||||
record_id=31,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Source Wins",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={"organization": "ORNL"},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([record])
|
||||
|
||||
assert payload["features"][0]["geometry"]["coordinates"] == [-84.31, 35.93]
|
||||
assert payload["features"][0]["properties"]["location_source"] == "source_coordinates"
|
||||
compute_center_locations.set_compute_center_location_cache({})
|
||||
|
||||
|
||||
def test_compute_center_geojson_uses_stored_location_when_source_coords_missing():
|
||||
compute_center_locations.set_compute_center_location_cache({
|
||||
"epoch_ai_gpu:epoch_ai_gpu-32": {
|
||||
"source": "epoch_ai_gpu",
|
||||
"source_id": "epoch_ai_gpu-32",
|
||||
"name": "Stored Cluster",
|
||||
"city": "Memphis",
|
||||
"country": "United States",
|
||||
"latitude": 35.1495,
|
||||
"longitude": -90.049,
|
||||
"precision": "city",
|
||||
"confidence": 0.72,
|
||||
"location_source": "manual_selection",
|
||||
"source_note": "Saved by user",
|
||||
"needs_confirmation": False,
|
||||
"verified_at": "2026-05-08T00:00:00Z",
|
||||
}
|
||||
})
|
||||
record = _build_record(
|
||||
record_id=32,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Stored Cluster",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"value": "1200", "unit": "TFlop/s"},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
feature = payload["features"][0]
|
||||
assert feature["geometry"]["coordinates"] == [-90.049, 35.1495]
|
||||
assert feature["properties"]["location_source"] == "stored_compute_center_location"
|
||||
assert feature["properties"]["needs_confirmation"] is False
|
||||
compute_center_locations.set_compute_center_location_cache({})
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_does_not_use_registry_aliases():
|
||||
registry_record = _build_record(
|
||||
record_id=3,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
@@ -114,23 +224,51 @@ def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([hinted_record])
|
||||
payload = convert_compute_centers_to_geojson([registry_record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||
assert coords[0] == pytest.approx(-84.3107)
|
||||
assert coords[1] == pytest.approx(35.9319)
|
||||
assert payload["features"][0]["properties"]["is_estimated"] is True
|
||||
assert payload["features"][0]["properties"]["location_precision"] == "estimated_site"
|
||||
assert payload["features"] == []
|
||||
assert len(payload["unresolved"]) == 1
|
||||
assert payload["unresolved"][0]["name"] == "Frontier"
|
||||
assert "source coords" in payload["unresolved"][0]["failure_reason"]
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
|
||||
centroid_record = _build_record(
|
||||
def test_convert_compute_centers_to_geojson_does_not_use_city_fallback():
|
||||
city_record = _build_record(
|
||||
record_id=4,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Sample GPU Cluster",
|
||||
country="United States",
|
||||
city="San Francisco, CA",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Sample Operator",
|
||||
"value": "10000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([city_record])
|
||||
|
||||
assert payload["features"] == []
|
||||
assert len(payload["unresolved"]) == 1
|
||||
assert payload["unresolved"][0]["city"] == "San Francisco, CA"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_does_not_online_geocode_on_startup(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
|
||||
def _explode(_query):
|
||||
raise AssertionError("startup GeoJSON must not call online geocoding")
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode)
|
||||
country_record = _build_record(
|
||||
record_id=4,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Unknown Cluster",
|
||||
country="United States",
|
||||
country="France",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
@@ -141,16 +279,339 @@ def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([centroid_record])
|
||||
payload = convert_compute_centers_to_geojson([country_record])
|
||||
|
||||
assert len(payload["features"]) == 1
|
||||
props = payload["features"][0]["properties"]
|
||||
coords = payload["features"][0]["geometry"]["coordinates"]
|
||||
assert coords[0] == pytest.approx(-98.5795)
|
||||
assert coords[1] == pytest.approx(39.8283)
|
||||
assert props["is_estimated"] is True
|
||||
assert props["location_precision"] == "estimated_country"
|
||||
assert props["geography_mode"] == "country_centroid"
|
||||
assert payload["features"] == []
|
||||
assert len(payload["unresolved"]) == 1
|
||||
assert payload["unresolved"][0]["operator"] == "Unknown Operator"
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_records_diagnostics_when_online_geocode_fails(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
country_record = _build_record(
|
||||
record_id=5,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Unknown French Cluster",
|
||||
country="France",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Unknown Operator",
|
||||
"value": "10000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([country_record])
|
||||
|
||||
assert payload["features"] == []
|
||||
assert len(payload["unresolved"]) == 1
|
||||
diagnostic = payload["unresolved"][0]
|
||||
assert diagnostic["record_id"] == 5
|
||||
assert diagnostic["source_id"] == "epoch_ai_gpu-5"
|
||||
assert diagnostic["country"] == "France"
|
||||
assert diagnostic["operator"] == "Unknown Operator"
|
||||
assert diagnostic["failure_reason"]
|
||||
assert diagnostic["attempted_queries"] == []
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_records_diagnostics_when_no_country(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
unknown_record = _build_record(
|
||||
record_id=6,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Unknown Offshore Cluster",
|
||||
country="",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={
|
||||
"organization": "Unknown Operator",
|
||||
"value": "10000",
|
||||
"unit": "TFlop/s",
|
||||
},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([unknown_record])
|
||||
|
||||
assert payload["features"] == []
|
||||
assert len(payload["unresolved"]) == 1
|
||||
assert payload["unresolved"][0]["failure_reason"]
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_never_emits_zero_coordinates(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
|
||||
def _zero_geocode(query):
|
||||
return {
|
||||
"lat": "0",
|
||||
"lon": "0",
|
||||
"display_name": "Null Island",
|
||||
"address": {"city": "", "country": ""},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", _zero_geocode)
|
||||
record = _build_record(
|
||||
record_id=7,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Null Island Cluster",
|
||||
country="",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Null Inc"},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([record])
|
||||
|
||||
for feature in payload["features"]:
|
||||
coords = feature["geometry"]["coordinates"]
|
||||
assert coords[0] not in (0, 0.0)
|
||||
assert coords[1] not in (0, 0.0)
|
||||
|
||||
|
||||
def test_convert_compute_centers_to_geojson_rejects_country_or_unknown_precision(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
record = _build_record(
|
||||
record_id=8,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Phantom System",
|
||||
country="Liechtenstein",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Phantom Operator", "rmax": 100.0},
|
||||
)
|
||||
|
||||
payload = convert_compute_centers_to_geojson([record])
|
||||
|
||||
for feature in payload["features"]:
|
||||
assert feature["properties"]["location_precision"] in {"precise", "site", "city"}
|
||||
assert payload["features"] == []
|
||||
assert payload["unresolved"], "phantom record must surface as diagnostic"
|
||||
|
||||
|
||||
def test_resolve_full_returns_diagnostic_for_unresolved(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
record = _build_record(
|
||||
record_id=11,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Phantom Cluster",
|
||||
country="Bhutan",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Mystery Operator"},
|
||||
)
|
||||
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.failure_reason
|
||||
assert result.diagnostic.country == "Bhutan"
|
||||
|
||||
|
||||
def test_collect_location_candidates_ignores_registry_and_uses_online(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_ror(query):
|
||||
assert query == "Oak Ridge National Laboratory"
|
||||
return {
|
||||
"id": "https://ror.org/01qz5mb56",
|
||||
"names": [
|
||||
{"types": ["ror_display"], "value": "Oak Ridge National Laboratory"}
|
||||
],
|
||||
"locations": [
|
||||
{
|
||||
"geonames_id": 4646571,
|
||||
"geonames_details": {
|
||||
"name": "Oak Ridge",
|
||||
"country_subdivision_name": "Tennessee",
|
||||
"country_name": "United States",
|
||||
"lat": 36.01036,
|
||||
"lng": -84.26964,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
candidates, attempted = compute_center_locations.collect_location_candidates(
|
||||
name="Frontier",
|
||||
operator="Oak Ridge National Laboratory",
|
||||
country="United States",
|
||||
)
|
||||
assert candidates, "online source-traced query must produce a candidate"
|
||||
best = candidates[0]
|
||||
assert best.source == "ror_organization_registry"
|
||||
assert best.precision == "city"
|
||||
assert best.needs_confirmation is True
|
||||
assert attempted[0] == "ror:Oak Ridge National Laboratory"
|
||||
|
||||
|
||||
def test_collect_location_candidates_returns_online_when_registry_misses(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
if "Lyon" not in query and "Mystery Operator" not in query and "Lyon, France" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "45.7640",
|
||||
"lon": "4.8357",
|
||||
"display_name": "Lyon, Auvergne-Rhône-Alpes, France",
|
||||
"address": {"city": "Lyon", "state": "Auvergne-Rhône-Alpes", "country": "France"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", _fake_geocode)
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||
candidates, attempted = compute_center_locations.collect_location_candidates(
|
||||
name="Mystery System",
|
||||
operator="Mystery Operator",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
assert candidates, "online geocoding must produce a candidate"
|
||||
online_candidates = [c for c in candidates if c.source == "nominatim_online_geocode"]
|
||||
assert online_candidates, "must include at least one online candidate"
|
||||
online = online_candidates[0]
|
||||
assert online.precision == "city"
|
||||
assert online.needs_confirmation is True
|
||||
assert online.suggested_registry_entry is not None
|
||||
assert attempted, "must record attempted query strings"
|
||||
|
||||
|
||||
def test_collect_location_candidates_failure_returns_attempted_queries(monkeypatch):
|
||||
compute_center_locations._geocode_online.cache_clear()
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
candidates, attempted = compute_center_locations.collect_location_candidates(
|
||||
name="Mystery Offshore Cluster",
|
||||
operator="Mystery Operator",
|
||||
country="Bhutan",
|
||||
)
|
||||
assert candidates == []
|
||||
assert attempted, "even on failure we record attempted queries for diagnostics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_compute_center_location_skips_llm_when_candidates_exist(monkeypatch):
|
||||
candidate = compute_center_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon",
|
||||
precision="city",
|
||||
confidence=0.62,
|
||||
query="Lyon, France",
|
||||
source="nominatim_online_geocode",
|
||||
source_note="fixture",
|
||||
matched_fields=("city", "country"),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(visualization_api, "_load_compute_center_record", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
visualization_api,
|
||||
"collect_location_candidates",
|
||||
lambda **_kwargs: ([candidate], ["Lyon, France"]),
|
||||
)
|
||||
|
||||
async def _explode(**_kwargs):
|
||||
raise AssertionError("LLM fallback should not run when a normal candidate exists")
|
||||
|
||||
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _explode)
|
||||
|
||||
response = await visualization_api.collect_compute_center_location(
|
||||
"epoch_ai_gpu-test",
|
||||
CollectComputeCenterLocationRequest(
|
||||
name="Mystery Cluster",
|
||||
source="epoch_ai_gpu",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "nominatim_online_geocode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_compute_center_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
llm_candidate = compute_center_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon, France",
|
||||
precision="city",
|
||||
confidence=0.74,
|
||||
query="llm_factcheck:compute_center:Mystery Cluster",
|
||||
source="llm_location_factcheck",
|
||||
source_note="LLM location factcheck fallback",
|
||||
matched_fields=("name",),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(visualization_api, "_load_compute_center_record", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
visualization_api,
|
||||
"collect_location_candidates",
|
||||
lambda **_kwargs: ([], ["Mystery Cluster, France"]),
|
||||
)
|
||||
|
||||
from app.services.location.llm_fallback import LocationLLMFallbackResult, LocationSearchEvidenceResult
|
||||
|
||||
async def _search_evidence(**_kwargs):
|
||||
return LocationSearchEvidenceResult(
|
||||
evidence=[
|
||||
{
|
||||
"title": "Mystery Cluster source",
|
||||
"url": "https://example.test/mystery",
|
||||
"snippet": "Mystery Cluster is in Lyon.",
|
||||
}
|
||||
],
|
||||
attempted_queries=["web_search:compute_center:Mystery Cluster France physical location"],
|
||||
)
|
||||
|
||||
async def _fallback(**_kwargs):
|
||||
return LocationLLMFallbackResult(
|
||||
candidates=[llm_candidate],
|
||||
attempted_queries=["llm_factcheck:compute_center:Mystery Cluster"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(visualization_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(visualization_api, "get_web_search_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(visualization_api, "collect_location_search_evidence", _search_evidence)
|
||||
monkeypatch.setattr(visualization_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||
|
||||
response = await visualization_api.collect_compute_center_location(
|
||||
"epoch_ai_gpu-test",
|
||||
CollectComputeCenterLocationRequest(
|
||||
name="Mystery Cluster",
|
||||
source="epoch_ai_gpu",
|
||||
country="France",
|
||||
),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "llm_location_factcheck"
|
||||
assert response["best_candidate"]["needs_confirmation"] is True
|
||||
assert response["attempted_queries"] == [
|
||||
"Mystery Cluster, France",
|
||||
"web_search:compute_center:Mystery Cluster France physical location",
|
||||
"llm_factcheck:compute_center:Mystery Cluster",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -292,6 +753,9 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
def scalar(self):
|
||||
return self._scalar_value
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
@@ -304,13 +768,18 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, query):
|
||||
query_text = str(query)
|
||||
query_text = str(query).lower()
|
||||
if "bgp_incidents" in query_text:
|
||||
return _ScalarResult(scalar_value=2)
|
||||
if "bgp_anomalies" in query_text:
|
||||
return _ScalarResult(scalar_value=3)
|
||||
if "ais_raw_observations" in query_text or "vessel_position" in query_text:
|
||||
return _ScalarResult(rows=[])
|
||||
return _ScalarResult(rows=records)
|
||||
|
||||
async def get(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
@@ -345,3 +814,304 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
assert stats["bgp_collector_count"] == 2
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_location_endpoint_returns_candidates_for_known_record(monkeypatch):
|
||||
def _fake_ror(query):
|
||||
assert query == "Oak Ridge National Laboratory"
|
||||
return {
|
||||
"id": "https://ror.org/01qz5mb56",
|
||||
"names": [
|
||||
{"types": ["ror_display"], "value": "Oak Ridge National Laboratory"}
|
||||
],
|
||||
"locations": [
|
||||
{
|
||||
"geonames_id": 4646571,
|
||||
"geonames_details": {
|
||||
"name": "Oak Ridge",
|
||||
"country_subdivision_name": "Tennessee",
|
||||
"country_name": "United States",
|
||||
"lat": 36.01036,
|
||||
"lng": -84.26964,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
|
||||
target_record = _build_record(
|
||||
record_id=42,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Oak Ridge National Laboratory", "rmax": 1102000.0},
|
||||
)
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _ScalarResult([target_record])
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/visualization/compute-centers/top500-42/collect-location",
|
||||
json={
|
||||
"name": "Frontier",
|
||||
"operator": "Oak Ridge National Laboratory",
|
||||
"country": "United States",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["candidates"], "must include candidates"
|
||||
best = body["best_candidate"]
|
||||
assert best["precision"] in {"precise", "site", "city"}
|
||||
assert best["source"] == "ror_organization_registry"
|
||||
assert best["needs_confirmation"] is True
|
||||
assert best["matched_fields"], "matched_fields must be populated"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_location_endpoint_returns_failure_reason(monkeypatch):
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _ScalarResult([])
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/visualization/compute-centers/epoch-mystery-99/collect-location",
|
||||
json={
|
||||
"name": "Mystery Cluster",
|
||||
"operator": "Mystery Operator",
|
||||
"country": "Bhutan",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert body["failure_reason"]
|
||||
assert body["candidates"] == []
|
||||
assert body["attempted_queries"], "must include attempted queries"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_location_endpoint_upserts_and_geojson_can_render():
|
||||
target_record = _build_record(
|
||||
record_id=52,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Saved Cluster",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"value": "1200", "unit": "TFlop/s"},
|
||||
)
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self):
|
||||
self.saved = []
|
||||
|
||||
async def execute(self, _query):
|
||||
if self.saved:
|
||||
return _ScalarResult(self.saved)
|
||||
return _ScalarResult([target_record])
|
||||
|
||||
async def scalar(self, _query):
|
||||
return None
|
||||
|
||||
def add(self, record):
|
||||
self.saved.append(record)
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def refresh(self, _record):
|
||||
return None
|
||||
|
||||
fake_session = _FakeSession()
|
||||
|
||||
async def override_get_db():
|
||||
yield fake_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/visualization/compute-centers/epoch_ai_gpu-52/location",
|
||||
json={
|
||||
"source": "epoch_ai_gpu",
|
||||
"name": "Saved Cluster",
|
||||
"latitude": 35.1495,
|
||||
"longitude": -90.049,
|
||||
"precision": "city",
|
||||
"confidence": 0.72,
|
||||
"location_source": "ror_organization_registry",
|
||||
"source_note": "Selected by user",
|
||||
"raw_payload": {"source": "ror_organization_registry"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert fake_session.saved
|
||||
|
||||
payload = convert_compute_centers_to_geojson([target_record])
|
||||
assert len(payload["features"]) == 1
|
||||
feature = payload["features"][0]
|
||||
assert feature["geometry"]["coordinates"] == [-90.049, 35.1495]
|
||||
assert feature["properties"]["location_source"] == "stored_compute_center_location"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
compute_center_locations.set_compute_center_location_cache({})
|
||||
|
||||
|
||||
def test_resolution_chain_orders_source_coords_first(monkeypatch):
|
||||
def _explode(_query):
|
||||
raise AssertionError("source coords must short-circuit before online geocoding")
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode)
|
||||
record = _build_record(
|
||||
record_id=20,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={"organization": "ORNL"},
|
||||
)
|
||||
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||
assert result.is_resolved
|
||||
assert result.location.location_precision == "precise"
|
||||
assert result.location.location_source == "source_coordinates"
|
||||
|
||||
|
||||
def test_no_country_centroid_or_major_compute_city_fallback(monkeypatch):
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
record = _build_record(
|
||||
record_id=21,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Phantom System",
|
||||
country="France",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Phantom Operator"},
|
||||
)
|
||||
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||
assert result.location is None, "must NOT fall back to country centroid or hashed major city"
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.failure_reason
|
||||
|
||||
|
||||
def test_repository_has_no_forbidden_precision_tokens():
|
||||
"""Static guard: forbidden fallback strategies must not regress into the codebase.
|
||||
|
||||
Each forbidden token may appear at most once per target file, and only inside
|
||||
the FORBIDDEN_PRECISIONS guard list (so we still reject them at runtime).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
forbidden_tokens = (
|
||||
"country_centroid",
|
||||
"country_major_compute_city",
|
||||
"estimated_country",
|
||||
)
|
||||
targets = [
|
||||
backend_root / "app" / "services" / "compute_center_locations.py",
|
||||
backend_root / "app" / "api" / "v1" / "visualization.py",
|
||||
]
|
||||
for target in targets:
|
||||
text = target.read_text(encoding="utf-8")
|
||||
for token in forbidden_tokens:
|
||||
occurrences = text.count(token)
|
||||
assert occurrences <= 1, (
|
||||
f"{token} appears {occurrences} times in {target}; "
|
||||
"should only appear in FORBIDDEN_PRECISIONS guard list."
|
||||
)
|
||||
if occurrences == 1:
|
||||
assert "FORBIDDEN_PRECISIONS" in text, (
|
||||
f"{token} appears in {target} outside the FORBIDDEN_PRECISIONS guard"
|
||||
)
|
||||
|
||||
261
backend/tests/test_web_search_tools.py
Normal file
261
backend/tests/test_web_search_tools.py
Normal file
@@ -0,0 +1,261 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import settings as settings_api
|
||||
from app.api.v1.settings import (
|
||||
WebSearchIntegrationUpdate,
|
||||
_build_web_search_payload,
|
||||
_mask_secret,
|
||||
_normalize_web_search_payload,
|
||||
_resolve_web_search_api_key,
|
||||
)
|
||||
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
|
||||
from app.services.ai_tools.web_search import WebSearchClient
|
||||
from app.services.credential_guides import generate_credential_guide
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
from app.services.location.models import LocationQuery
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_web_search_env_files(monkeypatch, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,))
|
||||
return env_file
|
||||
|
||||
|
||||
def test_normalize_web_search_payload_adds_default_provider():
|
||||
payload = _normalize_web_search_payload({})
|
||||
|
||||
assert payload["default_provider"] == "tavily"
|
||||
assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com"
|
||||
|
||||
|
||||
def test_web_search_key_prefers_provider_env(isolated_web_search_env_files):
|
||||
isolated_web_search_env_files.write_text(
|
||||
"TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_web_search_api_key("tavily", {"api_key": ""})
|
||||
|
||||
assert value == "tavily-env-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"web_search": {
|
||||
"default_provider": "tavily",
|
||||
"providers": {
|
||||
"tavily": {
|
||||
"provider": "tavily",
|
||||
"api_key": "tvly-old-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = WebSearchIntegrationUpdate(
|
||||
enabled=True,
|
||||
provider="tavily",
|
||||
base_url="https://api.tavily.com",
|
||||
api_key=_mask_secret("tvly-old-secret")["preview"],
|
||||
)
|
||||
|
||||
payload = _build_web_search_payload(current, update)
|
||||
|
||||
assert payload["enabled"] is True
|
||||
assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_adapter_normalizes_results(monkeypatch):
|
||||
config = WebSearchConfig(
|
||||
enabled=True,
|
||||
default_provider="tavily",
|
||||
provider="tavily",
|
||||
providers={
|
||||
"tavily": WebSearchProviderConfig(
|
||||
provider="tavily",
|
||||
base_url="https://api.tavily.com",
|
||||
api_key="key",
|
||||
)
|
||||
},
|
||||
)
|
||||
client = WebSearchClient(config)
|
||||
|
||||
async def fake_request_json(*args, **kwargs):
|
||||
return {
|
||||
"query": "Alem.Cloud",
|
||||
"results": [
|
||||
{
|
||||
"title": "Alem.Cloud official",
|
||||
"url": "https://example.test/alem",
|
||||
"content": "Alem.Cloud is in Astana.",
|
||||
"score": 0.9,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
||||
|
||||
results = await client.search("Alem.Cloud")
|
||||
|
||||
assert results[0].source_provider == "tavily"
|
||||
assert results[0].url == "https://example.test/alem"
|
||||
assert "Astana" in results[0].snippet
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_searxng_adapter_allows_empty_api_key(monkeypatch):
|
||||
config = WebSearchConfig(
|
||||
enabled=True,
|
||||
default_provider="searxng",
|
||||
provider="searxng",
|
||||
providers={
|
||||
"searxng": WebSearchProviderConfig(
|
||||
provider="searxng",
|
||||
base_url="http://localhost:8080",
|
||||
api_key="",
|
||||
)
|
||||
},
|
||||
)
|
||||
client = WebSearchClient(config)
|
||||
|
||||
async def fake_request_json(*args, **kwargs):
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"title": "TAIPEI-1",
|
||||
"url": "https://example.test/taipei",
|
||||
"content": "TAIPEI-1 is in Taipei.",
|
||||
"score": 2,
|
||||
"engine": "duckduckgo",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
||||
|
||||
results = await client.search("TAIPEI-1")
|
||||
|
||||
assert results[0].source_provider == "searxng"
|
||||
assert results[0].metadata["engine"] == "duckduckgo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch):
|
||||
class EmptySearchClient:
|
||||
async def search(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
result = await collect_location_search_evidence(
|
||||
web_search_client=EmptySearchClient(),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.evidence == []
|
||||
assert "no usable" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_skips_when_search_evidence_empty():
|
||||
class ExplodingAIClient:
|
||||
async def analyze(self, *_args, **_kwargs):
|
||||
raise AssertionError("LLM should not be called without evidence")
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=ExplodingAIClient(),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
search_evidence=[],
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "no WebSearch evidence" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_guide_keeps_default_without_search_evidence():
|
||||
class EmptySearchClient:
|
||||
async def search(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
class ExplodingAIClient:
|
||||
async def analyze(self, *_args, **_kwargs):
|
||||
raise AssertionError("AI should not be called without search evidence")
|
||||
|
||||
async def fake_get_store(_db):
|
||||
return None, {}
|
||||
|
||||
import app.services.credential_guides as credential_guides
|
||||
|
||||
original = credential_guides._get_guide_store
|
||||
credential_guides._get_guide_store = fake_get_store
|
||||
try:
|
||||
guide = await generate_credential_guide(
|
||||
object(),
|
||||
"barentswatch",
|
||||
ExplodingAIClient(),
|
||||
EmptySearchClient(),
|
||||
)
|
||||
finally:
|
||||
credential_guides._get_guide_store = original
|
||||
|
||||
assert guide["source"] == "default"
|
||||
assert guide["verification_status"] == "unverified_no_search_evidence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_guide_uses_search_evidence(monkeypatch):
|
||||
class SearchClient:
|
||||
async def search(self, *args, **kwargs):
|
||||
from app.services.ai_tools.schemas import SearchEvidence
|
||||
|
||||
return [
|
||||
SearchEvidence(
|
||||
title="Official docs",
|
||||
url="https://docs.example.test",
|
||||
snippet="Create an AIS client.",
|
||||
source_provider="tavily",
|
||||
)
|
||||
]
|
||||
|
||||
class AIClient:
|
||||
async def analyze(self, payload):
|
||||
assert payload.context["search_evidence"]
|
||||
return SimpleNamespace(content="## Generated\n\nSources included.")
|
||||
|
||||
saved = {}
|
||||
|
||||
async def fake_get_store(_db):
|
||||
return None, saved
|
||||
|
||||
async def fake_save(db, provider, title, markdown, **metadata):
|
||||
return {
|
||||
"provider": provider,
|
||||
"title": title,
|
||||
"markdown": markdown,
|
||||
"source": "ai",
|
||||
**metadata,
|
||||
}
|
||||
|
||||
import app.services.credential_guides as credential_guides
|
||||
|
||||
monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store)
|
||||
monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save)
|
||||
|
||||
guide = await generate_credential_guide(
|
||||
object(),
|
||||
"barentswatch",
|
||||
AIClient(),
|
||||
SearchClient(),
|
||||
)
|
||||
|
||||
assert guide["source"] == "ai"
|
||||
assert guide["verification_status"] == "verified_with_search_evidence"
|
||||
assert guide["sources"][0]["url"] == "https://docs.example.test"
|
||||
138
backend/tests/test_websocket_manager.py
Normal file
138
backend/tests/test_websocket_manager.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import pytest
|
||||
import importlib
|
||||
|
||||
from app.core.websocket.manager import ConnectionManager
|
||||
from app.core.websocket.broadcaster import DataBroadcaster
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.accepted = False
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
|
||||
async def accept(self):
|
||||
self.accepted = True
|
||||
|
||||
async def send_json(self, message):
|
||||
self.sent.append(message)
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_subscribers_receive_channel_broadcasts():
|
||||
manager = ConnectionManager()
|
||||
socket = FakeWebSocket()
|
||||
|
||||
await manager.connect(socket, "user-1")
|
||||
manager.subscribe(socket, ["dashboard"])
|
||||
await manager.broadcast({"type": "data_frame", "channel": "dashboard"}, channel="dashboard")
|
||||
|
||||
assert socket.accepted is True
|
||||
assert socket.sent == [{"type": "data_frame", "channel": "dashboard"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_removes_channel_subscriptions():
|
||||
manager = ConnectionManager()
|
||||
socket = FakeWebSocket()
|
||||
|
||||
await manager.connect(socket, "user-1")
|
||||
manager.subscribe(socket, ["dashboard"])
|
||||
manager.disconnect(socket, "user-1")
|
||||
await manager.broadcast({"type": "data_frame", "channel": "dashboard"}, channel="dashboard")
|
||||
|
||||
assert socket.sent == []
|
||||
assert "dashboard" not in manager.channel_subscriptions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_subscribers_receive_only_matching_bbox_updates():
|
||||
manager = ConnectionManager()
|
||||
oslo_socket = FakeWebSocket()
|
||||
bergen_socket = FakeWebSocket()
|
||||
|
||||
await manager.connect(oslo_socket, "user-1")
|
||||
await manager.connect(bergen_socket, "user-2")
|
||||
manager.subscribe_vessels(
|
||||
oslo_socket,
|
||||
{"bbox": [10, 59, 11, 60], "zoom": 12, "limit": 1000},
|
||||
)
|
||||
manager.subscribe_vessels(
|
||||
bergen_socket,
|
||||
{"bbox": [5, 60, 6, 61], "zoom": 12, "limit": 1000},
|
||||
)
|
||||
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"vessels": [
|
||||
{"mmsi": 1, "lat": 59.9, "lon": 10.7},
|
||||
{"mmsi": 2, "lat": 60.3, "lon": 5.3},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert oslo_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]
|
||||
assert bergen_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 2, "lat": 60.3, "lon": 5.3}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_broadcast_removes_slow_connections():
|
||||
manager = ConnectionManager()
|
||||
|
||||
class BrokenWebSocket(FakeWebSocket):
|
||||
async def send_json(self, message):
|
||||
raise RuntimeError("client is gone")
|
||||
|
||||
socket = BrokenWebSocket()
|
||||
await manager.connect(socket, "user-1")
|
||||
manager.subscribe_vessels(socket, {"bbox": [10, 59, 11, 60], "zoom": 12})
|
||||
|
||||
await manager.broadcast_vessels({"vessels": [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]})
|
||||
|
||||
assert socket not in manager.vessel_subscriptions
|
||||
|
||||
|
||||
def test_vessel_subscription_rejects_large_bbox():
|
||||
manager = ConnectionManager()
|
||||
|
||||
with pytest.raises(ValueError, match="bbox is too large"):
|
||||
manager.subscribe_vessels(FakeWebSocket(), {"bbox": [-180, -90, 180, 90], "zoom": 1})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_broadcast_vessels(payload):
|
||||
sent.append(payload)
|
||||
|
||||
broadcaster_module = importlib.import_module("app.core.websocket.broadcaster")
|
||||
monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels)
|
||||
broadcaster = DataBroadcaster()
|
||||
broadcaster.enqueue_vessel_update(
|
||||
{
|
||||
"source": "aisstream_vessels",
|
||||
"vessels": [
|
||||
{"mmsi": 1, "lat": 59.0, "lon": 10.0},
|
||||
{"mmsi": 1, "lat": 59.1, "lon": 10.1},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
await broadcaster.flush_vessel_updates()
|
||||
|
||||
assert len(sent) == 1
|
||||
assert sent[0]["vessels"] == [
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.1,
|
||||
"lon": 10.1,
|
||||
"source": "aisstream_vessels",
|
||||
"action": "upsert",
|
||||
"created": None,
|
||||
}
|
||||
]
|
||||
@@ -8,6 +8,96 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.52.0] — 2026-05-12
|
||||
|
||||
Released: 2026-05-12
|
||||
|
||||
### Highlights
|
||||
- 新增邮箱验证码注册/找回密码链路、SMTP 设置面板和连接测试组件,补齐公开账号自助入口。
|
||||
- 重构 AIS 船只实时链路:新增受控船只 snapshot、WebSocket vessels 订阅、AISStream 长连接状态与节流广播。
|
||||
- 新增数据产品统计接口、受控 `/layers/*` 图层接口骨架,以及数据源产品域筛选和批量采集。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `/api/v1/data-products`、`/api/v1/layers/*` 和 `/api/v1/datasources/trigger-batch`,拆分全量统计与地图渲染数据。
|
||||
- 将 Earth 船只层迁移到 `/api/v1/vessels/snapshot`,并修正 vessels WebSocket 订阅 payload。
|
||||
- 更新中英文手册、采集器文档、运维手册和计划状态,覆盖注册、SMTP、AISStream、数据产品和图层保护流程。
|
||||
|
||||
---
|
||||
|
||||
## [0.51.1] — 2026-05-11
|
||||
|
||||
Released: 2026-05-11
|
||||
|
||||
### Highlights
|
||||
- 修复 Earth 静态资源引用方式,图标和国家边界数据改为模块相对 URL,避免部署路径变化时资源加载失败。
|
||||
- 将 Material Symbols Rounded 字体切换为本地资源,减少 Earth 页面首屏对外部字体服务的依赖。
|
||||
|
||||
### Fixed
|
||||
- 修复 BGP 广播图标、算力中心图标和国家边界 GeoJSON 在非固定 `/earth` 路径下可能失效的问题。
|
||||
|
||||
---
|
||||
|
||||
## [0.51.0] — 2026-05-11
|
||||
|
||||
Released: 2026-05-11
|
||||
|
||||
### ✨ Highlights
|
||||
- 新增 AI Settings 控制台页面与 `backend/app/services/ai_tools/` 工具层,串通 Web Search Provider 与轻量 Agent orchestrator。
|
||||
- 重写 Earth 算力中心候选「预览 / 保存」交互:单一委托 click + 内存 candidate Map,新增空心呼吸圈预览,保存后即时生成正式图标,后台刷新失败不再误报为保存失败。
|
||||
- 重写动作捕捉 zoom 识别:mirror-safe 的 trend + pose hold 双通道,张开/合拢手势直接对应 zoom_in/out 并支持持续触发;单臂 rotate 仅在另一只手明确静止时才允许。
|
||||
|
||||
### Improvements
|
||||
- 同步中英文 `earth-frontend-context.md`、`frontend-admin-frontend-context.md`、`faq.md`、`manual.md`、`quickstart.md`。
|
||||
- Earth 模块多处优化:bgp-cruise-adapter、interactable、satellites、presentation-controller、controls 调整与回归测试补全。
|
||||
|
||||
---
|
||||
|
||||
## [0.50.0] — 2026-05-10
|
||||
|
||||
Released: 2026-05-10
|
||||
|
||||
### ✨ Highlights
|
||||
- 新增 Earth 动作捕捉双通道控制:Browser Camera 本地识别与 Motion Agent WebSocket 高级接入,并补齐调试 HUD、骨架预览和手势冷却保护。
|
||||
- 新增 Motion 目标展示的 `PresentationController` 接入,动捕聚焦复用巡航卡片和 connector,同时保持 BGP/News 原巡航体验不变。
|
||||
- 扩展位置候选管线与 AI Provider 兜底,支持算力中心和 BGP 观测站候选采集、保存、待定位队列与 LLM factcheck。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 改进 `planet.sh`:支持可选 Motion Agent 启动、摄像头 index/URL 参数、WSL 摄像头引导、端口清理细化和 AI Provider/Motion 依赖自动处理。
|
||||
- Settings 与 Playground 支持多 provider AI 配置、密钥来源脱敏预览和运行时默认 provider 解析。
|
||||
- Docs 新增 FAQ 入口,并同步中英文手册、Earth 前端上下文、位置管线和启动脚本文档。
|
||||
- Earth 媒体面板记录直播/新闻 tab 状态,刷新后恢复用户上次选择。
|
||||
|
||||
---
|
||||
|
||||
## [0.49.0] — 2026-05-08
|
||||
|
||||
Released: 2026-05-08
|
||||
|
||||
### ✨ Features
|
||||
- 新增统一地理位置解析 Pipeline,支持 SourceCoordinates / Nominatim / Registry / Inherit 多策略链式 resolver。
|
||||
- 新增 BGP 采集站与算力中心地理定位服务(`bgp_collector_locations`、`compute_center_locations`、`bgp_event_locations`)。
|
||||
- 新增 Docs Gatekeeper 带鉴权文档 API(`/api/v1/docs`),按用户权限动态返回文档目录与内容。
|
||||
- 新增 Earth 全球新闻栏(`/api/v1/news/earth-feed`),根据地球视角坐标推断地区并聚合多源 RSS 信息流。
|
||||
- Earth 新增 Mobile 算力中心国家高亮(`mobile-center-country-highlight.js`)。
|
||||
|
||||
---
|
||||
|
||||
## [0.48.0] — 2026-05-07
|
||||
|
||||
Released: 2026-05-07
|
||||
|
||||
### ✨ Highlights
|
||||
- 自定义数据源新增 REST / WebSocket 映射运行时,并提供本地 AIS mock WebSocket,用于实时船只 upsert 链路验证。
|
||||
- AIS 原始观测、聚合策略、字段来源、冲突记录与船舶 enrichment 继续完善,Earth 船只实时展示链路更接近生产数据形态。
|
||||
- Earth 全球态势 summary 改为轻量 SQL 聚合,并在卫星 current 异常时回退到最近有效 TLE 批次,避免统计接口被大规模明细读取拖慢。
|
||||
|
||||
### 🔧 Improvements
|
||||
- 修复 `/geo/summary` 与 `/geo/satellites` 在大表下加载慢或超时的问题,并补充 `collected_data` 与 AIS raw 相关索引。
|
||||
- WebSocket 管理器支持匿名连接、频道订阅清理和更稳的连接生命周期测试,前端 WebSocket candidates / fallback 更可靠。
|
||||
- `planet.sh` 强化端口释放、端口诊断和前端启动流程,mock AIS server 提供 Bun 脚本入口。
|
||||
|
||||
---
|
||||
|
||||
## [0.47.0] — 2026-04-30
|
||||
|
||||
Released: 2026-04-30
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
This file contains Planet-specific documentation coverage rules. Documentation skills and agents should read this file before deciding which docs to update. Keep tool-specific workflow in skills; keep product and repository rules here.
|
||||
|
||||
## Audience Routing (mandatory)
|
||||
|
||||
Before deciding scope, classify the change by who performs the action:
|
||||
|
||||
- **Browser/UI end user** (login, account settings, configuring collectors or AI via UI, using Earth/Console pages): update `docs/technical/{zh,en}/manual.md` and `quickstart.md` only. Never put shell commands, log file paths, `planet.sh`, Docker operations, or `netsh portproxy` rules into these files.
|
||||
- **Operations / deployment / on-call** (`planet.sh`, log paths, SMTP fallbacks like `createuser`, LAN/portproxy, env-var tuning, troubleshooting order, Bun build conventions): update `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`). Never put UI button labels or screenshots into these files.
|
||||
- **Second-party developers** (component context, render order, internal pipelines): update the existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
If the same action has both a UI and a CLI path (e.g. user creation), describe the UI path in `manual.md` and the CLI path in `ops-runbook.md`, and cross-link them with a single sentence each.
|
||||
|
||||
## Scope Rules
|
||||
|
||||
- User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`.
|
||||
|
||||
@@ -25,11 +25,17 @@
|
||||
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
|
||||
- [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
|
||||
- [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md)
|
||||
- [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md)
|
||||
- [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md)
|
||||
- [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
|
||||
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
|
||||
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
|
||||
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md)
|
||||
- [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md)
|
||||
- [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md)
|
||||
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [Lightweight Agent Orchestrator 与 WebSearch 证据层计划](/home/ray/dev/linkong/planet/docs/plans/agents-light-orchestrator-websearch-plan.md)
|
||||
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
||||
|
||||
不适合放入这里的内容:
|
||||
|
||||
618
docs/plans/agents-light-orchestrator-websearch-plan.md
Normal file
618
docs/plans/agents-light-orchestrator-websearch-plan.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# Lightweight Agent Orchestrator and WebSearch Evidence Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Planet should not turn `aiprovider` into a general-purpose agent runtime.
|
||||
|
||||
`aiprovider` should remain the model gateway:
|
||||
|
||||
- provider compatibility
|
||||
- protocol adaptation
|
||||
- model authentication
|
||||
- request and response normalization
|
||||
|
||||
Agent behavior belongs in the backend, where Planet already owns business state,
|
||||
permissions, persistence, evidence records, and operator workflows.
|
||||
|
||||
The recommended direction is a lightweight backend Agent Orchestrator with a
|
||||
controlled tool layer. The first version should use fixed workflows instead of a
|
||||
free-form tool-calling loop.
|
||||
|
||||
|
||||
## Architecture Decision
|
||||
|
||||
Use this boundary:
|
||||
|
||||
```text
|
||||
aiprovider = model adapter only
|
||||
backend Agent = task orchestration + tools + evidence + policy + business rules
|
||||
```
|
||||
|
||||
This keeps model transport separate from Planet-specific behavior. It also lets
|
||||
OpenAI, MiniMax, Anthropic-compatible providers, Ollama, and later providers all
|
||||
reuse the same backend tools.
|
||||
|
||||
Recommended module shape:
|
||||
|
||||
```text
|
||||
backend/app/services/
|
||||
ai/
|
||||
agent_orchestrator.py
|
||||
tool_registry.py
|
||||
prompts.py
|
||||
schemas.py
|
||||
ai_tools/
|
||||
web_search.py
|
||||
web_fetch.py
|
||||
geo_resolve.py
|
||||
internal_data_query.py
|
||||
incident_query.py
|
||||
evidence_store.py
|
||||
situation/
|
||||
bgp_analyzer.py
|
||||
risk_scoring.py
|
||||
event_correlator.py
|
||||
alert_policy.py
|
||||
|
||||
aiprovider/
|
||||
provider_service.py
|
||||
main.py
|
||||
```
|
||||
|
||||
|
||||
## Phase 1: Controlled Workflow Agent
|
||||
|
||||
The first implementation should not be a full OpenClaw/Codex-style agent loop.
|
||||
Planet's immediate needs are better served by explicit workflows:
|
||||
|
||||
1. `tutorial_refresh`
|
||||
2. `geo_correction`
|
||||
3. `situation_brief`
|
||||
|
||||
Each workflow should:
|
||||
|
||||
1. collect evidence with backend tools
|
||||
2. normalize and store evidence
|
||||
3. call `AIProviderClient` through the configured global provider/model/key
|
||||
4. validate the result with Pydantic schemas
|
||||
5. return a proposal, candidate, or brief instead of directly mutating critical state
|
||||
|
||||
For location correction, the flow should be:
|
||||
|
||||
```text
|
||||
object name / type / current coordinate / description
|
||||
-> web_search
|
||||
-> web_fetch for selected results
|
||||
-> geo_resolve for city/site coordinates
|
||||
-> LLM structured extraction
|
||||
-> schema validation and confidence scoring
|
||||
-> pending review candidate
|
||||
```
|
||||
|
||||
The LLM output must be constrained to a schema such as:
|
||||
|
||||
```json
|
||||
{
|
||||
"object_id": "string",
|
||||
"object_type": "datacenter|ixp|submarine_cable|asn|city|facility|satellite",
|
||||
"current_location": {
|
||||
"lat": 0,
|
||||
"lon": 0
|
||||
},
|
||||
"suggested_location": {
|
||||
"lat": 0,
|
||||
"lon": 0
|
||||
},
|
||||
"confidence": 0.82,
|
||||
"reason": "short evidence-backed explanation",
|
||||
"evidence": [
|
||||
{
|
||||
"title": "source title",
|
||||
"url": "https://example.com/source",
|
||||
"quote": "short supporting excerpt",
|
||||
"retrieved_at": "2026-05-10T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"needs_human_review": true
|
||||
}
|
||||
```
|
||||
|
||||
The LLM may generate a suggestion, but it must not directly write final
|
||||
coordinates into the dimension tables.
|
||||
|
||||
|
||||
## Phase 2: Backend Tool Registry
|
||||
|
||||
Add a small Python tool interface in the backend:
|
||||
|
||||
```python
|
||||
class ToolResult(BaseModel):
|
||||
ok: bool
|
||||
data: Any = None
|
||||
error: str | None = None
|
||||
evidence: list[dict] = []
|
||||
```
|
||||
|
||||
Register tools through a backend registry:
|
||||
|
||||
```text
|
||||
web_search
|
||||
web_fetch
|
||||
geo_resolve
|
||||
internal_data_query
|
||||
incident_query
|
||||
evidence_store
|
||||
```
|
||||
|
||||
Do not put WebSearch inside `aiprovider`.
|
||||
|
||||
Reasons:
|
||||
|
||||
- search is a business tool, not a model-provider feature
|
||||
- search evidence must be stored and audited by the backend
|
||||
- different LLM providers should share the same search pipeline
|
||||
- Planet may switch between Tavily, Brave, Exa, SearXNG, or MiniMax MCP without
|
||||
changing model transport
|
||||
|
||||
The first WebSearch implementation should be an HTTP evidence provider. Tavily is
|
||||
the recommended first default because it is simple to call from the existing
|
||||
`httpx` backend stack and returns LLM/RAG-friendly search results. The interface
|
||||
should remain provider-neutral so Brave, Exa, SearXNG, or MiniMax MCP can be
|
||||
added later.
|
||||
|
||||
WebSearch configuration should live under PostgreSQL `system_settings` with the
|
||||
rest of external integrations:
|
||||
|
||||
```text
|
||||
external_integrations.web_search
|
||||
enabled
|
||||
provider
|
||||
api_key
|
||||
base_url
|
||||
max_results
|
||||
timeout_seconds
|
||||
```
|
||||
|
||||
Secret resolution should follow the existing settings pattern:
|
||||
|
||||
1. saved PostgreSQL secret
|
||||
2. provider-specific environment variable, for example `TAVILY_API_KEY`
|
||||
3. generic fallback `WEB_SEARCH_API_KEY`
|
||||
|
||||
### Common WebSearch Providers
|
||||
|
||||
The first implementation should model WebSearch as a provider-specific adapter
|
||||
behind one internal interface:
|
||||
|
||||
```text
|
||||
SearchEvidenceProvider.search(query, max_results, domains, freshness_days)
|
||||
-> list[SearchEvidence]
|
||||
```
|
||||
|
||||
Recommended provider ids and environment variables:
|
||||
|
||||
| Provider | Provider id | Env key | Default base URL | Primary use |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Tavily | `tavily` | `TAVILY_API_KEY` | `https://api.tavily.com` | Default hosted search for agent/RAG style results |
|
||||
| Brave Search API | `brave` | `BRAVE_SEARCH_API_KEY` | `https://api.search.brave.com` | Independent web index and low-level SERP results |
|
||||
| SerpAPI | `serpapi` | `SERPAPI_API_KEY` | `https://serpapi.com` | Search-engine-backed SERP data with engine options |
|
||||
| Exa | `exa` | `EXA_API_KEY` | `https://api.exa.ai` | Neural/semantic web search and result contents |
|
||||
| Firecrawl Search / Scrape | `firecrawl` | `FIRECRAWL_API_KEY` | `https://api.firecrawl.dev` | Search plus page scrape/markdown extraction |
|
||||
| SearXNG | `searxng` | optional `SEARXNG_API_KEY` | self-hosted instance URL | Self-hosted metasearch when external search APIs are undesirable |
|
||||
|
||||
The normalized configuration should support per-provider defaults while keeping
|
||||
one active provider:
|
||||
|
||||
```text
|
||||
external_integrations.web_search
|
||||
enabled: true
|
||||
default_provider: tavily
|
||||
providers:
|
||||
tavily:
|
||||
base_url: https://api.tavily.com
|
||||
api_key: <secret>
|
||||
max_results: 5
|
||||
search_depth: basic
|
||||
include_answer: false
|
||||
include_raw_content: false
|
||||
brave:
|
||||
base_url: https://api.search.brave.com
|
||||
api_key: <secret>
|
||||
endpoint_path: /res/v1/web/search
|
||||
max_results: 5
|
||||
serpapi:
|
||||
base_url: https://serpapi.com
|
||||
api_key: <secret>
|
||||
endpoint_path: /search.json
|
||||
engine: google
|
||||
max_results: 5
|
||||
exa:
|
||||
base_url: https://api.exa.ai
|
||||
api_key: <secret>
|
||||
endpoint_path: /search
|
||||
max_results: 5
|
||||
include_text: false
|
||||
firecrawl:
|
||||
base_url: https://api.firecrawl.dev
|
||||
api_key: <secret>
|
||||
search_path: /v2/search
|
||||
scrape_path: /v2/scrape
|
||||
max_results: 5
|
||||
scrape_formats: [markdown]
|
||||
searxng:
|
||||
base_url: http://localhost:8080
|
||||
api_key: <optional secret>
|
||||
endpoint_path: /
|
||||
max_results: 5
|
||||
categories: general
|
||||
engines: []
|
||||
```
|
||||
|
||||
Adapter notes:
|
||||
|
||||
- Tavily should call `/search` and normalize title, URL, snippet/content, score,
|
||||
and optional raw content.
|
||||
- Brave should call `/res/v1/web/search` and map web results into the same
|
||||
`SearchEvidence` shape.
|
||||
- SerpAPI should call `/search.json`, pass `engine`, and normalize organic
|
||||
results. Search-engine-specific fields should remain in provider metadata.
|
||||
- Exa should call `/search`; optional result text should be treated as fetched
|
||||
content only when enabled.
|
||||
- Firecrawl can be used both as `web_search` and `web_fetch`: `/v2/search`
|
||||
returns result URLs/descriptions and may include scrape options, while
|
||||
`/v2/scrape` can produce markdown for a selected URL.
|
||||
- SearXNG should query the configured instance with `q` and `format=json`.
|
||||
Public instances should not be assumed reliable for production; a controlled
|
||||
self-hosted instance is preferred.
|
||||
|
||||
The settings UI should expose only provider, base URL, key, max results, and a
|
||||
test button in the first version. Provider-specific advanced fields can stay
|
||||
collapsed or backend-only until a real workflow needs them.
|
||||
|
||||
### Frontend Configuration Window
|
||||
|
||||
Add a WebSearch configuration panel to the existing settings page, next to the
|
||||
LLM provider configuration. It should behave like the current AI provider secret
|
||||
controls: clear configured state, masked preview, explicit show/hide, test
|
||||
connection, and save feedback.
|
||||
|
||||
First-version visible fields:
|
||||
|
||||
```text
|
||||
WebSearch Provider
|
||||
API Base URL
|
||||
API Key
|
||||
Max Results
|
||||
Timeout Seconds
|
||||
Enable WebSearch
|
||||
Test Connection
|
||||
Save
|
||||
```
|
||||
|
||||
Provider dropdown options:
|
||||
|
||||
```text
|
||||
Tavily
|
||||
Brave Search API
|
||||
SerpAPI
|
||||
Exa
|
||||
Firecrawl Search / Scrape
|
||||
SearXNG
|
||||
```
|
||||
|
||||
Field behavior:
|
||||
|
||||
- Switching provider loads that provider's saved config and masked key preview.
|
||||
- Empty key input means keep the existing saved or environment key.
|
||||
- Typing a new key replaces only the selected provider's key.
|
||||
- Show key reveals the full current input value when the backend reveal endpoint
|
||||
allows it; hide key returns to the prefix-preserving masked preview.
|
||||
- The configured badge should only show `已配置` or `未配置`, not repeat the
|
||||
masked key text.
|
||||
- `Test Connection` sends the current unsaved draft to the backend and should
|
||||
not require a separate save first.
|
||||
- A successful test may save the draft as the new WebSearch default only if the
|
||||
API endpoint is explicitly designed to mirror the AI provider test behavior.
|
||||
Otherwise, test should be read-only and the Save button should persist.
|
||||
- Save success and test success must show visible feedback. Failures should show
|
||||
provider-specific but secret-safe error messages.
|
||||
|
||||
Provider-specific UI hints:
|
||||
|
||||
| Provider | UI hint |
|
||||
| --- | --- |
|
||||
| Tavily | Good default for agent/RAG style search. |
|
||||
| Brave Search API | Uses Brave's independent search index. |
|
||||
| SerpAPI | Supports search-engine-specific parameters such as `engine`. |
|
||||
| Exa | Good for semantic search and optional result text. |
|
||||
| Firecrawl | Can search and scrape pages into markdown. |
|
||||
| SearXNG | Requires a reachable self-hosted or trusted instance URL. |
|
||||
|
||||
Advanced fields can live in a collapsed section:
|
||||
|
||||
```text
|
||||
Endpoint Path
|
||||
Search Depth
|
||||
Engine
|
||||
Categories
|
||||
Engines
|
||||
Include Raw Content
|
||||
Scrape Formats
|
||||
Domain Allowlist
|
||||
```
|
||||
|
||||
The first version should keep the UI conservative. It should not expose every
|
||||
provider knob until backend workflows use those knobs.
|
||||
|
||||
### Web Fetch and Page Extraction
|
||||
|
||||
`web_fetch` is separate from `web_search`. Search finds candidate URLs; fetch
|
||||
turns selected pages into clean, citable evidence.
|
||||
|
||||
Recommended extraction chain:
|
||||
|
||||
```text
|
||||
1. plain httpx fetch
|
||||
2. trafilatura extraction for static HTML
|
||||
3. readability extraction as secondary cleanup
|
||||
4. Playwright fetch only for allowlisted JS-heavy pages
|
||||
5. Firecrawl scrape as hosted fallback when configured
|
||||
```
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Use `trafilatura` as the first local extractor because it is Python-native and
|
||||
matches the backend stack.
|
||||
- Prefer a Python readability implementation for local cleanup. Do not introduce
|
||||
a Node-only readability dependency for backend fetch.
|
||||
- Use Playwright sparingly for JavaScript-rendered pages. It should have domain
|
||||
allowlists, low concurrency, strict timeouts, response size limits, and no
|
||||
automatic form submission or login behavior.
|
||||
- Store `content_hash`, `retrieved_at`, final URL, title, extracted text
|
||||
preview, and extractor name in `ai_evidence`.
|
||||
- Keep short quotes for UI review, but do not store huge page bodies directly in
|
||||
every task record. Large extracted content should be truncated or stored once
|
||||
by hash.
|
||||
|
||||
The local/self-hosted stack should look like this:
|
||||
|
||||
```text
|
||||
SearXNG
|
||||
-> SearchEvidence URLs
|
||||
-> httpx fetch
|
||||
-> trafilatura / readability
|
||||
-> Playwright only when static extraction fails and the domain is allowed
|
||||
-> normalized evidence
|
||||
-> LLM structured output through AIProviderClient
|
||||
```
|
||||
|
||||
This route gives Planet a lower-cost and more controllable search path, while
|
||||
hosted providers remain available when search quality or maintenance effort
|
||||
matters more than self-hosting.
|
||||
|
||||
|
||||
## Phase 3: Limited Agent Loop
|
||||
|
||||
After the fixed workflows are stable, the backend can add a limited agent loop:
|
||||
|
||||
```text
|
||||
LLM sees an allowed tool list
|
||||
-> LLM requests a tool call
|
||||
-> backend validates and executes the tool
|
||||
-> tool result is added to context
|
||||
-> LLM continues
|
||||
-> final structured output after at most N steps
|
||||
```
|
||||
|
||||
Guardrails:
|
||||
|
||||
- max tool steps: 3 to 5
|
||||
- only read-only tools may run automatically
|
||||
- writes go to pending review first
|
||||
- all web evidence must be persisted
|
||||
- all final outputs must pass schema validation
|
||||
- prompts must include explicit evidence boundaries
|
||||
|
||||
Permission levels:
|
||||
|
||||
```text
|
||||
L0: pure analysis, no tools
|
||||
L1: read-only tools, web_search / web_fetch / internal_query
|
||||
L2: proposal generation, write pending review records
|
||||
L3: low-risk notifications and briefs
|
||||
L4: database mutation or alert triggering, human confirmation required
|
||||
```
|
||||
|
||||
|
||||
## Situational Awareness Boundary
|
||||
|
||||
Planet's situational-awareness layer should not rely on the LLM as the primary
|
||||
risk engine.
|
||||
|
||||
Use deterministic analysis for:
|
||||
|
||||
- anomaly type
|
||||
- affected prefixes
|
||||
- affected ASNs
|
||||
- geographic scope
|
||||
- duration
|
||||
- severity score
|
||||
- confidence
|
||||
- related events
|
||||
- raw evidence
|
||||
|
||||
Use the LLM for:
|
||||
|
||||
- readable summaries
|
||||
- risk explanation
|
||||
- likely impact narrative
|
||||
- next recommended actions
|
||||
- missing data requests
|
||||
|
||||
In short:
|
||||
|
||||
```text
|
||||
deterministic services compute the score
|
||||
LLM explains the evidence and options
|
||||
```
|
||||
|
||||
Proactive alerts should be triggered by deterministic rules or scheduled jobs,
|
||||
then optionally summarized by the Agent Orchestrator.
|
||||
|
||||
|
||||
## Persistence Model
|
||||
|
||||
Add lightweight persistence for auditability:
|
||||
|
||||
```text
|
||||
ai_tasks
|
||||
id
|
||||
task_type
|
||||
status
|
||||
input_json
|
||||
output_json
|
||||
model
|
||||
created_at
|
||||
finished_at
|
||||
error
|
||||
|
||||
ai_evidence
|
||||
id
|
||||
task_id
|
||||
source_type
|
||||
title
|
||||
url
|
||||
snippet
|
||||
content_hash
|
||||
retrieved_at
|
||||
credibility_score
|
||||
|
||||
ai_briefs
|
||||
id
|
||||
brief_type
|
||||
severity
|
||||
title
|
||||
summary
|
||||
evidence_ids
|
||||
related_entity_ids
|
||||
created_at
|
||||
acknowledged_at
|
||||
|
||||
ai_location_suggestions
|
||||
id
|
||||
object_type
|
||||
object_id
|
||||
old_lat
|
||||
old_lon
|
||||
new_lat
|
||||
new_lon
|
||||
confidence
|
||||
reason
|
||||
evidence_ids
|
||||
status
|
||||
```
|
||||
|
||||
The tables can be introduced incrementally. The first implementation may start
|
||||
with `ai_tasks` and `ai_evidence`, then add specialized tables when the UI needs
|
||||
review queues and acknowledgement state.
|
||||
|
||||
|
||||
## MVP Scope
|
||||
|
||||
The MVP should deliver three fixed capabilities:
|
||||
|
||||
### 1. Tutorial Refresh
|
||||
|
||||
Input:
|
||||
|
||||
- provider or tutorial topic
|
||||
- current tutorial text
|
||||
- known stale point, when available
|
||||
|
||||
Tools:
|
||||
|
||||
- `web_search`
|
||||
- `web_fetch`
|
||||
|
||||
Output:
|
||||
|
||||
- updated Markdown
|
||||
- source list
|
||||
- verification status
|
||||
|
||||
### 2. Geo Correction
|
||||
|
||||
Input:
|
||||
|
||||
- object id
|
||||
- object name
|
||||
- object type
|
||||
- current coordinates
|
||||
- source description
|
||||
|
||||
Tools:
|
||||
|
||||
- `web_search`
|
||||
- `web_fetch`
|
||||
- `geo_resolve`
|
||||
|
||||
Output:
|
||||
|
||||
- `LocationCorrection` JSON
|
||||
- evidence list
|
||||
- pending review candidate
|
||||
|
||||
### 3. Situation Brief
|
||||
|
||||
Input:
|
||||
|
||||
- anomaly event
|
||||
- deterministic findings
|
||||
- internal data summary
|
||||
|
||||
Tools:
|
||||
|
||||
- `internal_data_query`
|
||||
- optional `web_search`
|
||||
|
||||
Output:
|
||||
|
||||
- `SituationBrief` JSON
|
||||
- risk explanation
|
||||
- recommended actions
|
||||
- missing evidence list
|
||||
|
||||
|
||||
## Test Plan
|
||||
|
||||
Backend tests:
|
||||
|
||||
- WebSearch settings persist to `system_settings` and mask secrets in API responses.
|
||||
- Env fallback resolves provider-specific keys before `WEB_SEARCH_API_KEY`.
|
||||
- WebSearch provider normalizes success, empty results, 401, 429, and timeout responses.
|
||||
- `tutorial_refresh` uses evidence when available and marks output unverified when no evidence exists.
|
||||
- `geo_correction` returns pending review candidates and never writes final coordinates directly.
|
||||
- `situation_brief` accepts deterministic findings and returns schema-valid summaries.
|
||||
- Agent outputs fail closed when schema validation fails.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- WebSearch settings card shows configured state, masked key, connection test result, and save feedback.
|
||||
- Candidate review UI can display evidence links and pending location suggestions.
|
||||
- Situation brief UI can show evidence-backed summaries without exposing raw secrets.
|
||||
|
||||
Regression tests:
|
||||
|
||||
- existing `aiprovider` status and analysis calls remain unchanged
|
||||
- current LLM provider configuration remains the global model source
|
||||
- location pipeline tests continue to pass
|
||||
- datasource credential guide tests continue to pass
|
||||
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `aiprovider` remains model-adapter-only.
|
||||
- Backend tools are implemented directly in Python first; MCP support is optional and later.
|
||||
- Search is evidence collection, not model transport.
|
||||
- Writes to important domain tables require human confirmation.
|
||||
- Deterministic analysis owns risk scores; LLM output is explanatory and evidence-backed.
|
||||
384
docs/plans/custom-source-live-mock-plan.md
Normal file
384
docs/plans/custom-source-live-mock-plan.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Custom Source Live Mock 计划
|
||||
|
||||
**状态**:实施中
|
||||
**创建日期**:2026-05-01
|
||||
**任务名**:`Custom Source Live Mock`
|
||||
**核心目标**:把自定义源升级为同时支持 REST 与 WebSocket 的可映射采集入口,并提供本地 AIS mock WebSocket 服务,用于验证 Earth 船只实时新增与 upsert 链路。
|
||||
|
||||
## 背景
|
||||
|
||||
真实 AIS 接口变化频率不可控,无法稳定验证 Earth 页面“不刷新也能看到新船只”的实时链路。当前系统已经有自定义源基础设施:
|
||||
|
||||
- `datasource_configs` 保存 endpoint、auth、headers、config。
|
||||
- `datasource_mapping_templates` 保存目标 schema 的确定性映射模板。
|
||||
- `run-mapped` 支持保存后的自定义 REST 源通过 active mapping 写入目标数据。
|
||||
|
||||
但现有能力主要面向 REST sample 和批量 mapping,缺少以下能力:
|
||||
|
||||
- 自定义源不能明确选择 `REST` 或 `WebSocket` 采集模式。
|
||||
- WebSocket 长连接、订阅消息、重连、消息路径提取还没有通用 runtime。
|
||||
- `vessel_ais` 自定义数据写入后需要进入 AIS raw observation 和 `vessels` WS channel,才能真实验证 Earth 实时 upsert。
|
||||
- 删除自定义源时没有清晰的数据清理选项。
|
||||
- 设置中心里“采集调度 / 凭证 / 自定义源”入口混杂,用户很难判断该在哪里配置。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项目 | 决策 |
|
||||
|-----|------|
|
||||
| 计划名称 | `Custom Source Live Mock` |
|
||||
| 自定义源传输类型 | 支持 `REST` 与 `WebSocket` |
|
||||
| 采集写入方式 | 先映射到目标 schema,再由 destination handler 写入 |
|
||||
| AIS mock 目标 | 优先打通 `vessel_ais`,验证 Earth 船只实时新增和同 MMSI upsert |
|
||||
| mock 服务 runtime | 使用 `bun` 启动本地 mock WS 服务 |
|
||||
| 凭证配置 | 支持 headers、bearer、api key、basic,并保留 query/header API key 位置配置 |
|
||||
| 删除策略 | 删除自定义源时允许选择是否删除该源写入的数据 |
|
||||
| 合并语义 | 自定义源必须选择“合并到哪个内置数据”,作为内置源的补充数据进入同一聚合链路 |
|
||||
| UI 方向 | 自定义源创建和维护放在“配置中心 > 采集器设置”的采集器下拉框内联入口;数据源页保留总览与运行控制 |
|
||||
|
||||
## 范围
|
||||
|
||||
### 本阶段要做
|
||||
|
||||
- 自定义源可选择 `REST` 或 `WebSocket`。
|
||||
- 自定义源支持请求头、凭证、query params、body、WS subscribe message。
|
||||
- WebSocket 自定义源支持长连接、重连、消息解析、mapping、写入。
|
||||
- `vessel_ais` 自定义源写入 AIS raw observations,并广播 `vessels` channel。
|
||||
- 提供 mock AIS WS 服务,持续发送新增 MMSI 和位置变更。
|
||||
- 删除自定义源时提供“是否删除该源数据”的选项。
|
||||
- 梳理设置中心信息架构,明确后续 UI 重构方向。
|
||||
|
||||
### 暂不做
|
||||
|
||||
- 不新增任意动态数据库表。
|
||||
- 不允许用户提交可执行脚本作为 mapping。
|
||||
- 不让 LLM 进入正式采集链路。
|
||||
- 不把 mock 数据直接写 legacy `vessel_position`,优先写 AIS raw observations,保持可追踪和可删除。
|
||||
- 不在本阶段完成完整 `Earth Live Sync`,但要为后续 summary invalidation 留出 hook。
|
||||
|
||||
## 现状入口
|
||||
|
||||
| 能力 | 当前位置 |
|
||||
|-----|----------|
|
||||
| 自定义源配置模型 | `backend/app/models/datasource_config.py` |
|
||||
| 自定义源 mapping 模型 | `backend/app/models/datasource_mapping.py` |
|
||||
| 自定义源 API | `backend/app/api/v1/datasource_config.py` |
|
||||
| 目标 schema registry | `backend/app/core/target_schema_registry.py` |
|
||||
| mapping engine | `backend/app/services/datasource_mapping.py` |
|
||||
| 数据源总览 UI | `frontend/src/pages/DataSources/DataSources.tsx` |
|
||||
| 采集器设置 UI | `frontend/src/pages/Settings/Settings.tsx` |
|
||||
|
||||
## 目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Custom Source Config] --> B{source_type}
|
||||
B -->|rest| C[Mapped REST Runner]
|
||||
B -->|websocket| D[Mapped WS Runner]
|
||||
C --> E[Mapping Engine]
|
||||
D --> E
|
||||
E --> F[Target Schema Validator]
|
||||
F --> G{Destination Handler}
|
||||
G -->|vessel_ais| H[AIS Raw Observations]
|
||||
H --> I[AIS Aggregation]
|
||||
H --> J[vessels WS Channel]
|
||||
J --> K[Earth Vessel Upsert]
|
||||
```
|
||||
|
||||
## 数据配置设计
|
||||
|
||||
短期可以继续复用 `DataSourceConfig`,避免大迁移。语义约定如下:
|
||||
|
||||
| 字段 | 用途 |
|
||||
|-----|------|
|
||||
| `name` | 自定义源唯一名称,例如 `mock_ais_ws` |
|
||||
| `source_type` | `rest` 或 `websocket` |
|
||||
| `endpoint` | `http(s)://...` 或 `ws(s)://...` |
|
||||
| `auth_type` | `none`、`bearer`、`api_key`、`basic` |
|
||||
| `auth_config` | token、api_key、key name、basic username/password 等 |
|
||||
| `headers` | 静态请求头 |
|
||||
| `config` | method、params、body、timeout、retry、WS 订阅消息、重连策略、消息路径等 |
|
||||
|
||||
建议 `config` 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"transport": "websocket",
|
||||
"delivery_mode": "realtime_stream",
|
||||
"merge_target_source": "barentswatch_vessels",
|
||||
"target_schema": "vessel_ais",
|
||||
"method": "GET",
|
||||
"params": {},
|
||||
"body": null,
|
||||
"timeout": 30,
|
||||
"retry": 3,
|
||||
"ws_subscribe_message": {"type": "subscribe", "channel": "vessels"},
|
||||
"ws_message_path": "$.data",
|
||||
"ws_items_path": "$.vessels[*]",
|
||||
"ws_reconnect": true,
|
||||
"reconnect_delay_seconds": 3,
|
||||
"debug_max_messages": null,
|
||||
"delete_policy": "config_only"
|
||||
}
|
||||
```
|
||||
|
||||
## 后端实施计划
|
||||
|
||||
### Phase 1 — 自定义源类型与连接测试
|
||||
|
||||
- 允许 `source_type` 为 `rest` 或 `websocket`。
|
||||
- REST 连接测试保留现有 HTTP 请求逻辑。
|
||||
- WebSocket 连接测试新增:
|
||||
- 校验 endpoint 必须是 `ws://` 或 `wss://`。
|
||||
- 注入 headers 和 auth。
|
||||
- 连接后可选发送 `ws_subscribe_message`。
|
||||
- 读取一条消息或超时返回诊断。
|
||||
|
||||
### Phase 2 — Mapped REST Runner 补齐
|
||||
|
||||
现有 `run-mapped` 继续作为 REST 一次性采集入口,补齐:
|
||||
|
||||
- `GET/POST` method。
|
||||
- query params。
|
||||
- JSON body。
|
||||
- headers 和 auth 注入。
|
||||
- sample limit 与响应大小限制。
|
||||
- `vessel_ais` destination handler。
|
||||
|
||||
### Phase 3 — Mapped WebSocket Runner
|
||||
|
||||
新增通用 WebSocket runner,读取 `DataSourceConfig + active mapping`:
|
||||
|
||||
- 建立长连接。
|
||||
- 发送可选订阅消息。
|
||||
- 循环接收消息。
|
||||
- JSON parse。
|
||||
- 按 `ws_message_path/ws_items_path` 提取 item 或 list。
|
||||
- 使用 mapping engine 转换。
|
||||
- 使用 target schema validator 校验。
|
||||
- 调用 destination handler 写入。
|
||||
- 更新采集任务状态:
|
||||
- `connecting`
|
||||
- `streaming`
|
||||
- `reconnecting`
|
||||
- `stopped`
|
||||
- 维护运行指标:
|
||||
- `messages_seen`
|
||||
- `records_written`
|
||||
- `unique_entities`
|
||||
- `last_message_at`
|
||||
- `last_error`
|
||||
- 后台长连接不读取 `config.debug_max_messages`;该字段只用于显式的一次性调试运行,避免正式 WS 流被测试上限截断。
|
||||
|
||||
### Phase 4 — Destination Handler
|
||||
|
||||
为 target schema 建立明确写入处理器。
|
||||
|
||||
`vessel_ais` handler:
|
||||
|
||||
- 写入 `AISRawObservation`。
|
||||
- `source = datasource.name`。
|
||||
- `delivery_mode` 来自 config,默认 WS 为 `realtime_stream`、REST 为 `polling`。
|
||||
- `transport` 来自 `source_type`。
|
||||
- 生成幂等 observation hash。
|
||||
- 更新 AIS source health。
|
||||
- 广播 `vessels` channel,payload 使用当前 Earth 已支持的 upsert 格式。
|
||||
|
||||
`generic_records` handler:
|
||||
|
||||
- 写入通用 collected data 或后续 generic store。
|
||||
- 不直接进入 Earth。
|
||||
|
||||
### Phase 5 — 删除与数据清理
|
||||
|
||||
删除自定义源时新增清理策略:
|
||||
|
||||
| 选项 | 行为 |
|
||||
|-----|------|
|
||||
| 只删除配置 | 删除 `datasource_configs`,保留 mapping 和历史数据需要另行处理 |
|
||||
| 删除配置和 mapping | 删除配置及对应 `datasource_mapping_templates` |
|
||||
| 删除配置、mapping 和该源数据 | 同时删除该源写入的数据 |
|
||||
|
||||
数据删除范围:
|
||||
|
||||
- `collected_data.source == datasource.name`
|
||||
- `ais_raw_observations.source == datasource.name`
|
||||
- `ais_source_health.source == datasource.name`
|
||||
|
||||
不建议直接删除 legacy `vessel_position`,因为当前 legacy 表不带 source,无法安全归因。自定义 AIS 源应优先只写 raw observations。
|
||||
|
||||
删除数据后应触发:
|
||||
|
||||
- `vessels` channel 的 reload/invalidation 事件,提示 Earth 重新拉船只聚合。
|
||||
- 后续接入 `Earth Live Sync` 后,触发 `earth_summary` invalidation。
|
||||
|
||||
### Phase 6 — Mock AIS WebSocket 服务
|
||||
|
||||
新增脚本:
|
||||
|
||||
`scripts/mock-ais-ws-server.ts`
|
||||
|
||||
运行方式建议:
|
||||
|
||||
```bash
|
||||
bun run mock:ais-ws
|
||||
```
|
||||
|
||||
服务行为:
|
||||
|
||||
- 监听 `ws://localhost:8787/ais`。
|
||||
- 接受任意客户端连接。
|
||||
- 可记录收到的 subscribe message。
|
||||
- 每 1-2 秒发送一条 AIS-like JSON。
|
||||
- 每隔 N 条生成新 MMSI,验证船只数量增长。
|
||||
- 已存在 MMSI 随时间改变 `lat/lon/cog/heading`,验证同 MMSI upsert。
|
||||
- 支持固定 seed,保证测试可复现。
|
||||
|
||||
示例 payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "vessel",
|
||||
"data": {
|
||||
"mmsi": "999000001",
|
||||
"name": "MOCK VESSEL 001",
|
||||
"lat": 31.23,
|
||||
"lon": 121.47,
|
||||
"sog": 12.4,
|
||||
"cog": 86,
|
||||
"heading": 90,
|
||||
"received_at": "2026-05-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 前端实施计划
|
||||
|
||||
### 信息架构调整
|
||||
|
||||
自定义源不作为割裂的新入口,而是作为内置采集器的补充源,直接纳入“配置中心 > 采集器设置”的采集器选择器:
|
||||
|
||||
- 采集器下拉框同时展示内置采集器和自定义补充源。
|
||||
- 下拉框右侧提供加号按钮,用于添加自定义源。
|
||||
- 新建自定义源时必须选择“合并到内置数据”,例如合并到 `barentswatch_vessels`。
|
||||
- 选择自定义源后,右侧基础配置区域沿用正常采集器配置形态,支持连接测试、保存、endpoint、headers、auth、高级 JSON。
|
||||
- 自定义源比内置源多一个“删除自定义源”按钮。
|
||||
- 删除时弹出确认框,可勾选“同时删除该自定义源生成的所有数据”。
|
||||
|
||||
数据源页保留:
|
||||
|
||||
- 内置源总览。
|
||||
- 内置源最近状态。
|
||||
- 内置源手动触发。
|
||||
- 不展示自定义源管理入口;自定义源创建、维护、删除统一在采集器设置中完成。
|
||||
|
||||
### 自定义源表单
|
||||
|
||||
新增或重构自定义源表单:
|
||||
|
||||
- 源名称。
|
||||
- 类型:`REST` / `WebSocket`。
|
||||
- 合并到内置数据:必选,用于声明该源补充哪个内置数据域。
|
||||
- endpoint。
|
||||
- method/body/params,仅 REST 显示。
|
||||
- subscribe message/message path/items path,仅 WS 显示。
|
||||
- auth type。
|
||||
- headers。
|
||||
- target schema。
|
||||
- sample/test 按钮。
|
||||
- mapping assistant/preview。
|
||||
- 保存并运行。
|
||||
|
||||
### 删除确认
|
||||
|
||||
删除自定义源时弹出确认:
|
||||
|
||||
- 默认只删除配置。
|
||||
- 可勾选删除 mapping。
|
||||
- 可勾选删除该源写入的数据。
|
||||
- 显示将删除的数据范围和不可恢复提示。
|
||||
|
||||
## 验证方案
|
||||
|
||||
### Mock WS 验证路径
|
||||
|
||||
1. 启动 mock 服务:
|
||||
|
||||
```bash
|
||||
bun run mock:ais-ws
|
||||
```
|
||||
|
||||
2. 新建自定义源:
|
||||
|
||||
| 字段 | 值 |
|
||||
|-----|----|
|
||||
| name | `mock_ais_ws` |
|
||||
| source_type | `websocket` |
|
||||
| endpoint | `ws://localhost:8787/ais` |
|
||||
| merge_target_source | `barentswatch_vessels` |
|
||||
| target_schema | `vessel_ais` |
|
||||
| ws_message_path | `$.data` |
|
||||
|
||||
3. 保存 active mapping:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"items_path": "$"
|
||||
},
|
||||
"fields": {
|
||||
"mmsi": {"path": "$.mmsi", "type": "integer"},
|
||||
"name": {"path": "$.name", "type": "string"},
|
||||
"lat": {"path": "$.lat", "type": "float"},
|
||||
"lon": {"path": "$.lon", "type": "float"},
|
||||
"sog": {"path": "$.sog", "type": "float", "default": null},
|
||||
"cog": {"path": "$.cog", "type": "float", "default": null},
|
||||
"heading": {"path": "$.heading", "type": "integer", "default": null},
|
||||
"received_at": {"path": "$.received_at", "type": "datetime", "default": null}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 启动自定义源。
|
||||
|
||||
5. 打开 Earth 船只图层,不刷新页面观察:
|
||||
|
||||
- `vessels` WS channel 收到 `source = mock_ais_ws`。
|
||||
- HUD 船只数在新 MMSI 到达时增加。
|
||||
- 地球出现 `MOCK VESSEL`。
|
||||
- 同 MMSI 后续消息更新位置和航向,不重复叠加。
|
||||
|
||||
### 自动化测试
|
||||
|
||||
后端测试:
|
||||
|
||||
- WebSocket 自定义源连接测试。
|
||||
- WS message path 和 items path 提取。
|
||||
- mapping 到 `vessel_ais`。
|
||||
- 写入 AIS raw observation。
|
||||
- 广播 `vessels` channel。
|
||||
- 删除自定义源时按策略删除 mapping 和源数据。
|
||||
|
||||
前端测试:
|
||||
|
||||
- REST/WS 表单条件显示。
|
||||
- 删除确认选项。
|
||||
- mock 源配置保存 payload。
|
||||
- mapping preview 展示错误和成功记录。
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- WebSocket 自定义源是长连接,不能沿用一次性 REST 进度条。
|
||||
- 如果 mock 源写 legacy vessel 表,删除会变得不安全,因此先只写 raw observations。
|
||||
- 自定义 WS 可能消息量很大,必须有 backpressure、日志限流和任务取消能力。
|
||||
- 任意外部 WS 不能信任 payload,必须经过 mapping 和 schema validation。
|
||||
- headers/auth 不能进入 LLM mapping prompt。
|
||||
|
||||
## 交付顺序
|
||||
|
||||
1. Mock AIS WS 服务。
|
||||
2. 后端自定义 WS runner。
|
||||
3. `vessel_ais` destination handler 和 `vessels` broadcast。
|
||||
4. 删除自定义源及数据清理。
|
||||
5. 设置中心采集器下拉框内联自定义源 UI。
|
||||
6. 配置中心信息架构重整。
|
||||
7. 与 `Earth Live Sync` 对接 summary invalidation。
|
||||
146
docs/plans/data-products-layer-guard-redesign-plan.md
Normal file
146
docs/plans/data-products-layer-guard-redesign-plan.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 数据产品流水线、数据源批量运维与抗击穿图层接口计划
|
||||
|
||||
## Summary
|
||||
|
||||
前端展示分两类数据:
|
||||
|
||||
- **图层数据**:按 viewport、bbox、zoom、limit 返回,可降级、截断、缓存,用来保护服务器。
|
||||
- **聚合面板统计**:必须是全量统计,不受当前 viewport 限制,但不能实时扫全表;通过产品状态表或预计算统计提供。
|
||||
|
||||
也就是说:地图上低 zoom 可以只画摘要或局部数据,但面板里的“总船只数、总海缆数、BGP 活跃事件数、卫星数”等应该代表全局数据产品状态。
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- 已新增 `POST /api/v1/datasources/trigger-batch`,支持按选中 `source_ids` 或筛选条件批量触发,并返回 `triggered/skipped/failed`。
|
||||
- 已改造 `/datasources` 页面,支持产品域、层级、启用状态、最近执行状态、是否已有数据和关键词筛选,并支持复选框批量采集。
|
||||
- 已新增 `/api/v1/data-products` 和 `/api/v1/data-products/{product_id}/status`,聚合面板可以读取全量/全局统计口径。
|
||||
- 已新增 `/api/v1/layers/*` 受控图层接口骨架,要求 `bbox/zoom/limit`,响应包含 `visible_count/returned_count/diagnostics`。
|
||||
- 非船只图层当前先复用已有 GeoJSON 转换再做保护层;下一步应把 cables/BGP/satellites 的 bbox 过滤继续下推到各自产品查询,避免转换前仍加载过多候选。
|
||||
|
||||
## Key Changes
|
||||
|
||||
- 新增数据产品状态/统计层:
|
||||
- 每个产品维护全量统计:总实体数、活跃数、最近更新时间、使用源、缺失源、冲突数、构建状态。
|
||||
- 统计在采集成功或产品投影完成后更新,不在用户打开页面时临时全表聚合。
|
||||
- 前端聚合面板统一读取产品统计接口,而不是从图层返回量推断总数。
|
||||
- 新增接口:
|
||||
- `GET /api/v1/data-products`
|
||||
- `GET /api/v1/data-products/{product_id}/status`
|
||||
- `GET /api/v1/layers/{product}/...`
|
||||
- `POST /api/v1/datasources/trigger-batch`
|
||||
- `/layers/*` 只负责可视化数据:
|
||||
- 支持 bbox、zoom、limit、since。
|
||||
- 可以返回 `degraded`、`truncated`、`cache_hit`。
|
||||
- 返回 `visible_count` 和 `returned_count`,但不作为全量统计来源。
|
||||
- `/data-products/*/status` 负责全量统计:
|
||||
- 返回 `total_count`、`active_count`、`source_counts`、`last_built_at`、`health`。
|
||||
- 数据来自预计算状态或轻量索引统计。
|
||||
- 即使图层降级,统计也保持全量口径。
|
||||
|
||||
## Product Processing
|
||||
|
||||
- 船只:
|
||||
- 图层:bbox snapshot + WS 聚合流,受限返回。
|
||||
- 统计:全量唯一 MMSI、最近窗口活跃 MMSI、AISStream/BarentsWatch/source counts。
|
||||
- 海缆:
|
||||
- 图层:viewport 内 cable segments/landing points,低 zoom 可简化路线。
|
||||
- 统计:全量 cable count、landing point count、relation count、graph 构建状态。
|
||||
- 处理:专用 cable graph assembler,区分路线源、登陆点源、关系源、补充源,不使用统一字段融合函数。
|
||||
- BGP:
|
||||
- 图层:active incidents/anomalies/collectors,按窗口和 limit 返回。
|
||||
- 统计:全量活跃事件、最近 24h/7d 事件数、collector 数、incident/anomaly 分布。
|
||||
- 处理:专用事件流水线,区分 observation、anomaly、incident、geo hint、infrastructure inference。
|
||||
- 卫星:
|
||||
- 图层:可见卫星或受控 limit。
|
||||
- 统计:全量卫星数、最新 TLE epoch、源覆盖情况。
|
||||
- 处理:按 NORAD id 生成轨道快照,TLE epoch 最新优先。
|
||||
|
||||
## Data Source Page
|
||||
|
||||
- 增加筛选:
|
||||
- 产品类型、启用/禁用、最近成功/失败/运行中/未执行、已采集/未采集、凭证状态、文本搜索。
|
||||
- 增加复选框批量操作:
|
||||
- 批量启用、禁用、采集、强制采集。
|
||||
- 一键采集改为:采集全部启用源、采集筛选结果、采集选中源。
|
||||
- 后端 batch 逻辑:
|
||||
- 禁用源 skipped。
|
||||
- 运行中源按 force 处理。
|
||||
- 单个失败不影响其他源。
|
||||
- 返回 `triggered`、`skipped`、`failed`,并包含每个 source 的原因和 task_id。
|
||||
|
||||
## Protection Rules
|
||||
|
||||
- 所有 `/layers/*` 接口必须有保护层:
|
||||
- limit clamp。
|
||||
- bbox/zoom 校验。
|
||||
- 低 zoom 降级。
|
||||
- 短 TTL 缓存。
|
||||
- 慢查询超时。
|
||||
- diagnostics 返回降级原因。
|
||||
- 全量统计不走图层查询:
|
||||
- 不允许为了面板统计在请求时 `.all()` 全量加载。
|
||||
- 统计由采集/投影任务异步更新。
|
||||
- 统计缺失时返回 `unknown` 或 `stale`,不触发重型实时计算。
|
||||
- 缓存失效规则:
|
||||
- 采集成功后失效对应产品缓存。
|
||||
- 海缆 graph cache 在路线、登陆点或关系源成功采集后失效。
|
||||
- BGP incident/anomaly 生成后失效 BGP layer cache。
|
||||
- 船只实时流使用短 TTL 或 viewport 级缓存,不清全局缓存。
|
||||
- 接口观测:
|
||||
- 记录每个 layer endpoint 的耗时、返回数量、是否降级、是否缓存命中、limit 是否被 clamp。
|
||||
- 对高频 viewport 请求增加简单 per-IP 或 per-user rate limit。
|
||||
|
||||
## Frontend UX
|
||||
|
||||
- 数据源页:
|
||||
- 顶部统计可作为快捷筛选入口:全部、启用、禁用、运行中、失败、未采集。
|
||||
- 表格左侧增加复选框。
|
||||
- 工具栏显示“已选择 N 个”,并提供批量按钮。
|
||||
- 筛选结果和选中结果分清楚,避免误触发全部源。
|
||||
- 批量采集完成后弹出摘要:触发、跳过、失败数量,可展开查看原因。
|
||||
- 设置/配置页:
|
||||
- “采集器设置”改为“数据产品配置”。
|
||||
- 产品内按源角色分组展示,而不是简单列出 collector。
|
||||
- 海缆显示路线源、登陆点源、关系源、补充源。
|
||||
- BGP 显示实时观测源、历史回填源、地理 hint 源、检测输出。
|
||||
- 船只显示实时 AIS、轮询 AIS、自定义补充源。
|
||||
- Earth 图层交互:
|
||||
- 聚合面板统计读取 `/data-products/*/status`,保持全量口径。
|
||||
- 图层面板展示当前图层是否降级、截断、缓存命中。
|
||||
- 对象详情展示来源证据:
|
||||
- 船只:字段来源、冲突。
|
||||
- 海缆:路线源、登陆点源、关系源。
|
||||
- BGP:事件证据、分组依据、地理推断依据。
|
||||
- 产品 degraded 时仍显示可用部分,并提示缺失源角色。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 图层接口:
|
||||
- 大 limit 被 clamp。
|
||||
- 低 zoom 降级。
|
||||
- 大数据集不全量内存过滤。
|
||||
- diagnostics 正确说明截断、缓存、降级。
|
||||
- 全量统计:
|
||||
- 面板统计不受 bbox 影响。
|
||||
- 图层返回 1000 条时,产品统计仍显示全量总数。
|
||||
- 统计陈旧时返回 `stale=true` 和 `last_built_at`。
|
||||
- 采集成功后对应产品统计刷新。
|
||||
- 数据源批量:
|
||||
- 筛选、选中、批量采集行为正确。
|
||||
- skipped/failed/triggered 分组正确。
|
||||
- 禁用源在 batch 中被 skipped。
|
||||
- 运行中源按 force 参数处理。
|
||||
- batch 单源失败不阻断整体。
|
||||
- 产品处理:
|
||||
- 海缆缺 relation 时产品状态 degraded,但 cable layer 可用。
|
||||
- BGP observation 不直接变成前端 marker,必须经过 anomaly/incident 投影。
|
||||
- 船只 bbox snapshot 和 WS 节流继续有效。
|
||||
- 卫星列表不返回无限轨道点。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- 前端聚合面板以后只读 `/data-products/*/status`。
|
||||
- 地图图层只读 `/layers/*`。
|
||||
- 统计可以短暂 stale,但不能因实时全量统计击穿服务器。
|
||||
- 保留现有 collector,不为了重构而删除 BarentsWatch 或其他源。
|
||||
- 当前开发阶段允许前端从旧 `/visualization/geo/*` 迁移到 `/layers/*`。
|
||||
116
docs/plans/docs-audience-split-plan.md
Normal file
116
docs/plans/docs-audience-split-plan.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 文档受众分层重构计划
|
||||
|
||||
**状态**:待实施
|
||||
**创建日期**:2026-05-12
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.claude/commands/docs.md`,让以后写文档时自动按受众归档。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `manual.md` 把客户实际使用和开发/运维操作混在一份文档里:开头 200 多行讲的是 `planet.sh start/stop/restart/log/health/createuser/--allow-lan`、AI Provider 镜像构建、`netsh portproxy` 和故障排查顺序,后面才进入 Earth、控制台、AI、Docs 这些客户真正会用到的功能。
|
||||
|
||||
客户读到一半会被 shell 命令吓住,开发者想找运维细节又要在大段 UI 操作里翻。`documentation-coverage-rules.md` 现在也没有受众分层规则,未来文档继续混着写。
|
||||
|
||||
本计划假定客户已经能拿到账号登录使用 — 注册/验证流程本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。
|
||||
|
||||
## 新的文档地形
|
||||
|
||||
| 文档 | 受众 | Gatekeeper 组 | 范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| `manual.md` (zh+en) | 纯客户/最终用户 | `public` | 注册、登录、账户、设置 UI、collector 配置、AI 配置、Console 页面、Earth、Docs 浏览 |
|
||||
| `quickstart.md` (zh+en) | 纯客户 | `public` | "我刚拿到 Planet 怎么开始用" — 打开 URL → 注册 → 验证 → 登录 → 第一次配置 |
|
||||
| `ops-runbook.md` (zh+en, **新增**) | 运维/部署人员 | `docs_admin` | `planet.sh` 完整命令、健康检查、日志位置、LAN/portproxy、故障排查顺序、createuser CLI、Bun 构建约定 |
|
||||
| `ops-planet-sh-startup.md` (已存在) | 运维 | `docs_admin` | 启动性能、AI Provider 镜像、`PLANET_LOAD_ZSHRC_ENV` 深度调优 — 保持不动 |
|
||||
| 现有 `*-context.md` / `backend-*.md` | 二次开发者 | `docs_developer` | 保持现状 |
|
||||
|
||||
`backend-system-service-control.md` 偏后端服务控制原理,**不**和 `ops-runbook.md` 重复 — runbook 讲"运维要敲什么命令",service-control 讲"后端怎么实现服务管控"。
|
||||
|
||||
## manual.md 重写后的章节顺序(客户旅程)
|
||||
|
||||
1. **欢迎与入口** — Planet 是什么、几个入口(Earth 公开 / Console 需登录 / Docs / API)
|
||||
2. **注册账户** — 打开 `/login` → 点"注册" → 填用户名/邮箱/密码 → 收邮件 → 输入 6 位验证码 → 登录
|
||||
3. **登录与找回密码** — 登录页、忘记密码流程
|
||||
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
|
||||
5. **Console 总览** — 左侧菜单结构、各路由用途
|
||||
6. **配置数据采集器** — `/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR)
|
||||
8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件)
|
||||
9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组
|
||||
10. **数据探索** — `/datasources`、`/data`、`/bgp`、`/alerts/*`
|
||||
11. **AI 测试台** — `/ai?tab=playground`
|
||||
12. **Earth 公开页面** — 现 manual.md 的 Earth 章节原样保留(图层、图例、搜索、位置候选、设置、视角、动捕、巡航、移动端)
|
||||
13. **Docs 文档站** — 当前 Docs 章节保留(权限组说明)
|
||||
|
||||
不再出现:`planet.sh`、`./planet.sh log`、`netsh portproxy`、`source ~/.zshrc && bun run build`、"故障排查顺序"、"开发命令约定"。
|
||||
|
||||
## quickstart.md 重写
|
||||
|
||||
当前 quickstart 假设读者会自己 `git clone` 然后 `./planet.sh start`,这是给开发者看的。改为:
|
||||
|
||||
- 打开管理员给你的 URL
|
||||
- 注册账号 + 邮箱验证
|
||||
- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 看 Earth
|
||||
|
||||
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
|
||||
|
||||
## ops-runbook.md 内容大纲
|
||||
|
||||
抽自现 manual.md,重新组织:
|
||||
|
||||
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123`、`linkong/12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`)
|
||||
2. 启停与按模块重启 — `start/stop/restart` 及 `-b -f -a -d`
|
||||
3. 健康检查 — `./planet.sh health`
|
||||
4. 日志 — `./planet.sh log` 及 `-f -b -a`,日志文件路径
|
||||
5. 创建用户(CLI 兜底)— `./planet.sh createuser`;说明这是公开注册不可用(SMTP 未配置)时的兜底
|
||||
6. 局域网/WSL 访问 — `--allow-lan`、`netsh portproxy`、防火墙
|
||||
7. AI Provider 环境变量与构建 — `aiprovider/.env`、`~/.zshrc`、`PLANET_LOAD_ZSHRC_ENV`
|
||||
8. 故障排查顺序 — 现 manual 末尾那段,原样搬来
|
||||
9. 开发命令约定 — Bun、`bun run build`、为什么不用 npm
|
||||
|
||||
## documentation-coverage-rules.md 增量
|
||||
|
||||
在现有"覆盖清单"末尾新增一段:
|
||||
|
||||
> **受众分层(强制)**
|
||||
>
|
||||
> - 客户/最终用户能在浏览器里完成的操作 → 只写到 `manual.md` / `quickstart.md`
|
||||
> - 需要 SSH/shell/Docker/`planet.sh`/日志文件路径/端口转发 → 只写到 `ops-runbook.md`(或现有 `ops-*.md`),**禁止**出现在 manual/quickstart
|
||||
> - 同一动作两种入口(如"创建用户"既能 UI 也能 CLI)→ UI 路径写 manual.md,CLI 路径写 ops-runbook.md,互相用一句话相互引用
|
||||
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
|
||||
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
|
||||
|
||||
## .claude/commands/docs.md 增量
|
||||
|
||||
在 "Step 2 — Decide Scope" 后插一段:
|
||||
|
||||
> **Document Audience Routing (Planet)**
|
||||
>
|
||||
> 在 Planet 仓库内,写文档前先判断动作的执行者:
|
||||
>
|
||||
> - 浏览器 UI 用户 → `docs/technical/{zh,en}/manual.md` / `quickstart.md`
|
||||
> - shell/容器/运维 → `docs/technical/{zh,en}/ops-runbook.md` 或现有 `ops-*.md`
|
||||
> - 二次开发者 → 现有 `*-context.md` / `backend-*.md`
|
||||
>
|
||||
> 永远不要把 shell 命令、日志路径、Docker 操作写进 manual/quickstart;永远不要把 UI 截图/按钮路径写进 ops-*。
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
- `docs/technical/zh/manual.md` & `en/manual.md` — 重写
|
||||
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
|
||||
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
|
||||
- `docs/documentation-coverage-rules.md` — 加受众分层段
|
||||
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
|
||||
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook` 到 `DOCS_METADATA`(`docs_admin` 组)
|
||||
|
||||
## 依赖
|
||||
|
||||
manual.md 的"注册账户"和"登录与找回密码"两章需要前后端注册/验证流程已经落地,否则文档会描述不存在的功能。注册功能本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。建议先实现注册再重写 manual,避免文档与代码错位。
|
||||
|
||||
## 验证
|
||||
|
||||
- `rg -n 'planet\.sh' docs/technical/zh/manual.md docs/technical/en/manual.md docs/technical/zh/quickstart.md docs/technical/en/quickstart.md` 应该为空
|
||||
- `rg -n '注册账户|register|邮箱验证' docs/technical/zh/manual.md docs/technical/en/manual.md` 应该有命中
|
||||
- `rg -n 'planet\.sh' docs/technical/zh/ops-runbook.md docs/technical/en/ops-runbook.md` 应该有命中
|
||||
- `frontend/src/pages/Docs/docs-content.ts` 中 `ops-runbook` 出现且分组为 `docs_admin`
|
||||
- zh/en manual 章节标题对齐(按 `documentation-coverage-rules.md` 现有要求)
|
||||
- Docs 站点访问:未登录看 manual/quickstart 正常;非 `docs_admin` 用户看不到 `ops-runbook`;`admin` 能看到
|
||||
92
docs/plans/docs-gatekeeper-auth-plan.md
Normal file
92
docs/plans/docs-gatekeeper-auth-plan.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Docs Gatekeeper 鉴权系统计划
|
||||
|
||||
**状态**:已实现,当前行为见 [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md)
|
||||
**创建日期**:2026-05-08
|
||||
**核心目标**:把 `/docs` 从前端公开打包 Markdown 改成后端受控读取,并通过用户 Gatekeeper 权限组划分公开文档、用户文档、开发文档和管理/运维文档。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 Docs 页面通过前端 `import.meta.glob(...?raw)` 把 `docs/technical/{zh,en}` 中注册过的 Markdown 直接打进前端 bundle。即使在前端隐藏目录或增加路由守卫,受保护 Markdown 仍可能出现在构建产物中,无法形成真正鉴权。
|
||||
|
||||
本阶段需要把文档正文读取迁到后端,并让后端根据当前用户身份返回可见目录和正文。Earth 仍保持公开访问,其它控制台模块暂不改变既有鉴权。
|
||||
|
||||
## 鉴权模型
|
||||
|
||||
保留现有 `users.role`,新增 `gatekeeper_groups` 作为可叠加的权限组。`role` 继续用于控制台和系统操作;Gatekeeper 只负责 Docs 等内容权限。
|
||||
|
||||
默认权限:
|
||||
|
||||
| 身份 | 默认 Docs 能力 |
|
||||
| --- | --- |
|
||||
| 未登录访客 | `public` |
|
||||
| 普通登录用户 | `public`,以及用户被分配的 Gatekeeper 组 |
|
||||
| `admin` | `docs_admin`,并隐含 `docs_developer` / `docs_user` |
|
||||
| `super_admin` | 全部 Docs 权限 |
|
||||
|
||||
Gatekeeper 组:
|
||||
|
||||
- `docs_user`:登录用户操作类文档。
|
||||
- `docs_developer`:开发、前端、后端、Earth 实现文档。
|
||||
- `docs_admin`:运维、服务控制、凭证、环境变量和敏感操作文档。
|
||||
|
||||
## 初步文档划分
|
||||
|
||||
`public`:
|
||||
|
||||
- `README.md`
|
||||
- `quickstart.md`
|
||||
- `manual.md`
|
||||
|
||||
`docs_developer`:
|
||||
|
||||
- `earth-frontend-context.md`
|
||||
- `earth-interactable-usage.md`
|
||||
- `earth-layer-style-reference.md`
|
||||
- `earth-render-layer-order.md`
|
||||
- `earth-satellite-footprint-policy.md`
|
||||
- `earth-bgp-context.md`
|
||||
- `earth-news-live-streams-collector-format.md`
|
||||
- `earth-toolbar-overlay-coordination.md`
|
||||
- `frontend-admin-frontend-context.md`
|
||||
- `frontend-layout-guidelines.md`
|
||||
- `backend-collectors.md`
|
||||
- `datasource-collector-settings-connectivity.md`
|
||||
- `backend-datasources-api-performance.md`
|
||||
- `agents-aiprovider.md`
|
||||
|
||||
`docs_admin`:
|
||||
|
||||
- `backend-system-service-control.md`
|
||||
- `ops-docker-compose-buildx-upgrade.md`
|
||||
- `ops-planet-sh-startup.md`
|
||||
|
||||
## 实施要点
|
||||
|
||||
后端新增:
|
||||
|
||||
- `GET /api/v1/docs/catalog`:返回当前用户可见文档目录;未登录只返回 `public`。
|
||||
- `GET /api/v1/docs/{lang}/{slug}`:返回单篇 Markdown;未登录访问受保护文档返回 `401`,已登录无权限返回 `403`。
|
||||
- 服务端维护文档 metadata 白名单,禁止任意路径读取。
|
||||
|
||||
用户管理新增:
|
||||
|
||||
- `users.gatekeeper_groups` JSON 字段。
|
||||
- 用户列表、创建和编辑支持展示/配置 Gatekeeper 权限组。
|
||||
- 只有 `super_admin` 能编辑 Gatekeeper 权限组。
|
||||
|
||||
前端 Docs 改造:
|
||||
|
||||
- 移除 Markdown raw import 作为正文来源。
|
||||
- 从后端 catalog 构建目录和搜索记录。
|
||||
- 从后端 content API 加载正文。
|
||||
- 对 `401` 显示登录入口,对 `403` 显示无权限提示。
|
||||
|
||||
## 验证
|
||||
|
||||
- 未登录用户只能看到和读取 `public` 文档。
|
||||
- 未登录直接访问受保护文档返回 `401` 并显示登录提示。
|
||||
- 无 Gatekeeper 组的普通用户访问开发文档返回 `403`。
|
||||
- `docs_developer` 用户能读开发文档,不能读管理/运维文档。
|
||||
- `admin` 和 `super_admin` 能读管理/运维文档。
|
||||
- 未知 slug、未知语言和路径穿越字符串不能读取文件。
|
||||
- 前端构建产物不再包含受保护 Markdown raw import 生成的文档模块。
|
||||
252
docs/plans/earth-mobile-center-country-highlight-plan.md
Normal file
252
docs/plans/earth-mobile-center-country-highlight-plan.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Earth Mobile Center Country Highlight Plan
|
||||
|
||||
## Goal
|
||||
|
||||
移动端打开 Earth 国界图层后,用屏幕中心,也就是当前镜头正对的地球表面位置,自动识别所在国家,并高亮该国家国界。
|
||||
|
||||
桌面端仍保持现有 hover 行为。移动端不引入新的国界渲染体系,而是复用已有 `country-boundaries.js` 的 GeoJSON 命中和 hover 高亮能力。
|
||||
|
||||
## Criteria for success
|
||||
|
||||
1. 移动端 `layout-mode-mobile` 下,国界图层开启后,屏幕中心所在国家会自动高亮。
|
||||
2. 移动端旋转、缩放、巡航或自动旋转地球时,高亮会跟随镜头中心更新。
|
||||
3. 屏幕中心落在海洋或没有命中地球时,国家高亮会清除。
|
||||
4. 国界图层关闭时,不执行中心国家识别,也不显示残留高亮。
|
||||
5. 桌面端 pointer hover 行为保持不变。
|
||||
6. 移动端抽屉、搜索、设置、媒体、详情等前景 UI 打开时,不因为用户操作 UI 产生明显误高亮或抖动。
|
||||
7. 中心识别有节流或状态缓存,不把 GeoJSON point-in-polygon 检测放到无条件每帧高频执行。
|
||||
8. 实现后能通过本地静态检查或前端构建,并用移动端 viewport 手动或 Playwright 验证核心场景。
|
||||
|
||||
## Existing pieces
|
||||
|
||||
当前项目已经具备大部分基础能力:
|
||||
|
||||
- [frontend/public/earth/js/country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
||||
- `updateCountryBoundaryHover(coords)`:根据 `{ lat, lon }` 命中国家并更新高亮线。
|
||||
- `clearCountryBoundaryHover()`:清除当前 hover 高亮。
|
||||
- `getShowCountryBoundaries()`:判断国界线图层是否可见。
|
||||
- [frontend/public/earth/js/utils.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/utils.js)
|
||||
- `screenToEarthCoords(clientX, clientY, camera, earth, domElement)`:屏幕坐标 raycast 到地球表面。
|
||||
- `vector3ToLatLon(vector)`:地球本地坐标转经纬度。
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- `COUNTRY_BOUNDARY_CONFIG` 已定义普通国界线和 hover 国界线样式。
|
||||
- 移动端布局状态已经通过 `layout-mode-mobile` body class 区分。
|
||||
|
||||
因此本需求的核心不是新增图层,而是补一个移动端中心取点控制器。
|
||||
|
||||
## Non-goals
|
||||
|
||||
- 不改变桌面端 hover 交互。
|
||||
- 不替换 `countries-admin0.min.geojson` 数据源。
|
||||
- 不新增后端 API。
|
||||
- 不把国家面填充做成新的 selected country 面状 shader。
|
||||
- 不为移动端增加永久准星 UI,除非后续产品明确需要视觉准星。
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. Add a small mobile center hover controller
|
||||
|
||||
新增一个轻量函数,建议放在现有主循环附近或单独模块,例如:
|
||||
|
||||
```text
|
||||
frontend/public/earth/js/mobile-center-country-highlight.js
|
||||
```
|
||||
|
||||
建议导出:
|
||||
|
||||
```js
|
||||
updateMobileCenterCountryHighlight({
|
||||
camera,
|
||||
earth,
|
||||
renderer,
|
||||
now,
|
||||
isBlocked,
|
||||
});
|
||||
|
||||
clearMobileCenterCountryHighlight();
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
1. 判断是否处于移动端。
|
||||
2. 判断国界图层是否开启。
|
||||
3. 判断当前是否被移动端前景 UI 阻塞。
|
||||
4. 对 renderer canvas 中心点做 raycast。
|
||||
5. 命中地球后转经纬度。
|
||||
6. 调用 `updateCountryBoundaryHover({ lat, lon })`。
|
||||
7. 无命中或禁用时调用 `clearCountryBoundaryHover()`。
|
||||
|
||||
### 2. Use canvas center, not window center
|
||||
|
||||
中心点应基于 renderer canvas rect 计算:
|
||||
|
||||
```js
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
```
|
||||
|
||||
这样在移动端安全区、地址栏变化、viewport resize 或 canvas 非全屏时仍然准确。
|
||||
|
||||
### 3. Convert center point into country hover coords
|
||||
|
||||
复用已有工具:
|
||||
|
||||
```js
|
||||
const point = screenToEarthCoords(clientX, clientY, camera, earth, renderer.domElement);
|
||||
if (!point) {
|
||||
clearCountryBoundaryHover();
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = vector3ToLatLon(point);
|
||||
updateCountryBoundaryHover(coords);
|
||||
```
|
||||
|
||||
注意:`screenToEarthCoords` 返回的是 earth local point,符合 `vector3ToLatLon` 的输入语义。
|
||||
|
||||
### 4. Gate updates by mobile and foreground UI state
|
||||
|
||||
建议新增一个本地判断函数:
|
||||
|
||||
```js
|
||||
function isMobileCenterCountryHighlightBlocked() {
|
||||
return (
|
||||
!document.body.classList.contains("layout-mode-mobile") ||
|
||||
document.body.classList.contains("earth-search-open") ||
|
||||
document.body.classList.contains("earth-settings-open") ||
|
||||
document.body.classList.contains("earth-media-open") ||
|
||||
document.body.classList.contains("earth-info-open")
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
如果移动端抽屉只是半收起、且没有覆盖中心视野,可以继续允许中心高亮。若实际体验里抽屉展开会遮挡中心点,再把 drawer open 状态纳入阻塞条件。
|
||||
|
||||
### 5. Throttle and cache center updates
|
||||
|
||||
GeoJSON polygon 命中不应该无条件每帧执行。
|
||||
|
||||
第一版建议:
|
||||
|
||||
- `throttleMs = 120`
|
||||
- 缓存上次经纬度,中心点变化小于 `0.05` 度时跳过。
|
||||
- 禁用、切回桌面、图层关闭、UI 阻塞时立即清除一次高亮。
|
||||
|
||||
伪代码:
|
||||
|
||||
```js
|
||||
if (now - lastUpdateAt < 120) return;
|
||||
if (Math.abs(coords.lat - lastLat) < 0.05 && Math.abs(coords.lon - lastLon) < 0.05) return;
|
||||
```
|
||||
|
||||
### 6. Wire into the Earth animation loop
|
||||
|
||||
在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 的动画循环中调用:
|
||||
|
||||
```js
|
||||
updateMobileCenterCountryHighlight({
|
||||
camera,
|
||||
earth,
|
||||
renderer,
|
||||
now: performance.now(),
|
||||
isBlocked: isMobileCenterCountryHighlightBlocked(),
|
||||
});
|
||||
```
|
||||
|
||||
这样自动旋转、手势旋转、缩放和巡航都会自然更新。
|
||||
|
||||
### 7. Keep desktop hover unchanged
|
||||
|
||||
桌面 pointer hover 仍然走当前逻辑。
|
||||
|
||||
移动端中心高亮只在 `layout-mode-mobile` 下生效,不应该监听 pointer move,也不应该抢占 desktop hover 状态。
|
||||
|
||||
### 8. Optional visual tuning
|
||||
|
||||
第一版复用:
|
||||
|
||||
- `COUNTRY_BOUNDARY_CONFIG.hoverLineColor`
|
||||
- `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity`
|
||||
- `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity`
|
||||
|
||||
如果移动端体验太强,可以后续加独立配置:
|
||||
|
||||
```js
|
||||
mobileCenterHoverLineOpacity
|
||||
mobileCenterHoverGlowOpacity
|
||||
```
|
||||
|
||||
但第一版不建议过早分叉样式。
|
||||
|
||||
## Verification
|
||||
|
||||
### Static checks
|
||||
|
||||
1. `npm` 前端构建或现有 lint/typecheck 命令通过。
|
||||
2. `rg` 确认新增函数只在移动端路径调用,不影响桌面 pointer hover。
|
||||
3. `git diff --stat` 和目标文件 diff 确认改动范围集中。
|
||||
|
||||
### Manual mobile checks
|
||||
|
||||
使用移动端 viewport,例如 390x844:
|
||||
|
||||
1. 打开 Earth。
|
||||
2. 开启国界图层。
|
||||
3. 转动地球到中国、美国、澳大利亚等大块陆地区域,确认中心国家国界高亮。
|
||||
4. 转动到太平洋或印度洋,确认高亮消失。
|
||||
5. 缩放地球,确认高亮仍跟随中心点。
|
||||
6. 打开移动端搜索、设置、媒体或详情面板,确认没有明显误高亮或抖动。
|
||||
7. 切回桌面 viewport,确认 hover 仍由鼠标位置控制。
|
||||
|
||||
### Playwright smoke check
|
||||
|
||||
如果已有 Playwright 流程,建议补一个移动端 smoke:
|
||||
|
||||
1. 设置 viewport 为手机尺寸。
|
||||
2. 打开 Earth 页面。
|
||||
3. 开启国界图层。
|
||||
4. 等待国界数据加载。
|
||||
5. 截图确认中心附近国家边界有 hover 高亮线。
|
||||
|
||||
这个 smoke 不必断言具体国家名称,因为当前功能核心是视觉高亮;更稳定的自动化可以后续通过暴露 debug state 实现。
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
### Polygon hit cost too高
|
||||
|
||||
风险:移动端设备上频繁 `featureContains` 可能带来卡顿。
|
||||
|
||||
缓解:
|
||||
|
||||
- 使用 `120ms` 节流。
|
||||
- 经纬度变化小于阈值时跳过。
|
||||
- 后续如仍慢,再为 GeoJSON features 预计算 bbox,先 bbox 粗筛再 point-in-polygon。
|
||||
|
||||
### UI blocking state 不完整
|
||||
|
||||
风险:某些移动端前景 UI 没有对应 body class,中心点被遮挡但高亮仍更新。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一版覆盖现有主要 class。
|
||||
- 验证时记录遗漏项,补充到 `isMobileCenterCountryHighlightBlocked()`。
|
||||
|
||||
### Desktop hover 被移动端状态污染
|
||||
|
||||
风险:移动端中心高亮和桌面 hover 共用 `_hoveredFeature` 状态。
|
||||
|
||||
缓解:
|
||||
|
||||
- 只在 `layout-mode-mobile` 下运行中心高亮。
|
||||
- 切出 mobile 或图层关闭时调用一次 `clearCountryBoundaryHover()`。
|
||||
- 不改 `updateCountryBoundaryHover()` 的语义。
|
||||
|
||||
## Milestones
|
||||
|
||||
1. 设计落地:完成本 plan,明确目标和验收标准。
|
||||
2. 最小实现:新增移动端中心取点 controller,并接入 animation loop。
|
||||
3. 性能保护:加入节流、经纬度阈值和禁用态清理。
|
||||
4. 验证:本地构建通过,移动端 viewport 手动检查通过。
|
||||
5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。
|
||||
|
||||
191
docs/plans/earth-motion-capture-gesture-control-plan.md
Normal file
191
docs/plans/earth-motion-capture-gesture-control-plan.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Earth Motion Capture Gesture Control Plan
|
||||
|
||||
## Goal
|
||||
|
||||
为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。
|
||||
|
||||
首版面向两颗 Logitech C1000 RGB 摄像头,但必须保持单摄像头兼容。后续任何 USB 摄像头、手机摄像头、RTSP/HTTP/WebRTC 视频源都应通过输入适配器接入,而不是改 Earth 渲染端。
|
||||
|
||||
## Architecture
|
||||
|
||||
实时链路分两种 provider,但进入 Earth 后协议一致:
|
||||
|
||||
```text
|
||||
Browser camera -> browser-local recognizer -> Motion Provider events -> Earth control functions
|
||||
Camera(s)/RTSP/HTTP -> Local Motion Capture Agent -> local WebSocket -> Motion Provider events -> Earth control functions
|
||||
```
|
||||
|
||||
关键原则:
|
||||
|
||||
- 实时控制不经过 SaaS 云端。
|
||||
- 实时控制不复用现有新闻、RSS、聚合数据接口。
|
||||
- 浏览器 provider 和 Agent provider 都不向云端上传视频帧,只输出低带宽语义事件。
|
||||
- Web/3D 客户端只消费统一事件并执行映射,不把具体输入源写进 Earth 交互逻辑。
|
||||
- 双摄首版用于冗余和稳定性,不承诺完整 3D 姿态重建。
|
||||
|
||||
## Motion Providers
|
||||
|
||||
Earth 使用统一 Motion Provider 抽象:
|
||||
|
||||
- `browser_camera`:默认 provider。使用 `getUserMedia` 获取摄像头,在浏览器本地加载 MediaPipe Tasks Vision,输出 `gesture` / `skeleton` / `status` 事件。适合 SaaS、WSL、Windows 浏览器、大屏演示和“不安装 app”的用户。
|
||||
- `motion_agent`:连接本地 Agent WebSocket。适合双摄、USB index、RTSP/HTTP 视频源、边缘设备和客户端集成。
|
||||
|
||||
设置项保存在 `planet.earth.settings.v2.shared.motionProvider`。`?motionProvider=browser` 强制浏览器摄像头,`?motionProvider=agent` 或 `?motionAgent=ws://...` 强制 Motion Agent。
|
||||
|
||||
## Motion Capture Agent
|
||||
|
||||
Agent 是本地 Edge 服务,职责包括:
|
||||
|
||||
- 读取摄像头:默认 USB index,支持单摄、双摄和未来 URL 视频源。
|
||||
- 运行识别:首版使用 OpenCV + MediaPipe;识别引擎藏在接口后,未来可替换为 ONNX、TensorRT、C++ 或 Rust worker。
|
||||
- 输出事件:通过 WebSocket 推送 `gesture`、`status`、`heartbeat`。
|
||||
- 控制节流:负责置信度阈值、防抖、冷却时间和连续手势限频。
|
||||
- 健康状态:报告摄像头数量、当前模式、识别 FPS、最近手势和错误。
|
||||
- 明确失败:缺少 CV 依赖、摄像头打不开、无可用输入时给出可读错误。
|
||||
|
||||
Python 不应成为性能瓶颈:重计算在 OpenCV/MediaPipe 原生代码中完成,Python 只做编排、状态机和事件推送。事件消息通常小于 1KB,频率不超过 20Hz。
|
||||
|
||||
## Event Protocol
|
||||
|
||||
本地默认地址:
|
||||
|
||||
```text
|
||||
ws://127.0.0.1:8765/ws/gestures
|
||||
```
|
||||
|
||||
事件类型:
|
||||
|
||||
- `gesture`
|
||||
- `status`
|
||||
- `heartbeat`
|
||||
|
||||
手势语义:
|
||||
|
||||
- `rotate_left`:左挥手,地球向左旋转。
|
||||
- `rotate_right`:右挥手,地球向右旋转。
|
||||
- `zoom_in`:双手张开,地球放大。
|
||||
- `zoom_out`:双手合拢,地球缩小。
|
||||
- `confirm`:握拳或确认动作,触发当前交互确认。
|
||||
|
||||
最小事件字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "gesture",
|
||||
"gesture": "rotate_left",
|
||||
"phase": "discrete",
|
||||
"confidence": 0.92,
|
||||
"intensity": 0.8,
|
||||
"timestamp_ms": 1770000000000,
|
||||
"seq": 42,
|
||||
"source": "motion-agent",
|
||||
"mode": "single",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Earth Client Integration
|
||||
|
||||
Earth 前端新增 motion-control adapter:
|
||||
|
||||
- 连接本地 Agent WebSocket。
|
||||
- 处理断线、重连、心跳和状态。
|
||||
- 过滤低置信度事件。
|
||||
- 将手势映射到 Earth 控制函数。
|
||||
- Agent 离线时不影响普通鼠标、触摸、巡航和图层交互。
|
||||
|
||||
Earth 端只暴露最小动作入口:
|
||||
|
||||
- `applyMotionRotate(direction, intensity)`
|
||||
- `applyMotionZoom(direction, intensity)`
|
||||
- `applyMotionConfirm()`
|
||||
|
||||
动作捕捉不直接操作 Three.js 内部对象,也不修改图层业务模块。
|
||||
|
||||
## SaaS Strategy
|
||||
|
||||
未来网页端做成 SaaS 后,默认实时手势链路仍在浏览器本地完成,不走云端 RPC。高级现场设备可选本地 Agent:
|
||||
|
||||
```text
|
||||
Browser SaaS page -> getUserMedia -> browser-local recognizer
|
||||
Browser SaaS page -> local secure bridge -> Local Motion Capture Agent (advanced)
|
||||
Cloud SaaS -> config/auth/status only
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 云端 RPC 会增加网络 RTT 和抖动。
|
||||
- 上传摄像头帧有隐私和带宽风险。
|
||||
- 大屏交互需要稳定体感延迟,云端只适合做配置、授权、设备状态和审计。
|
||||
|
||||
浏览器摄像头要求 HTTPS 或 localhost。Agent 模式在本地部署可使用 `ws://127.0.0.1:8765`;生产 HTTPS SaaS 若要接 Agent,需要补 `wss://127.0.0.1` 或等价本地安全桥接,避免浏览器混合内容限制。
|
||||
|
||||
## Latency Budget
|
||||
|
||||
目标体感延迟:
|
||||
|
||||
- 摄像头采集:16-33ms。
|
||||
- 识别:8-25ms。
|
||||
- 状态机:小于 2ms。
|
||||
- 本地 WebSocket:1-5ms。
|
||||
- 浏览器渲染:约 16ms。
|
||||
|
||||
实验室目标:从动作被识别到 Earth 响应 p95 小于 50ms;摄像头到画面响应端到端小于 120ms。
|
||||
|
||||
## Implementation Milestones
|
||||
|
||||
1. 保存本计划并注册到 `docs/plans/README.md`。
|
||||
2. 新增独立 motion agent 包,提供 CLI、配置、摄像头输入抽象、事件模型和 WebSocket server。
|
||||
3. 新增手势状态机,支持阈值、防抖、冷却和限频。
|
||||
4. 新增 Earth motion-control provider manager,默认接浏览器摄像头 provider,可切换到 Motion Agent provider。
|
||||
5. 增加 Agent 单元测试、协议测试和前端 adapter 静态验证。
|
||||
6. 更新中英文用户手册和 Earth 前端开发上下文。
|
||||
|
||||
## Debug Mode Addition
|
||||
|
||||
**当前状态**:Browser Camera provider 会在调试面板中显示本地 `<video>` 预览并叠加骨架;`只显示骨骼` 可关闭视频底图。Motion Agent provider 仍只发送 `skeleton` 事件,不传原始摄像头帧。
|
||||
|
||||
Earth 设置中增加“动捕调试模式” switch,并增加“动捕输入源”选择。开启后,Earth 会启动当前 provider 并显示独立 HUD 调试面板。Browser Camera 模式下调试面板可以显示本机浏览器视频预览;Motion Agent 模式下只画归一化骨架点和关节连线,不传原始摄像头画面。
|
||||
|
||||
Motion Agent 增加 `skeleton` 事件:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "skeleton",
|
||||
"camera_id": "usb:0",
|
||||
"matched_gesture": "rotate_left",
|
||||
"confidence": 0.91,
|
||||
"joints": [{ "id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98 }],
|
||||
"bones": [["left_shoulder", "left_elbow"]]
|
||||
}
|
||||
```
|
||||
|
||||
调试颜色约定:
|
||||
|
||||
- 未匹配动作:红色骨架。
|
||||
- 已匹配动作:绿色骨架,并显示匹配到的动作名。
|
||||
|
||||
权限先预留 `data-gatekeeper-permission="earth.motion_debug"` 标记,后续由 Gatekeeper 决定 switch 是否可见/可用。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Agent 单元测试:
|
||||
- 事件模型可序列化。
|
||||
- 低置信度手势被忽略。
|
||||
- 冷却期内重复手势被忽略。
|
||||
- 冷却后新手势可再次输出。
|
||||
- 无摄像头/缺依赖时错误可读。
|
||||
- Agent 协议测试:
|
||||
- `gesture`、`status`、`heartbeat` 字段稳定。
|
||||
- WebSocket 广播只发送语义事件。
|
||||
- Earth 前端验证:
|
||||
- motion-control provider manager 能消费浏览器 provider 和 Agent provider 的 mock 消息。
|
||||
- browser provider 在 mock `getUserMedia` 成功时进入 active 状态。
|
||||
- browser provider 在权限拒绝、无摄像头或非安全上下文时给出可读错误。
|
||||
- `skeleton` 事件能触发 `earth:motion-debug-frame`。
|
||||
- Agent 离线时不抛异常。
|
||||
- `rotate_left/right`、`zoom_in/out`、`confirm` 映射到 Earth 动作函数。
|
||||
- 文档验证:
|
||||
- 计划文档存在。
|
||||
- `docs/plans/README.md` 有入口。
|
||||
- 中英文使用说明不互相矛盾。
|
||||
66
docs/plans/earth-motion-gesture-interaction-v2-plan.md
Normal file
66
docs/plans/earth-motion-gesture-interaction-v2-plan.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Earth Motion Gesture Interaction V2 Plan
|
||||
|
||||
**状态**:已实现主体交互,并按实测调整。当前浏览器识别保留右手导航、头部切目标、左手上下切动捕图层、双手张开/收拢缩放;双手上举确认暂时关闭。Motion 目标展示已改为 `CruiseSequencer` + `PresentationController` 的 persistent 展示。
|
||||
|
||||
## Summary
|
||||
|
||||
把动捕从“几个单点手势触发函数”升级为一套更像大屏遥控器的交互层:右手负责地球导航,头部负责候选切换,左手上下切换动捕候选图层,双手负责缩放,调试面板支持“只显示骨骼”和暂停匹配。进入动捕模式后,Earth 自动软选中屏幕中心附近的正面可交互目标;确认动作预留为把目标升级为锁定,并用巡航/引导线式详情打开,不再模拟鼠标点击。
|
||||
|
||||
## Key Changes
|
||||
|
||||
- 手势语义 v1 固定为稳健小集:
|
||||
- 修正当前左右挥手语义反向问题:手势名以用户感知方向为准,provider 层输出正确 `rotate_left` / `rotate_right`。
|
||||
- 右手左/右/上/下挥控制地球水平/垂直旋转,新增 `rotate_up`、`rotate_down`。
|
||||
- 双手张开/靠近明确映射为 `zoom_in` / `zoom_out`。
|
||||
- 头往左/右歪新增 `focus_prev` / `focus_next`,在当前自动候选目标之间切换。
|
||||
- 左手上/下挥新增 `layer_prev` / `layer_next`,切换当前动捕候选图层并聚焦该图层最近目标。
|
||||
- 双手确认手势暂时关闭,避免与缩放和站姿误触混淆;协议仍保留 `confirm`。
|
||||
|
||||
- Motion Provider / Protocol:
|
||||
- 扩展 `MOTION_GESTURES`,新增 `rotate_up`、`rotate_down`、`focus_prev`、`focus_next`、`layer_prev`、`layer_next`。
|
||||
- Browser Camera provider 扩展 pose joints,保留肩/肘/腕,增加头部关键点,用于判断头歪。
|
||||
- 右手作为导航手;左手独立控制动捕候选图层。
|
||||
- 每类手势使用独立阈值和 cooldown,避免缩放/确认/旋转互相误触。
|
||||
|
||||
- Earth 交互层:
|
||||
- Motion adapter 支持水平/垂直旋转和 focus 切换 callback。
|
||||
- 进入动捕模式后,周期性从可交互对象中选出屏幕中心最近、位于地球正面的候选。
|
||||
- 软选中目标独立于 `lockedObject`,用 hover/linked 视觉态展示,不立即打开详情。
|
||||
- `focus_prev` / `focus_next` 在候选列表中切换;列表按屏幕中心距离、正面可见性、当前图层可见性排序。
|
||||
- `confirm` 预留为将软选中目标升级为 locked,并打开引导线详情;若没有候选,显示状态提示。
|
||||
|
||||
- 调试面板:
|
||||
- 在动捕 HUD / drawer 内增加“只显示骨骼”开关。
|
||||
- 增加“停止匹配动作”开关:暂停 gesture 执行,但不关闭摄像头预览或骨架绘制。
|
||||
- 设置持久化到 `planet.earth.settings.v2.shared.motionDebugSkeletonOnly`。
|
||||
- 开启后 canvas 不绘制视频帧,只绘制深色背景 + 红/绿骨骼线;摄像头仍继续用于识别。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Browser provider 单元测试:
|
||||
- 右手左/右挥输出的 `rotate_left` / `rotate_right` 与用户语义一致。
|
||||
- 右手上/下挥输出 `rotate_up` / `rotate_down`。
|
||||
- 双手张开输出 `zoom_in`,双手靠近输出 `zoom_out`。
|
||||
- 头部左右倾斜输出 `focus_prev` / `focus_next`。
|
||||
- 双手确认动作暂时不会触发。
|
||||
|
||||
- Motion adapter 测试:
|
||||
- 新增 gesture 能通过 `normalizeGestureMessage`。
|
||||
- `rotate_up/down` 调用垂直旋转逻辑。
|
||||
- `focus_prev/focus_next` 调用候选切换 callback。
|
||||
- `confirm` 在协议层保持兼容;浏览器 provider 当前不主动发出。
|
||||
|
||||
- Earth 前端验证:
|
||||
- 开启动捕模式后,屏幕中心附近正面目标自动软选中。
|
||||
- 头歪能在候选之间切换。
|
||||
- 左手上下切换图层后会在新图层中选择最近目标并展示 persistent 引导线详情。
|
||||
- 右手上下挥能旋转到南北方向目标。
|
||||
- “只显示骨骼”开关持久化,刷新后状态保持。
|
||||
- `bun --check` 覆盖新增/修改 Earth JS 模块,现有 motion tests 全绿。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- v1 采用“右手导航、头部切候选、左手切图层、双手缩放”的交互模型;确认手势保留协议但暂时关闭浏览器识别。
|
||||
- 自动选中是 soft focus,不覆盖现有 mouse locked selection;只有 `confirm` 才真正锁定目标。
|
||||
- 骨骼-only 只影响调试画面,不关闭摄像头、不影响识别。
|
||||
- Motion Agent 协议可以接收新增 gesture 名;旧 agent 只发旧 gesture 时仍兼容。
|
||||
67
docs/plans/earth-presentation-decoupled-architecture-plan.md
Normal file
67
docs/plans/earth-presentation-decoupled-architecture-plan.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Earth Presentation Decoupled Architecture Plan
|
||||
|
||||
## Goal
|
||||
|
||||
把 Earth 页面里的“详情卡片、连接器、隐藏策略、跟随更新”从具体业务交互里拆出来,形成统一的 Presentation 层。第一阶段只迁 Motion 动捕展示,修复卡片被鼠标移动误隐藏、连接器 interactable 端不贴合本体的问题;BGP/News 巡航保持现状,避免改变原有轮播体验。
|
||||
|
||||
## Current Issues
|
||||
|
||||
- Motion 展示复用了巡航卡片,但隐藏判断仍散落在 `main.js` 的 hover/mousemove 分支里,导致鼠标移动时卡片可能被 `hideInfoCard()` 清掉。
|
||||
- Motion 连接器 source 端目前主要使用屏幕点坐标,缺少本体视觉边界,线无法稳定贴住 marker、卫星或海缆本体。
|
||||
- 卡片、连接器、目标本体和生命周期策略耦合在 adapter 内,不利于后续把点击详情、动捕、巡航统一管理。
|
||||
|
||||
## Phase 1 Scope
|
||||
|
||||
- 新增 `PresentationController`。
|
||||
- Motion 使用 `PresentationController` 管理卡片、连接器和 persistent 生命周期。
|
||||
- BGP/News adapter 不迁移,继续使用现有 `CruiseSequencer`、卡片位置、线动画和 dwell/advance 行为。
|
||||
- InfoCard 和 CalloutConnector 继续作为底层 renderer,不重写 UI。
|
||||
|
||||
## Presentation Interface
|
||||
|
||||
`PresentationController.present(request)` 接收:
|
||||
|
||||
- `id`: presentation 唯一 id。
|
||||
- `owner`: `motion | cruise | click | hover`。
|
||||
- `card`: 提供 `render({ reveal })` 和 `hide()`。
|
||||
- `connector`: 提供 `sourceProvider`、`targetProvider`、`options`,由 controller 调用 `createConnectorPath()` 和 `connector.render()`。
|
||||
- `lifetime`: `persistent | timeout | sequenced`,Motion 默认 `persistent`。
|
||||
- `onDismiss(reason)`: 替换、关闭、停止等清理回调。
|
||||
|
||||
`PresentationController.update()` 每帧重算 active connector 的 source/target anchor。`dismiss(reason)` 统一清理卡片、连接器和计时器。
|
||||
|
||||
## Motion Integration
|
||||
|
||||
- Motion adapter 不再直接管理 `showInfoCard + connector.render + hideInfoCard`。
|
||||
- Motion request 使用 `owner: "motion"` 和 `lifetime: { mode: "persistent" }`。
|
||||
- Motion 切目标时替换当前 presentation。
|
||||
- Motion 关闭、页面销毁或用户关闭展示时 dismiss。
|
||||
- Motion source anchor 使用视觉近似矩形:
|
||||
- BGP / compute / vessel marker: 投影中心 + marker 尺寸近似。
|
||||
- satellite: 当前卫星位置 + point size 近似。
|
||||
- cable: localCenter + 小矩形近似。
|
||||
|
||||
## Cruise Compatibility
|
||||
|
||||
- BGP/News 第一阶段不迁移。
|
||||
- `CruiseSequencer` 的 `auto_advance` 不改。
|
||||
- 原巡航的 dwell、hide、advance、卡片固定锚点、连接器动画时序不改。
|
||||
- 后续迁移 BGP/News 前必须先补回归测试,再只替换渲染层,不改排序、聚焦和时序。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `presentation-controller.test.js`
|
||||
- `persistent` 不自动隐藏。
|
||||
- `timeout` 按配置隐藏。
|
||||
- 新 presentation 替换旧 presentation,并触发旧 `onDismiss("replace")`。
|
||||
- `dismiss(reason)` 清理卡片、连接器、计时器。
|
||||
- `update()` 重新获取 source/target anchor 并重绘 connector。
|
||||
- Motion 手动验证:
|
||||
- Motion 展示后移动鼠标,卡片不消失。
|
||||
- Motion 切目标后旧卡片和旧线被替换。
|
||||
- 卡片拖动、窗口 resize、地球旋转、卫星移动时 connector 两端跟随。
|
||||
- source 端贴近 interactable 视觉边缘。
|
||||
- 巡航回归:
|
||||
- BGP/News 自动轮播、dwell、隐藏、进入下一条不变。
|
||||
- 移动端 popup/drawer 行为不变。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AIS 多源采集、冲突记录与聚合接口计划
|
||||
|
||||
**状态**:v0-v3 已实现,v4+ 规划中
|
||||
**状态**:v0-v3 已实现,v3.1-v3.4 为 v4/v5 前置稳定化任务,v4 / v5 已落最小可用子集
|
||||
**创建日期**:2026-04-30
|
||||
**核心原则**:采集器只写原始观测;去重、合并、冲突解释放在聚合接口中完成
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
| 过期保护 | 实时流源断流超过 freshness 窗口后,不能仅凭“实时源”身份压过更新的轮询数据 |
|
||||
| 源健康状态 | 聚合时必须参考采集器健康状态,不能只看配置中的理论优先级 |
|
||||
| 媒体富化 | 船只图片等媒体信息不进入 AIS 实时聚合主链路,后续单独做 enrichment |
|
||||
| v4/v5 顺序 | 在聚合完整性、AISStream 实时链路、采集状态语义和基础身份信息显示修好之前,不进入策略配置和 enrichment UI |
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -175,10 +176,12 @@ freshness:
|
||||
|
||||
## 聚合接口
|
||||
|
||||
状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 不再兼容返回数据,而是返回 `410 Gone`;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws` 的 `vessels` 订阅。
|
||||
|
||||
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`。
|
||||
|
||||
```text
|
||||
GET /api/v1/visualization/geo/vessels
|
||||
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/visualization/vessels/{mmsi}
|
||||
GET /api/v1/visualization/vessels/{mmsi}/track
|
||||
GET /api/v1/visualization/vessels/{mmsi}/conflicts
|
||||
@@ -300,7 +303,7 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
|
||||
|
||||
## 版本拆分
|
||||
|
||||
计划按 5 个版本推进:
|
||||
计划先按 v0-v3 建立基础能力,再用 v3.1-v3.4 修复当前稳定性缺口,最后进入 v4/v5:
|
||||
|
||||
### v0 — 聚合基础设施(已实现)
|
||||
|
||||
@@ -316,7 +319,7 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
|
||||
目标是让展示接口开始消费聚合结果,但前端形状保持兼容。
|
||||
|
||||
1. 实现 AIS 聚合服务,先兼容读取现有表,再逐步切换到原始观测层。
|
||||
2. 将 `/geo/vessels` 和 `/vessels/{mmsi}` 改为走聚合服务。
|
||||
2. 将船只列表迁移到 `/api/v1/vessels/snapshot`,并让 `/vessels/{mmsi}` 走聚合服务。
|
||||
3. 将 `/vessels/{mmsi}/track` 改为走轨迹聚合逻辑。
|
||||
4. 返回 `field_sources`、`selected_reasons`、`quality_flags`、`conflict_count`。
|
||||
5. 加入 freshness fallback 和异常位置保护。
|
||||
@@ -341,29 +344,111 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
|
||||
3. 聚合结果返回 `source_summary`,展示每艘船的来源、观测数量、最新观测时间、传输模式和消息类型。
|
||||
4. 保留 `field_sources` 和 `selected_reasons`,用于解释动态字段来自实时流、静态字段来自可用非空来源。
|
||||
5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。
|
||||
6. `/geo/vessels` 不再默认限制 5000 艘;不传 `limit` 或传 `limit=0` 表示全量返回,前端默认也不再二次裁剪到 5000。
|
||||
6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom,默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。
|
||||
|
||||
### v4 — 策略配置
|
||||
### v3.1 — 聚合完整性修复(已被新快照接口取代)
|
||||
|
||||
原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。开发期产品尚未上线后,决策调整为直接淘汰 legacy 船只表兜底:船只快照只读取 `ais_raw_observations` 聚合结果,旧 `vessel_position + vessel_static` 不再合并进 `/api/v1/vessels/snapshot`。
|
||||
|
||||
因此以下 legacy merge 要求作废,保留在文档中只作为历史决策记录:
|
||||
|
||||
1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。
|
||||
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。
|
||||
3. raw 中不存在的 BarentsWatch-only MMSI 必须从 `vessel_position + vessel_static` 补齐。
|
||||
4. `bbox`、`type`、`limit` 过滤必须作用在合并后的最终集合上;不传 `limit` 或 `limit=0` 仍表示全量返回。
|
||||
5. 增加诊断统计,至少能看到 raw AISStream unique MMSI、raw BarentsWatch unique MMSI、legacy unique MMSI、final merged unique MMSI 和被 legacy 补齐的数量。
|
||||
6. 为 raw 只有 AISStream 子集、legacy 有更多 BarentsWatch 船只的场景补回归测试。
|
||||
|
||||
### v3.2 — AISStream 真实时链路(v4 前置)
|
||||
|
||||
目标是把 AISStream 从“一次 collector 收一批消息后结束”改成真正的 WebSocket 长连接实时数据源,并把实时变化推送到 Earth。
|
||||
|
||||
当前 `aisstream_vessels` 只在 collector `fetch()` 中连接 `wss://stream.aisstream.io/v0/stream`,默认收 `max_messages = 500` 条后结束。这不符合 WebSocket 流式数据源的运行语义,也不能保证新船、位置变化和航向变化实时出现在前端。
|
||||
|
||||
1. 为 AISStream 增加 streaming service / long-running runner,不再依赖单次 `fetch -> transform -> save -> completed` 表达实时采集。
|
||||
2. 外部 AISStream WebSocket 保持长连接,断线后指数退避重连,并持续更新 `AISSourceHealth`。
|
||||
3. 每条或小批量 AIS 消息标准化后写入 `ais_raw_observations`,按时间或数量短周期 commit,避免长事务堆积。
|
||||
4. 将新增船只、位置变化、航向变化和静态字段补充转换成 vessel delta。
|
||||
5. 通过应用内部 `/ws` 的 `vessels` channel 广播 delta,复用 `DataBroadcaster.broadcast_custom("vessels", payload)`。
|
||||
6. Earth 前端订阅 `vessels` channel,`vessels.js` 支持按 MMSI upsert marker,而不是每次全量 reload。
|
||||
7. 船只改变航向时,前端必须更新 course bin / marker bucket,避免 marker 方向滞后。
|
||||
8. freshness 超时或 AISStream 健康异常时,动态字段可回退到 BarentsWatch 最新可用观测。
|
||||
|
||||
### v3.3 — Streaming 采集状态语义(v4 前置)
|
||||
|
||||
目标是让采集页面正确表达 AISStream 这类长连接数据源,不再使用一次性 REST collector 的完成型进度条。
|
||||
|
||||
REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100 -> completed`。AISStream 的自然状态应是 `connecting -> streaming -> reconnecting -> stopped/failed`,没有固定总量,也不应在收到一批消息后显示“采集完成”。
|
||||
|
||||
1. AISStream 采集状态使用 indeterminate / streaming 状态,而不是百分比完成进度条。
|
||||
2. 设置页运行状态卡展示连接状态、已运行时长、本轮消息数、新增观测数、unique MMSI、message rate、最近消息时间、延迟和最近错误。
|
||||
3. `phase_message` 使用“正在接收 AISStream 实时消息”“重连中”“已停止”等长连接语义。
|
||||
4. 停止、重连和配置变更要有明确操作入口;配置变化后必须安全重订阅。
|
||||
5. 后端任务状态不能因为没有 `total_records` 就长期显示 `0%` 或误判失败。
|
||||
6. WebSocket 健康状态和 collector task 状态要分离:上游短暂断线是 `reconnecting`,不是普通采集任务完成或失败。
|
||||
|
||||
### v3.4 — 船只身份字段和名称聚合修复(v4 前置)
|
||||
|
||||
目标是把 MMSI、IMO、callsign 这类身份编号按字符串显示,并把仍然使用 MMSI 作为船名的记录视为信息聚合未完成,而不是正常船名。
|
||||
|
||||
1. 前端详情卡、hover、搜索结果和日志中的 `mmsi`、`imo`、`callsign` 必须作为 identifier 字段展示,禁止走 `toLocaleString()` 或数字千分位格式。
|
||||
2. GeoJSON 可增加 `mmsi_display` / `imo_display` 等字符串字段,但前端仍必须对 identifier key 做兜底格式保护。
|
||||
3. 聚合服务生成船名时,不能把 `MMSI 257123000` 当成真实 `name` 的成功结果;它只能作为 display fallback。
|
||||
4. 增加诊断查询,列出所有当前仍以 MMSI 号码或 `MMSI <number>` 作为船只名称的记录,包括:
|
||||
- `vessel_static.name` 为空或等于 MMSI fallback 的 MMSI;
|
||||
- raw observation 中没有任何非空 `name` / `MetaData.ShipName` / `ShipStaticData.Name` 的 MMSI;
|
||||
- 聚合结果最终 `name` 仍为 fallback 的 MMSI;
|
||||
- 每个 MMSI 的可用来源、最近观测时间、message types 和缺失原因。
|
||||
5. 对这些 fallback-name 船只建立待修复集合,优先通过 AISStream `ShipStaticData`、BarentsWatch 静态字段和后续 enrichment 缓存补齐。
|
||||
6. 船只详情面板需要区分“真实船名”和“显示兜底”:真实船名缺失时展示 `MMSI <id>` 可以继续作为标题,但字段来源应标注为 `fallback`,避免误以为聚合成功。
|
||||
7. 为 MMSI 千分位格式、fallback-name 诊断和名称来源解释补回归测试。
|
||||
|
||||
### v4 — 策略配置(v0 可用)
|
||||
|
||||
目标是开放系统级配置,但仍以安全默认值兜底。
|
||||
|
||||
1. 接入系统设置中的聚合策略配置。
|
||||
2. 支持 source priority、字段级规则、freshness 窗口和高级保护开关。
|
||||
3. 保存配置时校验未知字段、非法模式和危险动态字段锁定。
|
||||
4. 聚合接口返回当前命中的配置版本,方便排查。
|
||||
已落地的最小子集:
|
||||
|
||||
### v5 — 船舶资料 enrichment 与冲突治理
|
||||
1. 策略持久化在 `system_settings.category = 'vessel_aggregation_strategy'`,保存时自动版本递增。
|
||||
2. `app/services/vessel_aggregation_strategy.py` 暴露 `load_strategy / save_strategy / reset_strategy / validate_strategy`,并维护 `DEFAULT_STRATEGY` 兜底。
|
||||
3. 校验规则:
|
||||
- 未知 `field_rules.<name>` → `400 unknown vessel_ais field`;
|
||||
- 未知 mode → `400 mode must be one of ...`;
|
||||
- 动态字段(`lat/lon/sog/cog/heading/nav_status`)使用非 `newest` mode 时必须显式 `allow_dynamic_lock=true`,否则拒绝;
|
||||
- `freshness.realtime_stream_seconds` / `polling_seconds` 必须为非负整数;
|
||||
- `mode=locked` 必须带非空 `locked_source`。
|
||||
4. 聚合服务 `vessel_ais_aggregation.py` 在 `_select_position_observation` 中按 `freshness` 把过期实时流降级到 stale 候选;在 `_select_static_field` 中按 `field_rules.mode = source_priority / locked / newest / non_empty` 选源。
|
||||
5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/api/v1/vessels/snapshot` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。
|
||||
6. API:
|
||||
- `GET /api/v1/vessel-aggregation/strategy`
|
||||
- `PUT /api/v1/vessel-aggregation/strategy`(校验失败 400)
|
||||
- `DELETE /api/v1/vessel-aggregation/strategy`(恢复默认并 bump version)
|
||||
|
||||
未做项(留给 v4 后续):
|
||||
|
||||
- 系统设置 UI 中的策略编辑器尚未做,目前直接调 API;
|
||||
- `transport_priority`、`quality_flags` 级别的策略尚未引入;
|
||||
- `source_priority` 中的未知 source 不强校验,留给后续 warn-only 提示。
|
||||
|
||||
### v5 — 船舶资料 enrichment 与冲突治理(v0 可用)
|
||||
|
||||
目标是把 AIS 实时流里不稳定或低频出现的静态信息,补成可缓存、可审计的船舶资料层,同时把冲突解释变成可操作能力。
|
||||
|
||||
1. 做冲突治理 UI。
|
||||
2. 支持把人工选择沉淀成字段级规则。
|
||||
3. 支持恢复默认策略。
|
||||
4. 设计 `vessel_profile_enrichment`,按 `mmsi + imo + name + callsign` 异步补充船名、船型细分、AIS 大类、旗国、尺寸、建造年份、运营方等静态资料。
|
||||
5. 设计 `vessel_media_enrichment`,异步补充船只图片和外部详情缓存。
|
||||
6. enrichment 结果必须带 `source`、`fetched_at`、`expires_at`、`confidence` 和原始引用,不覆盖 AIS 原始观测。
|
||||
7. 聚合接口只读取已缓存 enrichment;请求链路不现场抓取第三方页面,避免慢请求和授权风险。
|
||||
8. 前端船只详情面板展示已缓存资料和媒体,并标注字段来源,不阻塞 AIS 实时链路。
|
||||
已落地的最小子集:
|
||||
|
||||
1. 新增模型 `app/models/vessel_enrichment.py::VesselProfileEnrichment` + `VesselMediaEnrichment`:以 `mmsi` 为主键,记录 `source / payload / fetched_at / expires_at / confidence / reference_url`;通过 `Base.metadata.create_all` 在 `init_db` 中建表。
|
||||
2. 服务 `app/services/vessel_enrichment.py` 提供 `upsert_vessel_profile_enrichment` / `upsert_vessel_media_enrichment` / `get_vessel_enrichment_bundle`;读路径只读缓存,过期记录(`expires_at < now`)直接过滤为 `None`,永不联网。
|
||||
3. 聚合接口在 `/api/v1/visualization/vessels/{mmsi}` 响应中追加 `enrichment.profile` 与 `enrichment.media` 字段(含 `source / fetched_at / expires_at / confidence / reference_url`);命中失败时返回 `null`,不阻塞 AIS 实时链路。
|
||||
4. 冲突治理 API:
|
||||
- `POST /api/v1/vessel-aggregation/conflicts/{mmsi}/{field}/promote-to-rule` 读取最近 `AISConflictRecord.selected_source`,写入 `field_rules[field] = {mode: source_priority, source_priority: [<source>]}` 并 bump version;
|
||||
- `DELETE` 对应路径移除该 field 的覆盖,恢复默认。
|
||||
5. 前端 Earth `info-card.js` 渲染 `船舶资料` 区块:profile.payload 标量字段平铺、媒体 `images` 数组缩略图、来源 / 更新时间 / 置信度元数据;缓存命中失败回退到 `资料缓存中`;常规字段在 `field_sources` 命中时附带来源 tag。
|
||||
|
||||
未做项(留给 v5 后续):
|
||||
|
||||
- 没有真正的异步 enrichment 抓取作业;当前依赖外部脚本/管理 API 写入缓存;
|
||||
- 冲突治理 UI 还没接入设置中心,目前只暴露 API;
|
||||
- enrichment 命中状态尚未广播到 `vessels` channel,详情面板首次打开时按需请求即可。
|
||||
|
||||
## 测试计划
|
||||
|
||||
@@ -377,6 +462,12 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
|
||||
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。
|
||||
- 同一时间窗口内多来源相近轨迹点只展示一个点。
|
||||
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
|
||||
- `/api/v1/vessels/snapshot` 只读取 AIS raw observation 聚合结果;legacy latest position 不再参与船只快照。
|
||||
- `/api/v1/visualization/geo/vessels` 返回 `410 Gone`,客户端必须迁移到新 snapshot API。
|
||||
- AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws` 的 `vessels` channel 推送增量。
|
||||
- AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。
|
||||
- `mmsi`、`imo`、`callsign` 等身份编号在前端不显示千分位符。
|
||||
- 聚合结果中仍以 MMSI fallback 作为船名的记录可以被诊断查询完整列出,并带来源和缺失原因。
|
||||
- 字段级配置可以覆盖默认来源优先级。
|
||||
- 聚合接口在没有冲突表时仍可返回兼容 GeoJSON。
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user