Compare commits

..

7 Commits

Author SHA1 Message Date
linkong
b87cb310fd release: bump version to 0.52.0 2026-05-12 17:15:02 +08:00
linkong
b15d097b9c default user 2026-05-12 09:20:13 +08:00
rayd1o
8955c58d19 release: bump version to 0.51.1 2026-05-11 10:59:41 +08:00
rayd1o
1cb51b1172 release: bump version to 0.51.0 2026-05-11 09:49:08 +08:00
rayd1o
455b8360d0 release: bump version to 0.50.0 2026-05-10 22:06:01 +08:00
linkong
e1984c7a35 release: bump version to 0.49.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:42:27 +08:00
linkong
bb9183b8a4 release: bump version to 0.48.0 2026-05-07 18:06:06 +08:00
208 changed files with 34096 additions and 2782 deletions

View File

@@ -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

View File

@@ -236,6 +236,8 @@ bun run build
推荐按下面顺序排查和配置。
端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
### 1. 在 WSL 中启动服务
```bash

View File

@@ -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 到国家轮廓时能高亮当前国家,便于校准地表和增强交互

View File

@@ -1 +1 @@
0.47.0
0.52.0

View File

@@ -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

View File

@@ -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"])

View File

@@ -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"}

View File

@@ -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),

View 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)

View File

@@ -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)

View File

@@ -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
View 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"),
}

View 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

View File

@@ -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

View 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)

View 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

View File

@@ -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":

View File

@@ -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]],
*,

View File

@@ -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

View File

@@ -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"),
),
),

View File

@@ -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()

View File

@@ -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()

View 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": []
}

View File

@@ -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)

View File

@@ -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",

View 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,
}

View File

@@ -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"),
)

View 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),
}

View File

@@ -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(

View File

@@ -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"),
)

View 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,
}

View File

@@ -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)

View 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.
"""

View 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,
}

View 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)

View 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",
)

View 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

View 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),
},
)

View 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,
}

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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"))

View 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

View File

@@ -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"

View 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()),
}

View File

@@ -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

View 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"]))

View 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,
)

View 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",
]

File diff suppressed because it is too large Load Diff

View 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)

View 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),
)

View 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",
]

View 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,))

View 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),
)

View 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

View File

@@ -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,))

View 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
View 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)

View File

@@ -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:

View 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

View File

@@ -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,

View 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()

View File

@@ -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,
)

View File

@@ -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__":

View File

@@ -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__":

View File

@@ -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")

View File

@@ -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:

View 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

View 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)

View File

@@ -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():

View 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"]

View 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

View 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

View 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

View 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

View 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)

View 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"

View 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"] == ""

View 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"]

View 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"

View File

@@ -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()

View File

@@ -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"
)

View 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"

View 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,
}
]

View File

@@ -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

View File

@@ -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`.

View File

@@ -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)
不适合放入这里的内容:

View 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.

View 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` channelpayload 使用当前 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。

View 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/*`

View 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、本地代理工具 tabWebSearch、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.mdCLI 路径写 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` 能看到

View 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 生成的文档模块。

View 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. 调优:根据截图或真机体验微调阻塞条件和节流阈值。

View 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。
- 本地 WebSocket1-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` 有入口。
- 中英文使用说明不互相矛盾。

View 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 时仍兼容。

View 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 行为不变。

View File

@@ -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