Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87cb310fd | ||
|
|
b15d097b9c | ||
|
|
8955c58d19 | ||
|
|
1cb51b1172 | ||
|
|
455b8360d0 | ||
|
|
e1984c7a35 |
@@ -46,6 +46,16 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
|
||||
- Keep filenames lowercase and hyphenated.
|
||||
- Apply the repository-specific rules file before writing.
|
||||
|
||||
#### Document Audience Routing (Planet)
|
||||
|
||||
In this repository, classify the action's performer before picking a target file:
|
||||
|
||||
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
|
||||
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
|
||||
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
|
||||
|
||||
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
|
||||
|
||||
### Step 3 — Write
|
||||
|
||||
@@ -236,6 +236,8 @@ bun run build
|
||||
|
||||
推荐按下面顺序排查和配置。
|
||||
|
||||
端口占用、`iphlpsvc` / portproxy、摄像头和依赖问题的集中排障入口见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
|
||||
|
||||
### 1. 在 WSL 中启动服务
|
||||
|
||||
```bash
|
||||
|
||||
@@ -32,6 +32,15 @@ AI_API_KEY=sk-cp-change-me
|
||||
AI_MAX_TOKENS=1200
|
||||
AI_ANTHROPIC_VERSION=2023-06-01
|
||||
|
||||
# Optional provider-specific keys used by Settings fallback before AI_API_KEY
|
||||
# MINIMAX_API_KEY=sk-cp-change-me
|
||||
# OPENAI_API_KEY=sk-change-me
|
||||
# ANTHROPIC_API_KEY=sk-ant-change-me
|
||||
# DEEPSEEK_API_KEY=sk-change-me
|
||||
# DASHSCOPE_API_KEY=sk-change-me
|
||||
# MOONSHOT_API_KEY=sk-change-me
|
||||
# OPENROUTER_API_KEY=sk-or-change-me
|
||||
|
||||
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
|
||||
# AI_PROVIDER=openai
|
||||
# AI_PROVIDER_API=openai-completions
|
||||
|
||||
@@ -5,14 +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,
|
||||
@@ -29,17 +32,21 @@ api_router.include_router(
|
||||
)
|
||||
api_router.include_router(datasources.router, prefix="/datasources", tags=["datasources"])
|
||||
api_router.include_router(collected_data.router, prefix="/collected", tags=["collected-data"])
|
||||
api_router.include_router(docs.router, prefix="/docs", tags=["docs"])
|
||||
api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
|
||||
api_router.include_router(data_products.router, prefix="/data-products", tags=["data-products"])
|
||||
api_router.include_router(layers.router, prefix="/layers", tags=["layers"])
|
||||
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||
api_router.include_router(
|
||||
vessel_aggregation.router,
|
||||
prefix="/vessel-aggregation",
|
||||
tags=["vessel-aggregation"],
|
||||
)
|
||||
api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"])
|
||||
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
|
||||
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
|
||||
@@ -1,26 +1,85 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import get_logger
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
blacklist_token,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.schemas.user import (
|
||||
ForgotPasswordRequest,
|
||||
ResendCodeRequest,
|
||||
ResetPasswordRequest,
|
||||
UserRegister,
|
||||
UserResponse,
|
||||
VerifyEmailRequest,
|
||||
)
|
||||
from app.services import otp
|
||||
from app.services.email import (
|
||||
EmailError,
|
||||
EmailNotConfiguredError,
|
||||
send_verification_email,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _token_response(user: User) -> dict:
|
||||
access_token = create_access_token(data={"sub": user.id})
|
||||
refresh = create_refresh_token(data={"sub": user.id})
|
||||
expires_in = (
|
||||
settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": expires_in,
|
||||
"refresh_token": refresh,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"gatekeeper_groups": user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
||||
"FROM users WHERE email = :email"
|
||||
),
|
||||
{"email": email},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
user.email_verified = bool(row[7])
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
@@ -28,7 +87,8 @@ async def login(
|
||||
):
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
|
||||
"FROM users WHERE username = :username"
|
||||
),
|
||||
{"username": form_data.username},
|
||||
)
|
||||
@@ -46,6 +106,8 @@ async def login(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
user.email_verified = bool(row[7])
|
||||
|
||||
if not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
@@ -57,24 +119,13 @@ async def login(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is inactive",
|
||||
)
|
||||
if not user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"code": "EMAIL_NOT_VERIFIED", "email": user.email},
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": user.id})
|
||||
refresh_token = create_refresh_token(data={"sub": user.id})
|
||||
|
||||
expires_in = None
|
||||
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
|
||||
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": expires_in,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
},
|
||||
}
|
||||
return _token_response(user)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@@ -95,6 +146,7 @@ async def refresh_token(
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,6 +163,181 @@ async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"role": current_user.role,
|
||||
"gatekeeper_groups": current_user.gatekeeper_groups or [],
|
||||
"is_active": current_user.is_active,
|
||||
"email_verified": getattr(current_user, "email_verified", True),
|
||||
"created_at": current_user.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: str) -> None:
|
||||
try:
|
||||
await send_verification_email(db, to=email, code=code, purpose=purpose)
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except EmailError as exc:
|
||||
logger.warning_event(
|
||||
"SMTP send failed",
|
||||
event="auth.email.send_failed",
|
||||
context={"email": email, "purpose": purpose, "error": str(exc)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(
|
||||
text("SELECT id, email_verified FROM users WHERE username = :u OR email = :e"),
|
||||
{"u": payload.username, "e": payload.email},
|
||||
)
|
||||
row = existing.fetchone()
|
||||
if row is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "USER_ALREADY_EXISTS", "message": "Username or email already in use"},
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password_hash=get_password_hash(payload.password),
|
||||
role="viewer",
|
||||
is_active=True,
|
||||
email_verified=False,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
code = otp.issue_code(payload.email, "register")
|
||||
except otp.OtpResendRateLimited as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
||||
) from exc
|
||||
await _send_code_or_raise(db, payload.email, code, "register")
|
||||
return {"status": "pending_verification", "email": payload.email}
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=Token)
|
||||
async def verify_email(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "USER_NOT_FOUND"},
|
||||
)
|
||||
try:
|
||||
otp.verify_code(payload.email, "register", payload.code)
|
||||
except otp.OtpExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpAttemptsExceeded as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE users SET email_verified = TRUE WHERE id = :id"),
|
||||
{"id": user.id},
|
||||
)
|
||||
await db.commit()
|
||||
user.email_verified = True
|
||||
return _token_response(user)
|
||||
|
||||
|
||||
@router.post("/resend-code")
|
||||
async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
# Avoid email enumeration; pretend success.
|
||||
return {"status": "ok"}
|
||||
if payload.purpose == "register" and user.email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "ALREADY_VERIFIED"},
|
||||
)
|
||||
try:
|
||||
code = otp.issue_code(payload.email, payload.purpose)
|
||||
except otp.OtpResendRateLimited as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
|
||||
) from exc
|
||||
await _send_code_or_raise(db, payload.email, code, payload.purpose)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
# Don't leak whether an email is registered.
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
code = otp.issue_code(payload.email, "reset_password")
|
||||
except otp.OtpResendRateLimited:
|
||||
# Silently accept; the user can retry after the cooldown.
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
await send_verification_email(db, to=payload.email, code=code, purpose="reset_password")
|
||||
except EmailNotConfiguredError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except EmailError as exc:
|
||||
logger.warning_event(
|
||||
"SMTP send failed",
|
||||
event="auth.email.send_failed",
|
||||
context={"email": payload.email, "purpose": "reset_password", "error": str(exc)},
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await _load_user_by_email(db, payload.email)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "OTP_INVALID"},
|
||||
)
|
||||
try:
|
||||
otp.verify_code(payload.email, "reset_password", payload.code)
|
||||
except otp.OtpExpired as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpAttemptsExceeded as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
except otp.OtpInvalid as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
await db.execute(
|
||||
text("UPDATE users SET password_hash = :p, email_verified = TRUE WHERE id = :id"),
|
||||
{"p": get_password_hash(payload.new_password), "id": user.id},
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -5,13 +5,26 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.user import User
|
||||
from app.services.bgp_collector_locations import (
|
||||
build_bgp_collector_location_query,
|
||||
collect_bgp_collector_location_candidates,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.ai_client import get_ai_provider_client
|
||||
from app.api.v1.settings import get_web_search_client
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -264,6 +277,119 @@ async def get_bgp_collector_summary(
|
||||
}
|
||||
|
||||
|
||||
class CollectBGPCollectorLocationRequest(BaseModel):
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/collectors/{collector_id}/collect-location")
|
||||
async def collect_bgp_collector_location(
|
||||
collector_id: str,
|
||||
payload: CollectBGPCollectorLocationRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run the shared location pipeline for a BGP route collector.
|
||||
|
||||
Mirrors ``POST /api/v1/visualization/compute-centers/{source_id}/collect-location``.
|
||||
Returns ranked candidates from source coordinates and Nominatim queries
|
||||
built around the collector's stored context (IXP / city / country). Stored
|
||||
collector locations provide context only; they are not emitted as
|
||||
candidates.
|
||||
"""
|
||||
if not collector_id or not collector_id.strip():
|
||||
raise HTTPException(status_code=400, detail="collector_id is required")
|
||||
|
||||
legacy = get_bgp_collector_location_dict(collector_id) or {}
|
||||
site = payload.site or legacy.get("matched_location_name")
|
||||
city = payload.city or legacy.get("city")
|
||||
country = payload.country or legacy.get("country")
|
||||
operator = payload.operator or "RIPE NCC"
|
||||
|
||||
candidates, attempted_queries = collect_bgp_collector_location_candidates(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
llm_failure_reason = None
|
||||
if not candidates:
|
||||
query = build_bgp_collector_location_query(
|
||||
collector=collector_id,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
operator=operator,
|
||||
)
|
||||
llm_result = None
|
||||
try:
|
||||
web_search_client = await get_web_search_client(db)
|
||||
search_result = await collect_location_search_evidence(
|
||||
web_search_client=web_search_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
)
|
||||
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||
if not search_result.evidence:
|
||||
llm_failure_reason = search_result.failure_reason
|
||||
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||
provider_client = await get_ai_provider_client(db)
|
||||
llm_result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="bgp_collector",
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
f"llm_factcheck:bgp_collector:{collector_id or 'unknown'}",
|
||||
]
|
||||
if llm_result is not None:
|
||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||
candidates = llm_result.candidates
|
||||
llm_failure_reason = llm_result.failure_reason
|
||||
|
||||
context = {
|
||||
"collector": collector_id,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"operator": operator,
|
||||
}
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates or online geocoding result reached"
|
||||
" city-level precision for this collector."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"llm_failure_reason": llm_failure_reason,
|
||||
"context": context,
|
||||
}
|
||||
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"name": collector_id,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": context,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/overview/summary")
|
||||
async def get_bgp_overview_summary(
|
||||
current_user: User = Depends(get_current_user),
|
||||
|
||||
98
backend/app/api/v1/data_products.py
Normal file
98
backend/app/api/v1/data_products.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import get_visualization_geo_summary
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
PRODUCT_DEFINITIONS: dict[str, dict] = {
|
||||
"vessels": {
|
||||
"name": "船只",
|
||||
"sources": ["aisstream_vessels", "barentswatch_vessels"],
|
||||
"primary_stat_key": "vessel_count",
|
||||
"stat_keys": ["vessel_count", "vessel_raw_unique_mmsi", "vessel_legacy_unique_mmsi"],
|
||||
},
|
||||
"cables": {
|
||||
"name": "海底光缆",
|
||||
"sources": [
|
||||
"arcgis_cables",
|
||||
"arcgis_landing_points",
|
||||
"arcgis_cable_landing_relation",
|
||||
"telegeography_cables",
|
||||
"telegeography_landing",
|
||||
"telegeography_systems",
|
||||
"fao_landing_points",
|
||||
],
|
||||
"primary_stat_key": "cable_count",
|
||||
"stat_keys": ["cable_count", "landing_point_count"],
|
||||
},
|
||||
"satellites": {
|
||||
"name": "卫星",
|
||||
"sources": ["celestrak_tle", "spacetrack_tle"],
|
||||
"primary_stat_key": "satellite_count",
|
||||
"stat_keys": ["satellite_count"],
|
||||
},
|
||||
"bgp": {
|
||||
"name": "BGP",
|
||||
"sources": [
|
||||
"ris_live_bgp",
|
||||
"bgpstream_bgp",
|
||||
"iptoasn_prefix_geo",
|
||||
"opengeofeed_prefix_geo",
|
||||
"nro_delegated_prefix_geo",
|
||||
],
|
||||
"primary_stat_key": "bgp_event_count",
|
||||
"stat_keys": ["bgp_event_count", "bgp_incident_count", "bgp_anomaly_count", "bgp_collector_count"],
|
||||
},
|
||||
"compute": {
|
||||
"name": "算力",
|
||||
"sources": ["top500", "epoch_ai_gpu"],
|
||||
"primary_stat_key": "compute_center_count",
|
||||
"stat_keys": ["compute_center_count", "supercomputer_count", "gpu_cluster_count"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_product_status(product_id: str, summary: dict) -> dict:
|
||||
definition = PRODUCT_DEFINITIONS[product_id]
|
||||
stats = summary.get("stats", {})
|
||||
product_stats = {key: stats.get(key, 0) for key in definition["stat_keys"]}
|
||||
total_count = int(product_stats.get(definition["primary_stat_key"]) or 0)
|
||||
return {
|
||||
"product_id": product_id,
|
||||
"name": definition["name"],
|
||||
"sources": definition["sources"],
|
||||
"generated_at": summary.get("generated_at") or to_iso8601_utc(datetime.now(UTC)),
|
||||
"total_count": total_count,
|
||||
"stats": product_stats,
|
||||
"build_state": "ready",
|
||||
"stats_scope": "global",
|
||||
"stats_freshness": "cached_or_indexed",
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_data_products(db: AsyncSession = Depends(get_db)):
|
||||
summary = await get_visualization_geo_summary(db)
|
||||
return {
|
||||
"generated_at": summary.get("generated_at"),
|
||||
"data": [
|
||||
_build_product_status(product_id, summary)
|
||||
for product_id in PRODUCT_DEFINITIONS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{product_id}/status")
|
||||
async def get_data_product_status(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if product_id not in PRODUCT_DEFINITIONS:
|
||||
raise HTTPException(status_code=404, detail="Unknown data product")
|
||||
summary = await get_visualization_geo_summary(db)
|
||||
return _build_product_status(product_id, summary)
|
||||
@@ -3,7 +3,8 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select, text
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
@@ -27,6 +28,29 @@ from app.services.scheduler import (
|
||||
router = APIRouter()
|
||||
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
||||
|
||||
PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("vessels", ("vessel", "ais")),
|
||||
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
|
||||
("satellites", ("tle", "satellite", "spacetrack", "celestrak")),
|
||||
("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")),
|
||||
("compute", ("top500", "gpu", "supercomputer", "compute")),
|
||||
("ai", ("huggingface", "epoch_ai")),
|
||||
("media", ("news", "tv", "live_stream")),
|
||||
)
|
||||
|
||||
|
||||
class DatasourceBatchTriggerRequest(BaseModel):
|
||||
source_ids: list[int] = Field(default_factory=list)
|
||||
force: bool = False
|
||||
module: Optional[str] = None
|
||||
product: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
priority: Optional[str] = None
|
||||
run_status: Optional[str] = None
|
||||
collected: Optional[bool] = None
|
||||
credential_status: Optional[str] = None
|
||||
q: Optional[str] = None
|
||||
|
||||
|
||||
def format_frequency_label(minutes: int) -> str:
|
||||
if minutes % 1440 == 0:
|
||||
@@ -47,6 +71,20 @@ def datasource_metadata(source: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def datasource_product_key(datasource: DataSource) -> str:
|
||||
haystack = " ".join(
|
||||
[
|
||||
datasource.source or "",
|
||||
datasource.name or "",
|
||||
datasource.collector_class or "",
|
||||
]
|
||||
).lower()
|
||||
for product, keywords in PRODUCT_SOURCE_KEYWORDS:
|
||||
if any(keyword in haystack for keyword in keywords):
|
||||
return product
|
||||
return "other"
|
||||
|
||||
|
||||
def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
|
||||
if datasource.last_run_at is None:
|
||||
return True
|
||||
@@ -110,6 +148,22 @@ async def _load_latest_task_ids(
|
||||
return {datasource_id: task_id for datasource_id, task_id in result.all()}
|
||||
|
||||
|
||||
async def _load_collected_record_counts(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
) -> dict[str, int]:
|
||||
if not sources:
|
||||
return {}
|
||||
|
||||
result = await db.execute(
|
||||
select(CollectedData.source, func.count(CollectedData.id))
|
||||
.where(CollectedData.source.in_(sources))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.group_by(CollectedData.source)
|
||||
)
|
||||
return {source: int(count or 0) for source, count in result.all()}
|
||||
|
||||
|
||||
async def _load_datasource_endpoint_overrides(
|
||||
db: AsyncSession,
|
||||
sources: list[str],
|
||||
@@ -161,6 +215,192 @@ async def _load_datasource_list_context(
|
||||
return running_tasks, endpoint_overrides
|
||||
|
||||
|
||||
def _apply_datasource_query_filters(
|
||||
query,
|
||||
*,
|
||||
module: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
priority: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
) -> object:
|
||||
if module:
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
if run_status and run_status not in {"running", "collected", "uncollected"}:
|
||||
if run_status == "not_run":
|
||||
query = query.where(DataSource.last_status.is_(None))
|
||||
else:
|
||||
query = query.where(DataSource.last_status == run_status)
|
||||
if q:
|
||||
like_value = f"%{q.strip()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
DataSource.name.ilike(like_value),
|
||||
DataSource.source.ilike(like_value),
|
||||
DataSource.collector_class.ilike(like_value),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def _filter_datasources_in_memory(
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
running_tasks: dict[int, CollectionTask],
|
||||
record_counts: dict[str, int],
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
) -> list[DataSource]:
|
||||
filtered: list[DataSource] = []
|
||||
for datasource in datasources:
|
||||
record_count = record_counts.get(datasource.source, 0)
|
||||
if product and datasource_product_key(datasource) != product:
|
||||
continue
|
||||
if collected is not None and (record_count > 0) != collected:
|
||||
continue
|
||||
if credential_status:
|
||||
metadata = datasource_metadata(datasource.source)
|
||||
if metadata["credential_status"] != credential_status:
|
||||
continue
|
||||
if run_status == "running" and datasource.id not in running_tasks:
|
||||
continue
|
||||
if run_status == "collected" and record_count <= 0:
|
||||
continue
|
||||
if run_status == "uncollected" and record_count > 0:
|
||||
continue
|
||||
filtered.append(datasource)
|
||||
return filtered
|
||||
|
||||
|
||||
async def _trigger_datasource_batch(
|
||||
db: AsyncSession,
|
||||
datasources: list[DataSource],
|
||||
*,
|
||||
force: bool,
|
||||
) -> dict:
|
||||
if not datasources:
|
||||
return {
|
||||
"status": "noop",
|
||||
"message": "No matching data sources to trigger",
|
||||
"force": force,
|
||||
"triggered": [],
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
running_tasks = await _load_latest_running_tasks(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
|
||||
for datasource in datasources:
|
||||
if not datasource.is_active:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "disabled",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
if running_task is not None:
|
||||
if not force:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "already_running",
|
||||
"task_id": running_task.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
cancelled = await cancel_running_collector_now(datasource.source)
|
||||
if not cancelled:
|
||||
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "within_frequency_window",
|
||||
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
||||
"next_run_at": to_iso8601_utc(
|
||||
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
|
||||
|
||||
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
|
||||
datasource = None
|
||||
try:
|
||||
@@ -355,16 +595,23 @@ async def list_datasources(
|
||||
module: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
priority: Optional[str] = None,
|
||||
product: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
collected: Optional[bool] = None,
|
||||
credential_status: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if module:
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
query = query.where(DataSource.priority == priority)
|
||||
query = _apply_datasource_query_filters(
|
||||
query,
|
||||
module=module,
|
||||
is_active=is_active,
|
||||
priority=priority,
|
||||
run_status=run_status,
|
||||
q=q,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
@@ -372,11 +619,22 @@ async def list_datasources(
|
||||
collector_list = []
|
||||
config = get_data_sources_config()
|
||||
running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
record_counts=record_counts,
|
||||
product=product,
|
||||
run_status=run_status,
|
||||
collected=collected,
|
||||
credential_status=credential_status,
|
||||
)
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
|
||||
last_run_at = datasource.last_run_at
|
||||
last_status = datasource.last_status
|
||||
collected_records = record_counts.get(datasource.source, 0)
|
||||
|
||||
collector_list.append(
|
||||
{
|
||||
@@ -384,6 +642,7 @@ async def list_datasources(
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
**datasource_metadata(datasource.source),
|
||||
"product": datasource_product_key(datasource),
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
"frequency": format_frequency_label(datasource.frequency_minutes),
|
||||
@@ -405,6 +664,8 @@ async def list_datasources(
|
||||
"phase_unit": running_task.phase_unit if running_task else None,
|
||||
"records_processed": running_task.records_processed if running_task else None,
|
||||
"total_records": running_task.total_records if running_task else None,
|
||||
"collected_records": collected_records,
|
||||
"has_collected_data": collected_records > 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -419,110 +680,46 @@ async def trigger_all_datasources(
|
||||
):
|
||||
result = await db.execute(
|
||||
select(DataSource)
|
||||
.where(DataSource.is_active == True)
|
||||
.where(DataSource.is_active.is_(True))
|
||||
.order_by(DataSource.module, DataSource.id)
|
||||
)
|
||||
datasources = result.scalars().all()
|
||||
return await _trigger_datasource_batch(db, datasources, force=force)
|
||||
|
||||
if not datasources:
|
||||
return {
|
||||
"status": "noop",
|
||||
"message": "No active data sources to trigger",
|
||||
"triggered": [],
|
||||
"skipped": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
previous_task_ids: dict[int, Optional[int]] = {}
|
||||
triggered_sources: list[dict] = []
|
||||
skipped_sources: list[dict] = []
|
||||
failed_sources: list[dict] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
running_tasks = await _load_latest_running_tasks(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
)
|
||||
|
||||
for datasource in datasources:
|
||||
running_task = running_tasks.get(datasource.id)
|
||||
if running_task is not None:
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "already_running",
|
||||
"task_id": running_task.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if not force and not is_due_for_collection(datasource, now):
|
||||
skipped_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "within_frequency_window",
|
||||
"last_run_at": to_iso8601_utc(datasource.last_run_at),
|
||||
"next_run_at": to_iso8601_utc(
|
||||
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
previous_task_ids[datasource.id] = None
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
failed_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"reason": "trigger_failed",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
triggered_sources.append(
|
||||
{
|
||||
"id": datasource.id,
|
||||
"source": datasource.source,
|
||||
"name": datasource.name,
|
||||
"task_id": None,
|
||||
}
|
||||
@router.post("/trigger-batch")
|
||||
async def trigger_datasource_batch(
|
||||
payload: DatasourceBatchTriggerRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if payload.source_ids:
|
||||
query = query.where(DataSource.id.in_(payload.source_ids))
|
||||
else:
|
||||
query = _apply_datasource_query_filters(
|
||||
query,
|
||||
module=payload.module,
|
||||
is_active=payload.is_active,
|
||||
priority=payload.priority,
|
||||
run_status=payload.run_status,
|
||||
q=payload.q,
|
||||
)
|
||||
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[datasource.id for datasource in datasources],
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
running_tasks, _ = await _load_datasource_list_context(db, datasources)
|
||||
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
|
||||
datasources = _filter_datasources_in_memory(
|
||||
datasources,
|
||||
running_tasks=running_tasks,
|
||||
record_counts=record_counts,
|
||||
product=None if payload.source_ids else payload.product,
|
||||
run_status=None if payload.source_ids else payload.run_status,
|
||||
collected=None if payload.source_ids else payload.collected,
|
||||
credential_status=None if payload.source_ids else payload.credential_status,
|
||||
)
|
||||
for datasource_id in previous_task_ids:
|
||||
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
|
||||
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.1)
|
||||
pending = [item for item in triggered_sources if item["task_id"] is None]
|
||||
if not pending:
|
||||
break
|
||||
latest_task_ids = await _load_latest_task_ids(
|
||||
db,
|
||||
[item["id"] for item in pending],
|
||||
)
|
||||
for item in pending:
|
||||
task_id = latest_task_ids.get(item["id"])
|
||||
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
|
||||
item["task_id"] = task_id
|
||||
|
||||
return {
|
||||
"status": "triggered" if triggered_sources else "partial",
|
||||
"message": f"Triggered {len(triggered_sources)} data sources",
|
||||
"force": force,
|
||||
"triggered": triggered_sources,
|
||||
"skipped": skipped_sources,
|
||||
"failed": failed_sources,
|
||||
}
|
||||
return await _trigger_datasource_batch(db, datasources, force=payload.force)
|
||||
|
||||
|
||||
@router.get("/{source_id}")
|
||||
|
||||
102
backend/app/api/v1/docs.py
Normal file
102
backend/app/api/v1/docs.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Authenticated documentation APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
from app.services.docs_gatekeeper import (
|
||||
DOCS_BY_SLUG,
|
||||
VALID_DOCS_LANGS,
|
||||
can_read_doc,
|
||||
catalog_for_user,
|
||||
doc_path_for,
|
||||
title_for,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
optional_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_optional_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
|
||||
) -> User | None:
|
||||
if credentials is None:
|
||||
return None
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
if payload is None or payload.get("type") != "access" or payload.get("sub") is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(payload["sub"])},
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row is None or not row[5]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or inactive",
|
||||
)
|
||||
|
||||
user = User()
|
||||
user.id = row[0]
|
||||
user.username = row[1]
|
||||
user.email = row[2]
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def get_docs_catalog(current_user: User | None = Depends(get_optional_current_user)):
|
||||
return {
|
||||
"items": catalog_for_user(current_user),
|
||||
"authenticated": current_user is not None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{lang}/{slug}")
|
||||
async def get_doc_content(
|
||||
lang: str,
|
||||
slug: str,
|
||||
current_user: User | None = Depends(get_optional_current_user),
|
||||
):
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
entry = DOCS_BY_SLUG.get(slug)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
path = doc_path_for(entry, lang)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
|
||||
|
||||
if not can_read_doc(entry, current_user):
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient Docs permissions")
|
||||
|
||||
return {
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
"markdown": path.read_text(encoding="utf-8"),
|
||||
}
|
||||
229
backend/app/api/v1/layers.py
Normal file
229
backend/app/api/v1/layers.py
Normal file
@@ -0,0 +1,229 @@
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import (
|
||||
_parse_bbox,
|
||||
get_bgp_anomalies_geojson,
|
||||
get_bgp_collectors_geojson,
|
||||
get_bgp_incidents_geojson,
|
||||
get_cables_geojson,
|
||||
get_landing_points_geojson,
|
||||
get_satellites_geojson,
|
||||
)
|
||||
from app.api.v1.vessels import build_vessel_snapshot_response
|
||||
from app.db.session import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DEFAULT_LAYER_LIMIT = 1000
|
||||
MAX_LAYER_LIMIT = 5000
|
||||
LOW_ZOOM_FEATURE_LIMIT = 500
|
||||
|
||||
|
||||
def _clamp_limit(limit: int, zoom: int) -> tuple[int, bool]:
|
||||
clamped = min(max(limit, 1), MAX_LAYER_LIMIT)
|
||||
if zoom <= 3:
|
||||
return min(clamped, LOW_ZOOM_FEATURE_LIMIT), clamped != limit or clamped > LOW_ZOOM_FEATURE_LIMIT
|
||||
return clamped, clamped != limit
|
||||
|
||||
|
||||
def _coordinate_in_bbox(coord: Any, bbox: tuple[float, float, float, float]) -> bool:
|
||||
if not isinstance(coord, (list, tuple)) or len(coord) < 2:
|
||||
return False
|
||||
try:
|
||||
lon = float(coord[0])
|
||||
lat = float(coord[1])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
return lon_min <= lon <= lon_max and lat_min <= lat <= lat_max
|
||||
|
||||
|
||||
def _geometry_intersects_bbox(geometry: dict, bbox: tuple[float, float, float, float]) -> bool:
|
||||
coordinates = geometry.get("coordinates")
|
||||
geometry_type = geometry.get("type")
|
||||
if geometry_type == "Point":
|
||||
return _coordinate_in_bbox(coordinates, bbox)
|
||||
if geometry_type in {"LineString", "MultiPoint"}:
|
||||
return any(_coordinate_in_bbox(coord, bbox) for coord in coordinates or [])
|
||||
if geometry_type in {"Polygon", "MultiLineString"}:
|
||||
return any(
|
||||
_coordinate_in_bbox(coord, bbox)
|
||||
for line in coordinates or []
|
||||
for coord in line
|
||||
)
|
||||
if geometry_type == "MultiPolygon":
|
||||
return any(
|
||||
_coordinate_in_bbox(coord, bbox)
|
||||
for polygon in coordinates or []
|
||||
for line in polygon
|
||||
for coord in line
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _guard_geojson_layer(
|
||||
geojson: dict,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float],
|
||||
zoom: int,
|
||||
limit: int,
|
||||
) -> dict:
|
||||
bounded_limit, limit_clamped = _clamp_limit(limit, zoom)
|
||||
features = [
|
||||
feature
|
||||
for feature in geojson.get("features", [])
|
||||
if _geometry_intersects_bbox(feature.get("geometry") or {}, bbox)
|
||||
]
|
||||
visible_count = len(features)
|
||||
returned_features = features[:bounded_limit]
|
||||
return {
|
||||
**geojson,
|
||||
"features": returned_features,
|
||||
"visible_count": visible_count,
|
||||
"returned_count": len(returned_features),
|
||||
"diagnostics": {
|
||||
"bbox_limited": True,
|
||||
"limit": bounded_limit,
|
||||
"limit_clamped": limit_clamped,
|
||||
"truncated": visible_count > len(returned_features),
|
||||
"degraded": zoom <= 3 or visible_count > len(returned_features),
|
||||
"stats_scope": "viewport",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_layer_bbox(bbox: str) -> tuple[float, float, float, float]:
|
||||
parsed = _parse_bbox(bbox)
|
||||
if parsed is None:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
return parsed
|
||||
|
||||
|
||||
@router.get("/vessels/snapshot")
|
||||
async def get_vessel_layer_snapshot(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
vessel_type: Optional[str] = Query(None, alias="type"),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
parsed_bbox = _parse_layer_bbox(bbox)
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
vessel_type=vessel_type,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cables")
|
||||
async def get_cable_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_cables_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/landing-points")
|
||||
async def get_landing_point_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_landing_points_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/satellites")
|
||||
async def get_satellite_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_satellites_geojson(limit=bounded_limit, db=db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/anomalies")
|
||||
async def get_bgp_anomaly_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_anomalies_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=bounded_limit,
|
||||
db=db,
|
||||
),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/incidents")
|
||||
async def get_bgp_incident_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
severity: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("active"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
bounded_limit, _ = _clamp_limit(limit, zoom)
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_incidents_geojson(
|
||||
severity=severity,
|
||||
status=status,
|
||||
limit=min(bounded_limit, 500),
|
||||
db=db,
|
||||
),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/bgp/collectors")
|
||||
async def get_bgp_collector_layer(
|
||||
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20),
|
||||
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return _guard_geojson_layer(
|
||||
await get_bgp_collectors_geojson(db),
|
||||
bbox=_parse_layer_bbox(bbox),
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -7,10 +8,12 @@ from sqlalchemy import text
|
||||
from app.core.security import get_current_user, get_password_hash
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserResponse, UserUpdate
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
VALID_GATEKEEPER_GROUPS = {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
|
||||
def check_permission(current_user: User, required_roles: List[str]) -> bool:
|
||||
user_role_value = (
|
||||
@@ -52,7 +55,7 @@ async def list_users(
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = text(
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
f"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE {where_sql} ORDER BY created_at DESC LIMIT {page_size} OFFSET {offset}"
|
||||
)
|
||||
count_query = text(f"SELECT COUNT(*) FROM users WHERE {where_sql}")
|
||||
|
||||
@@ -75,6 +78,7 @@ async def list_users(
|
||||
"is_active": u[4],
|
||||
"last_login_at": u[5],
|
||||
"created_at": u[6],
|
||||
"gatekeeper_groups": u[7] or [],
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -95,7 +99,7 @@ async def get_user(
|
||||
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, role, is_active, last_login_at, created_at, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": user_id},
|
||||
)
|
||||
@@ -114,6 +118,7 @@ async def get_user(
|
||||
"is_active": user[4],
|
||||
"last_login_at": user[5],
|
||||
"created_at": user[6],
|
||||
"gatekeeper_groups": user[7] or [],
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +133,12 @@ async def create_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can create users",
|
||||
)
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE username = :username OR email = :email"),
|
||||
@@ -142,13 +153,14 @@ async def create_user(
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
await db.execute(
|
||||
text("""INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, :is_active, NOW(), NOW())"""),
|
||||
text("""INSERT INTO users (username, email, password_hash, role, gatekeeper_groups, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password_hash, :role, CAST(:gatekeeper_groups AS jsonb), :is_active, NOW(), NOW())"""),
|
||||
{
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"password_hash": hashed_password,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": json.dumps(user_data.gatekeeper_groups),
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
@@ -172,6 +184,7 @@ async def create_user(
|
||||
"username": user_data.username,
|
||||
"email": user_data.email,
|
||||
"role": user_data.role,
|
||||
"gatekeeper_groups": user_data.gatekeeper_groups,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
@@ -194,6 +207,18 @@ async def update_user(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change user role",
|
||||
)
|
||||
if not check_permission(current_user, ["super_admin"]) and user_data.gatekeeper_groups is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only super_admin can change Gatekeeper groups",
|
||||
)
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
invalid_groups = sorted(set(user_data.gatekeeper_groups) - VALID_GATEKEEPER_GROUPS)
|
||||
if invalid_groups:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported Gatekeeper groups: {', '.join(invalid_groups)}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT id FROM users WHERE id = :id"),
|
||||
@@ -213,6 +238,9 @@ async def update_user(
|
||||
if user_data.role is not None:
|
||||
update_fields.append("role = :role")
|
||||
params["role"] = user_data.role
|
||||
if user_data.gatekeeper_groups is not None:
|
||||
update_fields.append("gatekeeper_groups = CAST(:gatekeeper_groups AS jsonb)")
|
||||
params["gatekeeper_groups"] = json.dumps(user_data.gatekeeper_groups)
|
||||
if user_data.is_active is not None:
|
||||
update_fields.append("is_active = :is_active")
|
||||
params["is_active"] = user_data.is_active
|
||||
|
||||
39
backend/app/api/v1/vessels.py
Normal file
39
backend/app/api/v1/vessels.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Bounded vessel snapshot APIs for viewport-first consumers."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
|
||||
from app.db.session import get_db
|
||||
from app.services.vessel_ais_aggregation import MAX_SNAPSHOT_LIMIT
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/snapshot")
|
||||
async def get_vessel_snapshot(
|
||||
bbox: Optional[str] = Query(None, description="Viewport bbox as lon_min,lat_min,lon_max,lat_max"),
|
||||
zoom: int = Query(..., ge=1, le=20, description="Current map zoom level"),
|
||||
type: Optional[str] = Query(
|
||||
None,
|
||||
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
|
||||
),
|
||||
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
|
||||
since_minutes: int = Query(60, ge=1, le=1440),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not bbox:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
parsed_bbox = _parse_bbox(bbox)
|
||||
if parsed_bbox is None:
|
||||
raise HTTPException(status_code=400, detail="bbox is required")
|
||||
return await build_vessel_snapshot_response(
|
||||
db,
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
type_filter=type,
|
||||
limit=limit,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
@@ -4,17 +4,20 @@ Unified API for all visualization data sources.
|
||||
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from collections import OrderedDict
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import math
|
||||
import re
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.core.countries import get_country_centroid
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import get_db
|
||||
@@ -25,7 +28,22 @@ from app.models.collected_data import CollectedData
|
||||
from app.models.vessel import AISSourceHealth, VesselPosition, VesselStatic
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance
|
||||
from app.services.compute_center_locations import (
|
||||
RENDERABLE_PRECISIONS,
|
||||
ResolutionDiagnostic,
|
||||
build_compute_center_location_query,
|
||||
collect_location_candidates,
|
||||
refresh_compute_center_location_cache,
|
||||
resolve_compute_center_location_full,
|
||||
upsert_compute_center_location,
|
||||
)
|
||||
from app.services.ai_client import get_ai_provider_client
|
||||
from app.api.v1.settings import get_web_search_client
|
||||
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
from app.services.persistent_logs import record_system_log
|
||||
from app.services.vessel_ais_aggregation import (
|
||||
build_field_conflict_candidates,
|
||||
@@ -33,8 +51,10 @@ from app.services.vessel_ais_aggregation import (
|
||||
get_aggregated_vessel,
|
||||
get_aggregated_vessel_track,
|
||||
get_aggregated_vessels,
|
||||
get_aggregated_vessels_snapshot,
|
||||
get_vessel_conflict_records,
|
||||
get_vessel_raw_observations,
|
||||
MAX_SNAPSHOT_LIMIT,
|
||||
)
|
||||
from app.core.logging import get_logger
|
||||
|
||||
@@ -43,9 +63,23 @@ logger = get_logger(__name__, service="api")
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
TERRAIN_TILE_CACHE_MAX_ITEMS = 512
|
||||
TERRAIN_TILE_BATCH_MAX_ITEMS = 128
|
||||
TERRAIN_TILE_BATCH_CONCURRENCY = 16
|
||||
_terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict()
|
||||
VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
|
||||
|
||||
|
||||
class TerrariumTileRequest(BaseModel):
|
||||
z: int = Field(ge=0, le=14)
|
||||
x: int = Field(ge=0)
|
||||
y: int = Field(ge=0)
|
||||
|
||||
|
||||
class TerrariumTileBatchRequest(BaseModel):
|
||||
tiles: List[TerrariumTileRequest] = Field(min_length=1, max_length=TERRAIN_TILE_BATCH_MAX_ITEMS)
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
|
||||
|
||||
@@ -536,100 +570,6 @@ def _parse_float(value: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
COMPUTE_CENTER_COORDINATE_HINTS = (
|
||||
("el capitan", 37.6819, -121.7681),
|
||||
("livermore", 37.6819, -121.7681),
|
||||
("llnl", 37.6819, -121.7681),
|
||||
("lawrence livermore", 37.6819, -121.7681),
|
||||
("frontier", 35.9319, -84.3107),
|
||||
("oak ridge", 35.9319, -84.3107),
|
||||
("ornl", 35.9319, -84.3107),
|
||||
("aurora", 41.7130, -87.9820),
|
||||
("argonne", 41.7130, -87.9820),
|
||||
("anl", 41.7130, -87.9820),
|
||||
("fugaku", 34.6953, 135.1974),
|
||||
("kobe", 34.6953, 135.1974),
|
||||
("riken", 34.6953, 135.1974),
|
||||
("summit", 35.9319, -84.3107),
|
||||
("leonardo", 44.4949, 11.3426),
|
||||
("bologna", 44.4949, 11.3426),
|
||||
("alps", 46.0037, 8.9511),
|
||||
("lugano", 46.0037, 8.9511),
|
||||
("sunway taihulight", 31.4912, 120.3119),
|
||||
("wuxi", 31.4912, 120.3119),
|
||||
("tianhe-2", 23.1291, 113.2644),
|
||||
("tianhe-2a", 23.1291, 113.2644),
|
||||
("guangzhou", 23.1291, 113.2644),
|
||||
("colossus", 35.1495, -90.0490),
|
||||
("memphis", 35.1495, -90.0490),
|
||||
("xai", 35.1495, -90.0490),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hint_text(*parts: Any) -> str:
|
||||
return " ".join(
|
||||
str(part).strip().lower()
|
||||
for part in parts
|
||||
if part not in (None, "")
|
||||
)
|
||||
|
||||
|
||||
def _resolve_compute_center_coordinates(
|
||||
record: CollectedData,
|
||||
metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
latitude = _parse_float(get_record_field(record, "latitude"))
|
||||
longitude = _parse_float(get_record_field(record, "longitude"))
|
||||
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "precise",
|
||||
"geography_mode": "source_coordinates",
|
||||
"is_estimated": False,
|
||||
"estimated_reason": None,
|
||||
}
|
||||
|
||||
hint_text = _normalize_hint_text(
|
||||
record.name,
|
||||
get_record_field(record, "city"),
|
||||
get_record_field(record, "country"),
|
||||
metadata.get("site"),
|
||||
metadata.get("organization"),
|
||||
metadata.get("operator"),
|
||||
)
|
||||
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
|
||||
if needle in hint_text:
|
||||
return {
|
||||
"latitude": resolved_latitude,
|
||||
"longitude": resolved_longitude,
|
||||
"location_precision": "estimated_site",
|
||||
"geography_mode": "site_hint",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": f"Matched known site hint: {needle}",
|
||||
}
|
||||
|
||||
centroid = get_country_centroid(get_record_field(record, "country"))
|
||||
if centroid:
|
||||
return {
|
||||
"latitude": centroid.get("latitude"),
|
||||
"longitude": centroid.get("longitude"),
|
||||
"location_precision": "estimated_country",
|
||||
"geography_mode": "country_centroid",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "Estimated from country centroid",
|
||||
}
|
||||
|
||||
return {
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"location_precision": "unknown",
|
||||
"geography_mode": "unknown",
|
||||
"is_estimated": True,
|
||||
"estimated_reason": "No resolvable location hints",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
|
||||
if capacity_value is None:
|
||||
return "unknown"
|
||||
@@ -654,22 +594,49 @@ def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str
|
||||
|
||||
|
||||
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer."""
|
||||
features = []
|
||||
"""Convert compute infrastructure records into a unified GeoJSON layer.
|
||||
|
||||
Records that cannot be resolved to at least city-level precision are NOT
|
||||
silently dropped: they are returned in ``unresolved`` so the UI can offer
|
||||
the click-to-collect coordinate flow. The features list never contains
|
||||
``[0, 0]`` placeholders or country/region/unknown precision points.
|
||||
"""
|
||||
features: List[Dict[str, Any]] = []
|
||||
unresolved: List[Dict[str, Any]] = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
|
||||
latitude = coordinate_info.get("latitude")
|
||||
longitude = coordinate_info.get("longitude")
|
||||
result = resolve_compute_center_location_full(record, metadata)
|
||||
site_type = (
|
||||
"supercomputer"
|
||||
if record.source == "top500" or record.data_type == "supercomputer"
|
||||
else "gpu_cluster"
|
||||
)
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
|
||||
if not result.is_resolved:
|
||||
diagnostic = result.diagnostic or ResolutionDiagnostic(
|
||||
failure_reason="Unknown resolver failure",
|
||||
attempted_queries=(),
|
||||
record_id=getattr(record, "id", None),
|
||||
source=getattr(record, "source", None),
|
||||
source_id=getattr(record, "source_id", None),
|
||||
name=getattr(record, "name", None),
|
||||
)
|
||||
unresolved.append({
|
||||
**diagnostic.to_dict(),
|
||||
"site_type": site_type,
|
||||
})
|
||||
continue
|
||||
|
||||
location = result.location
|
||||
if location is None or not location.is_renderable:
|
||||
# Defensive: should not happen because is_resolved guards this.
|
||||
continue
|
||||
|
||||
location_props = location.to_geojson_properties()
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
|
||||
if site_type == "supercomputer":
|
||||
capacity_value = _parse_float(get_record_field(record, "rmax"))
|
||||
capacity_unit = "GFlops"
|
||||
@@ -699,15 +666,16 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
"id": record.id,
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [longitude or 0, latitude or 0],
|
||||
"coordinates": [longitude, latitude],
|
||||
},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"site_type": site_type,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"country": get_record_field(record, "country") or location.country,
|
||||
"city": get_record_field(record, "city") or location.city,
|
||||
"region": location.region,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"operator": operator,
|
||||
@@ -723,17 +691,14 @@ def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str
|
||||
"source": record.source,
|
||||
"updated_at": updated_at,
|
||||
"status": "observed",
|
||||
"location_precision": coordinate_info.get("location_precision"),
|
||||
"geography_mode": coordinate_info.get("geography_mode"),
|
||||
"is_estimated": coordinate_info.get("is_estimated", False),
|
||||
"estimated_reason": coordinate_info.get("estimated_reason"),
|
||||
**location_props,
|
||||
"data_type": "compute_center",
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
return {"type": "FeatureCollection", "features": features, "unresolved": unresolved}
|
||||
|
||||
|
||||
VESSEL_TYPE_FILTERS = {
|
||||
@@ -997,6 +962,52 @@ def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _safe_vessel_limit(value: int | None, *, default: int = 1000) -> int:
|
||||
if value is None or value <= 0:
|
||||
return default
|
||||
return min(value, MAX_SNAPSHOT_LIMIT)
|
||||
|
||||
|
||||
async def build_vessel_snapshot_response(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float] | None,
|
||||
zoom: int | None,
|
||||
type_filter: str | None,
|
||||
limit: int | None,
|
||||
since_minutes: int = 60,
|
||||
) -> dict[str, Any]:
|
||||
requested_types = _requested_vessel_types(type_filter)
|
||||
safe_limit = _safe_vessel_limit(limit)
|
||||
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
|
||||
observed_since = datetime.now(UTC) - timedelta(minutes=safe_since_minutes)
|
||||
features, diagnostics = await _load_raw_vessel_snapshot_features(
|
||||
db,
|
||||
bbox=bbox,
|
||||
limit=safe_limit,
|
||||
observed_since=observed_since,
|
||||
)
|
||||
features = _filter_vessel_features(
|
||||
features,
|
||||
bbox=bbox,
|
||||
requested_types=requested_types,
|
||||
)[:safe_limit]
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"count": len(features),
|
||||
"stats": _build_vessel_stats(features),
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"filtered_count": len(features),
|
||||
"bbox_applied": bbox is not None,
|
||||
"zoom": zoom,
|
||||
"limit": safe_limit,
|
||||
"since_minutes": safe_since_minutes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def convert_bgp_anomalies_to_geojson(
|
||||
records: List[BGPAnomaly],
|
||||
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
|
||||
@@ -1486,18 +1497,12 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
@router.get("/terrain/terrarium/{z}/{x}/{y}.png")
|
||||
async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
"""Proxy Terrarium elevation tiles through the backend to avoid browser CORS issues."""
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
if not _is_valid_terrain_tile(z, x, y):
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=20.0,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
content, content_type, headers = await _fetch_terrain_tile(client, z, x, y)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(
|
||||
status_code=exc.response.status_code,
|
||||
@@ -1509,22 +1514,140 @@ async def get_terrarium_tile(z: int, x: int, y: int):
|
||||
detail=f"Terrain tile fetch failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=content_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_terrain_tile(z: int, x: int, y: int) -> bool:
|
||||
if z < 0 or x < 0 or y < 0:
|
||||
return False
|
||||
max_tile = 2 ** z
|
||||
return x < max_tile and y < max_tile
|
||||
|
||||
|
||||
def _get_cached_terrain_tile(z: int, x: int, y: int) -> tuple[bytes, str, dict[str, str]] | None:
|
||||
key = (z, x, y)
|
||||
cached = _terrain_tile_cache.get(key)
|
||||
if cached is None:
|
||||
return None
|
||||
_terrain_tile_cache.move_to_end(key)
|
||||
content, content_type, headers = cached
|
||||
return content, content_type, dict(headers)
|
||||
|
||||
|
||||
def _cache_terrain_tile(
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
headers: dict[str, str],
|
||||
) -> None:
|
||||
key = (z, x, y)
|
||||
_terrain_tile_cache[key] = (content, content_type, dict(headers))
|
||||
_terrain_tile_cache.move_to_end(key)
|
||||
while len(_terrain_tile_cache) > TERRAIN_TILE_CACHE_MAX_ITEMS:
|
||||
_terrain_tile_cache.popitem(last=False)
|
||||
|
||||
|
||||
async def _fetch_terrain_tile(
|
||||
client: httpx.AsyncClient,
|
||||
z: int,
|
||||
x: int,
|
||||
y: int,
|
||||
) -> tuple[bytes, str, dict[str, str]]:
|
||||
cached = _get_cached_terrain_tile(z, x, y)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
url = TERRAIN_TILE_URL_TEMPLATE.format(z=z, x=x, y=y)
|
||||
upstream = await client.get(url)
|
||||
upstream.raise_for_status()
|
||||
|
||||
cache_control = upstream.headers.get("cache-control") or "public, max-age=86400"
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
headers = {
|
||||
"Cache-Control": cache_control,
|
||||
}
|
||||
etag = upstream.headers.get("etag")
|
||||
last_modified = upstream.headers.get("last-modified")
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if last_modified:
|
||||
headers["Last-Modified"] = last_modified
|
||||
|
||||
return Response(
|
||||
content=upstream.content,
|
||||
media_type=upstream.headers.get("content-type", "image/png"),
|
||||
headers=headers,
|
||||
)
|
||||
content_type = upstream.headers.get("content-type", "image/png")
|
||||
content = upstream.content
|
||||
_cache_terrain_tile(z, x, y, content, content_type, headers)
|
||||
return content, content_type, dict(headers)
|
||||
|
||||
|
||||
@router.post("/terrain/terrarium/batch")
|
||||
async def get_terrarium_tile_batch(payload: TerrariumTileBatchRequest):
|
||||
"""Fetch Terrarium elevation tiles in batches so the browser avoids many tiny requests."""
|
||||
unique_tiles: list[TerrariumTileRequest] = []
|
||||
seen: set[tuple[int, int, int]] = set()
|
||||
for tile in payload.tiles:
|
||||
key = (tile.z, tile.x, tile.y)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if not _is_valid_terrain_tile(tile.z, tile.x, tile.y):
|
||||
raise HTTPException(status_code=400, detail="Invalid terrain tile coordinates")
|
||||
unique_tiles.append(tile)
|
||||
|
||||
semaphore = asyncio.Semaphore(TERRAIN_TILE_BATCH_CONCURRENCY)
|
||||
results: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, Any]] = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
|
||||
async def fetch_one(tile: TerrariumTileRequest) -> None:
|
||||
async with semaphore:
|
||||
try:
|
||||
content, content_type, _headers = await _fetch_terrain_tile(
|
||||
client,
|
||||
tile.z,
|
||||
tile.x,
|
||||
tile.y,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"content_type": content_type,
|
||||
"data": base64.b64encode(content).decode("ascii"),
|
||||
},
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"status_code": exc.response.status_code,
|
||||
"message": f"upstream error: {exc.response.status_code}",
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
errors.append(
|
||||
{
|
||||
"z": tile.z,
|
||||
"x": tile.x,
|
||||
"y": tile.y,
|
||||
"status_code": 502,
|
||||
"message": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.gather(*(fetch_one(tile) for tile in unique_tiles))
|
||||
|
||||
return {
|
||||
"tiles": results,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/all")
|
||||
@@ -1659,33 +1782,297 @@ async def get_compute_centers_geojson(
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [],
|
||||
"unresolved": [],
|
||||
"count": 0,
|
||||
"stats": {
|
||||
"total": 0,
|
||||
"supercomputers": 0,
|
||||
"gpu_clusters": 0,
|
||||
"unresolved": 0,
|
||||
},
|
||||
}
|
||||
|
||||
await refresh_compute_center_location_cache(db)
|
||||
geojson = convert_compute_centers_to_geojson(records)
|
||||
features = geojson.get("features", [])
|
||||
unresolved = geojson.get("unresolved", [])
|
||||
# Belt-and-suspenders: ensure no Feature ever sneaks through without
|
||||
# city-or-better precision and finite, non-zero coordinates.
|
||||
sanitized_features: List[Dict[str, Any]] = []
|
||||
for feature in features:
|
||||
coords = feature.get("geometry", {}).get("coordinates") or []
|
||||
precision = feature.get("properties", {}).get("location_precision")
|
||||
if precision not in RENDERABLE_PRECISIONS:
|
||||
unresolved.append({
|
||||
"failure_reason": f"Rejected non-renderable precision '{precision}'",
|
||||
"record_id": feature.get("id"),
|
||||
"source_id": feature.get("properties", {}).get("source_id"),
|
||||
"name": feature.get("properties", {}).get("name"),
|
||||
})
|
||||
continue
|
||||
if (
|
||||
len(coords) != 2
|
||||
or coords[0] in (None, 0, 0.0)
|
||||
or coords[1] in (None, 0, 0.0)
|
||||
):
|
||||
unresolved.append({
|
||||
"failure_reason": "Rejected feature with [0,0] or invalid coordinates",
|
||||
"record_id": feature.get("id"),
|
||||
"source_id": feature.get("properties", {}).get("source_id"),
|
||||
"name": feature.get("properties", {}).get("name"),
|
||||
})
|
||||
continue
|
||||
sanitized_features.append(feature)
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(features),
|
||||
"type": "FeatureCollection",
|
||||
"features": sanitized_features,
|
||||
"unresolved": unresolved,
|
||||
"count": len(sanitized_features),
|
||||
"stats": {
|
||||
"total": len(features),
|
||||
"total": len(sanitized_features),
|
||||
"supercomputers": sum(
|
||||
1 for feature in features
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "supercomputer"
|
||||
),
|
||||
"gpu_clusters": sum(
|
||||
1 for feature in features
|
||||
1 for feature in sanitized_features
|
||||
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
|
||||
),
|
||||
"unresolved": len(unresolved),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CollectComputeCenterLocationRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
organization: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
record_id: Optional[int] = Field(default=None, alias="id")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class SaveComputeCenterLocationRequest(BaseModel):
|
||||
source: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
operator: Optional[str] = None
|
||||
site: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
latitude: float
|
||||
longitude: float
|
||||
precision: str = "city"
|
||||
confidence: Optional[float] = None
|
||||
location_source: Optional[str] = None
|
||||
source_url: Optional[str] = None
|
||||
source_note: Optional[str] = None
|
||||
raw_payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
needs_confirmation: bool = False
|
||||
verification_status: Optional[str] = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
@router.post("/compute-centers/{source_id}/collect-location")
|
||||
async def collect_compute_center_location(
|
||||
source_id: str,
|
||||
payload: CollectComputeCenterLocationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Run the full multi-query location collection pipeline for a record.
|
||||
|
||||
The endpoint accepts the source_id of a compute center plus contextual
|
||||
fields (name/operator/site/city/country/...) and returns ranked candidate
|
||||
locations from source coordinates, open organization lookups, and online
|
||||
geocoding combinations. The caller never has to type coordinates by hand:
|
||||
if any candidate is accepted it can be applied directly. If no candidate
|
||||
can reach city-level precision the response includes an explicit
|
||||
``failure_reason`` and the list of attempted queries.
|
||||
"""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
name = payload.name or (record.name if record else None)
|
||||
metadata = (record.extra_data or {}) if record else {}
|
||||
|
||||
operator = payload.operator or metadata.get("operator") or metadata.get("organization") or metadata.get("owner")
|
||||
site = payload.site or metadata.get("site")
|
||||
organization = payload.organization or metadata.get("organization")
|
||||
city = payload.city or get_record_field(record, "city") if record else payload.city
|
||||
country = payload.country or (get_record_field(record, "country") if record else None)
|
||||
source = payload.source or (record.source if record else None)
|
||||
record_id = payload.record_id or (record.id if record else None)
|
||||
|
||||
candidates, attempted_queries = collect_location_candidates(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
organization=organization,
|
||||
city=city,
|
||||
country=country,
|
||||
record_id=record_id,
|
||||
)
|
||||
llm_failure_reason = None
|
||||
if not candidates:
|
||||
query = build_compute_center_location_query(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
organization=organization,
|
||||
city=city,
|
||||
country=country,
|
||||
)
|
||||
llm_result = None
|
||||
try:
|
||||
web_search_client = await get_web_search_client(db)
|
||||
search_result = await collect_location_search_evidence(
|
||||
web_search_client=web_search_client,
|
||||
query=query,
|
||||
entity_type="compute_center",
|
||||
)
|
||||
attempted_queries = [*attempted_queries, *search_result.attempted_queries]
|
||||
if not search_result.evidence:
|
||||
llm_failure_reason = search_result.failure_reason
|
||||
raise RuntimeError(search_result.failure_reason or "no WebSearch evidence")
|
||||
provider_client = await get_ai_provider_client(db)
|
||||
llm_result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=provider_client,
|
||||
query=query,
|
||||
entity_type="compute_center",
|
||||
attempted_queries=attempted_queries,
|
||||
search_evidence=search_result.evidence,
|
||||
)
|
||||
except Exception as exc:
|
||||
if llm_failure_reason is None:
|
||||
llm_failure_reason = f"LLM location factcheck unavailable: {exc}"
|
||||
attempted_queries = [
|
||||
*attempted_queries,
|
||||
f"llm_factcheck:compute_center:{name or source_id or 'unknown'}",
|
||||
]
|
||||
if llm_result is not None:
|
||||
attempted_queries = [*attempted_queries, *llm_result.attempted_queries]
|
||||
candidates = llm_result.candidates
|
||||
llm_failure_reason = llm_result.failure_reason
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"record_id": record_id,
|
||||
"name": name,
|
||||
"success": False,
|
||||
"failure_reason": (
|
||||
"No source coordinates, organization lookup, or online geocoding"
|
||||
" result reached city-level precision."
|
||||
),
|
||||
"candidates": [],
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"llm_failure_reason": llm_failure_reason,
|
||||
"context": {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"record_id": record_id,
|
||||
"name": name,
|
||||
"success": True,
|
||||
"candidates": [candidate.to_dict() for candidate in candidates],
|
||||
"best_candidate": candidates[0].to_dict(),
|
||||
"attempted_queries": list(attempted_queries),
|
||||
"context": {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/compute-centers/{source_id}/location")
|
||||
async def save_compute_center_location(
|
||||
source_id: str,
|
||||
payload: SaveComputeCenterLocationRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Persist the user-selected compute-center location candidate."""
|
||||
if not source_id or not source_id.strip():
|
||||
raise HTTPException(status_code=400, detail="source_id is required")
|
||||
if payload.latitude in (0.0, None) or payload.longitude in (0.0, None):
|
||||
raise HTTPException(status_code=400, detail="latitude/longitude are required")
|
||||
if payload.precision not in RENDERABLE_PRECISIONS:
|
||||
raise HTTPException(status_code=400, detail="precision must be precise, site, or city")
|
||||
|
||||
record = await _load_compute_center_record(db, source_id)
|
||||
metadata = (record.extra_data or {}) if record else {}
|
||||
record_source = payload.source or (record.source if record else None)
|
||||
if not record_source:
|
||||
raise HTTPException(status_code=400, detail="source is required for unknown compute center")
|
||||
|
||||
operator = (
|
||||
payload.operator
|
||||
or metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
)
|
||||
site = payload.site or metadata.get("site") or metadata.get("organization")
|
||||
saved = await upsert_compute_center_location(
|
||||
db,
|
||||
source=record_source,
|
||||
source_id=source_id,
|
||||
name=payload.name or (record.name if record else None),
|
||||
operator=operator,
|
||||
site=site,
|
||||
city=payload.city or (get_record_field(record, "city") if record else None),
|
||||
country=payload.country or (get_record_field(record, "country") if record else None),
|
||||
latitude=payload.latitude,
|
||||
longitude=payload.longitude,
|
||||
precision=payload.precision,
|
||||
confidence=payload.confidence,
|
||||
location_source=payload.location_source or "manual_selection",
|
||||
source_url=payload.source_url,
|
||||
source_note=payload.source_note,
|
||||
raw_payload=payload.raw_payload,
|
||||
needs_confirmation=payload.needs_confirmation,
|
||||
verification_status=payload.verification_status
|
||||
or ("unverified" if payload.needs_confirmation else "verified"),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"source": saved.source,
|
||||
"source_id": saved.source_id,
|
||||
"location": saved.to_location_dict(),
|
||||
}
|
||||
|
||||
|
||||
async def _load_compute_center_record(db: AsyncSession, source_id: str) -> CollectedData | None:
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source_id == source_id)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.order_by(CollectedData.is_current.desc(), CollectedData.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
@router.get("/geo/vessels")
|
||||
async def get_vessels_geojson(
|
||||
bbox: Optional[str] = Query(
|
||||
@@ -1703,66 +2090,58 @@ async def get_vessels_geojson(
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return latest vessel positions as GeoJSON points."""
|
||||
parsed_bbox = _parse_bbox(bbox)
|
||||
requested_types = _requested_vessel_types(type)
|
||||
merged_features, diagnostics = await _load_merged_vessel_features(db)
|
||||
features = _filter_vessel_features(
|
||||
merged_features,
|
||||
bbox=parsed_bbox,
|
||||
requested_types=requested_types,
|
||||
"""Legacy vessel endpoint removed in favor of /api/v1/vessels/snapshot."""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail=(
|
||||
"Legacy vessel GeoJSON endpoint has been removed. "
|
||||
"Use /api/v1/vessels/snapshot with bbox, zoom, and limit."
|
||||
),
|
||||
)
|
||||
if limit and limit > 0:
|
||||
features = features[:limit]
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"count": len(features),
|
||||
"stats": _build_vessel_stats(features),
|
||||
"diagnostics": {
|
||||
**diagnostics,
|
||||
"filtered_count": len(features),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_merged_vessel_features(db: AsyncSession) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
aggregated_vessels = await get_aggregated_vessels(db)
|
||||
async def _load_raw_vessel_snapshot_features(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float] | None,
|
||||
limit: int,
|
||||
observed_since: datetime,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
if bbox is None:
|
||||
aggregated_vessels = await get_aggregated_vessels(
|
||||
db,
|
||||
limit=limit,
|
||||
observed_since=observed_since,
|
||||
)
|
||||
else:
|
||||
aggregated_vessels = await get_aggregated_vessels_snapshot(
|
||||
db,
|
||||
bbox=bbox,
|
||||
limit=limit,
|
||||
observed_since=observed_since,
|
||||
)
|
||||
raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels)
|
||||
|
||||
latest_times = (
|
||||
select(
|
||||
VesselPosition.mmsi.label("mmsi"),
|
||||
func.max(VesselPosition.received_at).label("received_at"),
|
||||
)
|
||||
.group_by(VesselPosition.mmsi)
|
||||
.subquery()
|
||||
)
|
||||
stmt = (
|
||||
select(VesselPosition, VesselStatic)
|
||||
.join(
|
||||
latest_times,
|
||||
(VesselPosition.mmsi == latest_times.c.mmsi)
|
||||
& (VesselPosition.received_at == latest_times.c.received_at),
|
||||
)
|
||||
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
|
||||
.order_by(VesselPosition.received_at.desc())
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.all())
|
||||
legacy_geojson = convert_vessels_to_geojson(rows)
|
||||
merged_features, diagnostics = _merge_vessel_features(
|
||||
raw_geojson.get("features", []),
|
||||
legacy_geojson.get("features", []),
|
||||
)
|
||||
return merged_features, {
|
||||
**diagnostics,
|
||||
"raw_feature_count": len(raw_geojson.get("features", [])),
|
||||
"legacy_feature_count": len(legacy_geojson.get("features", [])),
|
||||
features = raw_geojson.get("features", [])
|
||||
return features, {
|
||||
"raw_feature_count": len(features),
|
||||
"raw_unique_mmsi": len(
|
||||
{
|
||||
key
|
||||
for key in (_feature_mmsi_key(feature) for feature in features)
|
||||
if key is not None
|
||||
}
|
||||
),
|
||||
"legacy_feature_count": 0,
|
||||
"legacy_backfilled_mmsi": 0,
|
||||
"final_unique_mmsi": len(
|
||||
{
|
||||
key
|
||||
for key in (_feature_mmsi_key(feature) for feature in features)
|
||||
if key is not None
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/vessels/custom-supplements")
|
||||
async def get_vessel_custom_supplements(db: AsyncSession = Depends(get_db)):
|
||||
"""Group custom vessel_ais sources by their declared merge target for diagnostics."""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""WebSocket API endpoints"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
@@ -95,14 +94,44 @@ async def websocket_endpoint(
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "subscribe":
|
||||
channels = data.get("data", {}).get("channels", [])
|
||||
payload_data = data.get("data", {})
|
||||
if not isinstance(payload_data, dict):
|
||||
payload_data = {}
|
||||
channels = payload_data.get("channels", [])
|
||||
if isinstance(channels, str):
|
||||
channels = [channels]
|
||||
elif not isinstance(channels, list):
|
||||
channels = []
|
||||
channel = payload_data.get("channel")
|
||||
if channel and channel not in channels:
|
||||
channels = [*channels, channel]
|
||||
if is_anonymous:
|
||||
channels = [channel for channel in channels if channel in supported_channels]
|
||||
vessel_subscription = None
|
||||
if "vessels" in channels and "bbox" in payload_data:
|
||||
try:
|
||||
vessel_subscription = manager.subscribe_vessels(websocket, payload_data)
|
||||
except ValueError as exc:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_error",
|
||||
"data": {"channel": "vessels", "detail": str(exc)},
|
||||
}
|
||||
)
|
||||
continue
|
||||
channels = [channel for channel in channels if channel != "vessels"]
|
||||
manager.subscribe(websocket, channels)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_confirmed",
|
||||
"data": {"action": "subscribe", "channels": channels},
|
||||
"data": {
|
||||
"action": "subscribe",
|
||||
"channels": [
|
||||
*channels,
|
||||
*(["vessels"] if vessel_subscription else []),
|
||||
],
|
||||
"vessels": vessel_subscription,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "unsubscribe":
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Any, Dict, Optional
|
||||
FIELD_ALIASES = {
|
||||
"country": ("country",),
|
||||
"city": ("city",),
|
||||
"latitude": ("latitude",),
|
||||
"longitude": ("longitude",),
|
||||
"latitude": ("latitude", "lat"),
|
||||
"longitude": ("longitude", "lon", "lng"),
|
||||
"value": ("value",),
|
||||
"unit": ("unit",),
|
||||
"cores": ("cores",),
|
||||
@@ -14,6 +14,28 @@ FIELD_ALIASES = {
|
||||
"power": ("power",),
|
||||
}
|
||||
|
||||
NESTED_FIELD_ALIASES = {
|
||||
"latitude": (
|
||||
("location", "latitude"),
|
||||
("location", "lat"),
|
||||
("geo", "latitude"),
|
||||
("geo", "lat"),
|
||||
("coordinates", "latitude"),
|
||||
("coordinates", "lat"),
|
||||
),
|
||||
"longitude": (
|
||||
("location", "longitude"),
|
||||
("location", "lon"),
|
||||
("location", "lng"),
|
||||
("geo", "longitude"),
|
||||
("geo", "lon"),
|
||||
("geo", "lng"),
|
||||
("coordinates", "longitude"),
|
||||
("coordinates", "lon"),
|
||||
("coordinates", "lng"),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any:
|
||||
if isinstance(metadata, dict):
|
||||
@@ -21,9 +43,34 @@ def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback:
|
||||
value = metadata.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
for path in NESTED_FIELD_ALIASES.get(field, ()):
|
||||
current: Any = metadata
|
||||
for key in path:
|
||||
if not isinstance(current, dict):
|
||||
current = None
|
||||
break
|
||||
current = current.get(key)
|
||||
if current not in (None, ""):
|
||||
return current
|
||||
if field in {"latitude", "longitude"}:
|
||||
value = _get_coordinate_sequence_value(metadata, field)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
def _get_coordinate_sequence_value(metadata: Dict[str, Any], field: str) -> Any:
|
||||
for key in ("coordinates", "coord", "coords"):
|
||||
value = metadata.get(key)
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
continue
|
||||
# GeoJSON uses [longitude, latitude]. Most raw collector tuples in this
|
||||
# codebase use explicit field names, so only sequence aliases are treated
|
||||
# as GeoJSON-shaped to avoid guessing.
|
||||
return value[1] if field == "latitude" else value[0]
|
||||
return None
|
||||
|
||||
|
||||
def build_dynamic_metadata(
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
*,
|
||||
|
||||
@@ -105,7 +105,7 @@ async def get_current_user(
|
||||
)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
@@ -122,6 +122,7 @@ async def get_current_user(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
@@ -144,7 +145,7 @@ async def get_current_user_refresh(
|
||||
)
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT id, username, email, password_hash, role, is_active FROM users WHERE id = :id"
|
||||
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
|
||||
),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
@@ -161,6 +162,7 @@ async def get_current_user_refresh(
|
||||
user.password_hash = row[3]
|
||||
user.role = row[4]
|
||||
user.is_active = row[5]
|
||||
user.gatekeeper_groups = row[6] or []
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
@@ -15,6 +15,8 @@ class DataBroadcaster:
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.tasks: Dict[str, asyncio.Task] = {}
|
||||
self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {}
|
||||
self._vessel_flush_interval = 1.0
|
||||
|
||||
async def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||
"""Get dashboard statistics"""
|
||||
@@ -68,6 +70,9 @@ class DataBroadcaster:
|
||||
|
||||
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
|
||||
"""Broadcast custom data to a specific channel"""
|
||||
if channel == "vessels":
|
||||
self.enqueue_vessel_update(data)
|
||||
return
|
||||
await manager.broadcast(
|
||||
{
|
||||
"type": "data_frame",
|
||||
@@ -78,6 +83,58 @@ class DataBroadcaster:
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
def enqueue_vessel_update(self, data: Dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list):
|
||||
return
|
||||
source = data.get("source")
|
||||
action = data.get("action") or "upsert"
|
||||
created = data.get("created")
|
||||
for vessel in vessels:
|
||||
if not isinstance(vessel, dict):
|
||||
continue
|
||||
mmsi = vessel.get("mmsi")
|
||||
if mmsi in (None, ""):
|
||||
continue
|
||||
self._pending_vessel_updates[str(mmsi)] = {
|
||||
**vessel,
|
||||
"_source": source,
|
||||
"_action": action,
|
||||
"_created": created,
|
||||
}
|
||||
|
||||
async def flush_vessel_updates(self):
|
||||
if not self._pending_vessel_updates:
|
||||
return
|
||||
pending = self._pending_vessel_updates
|
||||
self._pending_vessel_updates = {}
|
||||
vessels = []
|
||||
for item in pending.values():
|
||||
vessel = dict(item)
|
||||
source = vessel.pop("_source", None)
|
||||
action = vessel.pop("_action", "upsert")
|
||||
created = vessel.pop("_created", None)
|
||||
vessel["source"] = source
|
||||
vessel["action"] = action
|
||||
vessel["created"] = created
|
||||
vessels.append(vessel)
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": "mixed",
|
||||
"created": None,
|
||||
"vessels": vessels,
|
||||
}
|
||||
)
|
||||
|
||||
async def broadcast_vessels_periodically(self):
|
||||
while self.running:
|
||||
try:
|
||||
await self.flush_vessel_updates()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(self._vessel_flush_interval)
|
||||
|
||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||
"""Broadcast datasource task progress updates to connected clients."""
|
||||
await manager.broadcast(
|
||||
@@ -95,6 +152,7 @@ class DataBroadcaster:
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
|
||||
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
|
||||
|
||||
def stop(self):
|
||||
"""Stop all broadcasters"""
|
||||
@@ -102,6 +160,7 @@ class DataBroadcaster:
|
||||
for task in self.tasks.values():
|
||||
task.cancel()
|
||||
self.tasks.clear()
|
||||
self._pending_vessel_updates.clear()
|
||||
|
||||
|
||||
broadcaster = DataBroadcaster()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""WebSocket Connection Manager"""
|
||||
|
||||
from typing import Dict, Set, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Set, Optional
|
||||
from fastapi import WebSocket
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000
|
||||
MAX_VESSEL_WS_MESSAGE_ITEMS = 1000
|
||||
MAX_VESSEL_BBOX_AREA = 2500.0
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
@@ -14,6 +19,7 @@ class ConnectionManager:
|
||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
||||
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
||||
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: str):
|
||||
@@ -72,6 +78,50 @@ class ConnectionManager:
|
||||
channels = list(self.websocket_channels.get(websocket, set()))
|
||||
if channels:
|
||||
self.unsubscribe(websocket, channels)
|
||||
self.vessel_subscriptions.pop(websocket, None)
|
||||
|
||||
def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]:
|
||||
subscription = self._normalize_vessel_subscription(config)
|
||||
self.channel_subscriptions.setdefault("vessels", set()).add(websocket)
|
||||
self.websocket_channels.setdefault(websocket, set()).add("vessels")
|
||||
self.vessel_subscriptions[websocket] = subscription
|
||||
return subscription
|
||||
|
||||
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
bbox = config.get("bbox")
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
|
||||
try:
|
||||
lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("bbox values must be numbers") from exc
|
||||
if lat_min > lat_max:
|
||||
lat_min, lat_max = lat_max, lat_min
|
||||
if lon_min > lon_max:
|
||||
lon_min, lon_max = lon_max, lon_min
|
||||
if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
|
||||
raise ValueError("bbox longitude values must be between -180 and 180")
|
||||
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
|
||||
raise ValueError("bbox latitude values must be between -90 and 90")
|
||||
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
|
||||
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
|
||||
|
||||
zoom = int(config.get("zoom") or 1)
|
||||
if zoom < 1 or zoom > 20:
|
||||
raise ValueError("zoom must be between 1 and 20")
|
||||
limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT)
|
||||
vessel_types = {
|
||||
str(item).strip().lower()
|
||||
for item in str(config.get("type") or "").split(",")
|
||||
if str(item).strip()
|
||||
}
|
||||
return {
|
||||
"bbox": (lon_min, lat_min, lon_max, lat_max),
|
||||
"zoom": zoom,
|
||||
"limit": limit,
|
||||
"type": vessel_types,
|
||||
"last_sent_at": None,
|
||||
}
|
||||
|
||||
async def send_personal_message(self, message: dict, user_id: str):
|
||||
if user_id in self.active_connections:
|
||||
@@ -92,6 +142,58 @@ class ConnectionManager:
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
async def broadcast_vessels(self, data: dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list) or not vessels:
|
||||
return
|
||||
|
||||
for connection, subscription in list(self.vessel_subscriptions.items()):
|
||||
matched = [
|
||||
vessel
|
||||
for vessel in vessels
|
||||
if self._vessel_matches_subscription(vessel, subscription)
|
||||
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
|
||||
if not matched:
|
||||
continue
|
||||
subscription["last_sent_at"] = datetime.now(UTC)
|
||||
message = {
|
||||
"type": "data_frame",
|
||||
"channel": "vessels",
|
||||
"timestamp": subscription["last_sent_at"].isoformat(),
|
||||
"payload": {
|
||||
**data,
|
||||
"vessels": matched,
|
||||
"subscription": {
|
||||
"bbox": list(subscription["bbox"]),
|
||||
"zoom": subscription["zoom"],
|
||||
"limit": subscription["limit"],
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
def _vessel_matches_subscription(
|
||||
self,
|
||||
vessel: dict[str, Any],
|
||||
subscription: dict[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
lon = float(vessel.get("lon"))
|
||||
lat = float(vessel.get("lat"))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = subscription["bbox"]
|
||||
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
|
||||
return False
|
||||
requested_types = subscription.get("type") or set()
|
||||
if not requested_types:
|
||||
return True
|
||||
type_name = str(vessel.get("vessel_type_name") or "").lower()
|
||||
return any(requested_type in type_name for requested_type in requested_types)
|
||||
|
||||
async def close_all(self):
|
||||
for user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
@@ -99,6 +201,7 @@ class ConnectionManager:
|
||||
self.active_connections.clear()
|
||||
self.channel_subscriptions.clear()
|
||||
self.websocket_channels.clear()
|
||||
self.vessel_subscriptions.clear()
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
328
backend/app/data/seeds/ripe_ris_collector_locations_seed.json
Normal file
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"_comment": "Seed payload for the bgp_collector_locations DB table. Coordinates were migrated from the legacy RIPE_RIS_COLLECTOR_COORDS table and default to city-center; seeded rows are unverified and should be upgraded in the database with source evidence when known.",
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc00",
|
||||
"aliases": ["rrc00", "RIPE RIS rrc00", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc01",
|
||||
"aliases": ["rrc01", "RIPE RIS rrc01", "LINX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "LINX",
|
||||
"city": "London",
|
||||
"country": "United Kingdom",
|
||||
"latitude": 51.5072,
|
||||
"longitude": -0.1276,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc03",
|
||||
"aliases": ["rrc03", "RIPE RIS rrc03", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc04",
|
||||
"aliases": ["rrc04", "RIPE RIS rrc04", "CIXP", "CERN Internet Exchange Point"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "CIXP",
|
||||
"city": "Geneva",
|
||||
"country": "Switzerland",
|
||||
"latitude": 46.2044,
|
||||
"longitude": 6.1432,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc05",
|
||||
"aliases": ["rrc05", "RIPE RIS rrc05", "VIX", "Vienna Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "VIX",
|
||||
"city": "Vienna",
|
||||
"country": "Austria",
|
||||
"latitude": 48.2082,
|
||||
"longitude": 16.3738,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc06",
|
||||
"aliases": ["rrc06", "RIPE RIS rrc06", "JPIX", "Otemachi"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "JPIX",
|
||||
"city": "Otemachi",
|
||||
"country": "Japan",
|
||||
"latitude": 35.686,
|
||||
"longitude": 139.7671,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc07",
|
||||
"aliases": ["rrc07", "RIPE RIS rrc07", "Netnod", "Netnod Stockholm"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Netnod Stockholm",
|
||||
"city": "Stockholm",
|
||||
"country": "Sweden",
|
||||
"latitude": 59.3293,
|
||||
"longitude": 18.0686,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc10",
|
||||
"aliases": ["rrc10", "RIPE RIS rrc10", "MIX", "Milan Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "MIX",
|
||||
"city": "Milan",
|
||||
"country": "Italy",
|
||||
"latitude": 45.4642,
|
||||
"longitude": 9.19,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc11",
|
||||
"aliases": ["rrc11", "RIPE RIS rrc11", "NYIIX", "New York International Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "NYIIX",
|
||||
"city": "New York",
|
||||
"country": "United States",
|
||||
"latitude": 40.7128,
|
||||
"longitude": -74.006,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc12",
|
||||
"aliases": ["rrc12", "RIPE RIS rrc12", "DE-CIX", "DE-CIX Frankfurt"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "DE-CIX Frankfurt",
|
||||
"city": "Frankfurt",
|
||||
"country": "Germany",
|
||||
"latitude": 50.1109,
|
||||
"longitude": 8.6821,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc13",
|
||||
"aliases": ["rrc13", "RIPE RIS rrc13", "MSK-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "MSK-IX",
|
||||
"city": "Moscow",
|
||||
"country": "Russia",
|
||||
"latitude": 55.7558,
|
||||
"longitude": 37.6173,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc14",
|
||||
"aliases": ["rrc14", "RIPE RIS rrc14", "PAIX", "Palo Alto Internet Exchange"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "PAIX",
|
||||
"city": "Palo Alto",
|
||||
"country": "United States",
|
||||
"latitude": 37.4419,
|
||||
"longitude": -122.143,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc15",
|
||||
"aliases": ["rrc15", "RIPE RIS rrc15", "PTT.br Sao Paulo", "PTTMetro Sao Paulo"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "PTT.br",
|
||||
"city": "Sao Paulo",
|
||||
"country": "Brazil",
|
||||
"latitude": -23.5558,
|
||||
"longitude": -46.6396,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc16",
|
||||
"aliases": ["rrc16", "RIPE RIS rrc16", "Equinix Miami", "NOTA Miami"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Equinix Miami",
|
||||
"city": "Miami",
|
||||
"country": "United States",
|
||||
"latitude": 25.7617,
|
||||
"longitude": -80.1918,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc18",
|
||||
"aliases": ["rrc18", "RIPE RIS rrc18", "CATNIX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "CATNIX",
|
||||
"city": "Barcelona",
|
||||
"country": "Spain",
|
||||
"latitude": 41.3874,
|
||||
"longitude": 2.1686,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc19",
|
||||
"aliases": ["rrc19", "RIPE RIS rrc19", "NAPAfrica", "JINX", "NAPAfrica Johannesburg"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "NAPAfrica Johannesburg",
|
||||
"city": "Johannesburg",
|
||||
"country": "South Africa",
|
||||
"latitude": -26.2041,
|
||||
"longitude": 28.0473,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc20",
|
||||
"aliases": ["rrc20", "RIPE RIS rrc20", "SwissIX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "SwissIX",
|
||||
"city": "Zurich",
|
||||
"country": "Switzerland",
|
||||
"latitude": 47.3769,
|
||||
"longitude": 8.5417,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc21",
|
||||
"aliases": ["rrc21", "RIPE RIS rrc21", "France-IX Paris"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "France-IX Paris",
|
||||
"city": "Paris",
|
||||
"country": "France",
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc22",
|
||||
"aliases": ["rrc22", "RIPE RIS rrc22", "InterLAN Bucharest"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "InterLAN Bucharest",
|
||||
"city": "Bucharest",
|
||||
"country": "Romania",
|
||||
"latitude": 44.4268,
|
||||
"longitude": 26.1025,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc23",
|
||||
"aliases": ["rrc23", "RIPE RIS rrc23", "Equinix Singapore"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "Equinix Singapore",
|
||||
"city": "Singapore",
|
||||
"country": "Singapore",
|
||||
"latitude": 1.3521,
|
||||
"longitude": 103.8198,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc24",
|
||||
"aliases": ["rrc24", "RIPE RIS rrc24", "LACNIC Montevideo"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "LACNIC Montevideo",
|
||||
"city": "Montevideo",
|
||||
"country": "Uruguay",
|
||||
"latitude": -34.9011,
|
||||
"longitude": -56.1645,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc25",
|
||||
"aliases": ["rrc25", "RIPE RIS rrc25", "AMS-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "AMS-IX",
|
||||
"city": "Amsterdam",
|
||||
"country": "Netherlands",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"canonical_name": "RIPE RIS rrc26",
|
||||
"aliases": ["rrc26", "RIPE RIS rrc26", "UAE-IX"],
|
||||
"operator": "RIPE NCC",
|
||||
"site": "UAE-IX",
|
||||
"city": "Dubai",
|
||||
"country": "United Arab Emirates",
|
||||
"latitude": 25.2048,
|
||||
"longitude": 55.2708,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
"source_note": "Migrated from RIPE_RIS_COLLECTOR_COORDS legacy table",
|
||||
"verified_at": null
|
||||
}
|
||||
],
|
||||
"city_fallbacks": []
|
||||
}
|
||||
@@ -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,9 +122,11 @@ async def init_db():
|
||||
import app.models.datasource_config # noqa: F401
|
||||
import app.models.alert # noqa: F401
|
||||
import app.models.bgp_anomaly # noqa: F401
|
||||
import app.models.bgp_collector_location # noqa: F401
|
||||
import app.models.bgp_incident # noqa: F401
|
||||
import app.models.bgp_observation # noqa: F401
|
||||
import app.models.collected_data # noqa: F401
|
||||
import app.models.compute_center_location # noqa: F401
|
||||
import app.models.system_setting # noqa: F401
|
||||
import app.models.playground_session # noqa: F401
|
||||
import app.models.playground_message # noqa: F401
|
||||
@@ -128,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(
|
||||
"""
|
||||
@@ -188,6 +234,26 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_desc
|
||||
ON ais_raw_observations (target_schema, observed_at DESC)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ais_raw_payload_lon_lat
|
||||
ON ais_raw_observations (
|
||||
((normalized_payload->>'lon')::double precision),
|
||||
((normalized_payload->>'lat')::double precision)
|
||||
)
|
||||
WHERE target_schema = 'vessel_ais'
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -208,5 +274,14 @@ async def init_db():
|
||||
)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
from app.services.bgp_collector_locations import (
|
||||
seed_default_bgp_collector_locations,
|
||||
)
|
||||
from app.services.compute_center_locations import (
|
||||
seed_compute_center_locations_from_source_coords,
|
||||
)
|
||||
|
||||
await seed_default_bgp_collector_locations(session)
|
||||
await seed_compute_center_locations_from_source_coords(session)
|
||||
await seed_default_datasources(session)
|
||||
await ensure_default_admin_user(session)
|
||||
|
||||
@@ -6,8 +6,10 @@ from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.playground_session import PlaygroundSession
|
||||
from app.models.playground_message import PlaygroundMessage
|
||||
@@ -27,8 +29,10 @@ __all__ = [
|
||||
"AlertSeverity",
|
||||
"AlertStatus",
|
||||
"BGPAnomaly",
|
||||
"BGPCollectorLocation",
|
||||
"BGPIncident",
|
||||
"BGPObservation",
|
||||
"ComputeCenterLocationRecord",
|
||||
"SystemLog",
|
||||
"AuditLog",
|
||||
"PlaygroundSession",
|
||||
|
||||
52
backend/app/models/bgp_collector_location.py
Normal file
52
backend/app/models/bgp_collector_location.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Stored BGP route-collector locations."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class BGPCollectorLocation(Base):
|
||||
"""Current known location for a BGP route collector."""
|
||||
|
||||
__tablename__ = "bgp_collector_locations"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
collector_id = Column(String(100), nullable=False, unique=True, index=True)
|
||||
operator = Column(String(255), nullable=True)
|
||||
site = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
country = Column(String(255), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
precision = Column(String(30), nullable=False, default="city")
|
||||
confidence = Column(Float, nullable=True)
|
||||
source = Column(String(80), nullable=False, default="legacy_seed", index=True)
|
||||
source_url = Column(String(500), nullable=True)
|
||||
source_note = Column(Text, nullable=True)
|
||||
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||
needs_confirmation = Column(Boolean, nullable=False, default=True, index=True)
|
||||
verification_status = Column(String(30), nullable=False, default="unverified", index=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_location_dict(self) -> dict:
|
||||
return {
|
||||
"city": self.city,
|
||||
"country": self.country,
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"precision": self.precision,
|
||||
"source": self.source,
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"matched_location_name": self.site or self.collector_id,
|
||||
"verified_at": to_iso8601_utc(self.verified_at),
|
||||
"confidence": self.confidence,
|
||||
"operator": self.operator,
|
||||
"site": self.site,
|
||||
"verification_status": self.verification_status,
|
||||
"source_note": self.source_note,
|
||||
"source_url": self.source_url,
|
||||
}
|
||||
60
backend/app/models/compute_center_location.py
Normal file
60
backend/app/models/compute_center_location.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Stored compute-center locations."""
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class ComputeCenterLocationRecord(Base):
|
||||
"""Current known location for a compute-center record."""
|
||||
|
||||
__tablename__ = "compute_center_locations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "source_id", name="uq_compute_center_location_source_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(100), nullable=False, index=True)
|
||||
source_id = Column(String(255), nullable=False, index=True)
|
||||
name = Column(String(500), nullable=True)
|
||||
operator = Column(String(255), nullable=True)
|
||||
site = Column(String(255), nullable=True)
|
||||
city = Column(String(255), nullable=True)
|
||||
country = Column(String(255), nullable=True)
|
||||
latitude = Column(Float, nullable=True)
|
||||
longitude = Column(Float, nullable=True)
|
||||
precision = Column(String(30), nullable=False, default="city")
|
||||
confidence = Column(Float, nullable=True)
|
||||
location_source = Column(String(80), nullable=False, default="stored_compute_center_location", index=True)
|
||||
source_url = Column(String(500), nullable=True)
|
||||
source_note = Column(Text, nullable=True)
|
||||
raw_payload = Column(JSON, nullable=False, default=dict)
|
||||
needs_confirmation = Column(Boolean, nullable=False, default=False, index=True)
|
||||
verification_status = Column(String(30), nullable=False, default="verified", index=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
def to_location_dict(self) -> dict:
|
||||
return {
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"operator": self.operator,
|
||||
"site": self.site,
|
||||
"city": self.city,
|
||||
"country": self.country,
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"precision": self.precision,
|
||||
"confidence": self.confidence,
|
||||
"location_source": self.location_source,
|
||||
"source_url": self.source_url,
|
||||
"source_note": self.source_note,
|
||||
"raw_payload": self.raw_payload or {},
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"verification_status": self.verification_status,
|
||||
"verified_at": to_iso8601_utc(self.verified_at),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||
from sqlalchemy import Boolean, Column, DateTime, Integer, JSON, String
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.session import Base
|
||||
@@ -12,7 +12,10 @@ class User(Base):
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), default="viewer")
|
||||
gatekeeper_groups = Column(JSON, default=list)
|
||||
is_active = Column(Boolean, default=True)
|
||||
email_verified = Column(Boolean, default=False, nullable=False)
|
||||
pending_email = Column(String(255), nullable=True)
|
||||
last_login_at = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(
|
||||
|
||||
@@ -12,17 +12,20 @@ class UserBase(BaseModel):
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8)
|
||||
role: str = "viewer"
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
role: Optional[str] = None
|
||||
gatekeeper_groups: Optional[list[str]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
last_login_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
@@ -34,8 +37,36 @@ class UserInDB(UserBase):
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
role: str
|
||||
gatekeeper_groups: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
email_verified: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
|
||||
|
||||
class ResendCodeRequest(BaseModel):
|
||||
email: EmailStr
|
||||
purpose: str = Field(default="register", pattern="^(register|verify_email|reset_password)$")
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
email: EmailStr
|
||||
code: str = Field(..., min_length=6, max_length=6)
|
||||
new_password: str = Field(..., min_length=8, max_length=128)
|
||||
|
||||
7
backend/app/services/ai_tools/__init__.py
Normal file
7
backend/app/services/ai_tools/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Backend-owned AI tool services.
|
||||
|
||||
The services in this package are business tools used by Planet's backend
|
||||
orchestrators. They intentionally live outside ``aiprovider`` so model transport
|
||||
stays separate from evidence collection and domain policy.
|
||||
"""
|
||||
|
||||
48
backend/app/services/ai_tools/evidence_store.py
Normal file
48
backend/app/services/ai_tools/evidence_store.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Iterable
|
||||
|
||||
from app.services.ai_tools.schemas import FetchedEvidence, SearchEvidence
|
||||
|
||||
|
||||
def evidence_content_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_search_evidence(items: Iterable[SearchEvidence], *, limit: int = 5) -> list[dict]:
|
||||
normalized: list[dict] = []
|
||||
seen_urls: set[str] = set()
|
||||
for item in items:
|
||||
if not item.url or item.url in seen_urls:
|
||||
continue
|
||||
seen_urls.add(item.url)
|
||||
normalized.append(
|
||||
{
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
"snippet": item.snippet,
|
||||
"content": item.compact_text(),
|
||||
"score": item.score,
|
||||
"source_provider": item.source_provider,
|
||||
"retrieved_at": item.retrieved_at.isoformat(),
|
||||
"metadata": item.metadata,
|
||||
}
|
||||
)
|
||||
if len(normalized) >= limit:
|
||||
break
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_fetched_evidence(item: FetchedEvidence, *, text_limit: int = 1200) -> dict:
|
||||
text = " ".join(item.text.split())[:text_limit]
|
||||
return {
|
||||
"title": item.title,
|
||||
"url": item.final_url or item.url,
|
||||
"text": text,
|
||||
"content_hash": item.content_hash or evidence_content_hash(item.text),
|
||||
"extractor": item.extractor,
|
||||
"retrieved_at": item.retrieved_at.isoformat(),
|
||||
"metadata": item.metadata,
|
||||
}
|
||||
|
||||
63
backend/app/services/ai_tools/schemas.py
Normal file
63
backend/app/services/ai_tools/schemas.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchEvidence(BaseModel):
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
snippet: str = ""
|
||||
content: str = ""
|
||||
score: float | None = None
|
||||
source_provider: str = ""
|
||||
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def compact_text(self, limit: int = 700) -> str:
|
||||
text = " ".join((self.content or self.snippet or "").split())
|
||||
return text[:limit]
|
||||
|
||||
|
||||
class FetchedEvidence(BaseModel):
|
||||
url: str
|
||||
final_url: str = ""
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
content_hash: str = ""
|
||||
extractor: str = "basic_html"
|
||||
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WebSearchProviderConfig(BaseModel):
|
||||
provider: str = "tavily"
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
max_results: int = Field(default=5, ge=1, le=20)
|
||||
timeout_seconds: int = Field(default=20, ge=3, le=120)
|
||||
endpoint_path: str = ""
|
||||
search_depth: str = "basic"
|
||||
engine: str = "google"
|
||||
include_answer: bool = False
|
||||
include_raw_content: bool = False
|
||||
include_text: bool = False
|
||||
categories: str = "general"
|
||||
engines: list[str] = Field(default_factory=list)
|
||||
search_path: str = ""
|
||||
scrape_path: str = ""
|
||||
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
|
||||
|
||||
|
||||
class WebSearchConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
default_provider: str = "tavily"
|
||||
provider: str = "tavily"
|
||||
providers: dict[str, WebSearchProviderConfig] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def active_provider_config(self) -> WebSearchProviderConfig:
|
||||
return self.providers.get(self.default_provider) or self.providers.get(self.provider) or WebSearchProviderConfig(provider=self.default_provider or self.provider)
|
||||
|
||||
56
backend/app/services/ai_tools/web_fetch.py
Normal file
56
backend/app/services/ai_tools/web_fetch.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.services.ai_tools.schemas import FetchedEvidence
|
||||
|
||||
|
||||
class WebFetchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _extract_title_and_text(html: str) -> tuple[str, str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "svg"]):
|
||||
tag.decompose()
|
||||
title = soup.title.get_text(" ", strip=True) if soup.title else ""
|
||||
main = soup.find("main") or soup.find("article") or soup.body or soup
|
||||
text = main.get_text("\n", strip=True)
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
return title, "\n".join(lines)
|
||||
|
||||
|
||||
async def fetch_url_evidence(
|
||||
url: str,
|
||||
*,
|
||||
timeout_seconds: int = 20,
|
||||
max_bytes: int = 1_500_000,
|
||||
) -> FetchedEvidence:
|
||||
if not url:
|
||||
raise WebFetchError("url is required")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout_seconds,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "PlanetEvidenceFetcher/1.0"},
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
content = response.content[:max_bytes]
|
||||
except httpx.HTTPError as exc:
|
||||
raise WebFetchError(f"failed to fetch page: {exc}") from exc
|
||||
|
||||
title, text = _extract_title_and_text(content.decode(response.encoding or "utf-8", errors="ignore"))
|
||||
content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
return FetchedEvidence(
|
||||
url=url,
|
||||
final_url=str(response.url),
|
||||
title=title,
|
||||
text=text,
|
||||
content_hash=content_hash,
|
||||
extractor="beautifulsoup_basic",
|
||||
)
|
||||
|
||||
391
backend/app/services/ai_tools/web_search.py
Normal file
391
backend/app/services/ai_tools/web_search.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.ai_tools.schemas import SearchEvidence, WebSearchConfig, WebSearchProviderConfig
|
||||
|
||||
|
||||
WEB_SEARCH_PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"tavily": {
|
||||
"provider": "tavily",
|
||||
"label": "Tavily",
|
||||
"api_key_env": "TAVILY_API_KEY",
|
||||
"base_url": "https://api.tavily.com",
|
||||
"endpoint_path": "/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"search_depth": "basic",
|
||||
"include_answer": False,
|
||||
"include_raw_content": False,
|
||||
},
|
||||
"brave": {
|
||||
"provider": "brave",
|
||||
"label": "Brave Search API",
|
||||
"api_key_env": "BRAVE_SEARCH_API_KEY",
|
||||
"base_url": "https://api.search.brave.com",
|
||||
"endpoint_path": "/res/v1/web/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
"serpapi": {
|
||||
"provider": "serpapi",
|
||||
"label": "SerpAPI",
|
||||
"api_key_env": "SERPAPI_API_KEY",
|
||||
"base_url": "https://serpapi.com",
|
||||
"endpoint_path": "/search.json",
|
||||
"engine": "google",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
},
|
||||
"exa": {
|
||||
"provider": "exa",
|
||||
"label": "Exa",
|
||||
"api_key_env": "EXA_API_KEY",
|
||||
"base_url": "https://api.exa.ai",
|
||||
"endpoint_path": "/search",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"include_text": False,
|
||||
},
|
||||
"firecrawl": {
|
||||
"provider": "firecrawl",
|
||||
"label": "Firecrawl Search / Scrape",
|
||||
"api_key_env": "FIRECRAWL_API_KEY",
|
||||
"base_url": "https://api.firecrawl.dev",
|
||||
"search_path": "/v2/search",
|
||||
"scrape_path": "/v2/scrape",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 30,
|
||||
"scrape_formats": ["markdown"],
|
||||
},
|
||||
"searxng": {
|
||||
"provider": "searxng",
|
||||
"label": "SearXNG",
|
||||
"api_key_env": "SEARXNG_API_KEY",
|
||||
"base_url": "http://localhost:8080",
|
||||
"endpoint_path": "/",
|
||||
"max_results": 5,
|
||||
"timeout_seconds": 20,
|
||||
"categories": "general",
|
||||
"engines": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class WebSearchError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class WebSearchConfigurationError(WebSearchError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_web_search_provider(provider: str | None) -> str:
|
||||
return (provider or "tavily").strip().lower() or "tavily"
|
||||
|
||||
|
||||
def get_web_search_provider_preset(provider: str) -> dict[str, Any]:
|
||||
provider_id = normalize_web_search_provider(provider)
|
||||
preset = WEB_SEARCH_PROVIDER_PRESETS.get(provider_id)
|
||||
if not preset:
|
||||
raise ValueError(f"Unsupported web search provider: {provider}")
|
||||
return deepcopy(preset)
|
||||
|
||||
|
||||
def list_web_search_provider_presets() -> list[dict[str, Any]]:
|
||||
return [get_web_search_provider_preset(provider) for provider in WEB_SEARCH_PROVIDER_PRESETS]
|
||||
|
||||
|
||||
def provider_defaults(provider: str) -> WebSearchProviderConfig:
|
||||
preset = get_web_search_provider_preset(provider)
|
||||
return WebSearchProviderConfig(**{
|
||||
key: value
|
||||
for key, value in preset.items()
|
||||
if key in WebSearchProviderConfig.model_fields
|
||||
})
|
||||
|
||||
|
||||
class WebSearchClient:
|
||||
def __init__(self, config: WebSearchConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
max_results: int | None = None,
|
||||
domains: list[str] | None = None,
|
||||
freshness_days: int | None = None,
|
||||
) -> list[SearchEvidence]:
|
||||
if not self.config.enabled:
|
||||
raise WebSearchConfigurationError("WebSearch is disabled.")
|
||||
provider_config = self.config.active_provider_config
|
||||
provider = normalize_web_search_provider(provider_config.provider)
|
||||
if provider != "searxng" and not provider_config.api_key:
|
||||
raise WebSearchConfigurationError(f"{provider} API key is not configured.")
|
||||
query = " ".join(str(query or "").split())
|
||||
if not query:
|
||||
raise WebSearchConfigurationError("search query is required.")
|
||||
limit = max_results or provider_config.max_results
|
||||
if provider == "tavily":
|
||||
return await self._search_tavily(provider_config, query, limit, domains, freshness_days)
|
||||
if provider == "brave":
|
||||
return await self._search_brave(provider_config, query, limit, domains)
|
||||
if provider == "serpapi":
|
||||
return await self._search_serpapi(provider_config, query, limit)
|
||||
if provider == "exa":
|
||||
return await self._search_exa(provider_config, query, limit, domains)
|
||||
if provider == "firecrawl":
|
||||
return await self._search_firecrawl(provider_config, query, limit)
|
||||
if provider == "searxng":
|
||||
return await self._search_searxng(provider_config, query, limit, domains)
|
||||
raise WebSearchConfigurationError(f"Unsupported web search provider: {provider}")
|
||||
|
||||
async def test_connection(self) -> list[SearchEvidence]:
|
||||
return await self.search("Planet WebSearch connectivity test", max_results=1)
|
||||
|
||||
async def _request_json(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
provider_config: WebSearchProviderConfig,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=provider_config.timeout_seconds) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=json,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text or exc.response.reason_phrase
|
||||
raise WebSearchError(f"{provider_config.provider} request failed: {detail}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise WebSearchError(f"{provider_config.provider} request failed: {exc}") from exc
|
||||
except ValueError as exc:
|
||||
raise WebSearchError(f"{provider_config.provider} returned invalid JSON") from exc
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def _search_tavily(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
freshness_days: int | None,
|
||||
) -> list[SearchEvidence]:
|
||||
body: dict[str, Any] = {
|
||||
"api_key": config.api_key,
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
"search_depth": config.search_depth or "basic",
|
||||
"include_answer": config.include_answer,
|
||||
"include_raw_content": config.include_raw_content,
|
||||
}
|
||||
if domains:
|
||||
body["include_domains"] = domains
|
||||
if freshness_days:
|
||||
body["days"] = freshness_days
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||
provider_config=config,
|
||||
json=body,
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("content") or ""),
|
||||
content=str(item.get("raw_content") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="tavily",
|
||||
metadata={"query": data.get("query") or query},
|
||||
)
|
||||
for item in data.get("results") or []
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_brave(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
search_query = query
|
||||
if domains:
|
||||
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/res/v1/web/search"),
|
||||
provider_config=config,
|
||||
headers={"X-Subscription-Token": config.api_key},
|
||||
params={"q": search_query, "count": max_results},
|
||||
)
|
||||
results = (data.get("web") or {}).get("results") or []
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("description") or ""),
|
||||
source_provider="brave",
|
||||
metadata={"age": item.get("age")},
|
||||
)
|
||||
for item in results
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_serpapi(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
) -> list[SearchEvidence]:
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search.json"),
|
||||
provider_config=config,
|
||||
params={
|
||||
"api_key": config.api_key,
|
||||
"engine": config.engine or "google",
|
||||
"q": query,
|
||||
"num": max_results,
|
||||
},
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("link") or ""),
|
||||
snippet=str(item.get("snippet") or ""),
|
||||
source_provider="serpapi",
|
||||
metadata={"position": item.get("position")},
|
||||
)
|
||||
for item in data.get("organic_results") or []
|
||||
if isinstance(item, dict) and item.get("link")
|
||||
]
|
||||
|
||||
async def _search_exa(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
body: dict[str, Any] = {
|
||||
"query": query,
|
||||
"numResults": max_results,
|
||||
}
|
||||
if domains:
|
||||
body["includeDomains"] = domains
|
||||
if config.include_text:
|
||||
body["contents"] = {"text": True}
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.endpoint_path or "/search"),
|
||||
provider_config=config,
|
||||
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||
json=body,
|
||||
)
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("summary") or ""),
|
||||
content=str(item.get("text") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="exa",
|
||||
metadata={"id": item.get("id")},
|
||||
)
|
||||
for item in data.get("results") or []
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
|
||||
async def _search_firecrawl(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
) -> list[SearchEvidence]:
|
||||
data = await self._request_json(
|
||||
"POST",
|
||||
_join_url(config.base_url, config.search_path or "/v2/search"),
|
||||
provider_config=config,
|
||||
headers={"Authorization": f"Bearer {config.api_key}"},
|
||||
json={"query": query, "limit": max_results},
|
||||
)
|
||||
raw_results = data.get("data") or data.get("results") or []
|
||||
return [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or item.get("sourceURL") or ""),
|
||||
snippet=str(item.get("description") or item.get("markdown") or ""),
|
||||
source_provider="firecrawl",
|
||||
metadata={"status": item.get("status")},
|
||||
)
|
||||
for item in raw_results
|
||||
if isinstance(item, dict) and (item.get("url") or item.get("sourceURL"))
|
||||
]
|
||||
|
||||
async def _search_searxng(
|
||||
self,
|
||||
config: WebSearchProviderConfig,
|
||||
query: str,
|
||||
max_results: int,
|
||||
domains: list[str] | None,
|
||||
) -> list[SearchEvidence]:
|
||||
search_query = query
|
||||
if domains:
|
||||
search_query = f"{query} " + " ".join(f"site:{domain}" for domain in domains)
|
||||
params: dict[str, Any] = {
|
||||
"q": search_query,
|
||||
"format": "json",
|
||||
"categories": config.categories or "general",
|
||||
}
|
||||
if config.engines:
|
||||
params["engines"] = ",".join(config.engines)
|
||||
headers = {"Authorization": f"Bearer {config.api_key}"} if config.api_key else None
|
||||
data = await self._request_json(
|
||||
"GET",
|
||||
_join_url(config.base_url, config.endpoint_path or "/"),
|
||||
provider_config=config,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
results = data.get("results") or []
|
||||
evidence = [
|
||||
SearchEvidence(
|
||||
title=str(item.get("title") or ""),
|
||||
url=str(item.get("url") or ""),
|
||||
snippet=str(item.get("content") or ""),
|
||||
score=_float_or_none(item.get("score")),
|
||||
source_provider="searxng",
|
||||
metadata={"engine": item.get("engine")},
|
||||
)
|
||||
for item in results
|
||||
if isinstance(item, dict) and item.get("url")
|
||||
]
|
||||
return evidence[:max_results]
|
||||
|
||||
|
||||
def _join_url(base_url: str, path: str) -> str:
|
||||
return f"{(base_url or '').rstrip('/')}/{(path or '').lstrip('/')}"
|
||||
|
||||
|
||||
def _float_or_none(value: Any) -> float | None:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
324
backend/app/services/bgp_collector_locations.py
Normal file
324
backend/app/services/bgp_collector_locations.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""BGP route-collector location resolver.
|
||||
|
||||
Collector positions are stored in the ``bgp_collector_locations`` database
|
||||
table. The old JSON registry is now only a seed payload used during database
|
||||
initialization, not a runtime resolver or candidate source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.bgp_collector_location import BGPCollectorLocation
|
||||
from app.services.location import (
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
coerce_str,
|
||||
normalize_text,
|
||||
)
|
||||
|
||||
SEED_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "data"
|
||||
/ "seeds"
|
||||
/ "ripe_ris_collector_locations_seed.json"
|
||||
)
|
||||
|
||||
# ── Geocoder (kept at module level for monkeypatching + cache_clear) ──
|
||||
|
||||
_geocode_online = build_default_nominatim_geocoder()
|
||||
|
||||
|
||||
# ── In-process compatibility cache ──────────────────────────────────
|
||||
|
||||
|
||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]:
|
||||
return record.to_location_dict()
|
||||
|
||||
|
||||
def set_bgp_collector_location_cache(
|
||||
locations: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Replace the legacy compatibility cache in-place."""
|
||||
RIPE_RIS_COLLECTOR_COORDS.clear()
|
||||
RIPE_RIS_COLLECTOR_COORDS.update(
|
||||
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||
)
|
||||
|
||||
|
||||
async def refresh_bgp_collector_location_cache(
|
||||
session: AsyncSession,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result = await session.execute(select(BGPCollectorLocation))
|
||||
records = result.scalars().all()
|
||||
cache = {
|
||||
record.collector_id: _collector_record_to_dict(record)
|
||||
for record in records
|
||||
if record.collector_id
|
||||
}
|
||||
set_bgp_collector_location_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def _load_seed_payload() -> dict[str, Any]:
|
||||
with SEED_PATH.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"collector_id": collector_id,
|
||||
"operator": entry.get("operator") or "RIPE NCC",
|
||||
"site": entry.get("site"),
|
||||
"city": entry.get("city"),
|
||||
"country": entry.get("country"),
|
||||
"latitude": entry.get("latitude"),
|
||||
"longitude": entry.get("longitude"),
|
||||
"precision": entry.get("precision") or "city",
|
||||
"confidence": entry.get("confidence"),
|
||||
"source": "legacy_seed",
|
||||
"source_url": None,
|
||||
"source_note": entry.get("source_note")
|
||||
or "Seeded from legacy RIPE RIS collector coordinates",
|
||||
"raw_payload": entry,
|
||||
"needs_confirmation": True,
|
||||
"verification_status": "unverified",
|
||||
"verified_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def seed_default_bgp_collector_locations(session: AsyncSession) -> None:
|
||||
"""Seed default RIPE RIS collector locations without overwriting users."""
|
||||
payload = _load_seed_payload()
|
||||
for entry in payload.get("locations", []):
|
||||
aliases = entry.get("aliases") or []
|
||||
collector_ids = [
|
||||
coerce_str(alias)
|
||||
for alias in aliases
|
||||
if coerce_str(alias).startswith("rrc")
|
||||
]
|
||||
if not collector_ids:
|
||||
continue
|
||||
collector_id = collector_ids[0]
|
||||
existing = await session.scalar(
|
||||
select(BGPCollectorLocation).where(
|
||||
BGPCollectorLocation.collector_id == collector_id
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
session.add(
|
||||
BGPCollectorLocation(
|
||||
**_seed_entry_to_record_kwargs(entry, collector_id)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
await refresh_bgp_collector_location_cache(session)
|
||||
|
||||
|
||||
def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]:
|
||||
"""Return the current cached collector location dict, or ``{}`` if unknown."""
|
||||
return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {}))
|
||||
|
||||
|
||||
def iter_known_collector_names() -> Iterator[str]:
|
||||
"""Yield every collector technical name (rrcXX) known in the cache."""
|
||||
return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys()))
|
||||
|
||||
|
||||
# ── Pipeline construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
class StoredCollectorLocationResolver:
|
||||
"""Resolve a collector through the DB-backed compatibility cache."""
|
||||
|
||||
name = "stored_collector_location"
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
collector = coerce_str(query.name)
|
||||
if not collector:
|
||||
for alias in query.aliases:
|
||||
collector = coerce_str(alias)
|
||||
if collector:
|
||||
break
|
||||
if not collector:
|
||||
return ResolverOutput()
|
||||
location = get_bgp_collector_location_dict(collector)
|
||||
if not location:
|
||||
return ResolverOutput()
|
||||
latitude = location.get("latitude")
|
||||
longitude = location.get("longitude")
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=float(latitude),
|
||||
longitude=float(longitude),
|
||||
display_name=location.get("matched_location_name") or collector,
|
||||
precision=location.get("precision") or "city",
|
||||
confidence=float(location.get("confidence") or 0.85),
|
||||
query=f"stored_collector_location::{collector}",
|
||||
source=location.get("source") or self.name,
|
||||
source_note=location.get("source_note"),
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=bool(location.get("needs_confirmation")),
|
||||
city=location.get("city"),
|
||||
region=None,
|
||||
country=location.get("country"),
|
||||
matched_location_name=(
|
||||
location.get("matched_location_name") or collector
|
||||
),
|
||||
location_verified_at=location.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _bgp_collector_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Build the Nominatim query plan for a BGP collector."""
|
||||
extra = query.extra or {}
|
||||
site = str(extra.get("site") or "")
|
||||
operator = str(extra.get("operator") or "")
|
||||
city = query.city or ""
|
||||
country = query.country or ""
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
|
||||
def add(parts: list[tuple[str, str]]) -> None:
|
||||
non_empty = [(field, value) for field, value in parts if value]
|
||||
if not non_empty:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
fields: list[str] = []
|
||||
for field, value in non_empty:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(value)
|
||||
fields.append(field)
|
||||
if not cleaned:
|
||||
return
|
||||
composed = ", ".join(cleaned)
|
||||
if not any(composed == existing for existing, _ in plan):
|
||||
plan.append((composed, tuple(fields)))
|
||||
|
||||
add([("site", site), ("city", city), ("country", country)])
|
||||
add([("site", site), ("country", country)])
|
||||
add([("operator", operator), ("city", city), ("country", country)])
|
||||
add([("city", city), ("country", country)])
|
||||
return plan
|
||||
|
||||
|
||||
BGP_COLLECTOR_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
StoredCollectorLocationResolver(),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP collector to renderable coordinates from"
|
||||
" source coordinates or stored collector location."
|
||||
),
|
||||
)
|
||||
|
||||
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
NominatimResolver(
|
||||
query_plan_builder=_bgp_collector_query_plan,
|
||||
# Late-binding so tests can monkeypatch ``_geocode_online``.
|
||||
geocoder=lambda q: _geocode_online(q),
|
||||
),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP collector to renderable coordinates from"
|
||||
" source coordinates or online geocoding."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_bgp_collector_location(
|
||||
collector_name: str,
|
||||
*,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve a BGP collector to its best-known stored location."""
|
||||
stored = get_bgp_collector_location_dict(collector_name)
|
||||
name = coerce_str(collector_name) or None
|
||||
query = LocationQuery(
|
||||
name=name,
|
||||
aliases=tuple(filter(None, (collector_name,))),
|
||||
city=coerce_str(city or stored.get("city")) or None,
|
||||
country=coerce_str(country or stored.get("country")) or None,
|
||||
extra={
|
||||
"site": coerce_str(site or stored.get("site")),
|
||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||
},
|
||||
)
|
||||
return BGP_COLLECTOR_PIPELINE.resolve_best(query)
|
||||
|
||||
|
||||
def collect_bgp_collector_location_candidates(
|
||||
*,
|
||||
collector: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
query = build_bgp_collector_location_query(
|
||||
collector=collector,
|
||||
city=city,
|
||||
country=country,
|
||||
site=site,
|
||||
operator=operator,
|
||||
)
|
||||
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
|
||||
|
||||
|
||||
def build_bgp_collector_location_query(
|
||||
*,
|
||||
collector: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
) -> LocationQuery:
|
||||
stored = get_bgp_collector_location_dict(collector or "")
|
||||
name = coerce_str(collector) or None
|
||||
return LocationQuery(
|
||||
name=name,
|
||||
aliases=tuple(filter(None, (collector,))),
|
||||
city=coerce_str(city or stored.get("city")) or None,
|
||||
country=coerce_str(country or stored.get("country")) or None,
|
||||
extra={
|
||||
"site": coerce_str(site or stored.get("site")),
|
||||
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
||||
"collector": coerce_str(collector),
|
||||
},
|
||||
)
|
||||
155
backend/app/services/bgp_event_locations.py
Normal file
155
backend/app/services/bgp_event_locations.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""BGP event location resolver.
|
||||
|
||||
A BGP event (announcement / withdrawal / RIB entry) is geographically tied to
|
||||
the route collector that observed it. This module defines the pipeline that
|
||||
turns an event payload into renderable coordinates.
|
||||
|
||||
Current resolver chain:
|
||||
|
||||
SourceCoordinates → event payload itself carries lat/lon (rare; some
|
||||
enriched feeds do).
|
||||
InheritFromCollector → look up the owning collector via
|
||||
:func:`resolve_bgp_collector_location`.
|
||||
|
||||
Future plug-ins (no consumer changes required, just append to the list):
|
||||
|
||||
ASNFacilityResolver — origin/peer ASN → peeringdb facility.
|
||||
PrefixGeoResolver — prefix → IP range geo lookup (iptoasn / opengeofeed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.bgp_collector_locations import (
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.location import (
|
||||
InheritFromAnotherEntityResolver,
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
ResolutionResult,
|
||||
SourceCoordinatesResolver,
|
||||
coerce_str,
|
||||
)
|
||||
|
||||
|
||||
def _inherit_from_owning_collector(
|
||||
query: LocationQuery,
|
||||
) -> LocationCandidate | None:
|
||||
"""Look up the event's owning collector by exact name in the DB-backed cache."""
|
||||
extra = query.extra or {}
|
||||
collector_name = coerce_str(extra.get("collector"))
|
||||
if not collector_name:
|
||||
return None
|
||||
legacy = get_bgp_collector_location_dict(collector_name)
|
||||
if not legacy:
|
||||
return None
|
||||
latitude = legacy.get("latitude")
|
||||
longitude = legacy.get("longitude")
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return None
|
||||
return LocationCandidate(
|
||||
latitude=float(latitude),
|
||||
longitude=float(longitude),
|
||||
display_name=legacy.get("matched_location_name") or collector_name,
|
||||
precision=legacy.get("precision") or "city",
|
||||
confidence=float(legacy.get("confidence") or 0.85),
|
||||
query=f"inherit_from_collector::{collector_name}",
|
||||
source="inherited_from_collector",
|
||||
source_note=(
|
||||
f"Inherited from owning collector {collector_name}"
|
||||
),
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=bool(legacy.get("needs_confirmation")),
|
||||
city=legacy.get("city"),
|
||||
region=None,
|
||||
country=legacy.get("country"),
|
||||
matched_location_name=legacy.get("matched_location_name"),
|
||||
location_verified_at=legacy.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
|
||||
|
||||
BGP_EVENT_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
InheritFromAnotherEntityResolver(
|
||||
source_lookup=_inherit_from_owning_collector,
|
||||
name="inherited_from_collector",
|
||||
),
|
||||
# Plug new resolvers (peeringdb / ASN facility / prefix-geo) here.
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve BGP event coordinates: no source coords, owning"
|
||||
" collector unknown, and no fallback resolver matched."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_bgp_event_location(
|
||||
*,
|
||||
collector: str,
|
||||
source_latitude: float | None = None,
|
||||
source_longitude: float | None = None,
|
||||
site: str | None = None,
|
||||
operator: str | None = None,
|
||||
peer_asn: int | None = None,
|
||||
origin_asn: int | None = None,
|
||||
prefix: str | None = None,
|
||||
) -> ResolutionResult:
|
||||
"""Resolve a BGP event to its renderable coordinates.
|
||||
|
||||
The ``peer_asn`` / ``origin_asn`` / ``prefix`` arguments are accepted
|
||||
today so future resolvers (ASN→facility, prefix→geo) can consume them
|
||||
without callers needing to change.
|
||||
"""
|
||||
query = LocationQuery(
|
||||
name=collector or None,
|
||||
aliases=tuple(filter(None, (collector,))),
|
||||
source_latitude=source_latitude,
|
||||
source_longitude=source_longitude,
|
||||
extra={
|
||||
"collector": collector or "",
|
||||
"site": coerce_str(site),
|
||||
"operator": coerce_str(operator),
|
||||
"peer_asn": peer_asn,
|
||||
"origin_asn": origin_asn,
|
||||
"prefix": coerce_str(prefix),
|
||||
},
|
||||
)
|
||||
return BGP_EVENT_PIPELINE.resolve_best(query)
|
||||
|
||||
|
||||
def resolve_bgp_event_geo_dict(
|
||||
collector: str,
|
||||
*,
|
||||
source_latitude: float | None = None,
|
||||
source_longitude: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convenience wrapper returning the legacy ``collector_geo`` dict shape.
|
||||
|
||||
Preserves ``city``/``country``/``latitude``/``longitude`` keys (consumed
|
||||
by existing detectors / enrichment / DB serialization) and adds
|
||||
``precision``/``source``/``needs_confirmation`` for richer downstream use.
|
||||
"""
|
||||
result = resolve_bgp_event_location(
|
||||
collector=collector,
|
||||
source_latitude=source_latitude,
|
||||
source_longitude=source_longitude,
|
||||
)
|
||||
candidate = result.location
|
||||
if candidate is None:
|
||||
return {}
|
||||
return {
|
||||
"city": candidate.city,
|
||||
"country": candidate.country,
|
||||
"latitude": candidate.latitude,
|
||||
"longitude": candidate.longitude,
|
||||
"precision": candidate.precision,
|
||||
"source": candidate.source,
|
||||
"needs_confirmation": candidate.needs_confirmation,
|
||||
"matched_location_name": candidate.matched_location_name,
|
||||
"confidence": candidate.confidence,
|
||||
}
|
||||
@@ -138,7 +138,7 @@ class AISStreamCollector(BaseCollector):
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError as exc:
|
||||
except ImportError:
|
||||
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
|
||||
|
||||
start_time = datetime.now(UTC)
|
||||
|
||||
@@ -13,6 +13,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
get_bgp_collector_location_dict,
|
||||
)
|
||||
from app.services.bgp_event_locations import resolve_bgp_event_geo_dict
|
||||
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
||||
from app.services.bgp_detectors import (
|
||||
detect_mass_withdrawal_anomalies,
|
||||
@@ -23,32 +28,17 @@ from app.services.bgp_detectors import (
|
||||
)
|
||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
||||
|
||||
|
||||
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {
|
||||
"rrc00": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc01": {"city": "London", "country": "United Kingdom", "latitude": 51.5072, "longitude": -0.1276},
|
||||
"rrc03": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc04": {"city": "Geneva", "country": "Switzerland", "latitude": 46.2044, "longitude": 6.1432},
|
||||
"rrc05": {"city": "Vienna", "country": "Austria", "latitude": 48.2082, "longitude": 16.3738},
|
||||
"rrc06": {"city": "Otemachi", "country": "Japan", "latitude": 35.686, "longitude": 139.7671},
|
||||
"rrc07": {"city": "Stockholm", "country": "Sweden", "latitude": 59.3293, "longitude": 18.0686},
|
||||
"rrc10": {"city": "Milan", "country": "Italy", "latitude": 45.4642, "longitude": 9.19},
|
||||
"rrc11": {"city": "New York", "country": "United States", "latitude": 40.7128, "longitude": -74.006},
|
||||
"rrc12": {"city": "Frankfurt", "country": "Germany", "latitude": 50.1109, "longitude": 8.6821},
|
||||
"rrc13": {"city": "Moscow", "country": "Russia", "latitude": 55.7558, "longitude": 37.6173},
|
||||
"rrc14": {"city": "Palo Alto", "country": "United States", "latitude": 37.4419, "longitude": -122.143},
|
||||
"rrc15": {"city": "Sao Paulo", "country": "Brazil", "latitude": -23.5558, "longitude": -46.6396},
|
||||
"rrc16": {"city": "Miami", "country": "United States", "latitude": 25.7617, "longitude": -80.1918},
|
||||
"rrc18": {"city": "Barcelona", "country": "Spain", "latitude": 41.3874, "longitude": 2.1686},
|
||||
"rrc19": {"city": "Johannesburg", "country": "South Africa", "latitude": -26.2041, "longitude": 28.0473},
|
||||
"rrc20": {"city": "Zurich", "country": "Switzerland", "latitude": 47.3769, "longitude": 8.5417},
|
||||
"rrc21": {"city": "Paris", "country": "France", "latitude": 48.8566, "longitude": 2.3522},
|
||||
"rrc22": {"city": "Bucharest", "country": "Romania", "latitude": 44.4268, "longitude": 26.1025},
|
||||
"rrc23": {"city": "Singapore", "country": "Singapore", "latitude": 1.3521, "longitude": 103.8198},
|
||||
"rrc24": {"city": "Montevideo", "country": "Uruguay", "latitude": -34.9011, "longitude": -56.1645},
|
||||
"rrc25": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
|
||||
"rrc26": {"city": "Dubai", "country": "United Arab Emirates", "latitude": 25.2048, "longitude": 55.2708},
|
||||
}
|
||||
# Re-exported for backward compatibility with anything that imports
|
||||
# ``RIPE_RIS_COLLECTOR_COORDS`` from this module. New code should call
|
||||
# ``app.services.bgp_collector_locations.get_bgp_collector_location_dict()``
|
||||
# or ``resolve_bgp_collector_location()`` instead — those use the DB-backed
|
||||
# collector-location cache.
|
||||
__all__ = [
|
||||
"RIPE_RIS_COLLECTOR_COORDS",
|
||||
"normalize_bgp_event",
|
||||
"save_bgp_observations_for_batch",
|
||||
"create_bgp_anomalies_for_batch",
|
||||
]
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
@@ -131,7 +121,19 @@ def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, A
|
||||
)
|
||||
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
|
||||
# Routes through the BGP event pipeline: source coords (if any) →
|
||||
# collector inheritance. Returned dict keeps the legacy
|
||||
# {city, country, latitude, longitude} keys plus richer
|
||||
# {precision, source, needs_confirmation, matched_location_name, confidence}.
|
||||
collector_location = resolve_bgp_event_geo_dict(
|
||||
collector,
|
||||
source_latitude=payload.get("latitude"),
|
||||
source_longitude=payload.get("longitude"),
|
||||
)
|
||||
# Empty result (unknown collector & no source coords) — keep the
|
||||
# downstream-expected dict shape so detectors / serializers don't crash.
|
||||
if not collector_location:
|
||||
collector_location = get_bgp_collector_location_dict(collector)
|
||||
network_fields = extract_bgp_network_fields(prefix)
|
||||
metadata = {
|
||||
"project": project,
|
||||
|
||||
886
backend/app/services/compute_center_locations.py
Normal file
886
backend/app/services/compute_center_locations.py
Normal file
@@ -0,0 +1,886 @@
|
||||
"""Compute-center location resolver, built on the shared location pipeline.
|
||||
|
||||
This module is a thin domain wrapper that wires up
|
||||
:mod:`app.services.location` for compute centers:
|
||||
|
||||
SourceCoordinates
|
||||
|
||||
The online Nominatim step is intentionally reserved for the user-triggered
|
||||
``collect-location`` flow. The regular GeoJSON endpoint runs during Earth
|
||||
startup, so it must stay local and deterministic.
|
||||
|
||||
For the full design and the reason behind the abstraction (compute centers,
|
||||
BGP collectors, BGP events, and future entities all share one pipeline),
|
||||
see ``docs/plans/location-resolver-shared-pipeline-plan.md``.
|
||||
|
||||
The ``ComputeCenterLocation`` dataclass and the public function signatures are
|
||||
preserved verbatim so existing callers and tests do not need to change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.collected_data_fields import get_record_field
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.compute_center_location import ComputeCenterLocationRecord
|
||||
|
||||
from app.services.location import (
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
ROR_SEARCH_URL = "https://api.ror.org/v2/organizations"
|
||||
DEFAULT_ROR_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||
DEFAULT_ROR_TIMEOUT_SECONDS = 8.0
|
||||
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||
FORBIDDEN_PRECISIONS: tuple[str, ...] = (
|
||||
"country",
|
||||
"estimated_country",
|
||||
"country_major_compute_city",
|
||||
"region",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
# ── Public dataclasses ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComputeCenterLocation:
|
||||
latitude: float | None
|
||||
longitude: float | None
|
||||
location_precision: str
|
||||
geography_mode: str
|
||||
is_estimated: bool
|
||||
estimated_reason: str | None = None
|
||||
location_confidence: float | None = None
|
||||
location_source: str | None = None
|
||||
location_source_note: str | None = None
|
||||
location_verified_at: str | None = None
|
||||
matched_location_name: str | None = None
|
||||
needs_confirmation: bool = False
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
|
||||
@property
|
||||
def is_renderable(self) -> bool:
|
||||
if self.latitude in (None, 0.0) or self.longitude in (None, 0.0):
|
||||
return False
|
||||
return self.location_precision in RENDERABLE_PRECISIONS
|
||||
|
||||
def to_geojson_properties(self) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"location_precision": self.location_precision,
|
||||
"geography_mode": self.geography_mode,
|
||||
"is_estimated": self.is_estimated,
|
||||
"estimated_reason": self.estimated_reason,
|
||||
"location_confidence": self.location_confidence,
|
||||
"location_source": self.location_source,
|
||||
"location_source_note": self.location_source_note,
|
||||
"location_verified_at": self.location_verified_at,
|
||||
"matched_location_name": self.matched_location_name,
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionDiagnostic:
|
||||
failure_reason: str
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
record_id: int | None = None
|
||||
source: str | None = None
|
||||
source_id: str | None = None
|
||||
name: str | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
site: str | None = None
|
||||
operator: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"failure_reason": self.failure_reason,
|
||||
"attempted_queries": list(self.attempted_queries),
|
||||
"record_id": self.record_id,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"country": self.country,
|
||||
"city": self.city,
|
||||
"site": self.site,
|
||||
"operator": self.operator,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
location: ComputeCenterLocation | None
|
||||
diagnostic: ResolutionDiagnostic | None
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return bool(self.location and self.location.is_renderable)
|
||||
|
||||
|
||||
# ── Geocoder (kept at module level so tests can monkeypatch + cache_clear) ──
|
||||
|
||||
_geocode_online = build_default_nominatim_geocoder()
|
||||
|
||||
|
||||
# ── Stored location cache ───────────────────────────────────────────
|
||||
|
||||
|
||||
COMPUTE_CENTER_LOCATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _cache_key(source: str | None, source_id: str | None) -> str:
|
||||
return f"{coerce_str(source)}:{coerce_str(source_id)}"
|
||||
|
||||
|
||||
def set_compute_center_location_cache(
|
||||
locations: dict[str, dict[str, Any]],
|
||||
) -> None:
|
||||
COMPUTE_CENTER_LOCATION_CACHE.clear()
|
||||
COMPUTE_CENTER_LOCATION_CACHE.update(
|
||||
{coerce_str(key): dict(value) for key, value in locations.items()}
|
||||
)
|
||||
|
||||
|
||||
async def refresh_compute_center_location_cache(
|
||||
session: AsyncSession,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result = await session.execute(select(ComputeCenterLocationRecord))
|
||||
records = result.scalars().all()
|
||||
cache = {}
|
||||
for record in records:
|
||||
if not hasattr(record, "to_location_dict"):
|
||||
continue
|
||||
if not record.source or not record.source_id:
|
||||
continue
|
||||
cache[_cache_key(record.source, record.source_id)] = record.to_location_dict()
|
||||
set_compute_center_location_cache(cache)
|
||||
return cache
|
||||
|
||||
|
||||
def get_compute_center_location_dict(
|
||||
source: str | None,
|
||||
source_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return dict(COMPUTE_CENTER_LOCATION_CACHE.get(_cache_key(source, source_id), {}))
|
||||
|
||||
|
||||
# ── Pipeline construction ──────────────────────────────────────────
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _lookup_ror_organization(query: str) -> dict[str, Any] | None:
|
||||
"""Lookup a research organization in ROR for user-triggered candidates."""
|
||||
if not query:
|
||||
return None
|
||||
response = httpx.get(
|
||||
ROR_SEARCH_URL,
|
||||
params={"query": query},
|
||||
headers={"User-Agent": DEFAULT_ROR_USER_AGENT},
|
||||
timeout=DEFAULT_ROR_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("items") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list) or not items:
|
||||
return None
|
||||
first = items[0]
|
||||
if not isinstance(first, dict):
|
||||
return None
|
||||
organization = first.get("organization")
|
||||
if isinstance(organization, dict):
|
||||
return organization
|
||||
return first
|
||||
|
||||
|
||||
def _compute_center_ror_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
extra = query.extra or {}
|
||||
raw_parts: list[tuple[str, str]] = [
|
||||
("site", coerce_str(extra.get("site"))),
|
||||
("operator", coerce_str(extra.get("operator"))),
|
||||
("organization", coerce_str(extra.get("organization"))),
|
||||
]
|
||||
for field, value in tuple(raw_parts):
|
||||
if "/" not in value:
|
||||
continue
|
||||
raw_parts.extend(
|
||||
(field, part.strip())
|
||||
for part in value.split("/")
|
||||
if len(part.strip()) >= 3
|
||||
)
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
seen: set[str] = set()
|
||||
for field, value in raw_parts:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
plan.append((value, (field,)))
|
||||
return plan
|
||||
|
||||
|
||||
def _organization_label(organization: dict[str, Any], fallback: str) -> str:
|
||||
names = organization.get("names")
|
||||
if isinstance(names, list):
|
||||
for name in names:
|
||||
if not isinstance(name, dict):
|
||||
continue
|
||||
types = name.get("types")
|
||||
if isinstance(types, list) and "ror_display" in types:
|
||||
value = coerce_str(name.get("value"))
|
||||
if value:
|
||||
return value
|
||||
for name in names:
|
||||
if isinstance(name, dict):
|
||||
value = coerce_str(name.get("value"))
|
||||
if value:
|
||||
return value
|
||||
return fallback
|
||||
|
||||
|
||||
class ROROrganizationResolver:
|
||||
"""Resolve source-provided organization/site text through the open ROR API."""
|
||||
|
||||
name = "ror_organization_registry"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query_plan_builder=_compute_center_ror_query_plan,
|
||||
lookup=lambda q: _lookup_ror_organization(q),
|
||||
confidence: float = 0.68,
|
||||
) -> None:
|
||||
self._query_plan_builder = query_plan_builder
|
||||
self._lookup = lookup
|
||||
self._confidence = confidence
|
||||
|
||||
def resolve(self, query: LocationQuery):
|
||||
from app.services.location import ResolverOutput
|
||||
from app.services.location.text import parse_float
|
||||
|
||||
attempted: list[str] = []
|
||||
candidates: list[LocationCandidate] = []
|
||||
context_country = normalize_text(normalize_country_text(query.country))
|
||||
|
||||
for ror_query, matched_fields in self._query_plan_builder(query):
|
||||
attempted.append(f"ror:{ror_query}")
|
||||
try:
|
||||
organization = self._lookup(ror_query)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(organization, dict):
|
||||
continue
|
||||
locations = organization.get("locations")
|
||||
if not isinstance(locations, list) or not locations:
|
||||
continue
|
||||
location = locations[0]
|
||||
if not isinstance(location, dict):
|
||||
continue
|
||||
details = location.get("geonames_details")
|
||||
if not isinstance(details, dict):
|
||||
continue
|
||||
latitude = parse_float(details.get("lat"))
|
||||
longitude = parse_float(details.get("lng"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
|
||||
country = normalize_country_text(details.get("country_name"))
|
||||
if context_country and normalize_text(country) != context_country:
|
||||
continue
|
||||
|
||||
city = coerce_str(details.get("name")) or None
|
||||
region = coerce_str(details.get("country_subdivision_name")) or None
|
||||
display_name = _organization_label(organization, ror_query)
|
||||
ror_id = coerce_str(organization.get("id"))
|
||||
geonames_id = location.get("geonames_id")
|
||||
source_note = (
|
||||
f"ROR organization match: {display_name}"
|
||||
+ (f" ({ror_id})" if ror_id else "")
|
||||
+ (f"; GeoNames {geonames_id}" if geonames_id else "")
|
||||
)
|
||||
candidates.append(
|
||||
LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=display_name,
|
||||
precision="city",
|
||||
confidence=self._confidence,
|
||||
query=ror_query,
|
||||
source=self.name,
|
||||
source_note=source_note,
|
||||
matched_fields=matched_fields,
|
||||
needs_confirmation=True,
|
||||
city=city,
|
||||
region=region,
|
||||
country=country or query.country,
|
||||
matched_location_name=display_name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
)
|
||||
|
||||
return ResolverOutput(
|
||||
candidates=tuple(candidates),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
|
||||
|
||||
class StoredComputeCenterLocationResolver:
|
||||
"""Resolve a compute center through the DB-backed current-location cache."""
|
||||
|
||||
name = "stored_compute_center_location"
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
extra = query.extra or {}
|
||||
stored = get_compute_center_location_dict(
|
||||
coerce_str(extra.get("source")),
|
||||
coerce_str(extra.get("source_id")),
|
||||
)
|
||||
if not stored:
|
||||
return ResolverOutput()
|
||||
latitude = parse_float(stored.get("latitude"))
|
||||
longitude = parse_float(stored.get("longitude"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=stored.get("name") or query.name or "Compute center",
|
||||
precision=stored.get("precision") or "city",
|
||||
confidence=float(stored.get("confidence") or 0.85),
|
||||
query=f"stored_compute_center_location::{stored.get('source')}:{stored.get('source_id')}",
|
||||
source=self.name,
|
||||
source_note=stored.get("source_note"),
|
||||
matched_fields=("source", "source_id"),
|
||||
needs_confirmation=bool(stored.get("needs_confirmation")),
|
||||
city=stored.get("city") or query.city,
|
||||
region=None,
|
||||
country=stored.get("country") or query.country,
|
||||
matched_location_name=stored.get("site") or stored.get("name") or query.name,
|
||||
location_verified_at=stored.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _short_system_name(name: Any) -> str:
|
||||
"""Strip vendor/system suffix from TOP500 names like ``"El Capitan - HPE Cray ..."``."""
|
||||
text = coerce_str(name)
|
||||
if not text:
|
||||
return ""
|
||||
head = text.split(" - ", 1)[0].strip()
|
||||
return head or text
|
||||
|
||||
|
||||
def _record_context(record: Any, metadata: dict[str, Any]) -> dict[str, str]:
|
||||
name = coerce_str(getattr(record, "name", None))
|
||||
return {
|
||||
"source": coerce_str(getattr(record, "source", None)),
|
||||
"source_id": coerce_str(getattr(record, "source_id", None)),
|
||||
"name": name,
|
||||
"name_short": _short_system_name(name),
|
||||
"city": coerce_str(get_record_field(record, "city")),
|
||||
"country": coerce_str(get_record_field(record, "country")),
|
||||
"site": coerce_str(metadata.get("site") or metadata.get("organization")),
|
||||
"operator": coerce_str(
|
||||
metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
),
|
||||
"organization": coerce_str(metadata.get("organization")),
|
||||
}
|
||||
|
||||
|
||||
def _context_to_query(
|
||||
context: dict[str, str],
|
||||
*,
|
||||
source_lat: float | None = None,
|
||||
source_lon: float | None = None,
|
||||
) -> LocationQuery:
|
||||
name = context.get("name") or None
|
||||
name_short = context.get("name_short") or ""
|
||||
aliases: tuple[str, ...] = ()
|
||||
if name_short and name_short != name:
|
||||
aliases = (name_short,)
|
||||
return LocationQuery(
|
||||
name=name,
|
||||
aliases=aliases,
|
||||
city=context.get("city") or None,
|
||||
country=context.get("country") or None,
|
||||
source_latitude=source_lat,
|
||||
source_longitude=source_lon,
|
||||
extra={
|
||||
"source": context.get("source") or "",
|
||||
"source_id": context.get("source_id") or "",
|
||||
"site": context.get("site") or "",
|
||||
"operator": context.get("operator") or "",
|
||||
"organization": context.get("organization") or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _compute_center_query_plan(
|
||||
query: LocationQuery,
|
||||
) -> list[tuple[str, tuple[str, ...]]]:
|
||||
"""Build the Nominatim query plan for a compute-center query.
|
||||
|
||||
Mirrors the legacy ``_build_online_query_plan`` ordering exactly.
|
||||
"""
|
||||
name = query.name or ""
|
||||
name_short = (query.aliases[0] if query.aliases else "") or name
|
||||
extra = query.extra or {}
|
||||
site = str(extra.get("site") or "")
|
||||
operator = str(extra.get("operator") or "")
|
||||
city = query.city or ""
|
||||
country = query.country or ""
|
||||
|
||||
plan: list[tuple[str, tuple[str, ...]]] = []
|
||||
|
||||
def add(parts: list[tuple[str, str]]) -> None:
|
||||
non_empty = [(field, value) for field, value in parts if value]
|
||||
if not non_empty:
|
||||
return
|
||||
seen: set[str] = set()
|
||||
cleaned: list[str] = []
|
||||
fields: list[str] = []
|
||||
for field, value in non_empty:
|
||||
key = normalize_text(value)
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(value)
|
||||
fields.append(field)
|
||||
if not cleaned:
|
||||
return
|
||||
composed = ", ".join(cleaned)
|
||||
if not any(composed == existing for existing, _ in plan):
|
||||
plan.append((composed, tuple(fields)))
|
||||
|
||||
add([("site", site), ("country", country)])
|
||||
add([("operator", operator), ("city", city), ("country", country)])
|
||||
add([("name", name_short), ("operator", operator), ("country", country)])
|
||||
add([("name", name_short), ("site", site)])
|
||||
add([("name", name_short), ("country", country)])
|
||||
add([("name", name_short), ("city", city), ("country", country)])
|
||||
add([("city", city), ("country", country)])
|
||||
if name and name != name_short:
|
||||
add([("name", name), ("country", country)])
|
||||
return plan
|
||||
|
||||
|
||||
COMPUTE_CENTER_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
StoredComputeCenterLocationResolver(),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
" or stored compute-center location."
|
||||
),
|
||||
)
|
||||
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
ROROrganizationResolver(),
|
||||
NominatimResolver(
|
||||
query_plan_builder=_compute_center_query_plan,
|
||||
# Late-binding so test monkeypatching of ``_geocode_online`` works.
|
||||
geocoder=lambda q: _geocode_online(q),
|
||||
),
|
||||
],
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
", ROR organization lookup, or online geocoding."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── Candidate → ComputeCenterLocation conversion ───────────────────
|
||||
|
||||
|
||||
_GEOGRAPHY_MODE_BY_SOURCE = {
|
||||
"source_coordinates": "source_coordinates",
|
||||
"stored_compute_center_location": "stored_compute_center_location",
|
||||
"ror_organization_registry": "ror_organization",
|
||||
"nominatim_online_geocode": "online_geocode",
|
||||
}
|
||||
|
||||
|
||||
def _candidate_to_location(
|
||||
candidate: LocationCandidate,
|
||||
*,
|
||||
context: dict[str, str],
|
||||
) -> ComputeCenterLocation:
|
||||
geography_mode = _GEOGRAPHY_MODE_BY_SOURCE.get(candidate.source, "online_geocode")
|
||||
is_estimated = candidate.needs_confirmation or candidate.source.startswith(
|
||||
"nominatim"
|
||||
)
|
||||
estimated_reason: str | None
|
||||
if candidate.source == "source_coordinates":
|
||||
estimated_reason = None
|
||||
elif candidate.source == "stored_compute_center_location":
|
||||
estimated_reason = candidate.source_note
|
||||
elif candidate.source == "ror_organization_registry":
|
||||
fields_summary = ", ".join(candidate.matched_fields) or "organization"
|
||||
estimated_reason = (
|
||||
f"Resolved by ROR organization lookup '{candidate.query}' "
|
||||
f"(matched fields: {fields_summary})"
|
||||
)
|
||||
elif candidate.source == "nominatim_online_geocode":
|
||||
fields_summary = ", ".join(candidate.matched_fields) or "name"
|
||||
estimated_reason = (
|
||||
f"Resolved by online geocoding query '{candidate.query}' "
|
||||
f"(matched fields: {fields_summary})"
|
||||
)
|
||||
else:
|
||||
estimated_reason = candidate.source_note
|
||||
|
||||
country = (
|
||||
candidate.country
|
||||
or normalize_country_text(context.get("country"))
|
||||
or context.get("country")
|
||||
or None
|
||||
)
|
||||
return ComputeCenterLocation(
|
||||
latitude=candidate.latitude,
|
||||
longitude=candidate.longitude,
|
||||
location_precision=candidate.precision,
|
||||
geography_mode=geography_mode,
|
||||
is_estimated=is_estimated,
|
||||
estimated_reason=estimated_reason,
|
||||
location_confidence=candidate.confidence,
|
||||
location_source=candidate.source,
|
||||
location_source_note=candidate.source_note,
|
||||
location_verified_at=candidate.location_verified_at,
|
||||
matched_location_name=candidate.matched_location_name
|
||||
or context.get("name")
|
||||
or None,
|
||||
needs_confirmation=candidate.needs_confirmation,
|
||||
city=candidate.city or context.get("city") or None,
|
||||
region=candidate.region,
|
||||
country=country,
|
||||
)
|
||||
|
||||
|
||||
def _diagnostic_for(
|
||||
record: Any,
|
||||
context: dict[str, str],
|
||||
*,
|
||||
failure_reason: str,
|
||||
attempted_queries: tuple[str, ...] = (),
|
||||
) -> ResolutionDiagnostic:
|
||||
return ResolutionDiagnostic(
|
||||
failure_reason=failure_reason,
|
||||
attempted_queries=attempted_queries,
|
||||
record_id=getattr(record, "id", None),
|
||||
source=getattr(record, "source", None),
|
||||
source_id=getattr(record, "source_id", None),
|
||||
name=context.get("name") or getattr(record, "name", None),
|
||||
country=context.get("country") or None,
|
||||
city=context.get("city") or None,
|
||||
site=context.get("site") or None,
|
||||
operator=context.get("operator") or None,
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def resolve_compute_center_location(
|
||||
record: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> ComputeCenterLocation:
|
||||
"""Backwards-compatible thin wrapper returning the renderable location only.
|
||||
|
||||
Records that cannot be resolved to city-level get a placeholder
|
||||
:class:`ComputeCenterLocation` with ``location_precision='unknown'``.
|
||||
Callers should generally prefer :func:`resolve_compute_center_location_full`.
|
||||
"""
|
||||
full = resolve_compute_center_location_full(record, metadata)
|
||||
return full.location or ComputeCenterLocation(
|
||||
latitude=None,
|
||||
longitude=None,
|
||||
location_precision="unknown",
|
||||
geography_mode="unresolved",
|
||||
is_estimated=True,
|
||||
estimated_reason="No resolvable location hints",
|
||||
location_confidence=0.0,
|
||||
location_source="unknown",
|
||||
location_source_note=(
|
||||
"No source coordinates, ROR organization match, or online"
|
||||
" geocoding result."
|
||||
),
|
||||
matched_location_name=None,
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
|
||||
def resolve_compute_center_location_full(
|
||||
record: Any,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
*,
|
||||
allow_online: bool = False,
|
||||
) -> ResolutionResult:
|
||||
metadata = metadata or {}
|
||||
context = _record_context(record, metadata)
|
||||
|
||||
from app.services.location.text import parse_float as _parse_float
|
||||
|
||||
source_lat = _parse_float(get_record_field(record, "latitude"))
|
||||
source_lon = _parse_float(get_record_field(record, "longitude"))
|
||||
if source_lat in (None, 0.0):
|
||||
source_lat = None
|
||||
if source_lon in (None, 0.0):
|
||||
source_lon = None
|
||||
|
||||
query = _context_to_query(
|
||||
context, source_lat=source_lat, source_lon=source_lon
|
||||
)
|
||||
pipeline = (
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE
|
||||
if allow_online
|
||||
else COMPUTE_CENTER_PIPELINE
|
||||
)
|
||||
pipeline_result = pipeline.resolve_best(query)
|
||||
|
||||
if pipeline_result.location and pipeline_result.location.precision in RENDERABLE_PRECISIONS:
|
||||
location = _candidate_to_location(pipeline_result.location, context=context)
|
||||
return ResolutionResult(location=location, diagnostic=None)
|
||||
|
||||
return ResolutionResult(
|
||||
location=None,
|
||||
diagnostic=_diagnostic_for(
|
||||
record,
|
||||
context,
|
||||
failure_reason=(
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
", ROR organization lookup, or online geocoding."
|
||||
if allow_online
|
||||
else (
|
||||
"Could not resolve to city-level coordinates from source coords"
|
||||
" or stored compute-center location."
|
||||
)
|
||||
),
|
||||
attempted_queries=pipeline_result.attempted_queries,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def collect_location_candidates(
|
||||
*,
|
||||
name: str | None = None,
|
||||
source: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
organization: str | None = None,
|
||||
record_id: int | None = None,
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
"""Run the full resolution chain and return ranked candidates with attempted queries.
|
||||
|
||||
The unused ``source`` / ``source_id`` / ``record_id`` arguments are kept
|
||||
for backward compatibility with the API handler that calls this function.
|
||||
"""
|
||||
query = build_compute_center_location_query(
|
||||
name=name,
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
operator=operator,
|
||||
site=site,
|
||||
city=city,
|
||||
country=country,
|
||||
organization=organization,
|
||||
)
|
||||
return COMPUTE_CENTER_COLLECTION_PIPELINE.collect_candidates(query)
|
||||
|
||||
|
||||
def build_compute_center_location_query(
|
||||
*,
|
||||
name: str | None = None,
|
||||
source: str | None = None,
|
||||
source_id: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
organization: str | None = None,
|
||||
) -> LocationQuery:
|
||||
name_value = coerce_str(name)
|
||||
context: dict[str, str] = {
|
||||
"source": coerce_str(source),
|
||||
"source_id": coerce_str(source_id),
|
||||
"name": name_value,
|
||||
"name_short": _short_system_name(name_value),
|
||||
"city": coerce_str(city),
|
||||
"country": coerce_str(country),
|
||||
"site": coerce_str(site or organization),
|
||||
"operator": coerce_str(operator or organization),
|
||||
"organization": coerce_str(organization),
|
||||
}
|
||||
return _context_to_query(context)
|
||||
|
||||
|
||||
def _record_operator(metadata: dict[str, Any]) -> str | None:
|
||||
return coerce_str(
|
||||
metadata.get("operator")
|
||||
or metadata.get("organization")
|
||||
or metadata.get("owner")
|
||||
or metadata.get("manufacturer")
|
||||
) or None
|
||||
|
||||
|
||||
async def seed_compute_center_locations_from_source_coords(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
"""Seed stored compute-center locations only from real source coordinates."""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source.in_(["top500", "epoch_ai_gpu"]))
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
changed = False
|
||||
|
||||
for record in records:
|
||||
source_value = coerce_str(getattr(record, "source", None))
|
||||
source_id = coerce_str(getattr(record, "source_id", None))
|
||||
if not source_value or not source_id:
|
||||
continue
|
||||
latitude = parse_float(get_record_field(record, "latitude"))
|
||||
longitude = parse_float(get_record_field(record, "longitude"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
continue
|
||||
existing = await session.scalar(
|
||||
select(ComputeCenterLocationRecord)
|
||||
.where(ComputeCenterLocationRecord.source == source_value)
|
||||
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
metadata = record.extra_data or {}
|
||||
session.add(
|
||||
ComputeCenterLocationRecord(
|
||||
source=source_value,
|
||||
source_id=source_id,
|
||||
name=getattr(record, "name", None),
|
||||
operator=_record_operator(metadata),
|
||||
site=coerce_str(metadata.get("site") or metadata.get("organization")) or None,
|
||||
city=coerce_str(get_record_field(record, "city")) or None,
|
||||
country=coerce_str(get_record_field(record, "country")) or None,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
location_source="source_coordinates",
|
||||
source_note="Seeded from source-provided compute-center coordinates",
|
||||
raw_payload={
|
||||
"record_id": getattr(record, "id", None),
|
||||
"source": source_value,
|
||||
"source_id": source_id,
|
||||
},
|
||||
needs_confirmation=False,
|
||||
verification_status="source_provided",
|
||||
verified_at=None,
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await session.commit()
|
||||
await refresh_compute_center_location_cache(session)
|
||||
|
||||
|
||||
async def upsert_compute_center_location(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
source: str,
|
||||
source_id: str,
|
||||
name: str | None = None,
|
||||
operator: str | None = None,
|
||||
site: str | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
precision: str = "city",
|
||||
confidence: float | None = None,
|
||||
location_source: str = "manual_selection",
|
||||
source_url: str | None = None,
|
||||
source_note: str | None = None,
|
||||
raw_payload: dict[str, Any] | None = None,
|
||||
needs_confirmation: bool = False,
|
||||
verification_status: str = "verified",
|
||||
) -> ComputeCenterLocationRecord:
|
||||
existing = await session.scalar(
|
||||
select(ComputeCenterLocationRecord)
|
||||
.where(ComputeCenterLocationRecord.source == source)
|
||||
.where(ComputeCenterLocationRecord.source_id == source_id)
|
||||
)
|
||||
verified_at = None if needs_confirmation else datetime.now(UTC)
|
||||
values = {
|
||||
"name": name,
|
||||
"operator": operator,
|
||||
"site": site,
|
||||
"city": city,
|
||||
"country": country,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"precision": precision,
|
||||
"confidence": confidence,
|
||||
"location_source": location_source,
|
||||
"source_url": source_url,
|
||||
"source_note": source_note,
|
||||
"raw_payload": raw_payload or {},
|
||||
"needs_confirmation": needs_confirmation,
|
||||
"verification_status": verification_status,
|
||||
"verified_at": verified_at,
|
||||
}
|
||||
if existing:
|
||||
for key, value in values.items():
|
||||
setattr(existing, key, value)
|
||||
record = existing
|
||||
else:
|
||||
record = ComputeCenterLocationRecord(
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
**values,
|
||||
)
|
||||
session.add(record)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
await refresh_compute_center_location_cache(session)
|
||||
return record
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy import select
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.services.ai_tools.evidence_store import normalize_search_evidence
|
||||
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||
|
||||
|
||||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||||
@@ -153,10 +155,26 @@ async def get_credential_guide(db, provider: str) -> dict[str, Any]:
|
||||
"markdown": custom.get("markdown") if custom else default.markdown,
|
||||
"prompt": default.prompt,
|
||||
"source": "ai" if custom else "default",
|
||||
"sources": custom.get("sources", []) if custom else [],
|
||||
"verification_status": (
|
||||
custom.get("verification_status", "verified_with_search_evidence")
|
||||
if custom
|
||||
else "default_unverified"
|
||||
),
|
||||
"verification_error": custom.get("verification_error") if custom else None,
|
||||
}
|
||||
|
||||
|
||||
async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]:
|
||||
async def save_credential_guide(
|
||||
db,
|
||||
provider: str,
|
||||
title: str,
|
||||
markdown: str,
|
||||
*,
|
||||
sources: list[dict[str, Any]] | None = None,
|
||||
verification_status: str = "verified_with_search_evidence",
|
||||
verification_error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||
if default is None:
|
||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||
@@ -165,6 +183,9 @@ async def save_credential_guide(db, provider: str, title: str, markdown: str) ->
|
||||
store[provider] = {
|
||||
"title": title or default.title,
|
||||
"markdown": markdown,
|
||||
"sources": sources or [],
|
||||
"verification_status": verification_status,
|
||||
"verification_error": verification_error,
|
||||
}
|
||||
if record is None:
|
||||
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
|
||||
@@ -192,28 +213,58 @@ async def generate_credential_guide(
|
||||
db,
|
||||
provider: str,
|
||||
ai_client: AIProviderClient,
|
||||
web_search_client: WebSearchClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
default = DEFAULT_CREDENTIAL_GUIDES.get(provider)
|
||||
if default is None:
|
||||
raise ValueError(f"Unsupported credential guide provider: {provider}")
|
||||
|
||||
search_evidence: list[dict[str, Any]] = []
|
||||
search_error: str | None = None
|
||||
if web_search_client is not None:
|
||||
try:
|
||||
evidence = await web_search_client.search(
|
||||
_credential_guide_search_query(default),
|
||||
max_results=5,
|
||||
)
|
||||
search_evidence = normalize_search_evidence(evidence, limit=5)
|
||||
except WebSearchError as exc:
|
||||
search_error = str(exc)
|
||||
except Exception as exc:
|
||||
search_error = f"WebSearch unavailable: {exc}"
|
||||
|
||||
if not search_evidence:
|
||||
guide = await get_credential_guide(db, provider)
|
||||
guide["verification_status"] = "unverified_no_search_evidence"
|
||||
guide["verification_error"] = search_error
|
||||
guide["sources"] = []
|
||||
return guide
|
||||
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate credential guide for {provider}",
|
||||
objective=default.prompt,
|
||||
objective=(
|
||||
default.prompt
|
||||
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
|
||||
+ "如果证据不足,明确说明需要以官方页面为准。"
|
||||
),
|
||||
context={
|
||||
"provider": provider,
|
||||
"current_default_guide": default.markdown,
|
||||
"product_context": "Planet collector credential settings",
|
||||
"search_evidence": search_evidence,
|
||||
},
|
||||
observations=[
|
||||
"Use concise Chinese markdown.",
|
||||
"Prefer stable concepts over brittle UI labels.",
|
||||
"Include verification and troubleshooting steps.",
|
||||
"Include a short sources section with the provided URLs.",
|
||||
],
|
||||
constraints=[
|
||||
"Do not ask the user for secrets.",
|
||||
"Do not include fabricated screenshots.",
|
||||
"Do not invent source URLs or product UI labels.",
|
||||
"Use only the provided search_evidence as factual support.",
|
||||
"Return markdown only.",
|
||||
],
|
||||
)
|
||||
@@ -221,4 +272,19 @@ async def generate_credential_guide(
|
||||
markdown = response.content.strip()
|
||||
if not markdown:
|
||||
markdown = default.markdown
|
||||
return await save_credential_guide(db, provider, default.title, markdown)
|
||||
return await save_credential_guide(
|
||||
db,
|
||||
provider,
|
||||
default.title,
|
||||
markdown,
|
||||
sources=search_evidence,
|
||||
verification_status="verified_with_search_evidence",
|
||||
)
|
||||
|
||||
|
||||
def _credential_guide_search_query(default: CredentialGuideDefault) -> str:
|
||||
if default.provider == "barentswatch":
|
||||
return "BarentsWatch developer tutorial AIS API OAuth client credentials"
|
||||
if default.provider == "aisstream":
|
||||
return "AISStream API key documentation websocket stream"
|
||||
return f"{default.provider} API credentials documentation"
|
||||
|
||||
121
backend/app/services/docs_gatekeeper.py
Normal file
121
backend/app/services/docs_gatekeeper.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Server-side Docs metadata and Gatekeeper authorization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
DocsAccess = Literal["public", "docs_user", "docs_developer", "docs_admin"]
|
||||
DocsLang = Literal["zh", "en"]
|
||||
|
||||
VALID_DOCS_LANGS = {"zh", "en"}
|
||||
DOCS_README_FILENAME = "README.md"
|
||||
DEFAULT_DOCS_SLUG = "overview"
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
TECHNICAL_DOCS_ROOT = REPO_ROOT / "docs" / "technical"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DocsMetadata:
|
||||
filename: str
|
||||
slug: str
|
||||
access: DocsAccess
|
||||
group: str
|
||||
order: int
|
||||
zh_title: str
|
||||
en_title: str
|
||||
|
||||
|
||||
DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
|
||||
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 1, "快速开始", "Quickstart"),
|
||||
DocsMetadata("manual.md", "manual", "public", "Manual", 2, "Planet 使用手册", "Planet Manual"),
|
||||
DocsMetadata("faq.md", "faq", "public", "Manual", 3, "常见问题", "FAQ"),
|
||||
DocsMetadata("location-pipeline-user.md", "location-pipeline-user", "public", "Manual", 4, "Earth 位置候选采集使用手册", "Earth Location Candidate Collection User Guide"),
|
||||
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
|
||||
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
|
||||
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),
|
||||
DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "Earth 卫星覆盖策略", "Earth Satellite Footprint Policy"),
|
||||
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Earth", 15, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "Earth 可交互图标接入", "Earth Interactable Usage"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "Earth 工具栏与浮层协同", "Earth Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Frontend", 22, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||
DocsMetadata("backend-collectors.md", "backend-collectors", "docs_developer", "Backend", 30, "数据采集系统", "Data Collectors"),
|
||||
DocsMetadata("backend-system-service-control.md", "backend-system-service-control", "docs_admin", "Backend", 31, "系统服务控制", "System Service Control"),
|
||||
DocsMetadata("datasource-collector-settings-connectivity.md", "datasource-collector-settings-connectivity", "docs_developer", "Backend", 32, "数据源、采集器设置与连接验证", "Datasource Collector Settings and Connectivity"),
|
||||
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||
DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "Planet 运维手册", "Planet Ops Runbook"),
|
||||
DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"),
|
||||
DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"),
|
||||
)
|
||||
|
||||
DOCS_BY_SLUG = {entry.slug: entry for entry in DOCS_METADATA}
|
||||
|
||||
|
||||
def get_user_gatekeeper_groups(user: User | None) -> set[str]:
|
||||
if user is None:
|
||||
return set()
|
||||
|
||||
role = user.role.value if hasattr(user.role, "value") else str(user.role or "")
|
||||
if role == "super_admin":
|
||||
return {"docs_user", "docs_developer", "docs_admin"}
|
||||
if role == "admin":
|
||||
return {"docs_user", "docs_developer", "docs_admin"}
|
||||
|
||||
groups = set()
|
||||
raw_groups = user.gatekeeper_groups or []
|
||||
if isinstance(raw_groups, list):
|
||||
groups.update(str(group) for group in raw_groups)
|
||||
|
||||
if "docs_admin" in groups:
|
||||
groups.update({"docs_developer", "docs_user"})
|
||||
if "docs_developer" in groups:
|
||||
groups.add("docs_user")
|
||||
return groups
|
||||
|
||||
|
||||
def can_read_doc(entry: DocsMetadata, user: User | None) -> bool:
|
||||
if entry.access == "public":
|
||||
return True
|
||||
return entry.access in get_user_gatekeeper_groups(user)
|
||||
|
||||
|
||||
def doc_path_for(entry: DocsMetadata, lang: str) -> Path:
|
||||
if lang not in VALID_DOCS_LANGS:
|
||||
raise ValueError("Unsupported docs language")
|
||||
return TECHNICAL_DOCS_ROOT / lang / entry.filename
|
||||
|
||||
|
||||
def title_for(entry: DocsMetadata, lang: str) -> str:
|
||||
return entry.zh_title if lang == "zh" else entry.en_title
|
||||
|
||||
|
||||
def catalog_for_user(user: User | None) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
for entry in DOCS_METADATA:
|
||||
if not can_read_doc(entry, user):
|
||||
continue
|
||||
for lang in sorted(VALID_DOCS_LANGS):
|
||||
if not doc_path_for(entry, lang).exists():
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"slug": entry.slug,
|
||||
"filename": entry.filename,
|
||||
"lang": lang,
|
||||
"title": title_for(entry, lang),
|
||||
"group": entry.group,
|
||||
"order": entry.order,
|
||||
"access": entry.access,
|
||||
}
|
||||
)
|
||||
return sorted(items, key=lambda item: (item["lang"], item["order"], item["title"]))
|
||||
123
backend/app/services/email.py
Normal file
123
backend/app/services/email.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""SMTP-backed email sender.
|
||||
|
||||
Generic primitive used by registration/verification today, reusable for alert
|
||||
digests and other notifications later. Configuration lives in the `smtp` row of
|
||||
`system_settings` and is loaded once per send (small surface, no caching layer
|
||||
yet to keep behavior obvious after settings changes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from email.message import EmailMessage
|
||||
from typing import Literal, Optional
|
||||
|
||||
import aiosmtplib
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
OtpPurpose = Literal["register", "verify_email", "reset_password"]
|
||||
|
||||
|
||||
class EmailError(Exception):
|
||||
code: str = "EMAIL_ERROR"
|
||||
|
||||
|
||||
class EmailNotConfiguredError(EmailError):
|
||||
code = "EMAIL_PROVIDER_NOT_CONFIGURED"
|
||||
|
||||
|
||||
class EmailSendError(EmailError):
|
||||
code = "EMAIL_SEND_FAILED"
|
||||
|
||||
|
||||
async def _load_smtp_config(db: AsyncSession) -> dict:
|
||||
from app.api.v1.settings import get_setting_payload # local import avoids cycle
|
||||
|
||||
payload = await get_setting_payload(db, "smtp")
|
||||
if not payload.get("host") or not payload.get("from_address"):
|
||||
raise EmailNotConfiguredError("SMTP host/from_address not set")
|
||||
return payload
|
||||
|
||||
|
||||
async def send_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
text_body: str,
|
||||
html_body: Optional[str] = None,
|
||||
config: Optional[dict] = None,
|
||||
) -> None:
|
||||
cfg = config or await _load_smtp_config(db)
|
||||
|
||||
message = EmailMessage()
|
||||
from_name = (cfg.get("from_name") or "").strip()
|
||||
from_address = cfg["from_address"]
|
||||
message["From"] = f"{from_name} <{from_address}>" if from_name else from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(text_body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
use_tls = bool(cfg.get("use_tls", True))
|
||||
use_starttls = bool(cfg.get("use_starttls", False))
|
||||
port = int(cfg.get("port") or (465 if use_tls else 587))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=cfg["host"],
|
||||
port=port,
|
||||
username=cfg.get("username") or None,
|
||||
password=cfg.get("password") or None,
|
||||
use_tls=use_tls and not use_starttls,
|
||||
start_tls=use_starttls,
|
||||
timeout=int(cfg.get("timeout_seconds") or 20),
|
||||
)
|
||||
except aiosmtplib.SMTPException as exc:
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
except OSError as exc:
|
||||
raise EmailSendError(str(exc)) from exc
|
||||
|
||||
|
||||
_SUBJECTS: dict[OtpPurpose, str] = {
|
||||
"register": "Confirm your Planet account",
|
||||
"verify_email": "Verify your Planet email",
|
||||
"reset_password": "Reset your Planet password",
|
||||
}
|
||||
|
||||
_HEADLINES: dict[OtpPurpose, str] = {
|
||||
"register": "Welcome to Planet — confirm your email to activate your account.",
|
||||
"verify_email": "Confirm your new email address to keep your Planet account active.",
|
||||
"reset_password": "Use this code to set a new password for your Planet account.",
|
||||
}
|
||||
|
||||
|
||||
async def send_verification_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
to: str,
|
||||
code: str,
|
||||
purpose: OtpPurpose,
|
||||
config: Optional[dict] = None,
|
||||
) -> None:
|
||||
subject = _SUBJECTS[purpose]
|
||||
headline = _HEADLINES[purpose]
|
||||
text_body = (
|
||||
f"{headline}\n\n"
|
||||
f"Your verification code: {code}\n"
|
||||
"This code expires in 10 minutes. If you did not request it, ignore this email.\n"
|
||||
)
|
||||
html_body = (
|
||||
f"<p>{headline}</p>"
|
||||
f"<p style=\"font-size:24px;letter-spacing:4px;font-family:monospace\"><b>{code}</b></p>"
|
||||
"<p>This code expires in 10 minutes. If you did not request it, ignore this email.</p>"
|
||||
)
|
||||
await send_email(
|
||||
db,
|
||||
to=to,
|
||||
subject=subject,
|
||||
text_body=text_body,
|
||||
html_body=html_body,
|
||||
config=config,
|
||||
)
|
||||
57
backend/app/services/location/__init__.py
Normal file
57
backend/app/services/location/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Shared location-resolution pipeline.
|
||||
|
||||
A reusable abstraction for "given a record, decide its lat/lon" — used by
|
||||
compute centers, BGP collectors, BGP events, and any future entity that needs
|
||||
location estimation.
|
||||
|
||||
Each domain wires its own :class:`LocationPipeline` from a sequence of
|
||||
:class:`LocationResolver` instances. Future algorithms (peeringdb, IXP tables,
|
||||
user-confirmed coordinates, …) plug in by implementing the protocol — no
|
||||
changes needed to consumers.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolutionDiagnostic,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
)
|
||||
from .pipeline import LocationPipeline, LocationResolver
|
||||
from .resolvers.inherit import InheritFromAnotherEntityResolver
|
||||
from .resolvers.nominatim import (
|
||||
NominatimResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
interpret_geocode_result,
|
||||
)
|
||||
from .resolvers.registry import RegistryResolver, default_score_alias_match
|
||||
from .resolvers.source_coordinates import SourceCoordinatesResolver
|
||||
from .text import (
|
||||
city_key,
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LocationCandidate",
|
||||
"LocationPipeline",
|
||||
"LocationQuery",
|
||||
"LocationResolver",
|
||||
"ResolutionDiagnostic",
|
||||
"ResolutionResult",
|
||||
"ResolverOutput",
|
||||
"InheritFromAnotherEntityResolver",
|
||||
"NominatimResolver",
|
||||
"RegistryResolver",
|
||||
"SourceCoordinatesResolver",
|
||||
"build_default_nominatim_geocoder",
|
||||
"city_key",
|
||||
"coerce_str",
|
||||
"default_score_alias_match",
|
||||
"interpret_geocode_result",
|
||||
"normalize_country_text",
|
||||
"normalize_text",
|
||||
"parse_float",
|
||||
]
|
||||
1064
backend/app/services/location/llm_fallback.py
Normal file
1064
backend/app/services/location/llm_fallback.py
Normal file
File diff suppressed because it is too large
Load Diff
128
backend/app/services/location/models.py
Normal file
128
backend/app/services/location/models.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Domain-neutral data structures for the location pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
# Renderable precision tiers, ordered from most precise to least.
|
||||
RENDERABLE_PRECISIONS: tuple[str, ...] = ("precise", "site", "city")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationQuery:
|
||||
"""Domain-neutral input for the resolution pipeline.
|
||||
|
||||
``name`` and ``aliases`` are matched against registry alias indexes;
|
||||
``city`` / ``country`` / ``region`` provide geographic context for both
|
||||
registry lookups and Nominatim queries; ``source_latitude`` /
|
||||
``source_longitude`` short-circuit when the record already carries
|
||||
coordinates; ``extra`` carries domain-specific fields (operator, site,
|
||||
organization, asn, peer_ip, …) that resolvers can opt into.
|
||||
"""
|
||||
|
||||
name: str | None = None
|
||||
aliases: tuple[str, ...] = ()
|
||||
city: str | None = None
|
||||
country: str | None = None
|
||||
region: str | None = None
|
||||
source_latitude: float | None = None
|
||||
source_longitude: float | None = None
|
||||
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocationCandidate:
|
||||
"""A resolved location candidate produced by a resolver."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
display_name: str
|
||||
precision: str # "precise" | "site" | "city" | (rejected: country/unknown)
|
||||
confidence: float
|
||||
query: str
|
||||
source: str
|
||||
source_note: str | None
|
||||
matched_fields: tuple[str, ...]
|
||||
needs_confirmation: bool
|
||||
city: str | None = None
|
||||
region: str | None = None
|
||||
country: str | None = None
|
||||
matched_location_name: str | None = None
|
||||
location_verified_at: str | None = None
|
||||
suggested_registry_entry: dict[str, Any] | None = None
|
||||
raw_payload: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"latitude": self.latitude,
|
||||
"longitude": self.longitude,
|
||||
"display_name": self.display_name,
|
||||
"precision": self.precision,
|
||||
"confidence": self.confidence,
|
||||
"query": self.query,
|
||||
"source": self.source,
|
||||
"source_note": self.source_note,
|
||||
"matched_fields": list(self.matched_fields),
|
||||
"needs_confirmation": self.needs_confirmation,
|
||||
"city": self.city,
|
||||
"region": self.region,
|
||||
"country": self.country,
|
||||
"matched_location_name": self.matched_location_name,
|
||||
"location_verified_at": self.location_verified_at,
|
||||
"suggested_registry_entry": self.suggested_registry_entry,
|
||||
"raw_payload": self.raw_payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolverOutput:
|
||||
"""What a single resolver returns from one ``resolve()`` call."""
|
||||
|
||||
candidates: tuple[LocationCandidate, ...] = ()
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionDiagnostic:
|
||||
"""Why we could not resolve, plus what we tried."""
|
||||
|
||||
failure_reason: str
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
record_id: int | None = None
|
||||
source: str | None = None
|
||||
source_id: str | None = None
|
||||
name: str | None = None
|
||||
country: str | None = None
|
||||
city: str | None = None
|
||||
site: str | None = None
|
||||
operator: str | None = None
|
||||
extra: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"failure_reason": self.failure_reason,
|
||||
"attempted_queries": list(self.attempted_queries),
|
||||
"record_id": self.record_id,
|
||||
"source": self.source,
|
||||
"source_id": self.source_id,
|
||||
"name": self.name,
|
||||
"country": self.country,
|
||||
"city": self.city,
|
||||
"site": self.site,
|
||||
"operator": self.operator,
|
||||
**({"extra": dict(self.extra)} if self.extra else {}),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
"""Pipeline output: best candidate (if any) + diagnostic on miss."""
|
||||
|
||||
location: LocationCandidate | None
|
||||
diagnostic: ResolutionDiagnostic | None
|
||||
attempted_queries: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return bool(self.location)
|
||||
126
backend/app/services/location/pipeline.py
Normal file
126
backend/app/services/location/pipeline.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Pipeline that runs a sequence of :class:`LocationResolver` instances."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
from .models import (
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolutionDiagnostic,
|
||||
ResolutionResult,
|
||||
ResolverOutput,
|
||||
)
|
||||
|
||||
|
||||
class LocationResolver(Protocol):
|
||||
"""Pluggable location resolution step.
|
||||
|
||||
Implementations: ``SourceCoordinatesResolver``, ``RegistryResolver``,
|
||||
``NominatimResolver``, ``InheritFromAnotherEntityResolver`` — see the
|
||||
``resolvers`` subpackage. New algorithms (peeringdb / IXP / user-confirmed
|
||||
coordinates) plug in by implementing this protocol; the pipeline does not
|
||||
care how candidates are produced.
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||
|
||||
|
||||
def default_candidate_sort_key(
|
||||
candidate: LocationCandidate,
|
||||
) -> tuple[int, int, float]:
|
||||
precision_rank = {"precise": 0, "site": 1, "city": 2}.get(
|
||||
candidate.precision, 9
|
||||
)
|
||||
source_rank = {
|
||||
"source_coordinates": 0,
|
||||
"stored_compute_center_location": 1,
|
||||
"stored_collector_location": 1,
|
||||
"ror_organization_registry": 2,
|
||||
"inherited": 3,
|
||||
"nominatim_online_geocode": 4,
|
||||
"local_registry": 8,
|
||||
"local_registry_city": 9,
|
||||
}.get(candidate.source, 9)
|
||||
return (source_rank, precision_rank, -float(candidate.confidence or 0))
|
||||
|
||||
|
||||
class LocationPipeline:
|
||||
"""Orchestrate a sequence of resolvers.
|
||||
|
||||
``collect_candidates`` runs every resolver and returns *all* deduped
|
||||
candidates plus the queries each resolver attempted (useful for
|
||||
user-facing "why didn't this work?" diagnostics).
|
||||
|
||||
``resolve_best`` returns the top candidate per
|
||||
:func:`default_candidate_sort_key` (or a custom sort).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resolvers: Sequence[LocationResolver],
|
||||
*,
|
||||
sort_key=default_candidate_sort_key,
|
||||
failure_reason: str = (
|
||||
"Could not resolve to renderable coordinates from any configured resolver."
|
||||
),
|
||||
) -> None:
|
||||
self._resolvers = list(resolvers)
|
||||
self._sort_key = sort_key
|
||||
self._failure_reason = failure_reason
|
||||
|
||||
@property
|
||||
def resolvers(self) -> tuple[LocationResolver, ...]:
|
||||
return tuple(self._resolvers)
|
||||
|
||||
def collect_candidates(
|
||||
self, query: LocationQuery
|
||||
) -> tuple[list[LocationCandidate], list[str]]:
|
||||
candidates: list[LocationCandidate] = []
|
||||
attempted: list[str] = []
|
||||
seen_keys: set[tuple[str, str, str]] = set()
|
||||
|
||||
for resolver in self._resolvers:
|
||||
output = resolver.resolve(query)
|
||||
for q in output.attempted_queries:
|
||||
if q and q not in attempted:
|
||||
attempted.append(q)
|
||||
for candidate in output.candidates:
|
||||
key = (
|
||||
candidate.source,
|
||||
f"{candidate.latitude:.4f}",
|
||||
f"{candidate.longitude:.4f}",
|
||||
)
|
||||
if key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(key)
|
||||
candidates.append(candidate)
|
||||
|
||||
candidates.sort(key=self._sort_key)
|
||||
return candidates, attempted
|
||||
|
||||
def resolve_best(self, query: LocationQuery) -> ResolutionResult:
|
||||
candidates, attempted = self.collect_candidates(query)
|
||||
if candidates:
|
||||
return ResolutionResult(
|
||||
location=candidates[0],
|
||||
diagnostic=None,
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
return ResolutionResult(
|
||||
location=None,
|
||||
diagnostic=ResolutionDiagnostic(
|
||||
failure_reason=self._failure_reason,
|
||||
attempted_queries=tuple(attempted),
|
||||
name=query.name,
|
||||
country=query.country,
|
||||
city=query.city,
|
||||
site=str(query.extra.get("site")) if query.extra.get("site") else None,
|
||||
operator=str(query.extra.get("operator"))
|
||||
if query.extra.get("operator")
|
||||
else None,
|
||||
),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
20
backend/app/services/location/resolvers/__init__.py
Normal file
20
backend/app/services/location/resolvers/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Built-in resolver implementations."""
|
||||
|
||||
from .inherit import InheritFromAnotherEntityResolver
|
||||
from .nominatim import (
|
||||
NominatimResolver,
|
||||
build_default_nominatim_geocoder,
|
||||
interpret_geocode_result,
|
||||
)
|
||||
from .registry import RegistryResolver, default_score_alias_match
|
||||
from .source_coordinates import SourceCoordinatesResolver
|
||||
|
||||
__all__ = [
|
||||
"InheritFromAnotherEntityResolver",
|
||||
"NominatimResolver",
|
||||
"RegistryResolver",
|
||||
"SourceCoordinatesResolver",
|
||||
"build_default_nominatim_geocoder",
|
||||
"default_score_alias_match",
|
||||
"interpret_geocode_result",
|
||||
]
|
||||
31
backend/app/services/location/resolvers/inherit.py
Normal file
31
backend/app/services/location/resolvers/inherit.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Resolver that inherits a candidate from another entity's resolution.
|
||||
|
||||
Used by BGP events to pick up the location of their owning collector. The
|
||||
``source_lookup`` callable is the only domain coupling — it receives the
|
||||
incoming :class:`LocationQuery` and returns either an already-resolved
|
||||
:class:`LocationCandidate` (typically by querying another pipeline) or
|
||||
``None`` to signal "no parent location available".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
|
||||
|
||||
class InheritFromAnotherEntityResolver:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source_lookup: Callable[[LocationQuery], LocationCandidate | None],
|
||||
name: str = "inherited",
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._lookup = source_lookup
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
result = self._lookup(query)
|
||||
if result is None:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(candidates=(result,))
|
||||
292
backend/app/services/location/resolvers/nominatim.py
Normal file
292
backend/app/services/location/resolvers/nominatim.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Nominatim-backed online geocoder.
|
||||
|
||||
The actual HTTP call is encapsulated in :func:`build_default_nominatim_geocoder`
|
||||
which returns an ``lru_cache``-wrapped function. Domain modules typically:
|
||||
|
||||
1. Build a default geocoder via :func:`build_default_nominatim_geocoder`.
|
||||
2. Re-export it under a stable module-level name (e.g. ``_geocode_online``).
|
||||
3. Pass a *late-binding lambda* (``lambda q: _geocode_online(q)``) to
|
||||
:class:`NominatimResolver`.
|
||||
|
||||
This ensures tests that ``monkeypatch.setattr(module, "_geocode_online", ...)``
|
||||
can swap the geocoder behavior without touching pipeline construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
from ..text import (
|
||||
coerce_str,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search"
|
||||
DEFAULT_USER_AGENT = "planet-earth-location-resolver/1.0"
|
||||
DEFAULT_MIN_INTERVAL_SECONDS = 1.1
|
||||
DEFAULT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
def build_default_nominatim_geocoder(
|
||||
*,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS,
|
||||
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
cache_size: int = 512,
|
||||
) -> Callable[[str], dict[str, Any] | None]:
|
||||
"""Return a cached, rate-limited Nominatim geocoder."""
|
||||
|
||||
last_request_at = [0.0]
|
||||
|
||||
@lru_cache(maxsize=cache_size)
|
||||
def geocode(query: str) -> dict[str, Any] | None:
|
||||
if not query:
|
||||
return None
|
||||
elapsed = time.monotonic() - last_request_at[0]
|
||||
if elapsed < min_interval_seconds:
|
||||
time.sleep(min_interval_seconds - elapsed)
|
||||
last_request_at[0] = time.monotonic()
|
||||
response = httpx.get(
|
||||
NOMINATIM_SEARCH_URL,
|
||||
params={
|
||||
"q": query,
|
||||
"format": "jsonv2",
|
||||
"limit": 1,
|
||||
"addressdetails": 1,
|
||||
},
|
||||
headers={"User-Agent": user_agent},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list) or not payload:
|
||||
return None
|
||||
result = payload[0]
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
return result
|
||||
|
||||
return geocode
|
||||
|
||||
|
||||
_DEFAULT_SITE_CATEGORIES = frozenset(
|
||||
{
|
||||
"amenity",
|
||||
"office",
|
||||
"building",
|
||||
"industrial",
|
||||
"research",
|
||||
"university",
|
||||
"education",
|
||||
"tourism",
|
||||
"shop",
|
||||
"man_made",
|
||||
"campus",
|
||||
"research_institute",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def interpret_geocode_result(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
matched_fields: tuple[str, ...],
|
||||
context_country: str | None,
|
||||
site_categories: frozenset[str] = _DEFAULT_SITE_CATEGORIES,
|
||||
site_promoting_match_fields: frozenset[str] = frozenset(
|
||||
{"site", "operator", "name"}
|
||||
),
|
||||
) -> tuple[float, float, dict[str, Any], str] | None:
|
||||
"""Validate a Nominatim raw result. Returns (lat, lon, address, classification)."""
|
||||
latitude = parse_float(result.get("lat"))
|
||||
longitude = parse_float(result.get("lon"))
|
||||
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
||||
return None
|
||||
|
||||
address = result.get("address") if isinstance(result.get("address"), dict) else {}
|
||||
if not isinstance(address, dict):
|
||||
address = {}
|
||||
|
||||
has_city_level = bool(
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or address.get("hamlet")
|
||||
or address.get("suburb")
|
||||
)
|
||||
osm_class = str(result.get("class") or "").lower()
|
||||
osm_type = str(result.get("type") or "").lower()
|
||||
is_site_like = osm_class in site_categories or osm_type in site_categories
|
||||
if not has_city_level and not is_site_like:
|
||||
return None
|
||||
|
||||
if context_country:
|
||||
normalized_context = normalize_text(normalize_country_text(context_country))
|
||||
normalized_result = normalize_text(
|
||||
normalize_country_text(address.get("country"))
|
||||
)
|
||||
if (
|
||||
normalized_context
|
||||
and normalized_result
|
||||
and normalized_context != normalized_result
|
||||
):
|
||||
return None
|
||||
|
||||
classification = (
|
||||
"site"
|
||||
if (
|
||||
is_site_like
|
||||
and has_city_level
|
||||
and any(field in site_promoting_match_fields for field in matched_fields)
|
||||
)
|
||||
else "city"
|
||||
)
|
||||
return float(latitude), float(longitude), address, classification
|
||||
|
||||
|
||||
def _candidate_from_geocode(
|
||||
*,
|
||||
query: LocationQuery,
|
||||
geocode_query: str,
|
||||
matched_fields: tuple[str, ...],
|
||||
raw_result: dict[str, Any],
|
||||
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None],
|
||||
source: str,
|
||||
site_confidence: float,
|
||||
city_confidence: float,
|
||||
) -> LocationCandidate | None:
|
||||
interpreted = interpret(
|
||||
raw_result,
|
||||
matched_fields=matched_fields,
|
||||
context_country=query.country,
|
||||
)
|
||||
if not interpreted:
|
||||
return None
|
||||
latitude, longitude, address, classification = interpreted
|
||||
city = (
|
||||
address.get("city")
|
||||
or address.get("town")
|
||||
or address.get("village")
|
||||
or address.get("municipality")
|
||||
or query.city
|
||||
or None
|
||||
)
|
||||
region = address.get("state") or address.get("region")
|
||||
country = address.get("country") or query.country or None
|
||||
display_name = raw_result.get("display_name") or geocode_query
|
||||
confidence = city_confidence if classification == "city" else site_confidence
|
||||
|
||||
extra = query.extra or {}
|
||||
suggested_registry_entry = {
|
||||
"canonical_name": (
|
||||
(query.aliases[0] if query.aliases else None)
|
||||
or query.name
|
||||
or display_name
|
||||
),
|
||||
"aliases": list(
|
||||
{
|
||||
value
|
||||
for value in [
|
||||
query.name,
|
||||
*query.aliases,
|
||||
coerce_str(extra.get("operator")),
|
||||
coerce_str(extra.get("site")),
|
||||
]
|
||||
if value
|
||||
}
|
||||
),
|
||||
"operator": coerce_str(extra.get("operator")) or None,
|
||||
"site": coerce_str(extra.get("site"))
|
||||
or coerce_str(extra.get("organization"))
|
||||
or None,
|
||||
"country": country,
|
||||
"city": city,
|
||||
"region": region,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"precision": classification,
|
||||
"confidence": confidence,
|
||||
"source_note": (
|
||||
f"Resolved via Nominatim query '{geocode_query}' → {display_name}"
|
||||
),
|
||||
}
|
||||
return LocationCandidate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
display_name=display_name,
|
||||
precision=classification,
|
||||
confidence=confidence,
|
||||
query=geocode_query,
|
||||
source=source,
|
||||
source_note=f"Nominatim search result: {display_name}",
|
||||
matched_fields=matched_fields,
|
||||
needs_confirmation=True,
|
||||
city=city,
|
||||
region=region,
|
||||
country=country,
|
||||
matched_location_name=display_name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=suggested_registry_entry,
|
||||
)
|
||||
|
||||
|
||||
class NominatimResolver:
|
||||
"""Run a domain-specific query plan against Nominatim."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
query_plan_builder: Callable[
|
||||
[LocationQuery], list[tuple[str, tuple[str, ...]]]
|
||||
],
|
||||
geocoder: Callable[[str], dict[str, Any] | None],
|
||||
name: str = "nominatim_online_geocode",
|
||||
site_confidence: float = 0.72,
|
||||
city_confidence: float = 0.62,
|
||||
interpret: Callable[..., tuple[float, float, dict[str, Any], str] | None] = (
|
||||
interpret_geocode_result
|
||||
),
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._query_plan_builder = query_plan_builder
|
||||
self._geocoder = geocoder
|
||||
self._site_confidence = site_confidence
|
||||
self._city_confidence = city_confidence
|
||||
self._interpret = interpret
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
plan = self._query_plan_builder(query)
|
||||
candidates: list[LocationCandidate] = []
|
||||
attempted: list[str] = []
|
||||
for geocode_query, matched_fields in plan:
|
||||
attempted.append(geocode_query)
|
||||
try:
|
||||
raw_result = self._geocoder(geocode_query)
|
||||
except Exception:
|
||||
continue
|
||||
if not raw_result:
|
||||
continue
|
||||
candidate = _candidate_from_geocode(
|
||||
query=query,
|
||||
geocode_query=geocode_query,
|
||||
matched_fields=matched_fields,
|
||||
raw_result=raw_result,
|
||||
interpret=self._interpret,
|
||||
source=self.name,
|
||||
site_confidence=self._site_confidence,
|
||||
city_confidence=self._city_confidence,
|
||||
)
|
||||
if candidate is not None:
|
||||
candidates.append(candidate)
|
||||
return ResolverOutput(
|
||||
candidates=tuple(candidates),
|
||||
attempted_queries=tuple(attempted),
|
||||
)
|
||||
323
backend/app/services/location/resolvers/registry.py
Normal file
323
backend/app/services/location/resolvers/registry.py
Normal file
@@ -0,0 +1,323 @@
|
||||
"""Resolver that matches a query against a local JSON registry.
|
||||
|
||||
Registry schema (a single JSON file):
|
||||
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "...",
|
||||
"aliases": ["...", "..."],
|
||||
"operator": "...",
|
||||
"site": "...",
|
||||
"city": "...",
|
||||
"country": "...",
|
||||
"region": "...",
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"precision": "precise" | "site" | "city",
|
||||
"confidence": 0.0,
|
||||
"verification_status": "verified",
|
||||
"source_note": "...",
|
||||
"verified_at": "YYYY-MM-DD"
|
||||
}
|
||||
],
|
||||
"city_fallbacks": [ {city, country, latitude, longitude, ...} ]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from ..models import (
|
||||
RENDERABLE_PRECISIONS,
|
||||
LocationCandidate,
|
||||
LocationQuery,
|
||||
ResolverOutput,
|
||||
)
|
||||
from ..text import (
|
||||
city_key,
|
||||
normalize_country_text,
|
||||
normalize_text,
|
||||
parse_float,
|
||||
)
|
||||
|
||||
# Field-priority weights when scoring "this query field text contains this
|
||||
# alias text". Tuned to match the legacy compute-center ordering — name beats
|
||||
# site beats operator beats city — which generalizes well to other domains.
|
||||
_DEFAULT_FIELD_PRIORITY = {
|
||||
"name": 8,
|
||||
"site": 6,
|
||||
"operator": 5,
|
||||
"city": 3,
|
||||
}
|
||||
|
||||
|
||||
def default_score_alias_match(
|
||||
alias_field: str, record_field: str, alias_text: str
|
||||
) -> int:
|
||||
score = max(0, len(alias_text))
|
||||
score += _DEFAULT_FIELD_PRIORITY.get(alias_field, 1)
|
||||
if alias_field == record_field:
|
||||
score += 4
|
||||
if alias_field == "name" and record_field in {"name", "name_short", "alias"}:
|
||||
score += 6
|
||||
if alias_field == "site" and record_field in {"site", "organization"}:
|
||||
score += 4
|
||||
if alias_field == "operator" and record_field in {"operator", "organization"}:
|
||||
score += 4
|
||||
return score
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _load_registry_file(path: str) -> dict[str, Any]:
|
||||
with Path(path).open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _build_alias_index(
|
||||
path: str,
|
||||
) -> tuple[tuple[dict[str, Any], tuple[tuple[str, str], ...]], ...]:
|
||||
index: list[tuple[dict[str, Any], tuple[tuple[str, str], ...]]] = []
|
||||
for entry in _load_registry_file(path).get("locations", []):
|
||||
aliases: list[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for alias in [entry.get("canonical_name"), *(entry.get("aliases") or [])]:
|
||||
normalized = normalize_text(alias)
|
||||
if normalized and normalized not in seen:
|
||||
aliases.append(("name", normalized))
|
||||
seen.add(normalized)
|
||||
for field_name in ("operator", "site", "city"):
|
||||
value = entry.get(field_name)
|
||||
normalized = normalize_text(value)
|
||||
if normalized and normalized not in seen:
|
||||
aliases.append((field_name, normalized))
|
||||
seen.add(normalized)
|
||||
index.append((entry, tuple(aliases)))
|
||||
return tuple(index)
|
||||
|
||||
|
||||
def _query_corpus(query: LocationQuery) -> dict[str, str]:
|
||||
"""Map a query into normalized strings keyed by source field."""
|
||||
fields: dict[str, str] = {
|
||||
"name": query.name or "",
|
||||
"city": query.city or "",
|
||||
"country": query.country or "",
|
||||
}
|
||||
for alias in query.aliases:
|
||||
if alias and alias != query.name:
|
||||
fields["name_short"] = alias
|
||||
break
|
||||
extra = query.extra or {}
|
||||
for key in ("site", "operator", "organization"):
|
||||
value = extra.get(key)
|
||||
if value:
|
||||
fields[key] = str(value)
|
||||
return {key: normalize_text(value) for key, value in fields.items() if value}
|
||||
|
||||
|
||||
def _country_compatible(entry: dict[str, Any], query: LocationQuery) -> bool:
|
||||
record_country = normalize_country_text(query.country)
|
||||
entry_country = normalize_country_text(entry.get("country"))
|
||||
if not record_country or not entry_country:
|
||||
return True
|
||||
return normalize_text(record_country) == normalize_text(entry_country)
|
||||
|
||||
|
||||
def _normalized_alias_matches(alias_normalized: str, record_text: str) -> bool:
|
||||
alias_tokens = alias_normalized.split()
|
||||
record_tokens = record_text.split()
|
||||
if not alias_tokens or not record_tokens:
|
||||
return False
|
||||
if len(alias_tokens) == 1:
|
||||
return alias_tokens[0] in record_tokens
|
||||
window_size = len(alias_tokens)
|
||||
return any(
|
||||
record_tokens[index : index + window_size] == alias_tokens
|
||||
for index in range(0, len(record_tokens) - window_size + 1)
|
||||
)
|
||||
|
||||
|
||||
def _entry_to_candidate(
|
||||
entry: dict[str, Any],
|
||||
*,
|
||||
matched_alias: str,
|
||||
matched_fields: Iterable[str],
|
||||
source: str,
|
||||
score_explainer: str,
|
||||
confidence_floor: float,
|
||||
) -> LocationCandidate:
|
||||
canonical_name = entry.get("canonical_name") or matched_alias
|
||||
# Registry entries are treated as candidates unless explicitly verified.
|
||||
# This prevents migrated hard-coded hints from appearing as factual
|
||||
# location evidence.
|
||||
is_verified = entry.get("verification_status") == "verified"
|
||||
precision = entry.get("precision") or "city"
|
||||
if precision not in RENDERABLE_PRECISIONS:
|
||||
precision = "city"
|
||||
fields_summary = ", ".join(sorted(set(matched_fields))) or "name"
|
||||
confidence_value = parse_float(entry.get("confidence"))
|
||||
confidence = (
|
||||
float(confidence_value)
|
||||
if confidence_value is not None
|
||||
else confidence_floor
|
||||
)
|
||||
return LocationCandidate(
|
||||
latitude=float(parse_float(entry.get("latitude")) or 0.0),
|
||||
longitude=float(parse_float(entry.get("longitude")) or 0.0),
|
||||
display_name=canonical_name,
|
||||
precision=precision,
|
||||
confidence=confidence,
|
||||
query=f"local_registry::{matched_alias or canonical_name}",
|
||||
source=source,
|
||||
source_note=entry.get("source_note")
|
||||
or f"{score_explainer}: matched {fields_summary}",
|
||||
matched_fields=tuple(sorted(set(matched_fields))) or ("name",),
|
||||
needs_confirmation=bool(entry.get("needs_confirmation")) or not is_verified,
|
||||
city=entry.get("city"),
|
||||
region=entry.get("region"),
|
||||
country=entry.get("country"),
|
||||
matched_location_name=canonical_name,
|
||||
location_verified_at=entry.get("verified_at") if is_verified else None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
|
||||
|
||||
class RegistryResolver:
|
||||
"""Match a query against a JSON registry (plus its city_fallbacks table)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
registry_path: Path | str,
|
||||
name: str = "local_registry",
|
||||
city_fallback_source: str = "local_registry_city",
|
||||
city_fallback_confidence_default: float = 0.65,
|
||||
confidence_default: float = 0.85,
|
||||
score_alias_match: Callable[[str, str, str], int] = default_score_alias_match,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self._registry_path = str(Path(registry_path))
|
||||
self._city_fallback_source = city_fallback_source
|
||||
self._city_fallback_confidence_default = city_fallback_confidence_default
|
||||
self._confidence_default = confidence_default
|
||||
self._score = score_alias_match
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Drop the cached registry — useful when the JSON file is edited."""
|
||||
_load_registry_file.cache_clear()
|
||||
_build_alias_index.cache_clear()
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
candidates: list[LocationCandidate] = []
|
||||
candidates.extend(self._registry_candidates(query))
|
||||
city_candidate = self._city_fallback_candidate(query)
|
||||
if city_candidate is not None:
|
||||
candidates.append(city_candidate)
|
||||
return ResolverOutput(candidates=tuple(candidates))
|
||||
|
||||
# ── internals ──────────────────────────────────────────────
|
||||
|
||||
def _registry_candidates(
|
||||
self, query: LocationQuery
|
||||
) -> list[LocationCandidate]:
|
||||
corpus = _query_corpus(query)
|
||||
if not corpus:
|
||||
return []
|
||||
|
||||
# When the query carries a name (a record-specific identifier), require
|
||||
# at least one alias match against a name-class field — otherwise a
|
||||
# generic shared field like operator="RIPE NCC" would promote every
|
||||
# registry entry that lists that operator, regardless of whether the
|
||||
# name matches.
|
||||
query_has_name = bool(corpus.get("name") or corpus.get("name_short"))
|
||||
|
||||
results: list[LocationCandidate] = []
|
||||
for entry, aliases in _build_alias_index(self._registry_path):
|
||||
best_alias = ""
|
||||
best_score = 0
|
||||
matched_fields: list[str] = []
|
||||
matched_via_name_alias = False
|
||||
for alias_field, alias_normalized in aliases:
|
||||
for record_field, record_text in corpus.items():
|
||||
if not _normalized_alias_matches(alias_normalized, record_text):
|
||||
continue
|
||||
score = self._score(
|
||||
alias_field, record_field, alias_normalized
|
||||
)
|
||||
if score > best_score or (
|
||||
score == best_score
|
||||
and len(alias_normalized) > len(best_alias)
|
||||
):
|
||||
best_score = score
|
||||
best_alias = alias_normalized
|
||||
if record_field not in matched_fields:
|
||||
matched_fields.append(record_field)
|
||||
if alias_field == "name" and record_field in {"name", "name_short"}:
|
||||
matched_via_name_alias = True
|
||||
if not matched_fields or best_score <= 0:
|
||||
continue
|
||||
if query_has_name and not matched_via_name_alias:
|
||||
continue
|
||||
if not _country_compatible(entry, query):
|
||||
continue
|
||||
results.append(
|
||||
_entry_to_candidate(
|
||||
entry,
|
||||
matched_alias=best_alias,
|
||||
matched_fields=matched_fields,
|
||||
source=self.name,
|
||||
score_explainer="Registry alias match",
|
||||
confidence_floor=self._confidence_default,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
def _city_fallback_candidate(
|
||||
self, query: LocationQuery
|
||||
) -> LocationCandidate | None:
|
||||
country = normalize_country_text(query.country)
|
||||
city = city_key(query.city)
|
||||
if not country or not city:
|
||||
return None
|
||||
|
||||
for fallback in _load_registry_file(self._registry_path).get(
|
||||
"city_fallbacks", []
|
||||
):
|
||||
fallback_country = normalize_country_text(fallback.get("country"))
|
||||
fallback_city = city_key(fallback.get("city"))
|
||||
if fallback_country != country or fallback_city != city:
|
||||
continue
|
||||
confidence_value = parse_float(fallback.get("confidence"))
|
||||
confidence = (
|
||||
float(confidence_value)
|
||||
if confidence_value is not None
|
||||
else self._city_fallback_confidence_default
|
||||
)
|
||||
return LocationCandidate(
|
||||
latitude=float(parse_float(fallback.get("latitude")) or 0.0),
|
||||
longitude=float(parse_float(fallback.get("longitude")) or 0.0),
|
||||
display_name=fallback.get("city") or "",
|
||||
precision="city",
|
||||
confidence=confidence,
|
||||
query=(
|
||||
f"city_fallback::{fallback.get('city')}, "
|
||||
f"{fallback.get('country')}"
|
||||
),
|
||||
source=self._city_fallback_source,
|
||||
source_note=fallback.get("source_note")
|
||||
or f"City fallback for {fallback.get('city')}, {fallback.get('country')}",
|
||||
matched_fields=("city", "country"),
|
||||
needs_confirmation=False,
|
||||
city=fallback.get("city"),
|
||||
region=fallback.get("region"),
|
||||
country=fallback.get("country"),
|
||||
matched_location_name=fallback.get("city"),
|
||||
location_verified_at=fallback.get("verified_at"),
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Resolver that consumes lat/lon already present on the source record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import LocationCandidate, LocationQuery, ResolverOutput
|
||||
from ..text import normalize_country_text
|
||||
|
||||
|
||||
class SourceCoordinatesResolver:
|
||||
"""Pass-through for records that already carry valid coordinates."""
|
||||
|
||||
name = "source_coordinates"
|
||||
|
||||
def __init__(self, *, source: str = "source_coordinates") -> None:
|
||||
self._source = source
|
||||
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
||||
lat = query.source_latitude
|
||||
lon = query.source_longitude
|
||||
if lat in (None, 0.0) or lon in (None, 0.0):
|
||||
return ResolverOutput()
|
||||
|
||||
country = normalize_country_text(query.country) or query.country
|
||||
candidate = LocationCandidate(
|
||||
latitude=float(lat),
|
||||
longitude=float(lon),
|
||||
display_name=query.name or "",
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
query="source_coordinates",
|
||||
source=self._source,
|
||||
source_note="Source record provided valid coordinates.",
|
||||
matched_fields=("source_coordinates",),
|
||||
needs_confirmation=False,
|
||||
city=query.city,
|
||||
region=query.region,
|
||||
country=country,
|
||||
matched_location_name=query.name,
|
||||
location_verified_at=None,
|
||||
suggested_registry_entry=None,
|
||||
)
|
||||
return ResolverOutput(candidates=(candidate,))
|
||||
41
backend/app/services/location/text.py
Normal file
41
backend/app/services/location/text.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Text-normalization helpers shared by every resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.core.countries import normalize_country
|
||||
|
||||
|
||||
def parse_float(value: Any) -> float | None:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def coerce_str(value: Any) -> str:
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def normalize_text(value: Any) -> str:
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
normalized = str(value).casefold()
|
||||
normalized = re.sub(r"[^a-z0-9一-鿿]+", " ", normalized)
|
||||
return re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
|
||||
def normalize_country_text(value: Any) -> str:
|
||||
normalized = normalize_country(value)
|
||||
return normalized or coerce_str(value)
|
||||
|
||||
|
||||
def city_key(city: Any) -> str:
|
||||
text = coerce_str(city).split(",", 1)[0]
|
||||
return normalize_text(text)
|
||||
100
backend/app/services/otp.py
Normal file
100
backend/app/services/otp.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""One-time verification codes backed by Redis.
|
||||
|
||||
Reusable primitive for register/verify-email/reset-password (and any future 2FA or
|
||||
phone-number verification). Codes are bcrypt-hashed before storage so a Redis dump
|
||||
does not leak active codes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Literal
|
||||
|
||||
import bcrypt
|
||||
|
||||
from app.core.security import redis_client
|
||||
|
||||
OtpPurpose = Literal["register", "verify_email", "reset_password"]
|
||||
|
||||
CODE_TTL_SECONDS = 600 # 10 minutes
|
||||
RESEND_COOLDOWN_SECONDS = 60
|
||||
MAX_ATTEMPTS = 5
|
||||
CODE_LENGTH = 6
|
||||
|
||||
|
||||
class OtpError(Exception):
|
||||
code: str = "OTP_ERROR"
|
||||
|
||||
|
||||
class OtpResendRateLimited(OtpError):
|
||||
code = "OTP_RESEND_RATE_LIMITED"
|
||||
|
||||
def __init__(self, retry_after_seconds: int) -> None:
|
||||
super().__init__(f"Resend allowed in {retry_after_seconds}s")
|
||||
self.retry_after_seconds = retry_after_seconds
|
||||
|
||||
|
||||
class OtpInvalid(OtpError):
|
||||
code = "OTP_INVALID"
|
||||
|
||||
|
||||
class OtpExpired(OtpError):
|
||||
code = "OTP_EXPIRED"
|
||||
|
||||
|
||||
class OtpAttemptsExceeded(OtpError):
|
||||
code = "OTP_ATTEMPTS_EXCEEDED"
|
||||
|
||||
|
||||
def _code_key(email: str, purpose: OtpPurpose) -> str:
|
||||
return f"otp:{purpose}:{email.lower()}"
|
||||
|
||||
|
||||
def _rate_key(email: str, purpose: OtpPurpose) -> str:
|
||||
return f"otp_rate:{purpose}:{email.lower()}"
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
# secrets.randbelow gives uniform 0..10**CODE_LENGTH-1 without modulo bias
|
||||
return f"{secrets.randbelow(10 ** CODE_LENGTH):0{CODE_LENGTH}d}"
|
||||
|
||||
|
||||
def check_resend_allowed(email: str, purpose: OtpPurpose) -> None:
|
||||
ttl = redis_client.ttl(_rate_key(email, purpose))
|
||||
if ttl and ttl > 0:
|
||||
raise OtpResendRateLimited(ttl)
|
||||
|
||||
|
||||
def issue_code(email: str, purpose: OtpPurpose) -> str:
|
||||
"""Generate a new code, persist its hash, and start the resend cooldown.
|
||||
|
||||
Caller is responsible for delivering the returned plaintext (e.g. via email).
|
||||
Any pre-existing code for the same (purpose, email) is overwritten.
|
||||
"""
|
||||
check_resend_allowed(email, purpose)
|
||||
code = _generate_code()
|
||||
hashed = bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode()
|
||||
payload = json.dumps({"hash": hashed, "attempts": 0})
|
||||
redis_client.set(_code_key(email, purpose), payload, ex=CODE_TTL_SECONDS)
|
||||
redis_client.set(_rate_key(email, purpose), "1", ex=RESEND_COOLDOWN_SECONDS)
|
||||
return code
|
||||
|
||||
|
||||
def verify_code(email: str, purpose: OtpPurpose, code: str) -> None:
|
||||
"""Validate and consume a code. Raises subclasses of OtpError on failure."""
|
||||
key = _code_key(email, purpose)
|
||||
raw = redis_client.get(key)
|
||||
if raw is None:
|
||||
raise OtpExpired("Code expired or never issued")
|
||||
record = json.loads(raw)
|
||||
attempts = int(record.get("attempts", 0))
|
||||
if attempts >= MAX_ATTEMPTS:
|
||||
redis_client.delete(key)
|
||||
raise OtpAttemptsExceeded("Too many invalid attempts")
|
||||
if not bcrypt.checkpw(code.encode(), record["hash"].encode()):
|
||||
record["attempts"] = attempts + 1
|
||||
ttl = redis_client.ttl(key)
|
||||
redis_client.set(key, json.dumps(record), ex=max(ttl, 1))
|
||||
raise OtpInvalid("Incorrect code")
|
||||
redis_client.delete(key)
|
||||
@@ -33,6 +33,7 @@ from app.services.playground_session_store import upsert_playground_session
|
||||
STREAM_CHUNK_SIZE = 24
|
||||
STREAM_INTERVAL_SECONDS = 0.08
|
||||
THINKING_PREVIEW_SECONDS = 2.6
|
||||
ORPHANED_RUN_MESSAGE = "后台生成任务已中断,请点击上一条用户消息的重试按钮重新生成。"
|
||||
|
||||
|
||||
class _ActiveRun:
|
||||
@@ -179,6 +180,7 @@ async def _build_thread_response(
|
||||
session: PlaygroundSession,
|
||||
) -> PlaygroundThreadResponse:
|
||||
messages = await _list_visible_messages(db, session_id=session.id)
|
||||
messages = await _reconcile_orphaned_active_messages(db, messages)
|
||||
id_map = {item.id: item.public_id for item in messages}
|
||||
return PlaygroundThreadResponse(
|
||||
session=session_to_response(session),
|
||||
@@ -186,6 +188,30 @@ async def _build_thread_response(
|
||||
)
|
||||
|
||||
|
||||
async def _reconcile_orphaned_active_messages(
|
||||
db: AsyncSession,
|
||||
messages: list[PlaygroundMessage],
|
||||
) -> list[PlaygroundMessage]:
|
||||
changed = False
|
||||
for item in messages:
|
||||
if item.status not in {"pending", "thinking", "answering"}:
|
||||
continue
|
||||
if item.public_id in _ACTIVE_RUNS:
|
||||
continue
|
||||
item.status = "error"
|
||||
item.content = item.content or ORPHANED_RUN_MESSAGE
|
||||
orphan_meta = "错误: 后台任务已中断"
|
||||
if orphan_meta not in (item.meta or []):
|
||||
item.meta = [*(item.meta or []), orphan_meta]
|
||||
changed = True
|
||||
if changed:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
for item in messages:
|
||||
await db.refresh(item)
|
||||
return messages
|
||||
|
||||
|
||||
async def get_thread(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -550,6 +576,15 @@ def _build_conversation_history(messages: Sequence[PlaygroundMessage], current_u
|
||||
return history[-8:]
|
||||
|
||||
|
||||
def _format_run_exception(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, str):
|
||||
return detail
|
||||
return str(detail)
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
async def _run_assistant_message(
|
||||
*,
|
||||
user_id: int,
|
||||
@@ -680,13 +715,18 @@ async def _run_assistant_message(
|
||||
await db.commit()
|
||||
raise
|
||||
except Exception as exc:
|
||||
error_message = _format_run_exception(exc)
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(PlaygroundMessage).where(PlaygroundMessage.id == assistant_message_id))
|
||||
message = result.scalar_one_or_none()
|
||||
if message is not None:
|
||||
message.status = "error"
|
||||
message.content = message.content or "分析失败,请检查 AI Provider 配置或稍后再试。"
|
||||
message.meta = [*(message.meta or []), f"错误: {type(exc).__name__}"]
|
||||
message.content = message.content or f"分析失败:{error_message}"
|
||||
message.meta = [
|
||||
*(message.meta or []),
|
||||
f"Request ID: {request_id}",
|
||||
f"错误: {error_message}",
|
||||
]
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
finally:
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import Float
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
|
||||
@@ -17,6 +18,10 @@ from app.services.vessel_types import normalize_vessel_type_name
|
||||
|
||||
VESSEL_AIS_SCHEMA = "vessel_ais"
|
||||
DEFAULT_AGGREGATION_WINDOW_HOURS = 24
|
||||
DEFAULT_SNAPSHOT_WINDOW_MINUTES = 60
|
||||
MAX_SNAPSHOT_LIMIT = 5000
|
||||
MAX_SNAPSHOT_CANDIDATE_MULTIPLIER = 20
|
||||
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS = 100_000
|
||||
BARENTSWATCH_DELIVERY_MODE = "polling"
|
||||
BARENTSWATCH_TRANSPORT = "http"
|
||||
AISSTREAM_DELIVERY_MODE = "realtime_stream"
|
||||
@@ -564,6 +569,46 @@ async def get_aggregated_vessels(
|
||||
return vessels
|
||||
|
||||
|
||||
async def get_aggregated_vessels_snapshot(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
bbox: tuple[float, float, float, float],
|
||||
limit: int = 1000,
|
||||
observed_since: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return a bounded viewport snapshot without loading the global AIS window."""
|
||||
|
||||
observed_since = observed_since or (
|
||||
datetime.now(UTC) - timedelta(minutes=DEFAULT_SNAPSHOT_WINDOW_MINUTES)
|
||||
)
|
||||
safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT)
|
||||
candidate_limit = min(
|
||||
max(safe_limit * MAX_SNAPSHOT_CANDIDATE_MULTIPLIER, safe_limit),
|
||||
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS,
|
||||
)
|
||||
lon_min, lat_min, lon_max, lat_max = bbox
|
||||
payload_lon = AISRawObservation.normalized_payload["lon"].as_string().cast(Float)
|
||||
payload_lat = AISRawObservation.normalized_payload["lat"].as_string().cast(Float)
|
||||
|
||||
stmt = (
|
||||
select(AISRawObservation)
|
||||
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
|
||||
.where(AISRawObservation.observed_at >= observed_since)
|
||||
.where(payload_lon >= lon_min)
|
||||
.where(payload_lon <= lon_max)
|
||||
.where(payload_lat >= lat_min)
|
||||
.where(payload_lat <= lat_max)
|
||||
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
|
||||
.limit(candidate_limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
if not hasattr(result, "scalars"):
|
||||
return []
|
||||
vessels = await aggregate_vessel_observations(db, result.scalars().all())
|
||||
return vessels[:safe_limit]
|
||||
|
||||
|
||||
async def get_aggregated_vessel(db: AsyncSession, mmsi: int) -> dict[str, Any] | None:
|
||||
observations = await get_vessel_raw_observations(db, mmsi, limit=1000)
|
||||
vessels = await aggregate_vessel_observations(db, observations)
|
||||
|
||||
@@ -17,15 +17,16 @@ async def create_admin():
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
print(f"用户 linkong 已存在,更新密码...")
|
||||
existing_user.set_password("LK12345678")
|
||||
print("用户 linkong 已存在,更新密码...")
|
||||
existing_user.set_password("12345678")
|
||||
existing_user.role = "super_admin"
|
||||
existing_user.email = "linkong@planet.local"
|
||||
else:
|
||||
print("创建管理员用户...")
|
||||
user = User(
|
||||
username="linkong",
|
||||
email="linkong@example.com",
|
||||
password_hash=get_password_hash("LK12345678"),
|
||||
email="linkong@planet.local",
|
||||
password_hash=get_password_hash("12345678"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
@@ -6,29 +6,58 @@ import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import engine, async_session_factory
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.user import User
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_admin():
|
||||
from sqlalchemy import text
|
||||
|
||||
async with async_session_factory() as session:
|
||||
result = await session.execute(text("SELECT id FROM users WHERE username = 'admin'"))
|
||||
if result.fetchone():
|
||||
print("Admin user already exists")
|
||||
created = []
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
session.add(
|
||||
User(
|
||||
username=default_user["username"],
|
||||
email=default_user["email"],
|
||||
password_hash=get_password_hash(default_user["password"]),
|
||||
role=default_user["role"],
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
created.append(default_user)
|
||||
|
||||
await session.commit()
|
||||
if not created:
|
||||
print("Default login users already exist")
|
||||
return
|
||||
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@planet.local",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
role="super_admin",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(admin)
|
||||
await session.commit()
|
||||
print("Admin user created: admin / admin123")
|
||||
for default_user in created:
|
||||
print(
|
||||
f"Default login user created: {default_user['username']} / {default_user['password']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -12,8 +12,20 @@ from sqlalchemy.orm import sessionmaker
|
||||
import bcrypt
|
||||
|
||||
|
||||
# Generate proper bcrypt hash
|
||||
ADMIN_PASSWORD_HASH = bcrypt.hashpw("admin123".encode(), bcrypt.gensalt()).decode()
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
"email": "admin@planet.local",
|
||||
"password": "admin123",
|
||||
"role": "super_admin",
|
||||
},
|
||||
{
|
||||
"username": "linkong",
|
||||
"email": "linkong@planet.local",
|
||||
"password": "12345678",
|
||||
"role": "super_admin",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_admin():
|
||||
@@ -22,23 +34,42 @@ async def create_admin():
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = 'admin'")
|
||||
)
|
||||
if result.fetchone():
|
||||
print("Admin user already exists")
|
||||
created = []
|
||||
for default_user in DEFAULT_LOGIN_USERS:
|
||||
result = await session.execute(
|
||||
text("SELECT id FROM users WHERE username = :username"),
|
||||
{"username": default_user["username"]},
|
||||
)
|
||||
if result.fetchone():
|
||||
continue
|
||||
|
||||
password_hash = bcrypt.hashpw(
|
||||
default_user["password"].encode(), bcrypt.gensalt()
|
||||
).decode()
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES (:username, :email, :password, :role, true, NOW(), NOW())
|
||||
"""),
|
||||
{
|
||||
"username": default_user["username"],
|
||||
"email": default_user["email"],
|
||||
"password": password_hash,
|
||||
"role": default_user["role"],
|
||||
},
|
||||
)
|
||||
created.append((default_user, password_hash))
|
||||
|
||||
await session.commit()
|
||||
if not created:
|
||||
print("Default login users already exist")
|
||||
return
|
||||
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO users (username, email, password_hash, role, is_active, created_at, updated_at)
|
||||
VALUES ('admin', 'admin@planet.local', :password, 'super_admin', true, NOW(), NOW())
|
||||
"""),
|
||||
{"password": ADMIN_PASSWORD_HASH},
|
||||
)
|
||||
await session.commit()
|
||||
print(f"Admin user created: admin / admin123")
|
||||
print(f"Hash: {ADMIN_PASSWORD_HASH}")
|
||||
for default_user, password_hash in created:
|
||||
print(
|
||||
f"Default login user created: {default_user['username']} / {default_user['password']}"
|
||||
)
|
||||
print(f"Hash: {password_hash}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,10 +2,45 @@
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def bgp_collector_location_cache():
|
||||
"""Mirror app startup seeding for tests that call sync BGP helpers."""
|
||||
from app.services.bgp_collector_locations import (
|
||||
SEED_PATH,
|
||||
set_bgp_collector_location_cache,
|
||||
)
|
||||
|
||||
payload = json.loads(SEED_PATH.read_text(encoding="utf-8"))
|
||||
cache = {}
|
||||
for entry in payload.get("locations", []):
|
||||
collector_id = next(
|
||||
alias for alias in entry.get("aliases", []) if str(alias).startswith("rrc")
|
||||
)
|
||||
cache[collector_id] = {
|
||||
"city": entry.get("city"),
|
||||
"country": entry.get("country"),
|
||||
"latitude": entry.get("latitude"),
|
||||
"longitude": entry.get("longitude"),
|
||||
"precision": entry.get("precision") or "city",
|
||||
"source": "legacy_seed",
|
||||
"needs_confirmation": True,
|
||||
"matched_location_name": entry.get("site") or collector_id,
|
||||
"verified_at": None,
|
||||
"confidence": entry.get("confidence"),
|
||||
"operator": entry.get("operator"),
|
||||
"site": entry.get("site"),
|
||||
"verification_status": "unverified",
|
||||
"source_note": entry.get("source_note"),
|
||||
}
|
||||
set_bgp_collector_location_cache(cache)
|
||||
yield
|
||||
set_bgp_collector_location_cache({})
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Tests for BGP observability helpers."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -54,11 +55,34 @@ class _FakeResult:
|
||||
def scalars(self):
|
||||
return _FakeScalarResult(self._rows)
|
||||
|
||||
def all(self):
|
||||
if self._rows and all(isinstance(row, BGPObservation) for row in self._rows):
|
||||
return [
|
||||
(row.prefix, row.origin_asn, row.collector, row.collector_geo)
|
||||
for row in self._rows
|
||||
]
|
||||
return self._rows
|
||||
|
||||
def scalar(self):
|
||||
if not self._rows:
|
||||
return 0
|
||||
first = self._rows[0]
|
||||
if isinstance(first, (int, float, str)):
|
||||
return first
|
||||
if isinstance(first, tuple) and len(first) == 1:
|
||||
return first[0]
|
||||
return len(self._rows)
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
if not self._rows:
|
||||
return None
|
||||
first = self._rows[0]
|
||||
if isinstance(first, CollectedData):
|
||||
return {"extra_data": first.extra_data}
|
||||
return first
|
||||
|
||||
|
||||
class _FakeAsyncSession:
|
||||
@@ -988,7 +1012,7 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
data_type="cable",
|
||||
extra_data={"cable_id": 20},
|
||||
)
|
||||
db = _FakeAsyncSession([[landing], [relation], [cable]])
|
||||
db = _FakeAsyncSession([[landing, relation, cable]])
|
||||
|
||||
result = await infer_related_infrastructure(
|
||||
db,
|
||||
@@ -1012,27 +1036,37 @@ async def test_infer_related_infrastructure_links_nearby_cables():
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_bgp_collector_coverage_summarizes_observations():
|
||||
now = datetime.now(UTC)
|
||||
obs_one = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
aggregate = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
observation_count=2,
|
||||
prefix_count=2,
|
||||
origin_asn_count=2,
|
||||
peer_asn_count=2,
|
||||
recent_15m_observation_count=2,
|
||||
recent_24h_observation_count=2,
|
||||
recent_7d_observation_count=2,
|
||||
recent_15m_prefix_count=2,
|
||||
recent_24h_prefix_count=2,
|
||||
recent_7d_prefix_count=2,
|
||||
latest_observed_at=now + timedelta(minutes=5),
|
||||
)
|
||||
latest = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
latest_event_type="withdrawal",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
top_event = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
prefix="203.0.113.0/24",
|
||||
origin_asn=64496,
|
||||
peer_asn=3333,
|
||||
event_type="announcement",
|
||||
observed_at=now,
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
count=1,
|
||||
)
|
||||
obs_two = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
scope = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
prefix="198.51.100.0/24",
|
||||
origin_asn=64497,
|
||||
peer_asn=3334,
|
||||
event_type="withdrawal",
|
||||
observed_at=now + timedelta(minutes=5),
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
db = _FakeAsyncSession([[obs_one, obs_two]])
|
||||
db = _FakeAsyncSession([[aggregate], [latest], [top_event], [scope]])
|
||||
|
||||
coverage = await build_bgp_collector_coverage(db, source_filter=BGP_SOURCES)
|
||||
|
||||
@@ -1363,18 +1397,39 @@ async def test_bgp_event_summary_api_returns_aggregates():
|
||||
@pytest.mark.asyncio
|
||||
async def test_bgp_collectors_api_returns_coverage():
|
||||
now = datetime.now(UTC)
|
||||
observation = BGPObservation(
|
||||
id=1,
|
||||
source="ris_live_bgp",
|
||||
aggregate = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
peer_asn=3333,
|
||||
prefix="203.0.113.0/24",
|
||||
event_type="announcement",
|
||||
origin_asn=64496,
|
||||
observed_at=now,
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
observation_count=1,
|
||||
prefix_count=1,
|
||||
origin_asn_count=1,
|
||||
peer_asn_count=1,
|
||||
recent_15m_observation_count=1,
|
||||
recent_24h_observation_count=1,
|
||||
recent_7d_observation_count=1,
|
||||
recent_15m_prefix_count=1,
|
||||
recent_24h_prefix_count=1,
|
||||
recent_7d_prefix_count=1,
|
||||
latest_observed_at=now,
|
||||
)
|
||||
latest = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
latest_event_type="announcement",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
top_event = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
event_type="announcement",
|
||||
count=1,
|
||||
)
|
||||
scope = SimpleNamespace(
|
||||
collector="rrc00",
|
||||
country="Netherlands",
|
||||
city="Amsterdam",
|
||||
)
|
||||
db = _FakeAsyncSession(
|
||||
[[aggregate], [latest], [top_event], [scope], [aggregate], [latest], [top_event], [scope]]
|
||||
)
|
||||
db = _FakeAsyncSession([[observation], [observation]])
|
||||
client = await _bgp_test_client(db)
|
||||
|
||||
try:
|
||||
|
||||
222
backend/tests/test_bgp_collector_locations.py
Normal file
222
backend/tests/test_bgp_collector_locations.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Tests for the BGP collector + event location services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import bgp as bgp_api
|
||||
from app.services import bgp_collector_locations
|
||||
from app.services.location.llm_fallback import LocationLLMFallbackResult
|
||||
from app.services.bgp_collector_locations import (
|
||||
RIPE_RIS_COLLECTOR_COORDS,
|
||||
collect_bgp_collector_location_candidates,
|
||||
iter_known_collector_names,
|
||||
resolve_bgp_collector_location,
|
||||
)
|
||||
from app.services.bgp_event_locations import (
|
||||
resolve_bgp_event_geo_dict,
|
||||
resolve_bgp_event_location,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_dict_view_preserves_backward_compatible_keys():
|
||||
rrc00 = RIPE_RIS_COLLECTOR_COORDS["rrc00"]
|
||||
assert rrc00["city"] == "Amsterdam"
|
||||
assert rrc00["country"] == "Netherlands"
|
||||
assert rrc00["latitude"] == pytest.approx(52.3676)
|
||||
assert rrc00["longitude"] == pytest.approx(4.9041)
|
||||
# New richer fields layered on top.
|
||||
assert rrc00["precision"] == "city"
|
||||
assert rrc00["source"] == "legacy_seed"
|
||||
assert rrc00["needs_confirmation"] is True
|
||||
|
||||
|
||||
def test_every_legacy_collector_present():
|
||||
expected = {
|
||||
"rrc00", "rrc01", "rrc03", "rrc04", "rrc05", "rrc06", "rrc07",
|
||||
"rrc10", "rrc11", "rrc12", "rrc13", "rrc14", "rrc15", "rrc16",
|
||||
"rrc18", "rrc19", "rrc20", "rrc21", "rrc22", "rrc23", "rrc24",
|
||||
"rrc25", "rrc26",
|
||||
}
|
||||
assert set(iter_known_collector_names()) == expected
|
||||
|
||||
|
||||
def test_resolve_bgp_collector_returns_stored_location():
|
||||
result = resolve_bgp_collector_location("rrc12")
|
||||
assert result.location is not None
|
||||
assert result.location.city == "Frankfurt"
|
||||
assert result.location.country == "Germany"
|
||||
assert result.location.precision == "city"
|
||||
assert result.location.source == "legacy_seed"
|
||||
assert result.location.needs_confirmation is True
|
||||
|
||||
|
||||
def test_resolve_unknown_bgp_collector_returns_diagnostic(monkeypatch):
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", lambda q: None)
|
||||
result = resolve_bgp_collector_location("rrc-doesnotexist")
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.failure_reason
|
||||
|
||||
|
||||
def test_collect_bgp_collector_candidates_uses_stored_context_without_registry(monkeypatch):
|
||||
bgp_collector_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
assert "CIXP" in query or "Geneva" in query
|
||||
return {
|
||||
"lat": "46.2044",
|
||||
"lon": "6.1432",
|
||||
"display_name": "Geneva, Switzerland",
|
||||
"address": {"city": "Geneva", "country": "Switzerland"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||
collector="rrc04",
|
||||
)
|
||||
assert attempted, "stored context should feed online query attempts"
|
||||
assert candidates, "online geocoding should produce at least one candidate"
|
||||
best = candidates[0]
|
||||
assert best.source == "nominatim_online_geocode"
|
||||
assert best.needs_confirmation is True
|
||||
assert all(candidate.source != "local_registry" for candidate in candidates)
|
||||
|
||||
|
||||
def test_collect_bgp_collector_candidates_uses_nominatim_when_registry_misses(monkeypatch):
|
||||
bgp_collector_locations._geocode_online.cache_clear()
|
||||
|
||||
def _fake_geocode(query):
|
||||
if "Lyon" not in query and "France-IX" not in query and "FR-IX" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "45.764",
|
||||
"lon": "4.8357",
|
||||
"display_name": "Lyon, Auvergne-Rhône-Alpes, France",
|
||||
"address": {"city": "Lyon", "country": "France"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(bgp_collector_locations, "_geocode_online", _fake_geocode)
|
||||
candidates, attempted = collect_bgp_collector_location_candidates(
|
||||
collector="rrc-mystery",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
assert attempted, "Nominatim plan should run"
|
||||
online = [c for c in candidates if c.source == "nominatim_online_geocode"]
|
||||
assert online, "online resolver must produce a candidate when registry misses"
|
||||
assert online[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_bgp_collector_location_uses_llm_when_candidates_empty(monkeypatch):
|
||||
llm_candidate = bgp_collector_locations.LocationCandidate(
|
||||
latitude=45.764,
|
||||
longitude=4.8357,
|
||||
display_name="Lyon, France",
|
||||
precision="city",
|
||||
confidence=0.74,
|
||||
query="llm_factcheck:bgp_collector:rrc-mystery",
|
||||
source="llm_location_factcheck",
|
||||
source_note="LLM location factcheck fallback",
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=True,
|
||||
city="Lyon",
|
||||
country="France",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"get_bgp_collector_location_dict",
|
||||
lambda _collector: {},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
bgp_api,
|
||||
"collect_bgp_collector_location_candidates",
|
||||
lambda **_kwargs: ([], ["Lyon, France"]),
|
||||
)
|
||||
|
||||
from app.services.location.llm_fallback import LocationSearchEvidenceResult
|
||||
|
||||
async def _search_evidence(**_kwargs):
|
||||
return LocationSearchEvidenceResult(
|
||||
evidence=[
|
||||
{
|
||||
"title": "RRC source",
|
||||
"url": "https://example.test/rrc",
|
||||
"snippet": "rrc-mystery is in Lyon.",
|
||||
}
|
||||
],
|
||||
attempted_queries=["web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city"],
|
||||
)
|
||||
|
||||
async def _fallback(**_kwargs):
|
||||
return LocationLLMFallbackResult(
|
||||
candidates=[llm_candidate],
|
||||
attempted_queries=["llm_factcheck:bgp_collector:rrc-mystery"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(bgp_api, "get_ai_provider_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "get_web_search_client", AsyncMock(return_value=object()))
|
||||
monkeypatch.setattr(bgp_api, "collect_location_search_evidence", _search_evidence)
|
||||
monkeypatch.setattr(bgp_api, "collect_llm_location_fallback_candidate", _fallback)
|
||||
|
||||
response = await bgp_api.collect_bgp_collector_location(
|
||||
"rrc-mystery",
|
||||
bgp_api.CollectBGPCollectorLocationRequest(city="Lyon", country="France"),
|
||||
current_user=object(),
|
||||
db=AsyncMock(),
|
||||
)
|
||||
|
||||
assert response["success"] is True
|
||||
assert response["best_candidate"]["source"] == "llm_location_factcheck"
|
||||
assert response["best_candidate"]["needs_confirmation"] is True
|
||||
assert response["attempted_queries"] == [
|
||||
"Lyon, France",
|
||||
"web_search:bgp_collector:rrc-mystery Lyon France physical location route collector city",
|
||||
"llm_factcheck:bgp_collector:rrc-mystery",
|
||||
]
|
||||
|
||||
|
||||
# ── BGP event resolver ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_event_resolver_inherits_from_owning_collector():
|
||||
geo = resolve_bgp_event_geo_dict("rrc25")
|
||||
assert geo["city"] == "Amsterdam"
|
||||
assert geo["country"] == "Netherlands"
|
||||
assert geo["source"] == "inherited_from_collector"
|
||||
assert geo["precision"] == "city"
|
||||
|
||||
|
||||
def test_event_resolver_does_not_match_unrelated_collectors():
|
||||
"""Regression: passing operator=RIPE NCC must NOT make every collector match."""
|
||||
rrc12 = resolve_bgp_event_geo_dict("rrc12")
|
||||
rrc25 = resolve_bgp_event_geo_dict("rrc25")
|
||||
assert rrc12["city"] == "Frankfurt"
|
||||
assert rrc25["city"] == "Amsterdam"
|
||||
assert rrc12["latitude"] != rrc25["latitude"]
|
||||
|
||||
|
||||
def test_event_resolver_uses_source_coordinates_when_present():
|
||||
geo = resolve_bgp_event_geo_dict(
|
||||
"rrc12",
|
||||
source_latitude=12.34,
|
||||
source_longitude=56.78,
|
||||
)
|
||||
assert geo["latitude"] == pytest.approx(12.34)
|
||||
assert geo["longitude"] == pytest.approx(56.78)
|
||||
assert geo["precision"] == "precise"
|
||||
assert geo["source"] == "source_coordinates"
|
||||
|
||||
|
||||
def test_event_resolver_returns_empty_for_unknown_collector_without_source_coords():
|
||||
geo = resolve_bgp_event_geo_dict("rrc-doesnotexist")
|
||||
assert geo == {}
|
||||
|
||||
|
||||
def test_event_resolver_full_result_carries_diagnostic_on_miss():
|
||||
result = resolve_bgp_event_location(collector="rrc-doesnotexist")
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
86
backend/tests/test_datasources_batch.py
Normal file
86
backend/tests/test_datasources_batch.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import datasources as datasources_api
|
||||
from app.models.datasource import DataSource
|
||||
|
||||
|
||||
def make_datasource(
|
||||
datasource_id: int,
|
||||
source: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
module: str = "L4",
|
||||
is_active: bool = True,
|
||||
last_status: str | None = None,
|
||||
last_run_at: datetime | None = None,
|
||||
frequency_minutes: int = 60,
|
||||
) -> DataSource:
|
||||
return DataSource(
|
||||
id=datasource_id,
|
||||
name=name or source,
|
||||
source=source,
|
||||
module=module,
|
||||
priority="P1",
|
||||
frequency_minutes=frequency_minutes,
|
||||
collector_class=source,
|
||||
is_active=is_active,
|
||||
last_status=last_status,
|
||||
last_run_at=last_run_at,
|
||||
)
|
||||
|
||||
|
||||
def test_datasource_product_key_groups_domain_specific_sources():
|
||||
assert datasources_api.datasource_product_key(make_datasource(1, "aisstream_vessels")) == "vessels"
|
||||
assert datasources_api.datasource_product_key(make_datasource(2, "telegeography_cables")) == "cables"
|
||||
assert datasources_api.datasource_product_key(make_datasource(3, "celestrak_tle")) == "satellites"
|
||||
assert datasources_api.datasource_product_key(make_datasource(4, "ris_live_bgp")) == "bgp"
|
||||
|
||||
|
||||
def test_filter_datasources_by_product_status_and_collected_state():
|
||||
vessels = make_datasource(1, "aisstream_vessels", last_status="success")
|
||||
cables = make_datasource(2, "telegeography_cables", last_status="failed")
|
||||
filtered = datasources_api._filter_datasources_in_memory(
|
||||
[vessels, cables],
|
||||
running_tasks={},
|
||||
record_counts={"aisstream_vessels": 12, "telegeography_cables": 0},
|
||||
product="vessels",
|
||||
run_status="success",
|
||||
collected=True,
|
||||
)
|
||||
|
||||
assert filtered == [vessels]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch):
|
||||
now = datetime.now(timezone.utc)
|
||||
disabled = make_datasource(1, "aisstream_vessels", is_active=False)
|
||||
not_due = make_datasource(2, "telegeography_cables", last_run_at=now, frequency_minutes=120)
|
||||
due = make_datasource(3, "ris_live_bgp", last_run_at=now - timedelta(hours=2))
|
||||
triggered_sources: list[str] = []
|
||||
|
||||
async def fake_running_tasks(_db, _ids):
|
||||
return {}
|
||||
|
||||
async def fake_latest_task_ids(_db, _ids):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_running_tasks", fake_running_tasks)
|
||||
monkeypatch.setattr(datasources_api, "_load_latest_task_ids", fake_latest_task_ids)
|
||||
monkeypatch.setattr(
|
||||
datasources_api,
|
||||
"run_collector_now",
|
||||
lambda source: triggered_sources.append(source) or True,
|
||||
)
|
||||
|
||||
result = await datasources_api._trigger_datasource_batch(
|
||||
object(),
|
||||
[disabled, not_due, due],
|
||||
force=False,
|
||||
)
|
||||
|
||||
assert [item["source"] for item in result["triggered"]] == ["ris_live_bgp"]
|
||||
assert {item["reason"] for item in result["skipped"]} == {"disabled", "within_frequency_window"}
|
||||
assert triggered_sources == ["ris_live_bgp"]
|
||||
116
backend/tests/test_docs_gatekeeper.py
Normal file
116
backend/tests/test_docs_gatekeeper.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Docs Gatekeeper API tests."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.api.v1 import docs as docs_api
|
||||
from app.main import app
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def make_user(role: str = "viewer", groups: list[str] | None = None) -> User:
|
||||
user = User(
|
||||
id=1,
|
||||
username="docs-user",
|
||||
email="docs@example.com",
|
||||
password_hash="x",
|
||||
role=role,
|
||||
is_active=True,
|
||||
)
|
||||
user.gatekeeper_groups = groups or []
|
||||
return user
|
||||
|
||||
|
||||
async def get_json(path: str, user: User | None = None):
|
||||
if user is not None:
|
||||
async def override_user():
|
||||
return user
|
||||
|
||||
app.dependency_overrides[docs_api.get_optional_current_user] = override_user
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.get(path)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_public_catalog_only_for_anonymous_user():
|
||||
response = await get_json("/api/v1/docs/catalog")
|
||||
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
assert {item["access"] for item in items} == {"public"}
|
||||
assert {item["slug"] for item in items if item["lang"] == "zh"} == {
|
||||
"overview",
|
||||
"quickstart",
|
||||
"manual",
|
||||
"faq",
|
||||
"location-pipeline-user",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_can_read_public_doc():
|
||||
response = await get_json("/api/v1/docs/zh/quickstart")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access"] == "public"
|
||||
assert "快速开始" in response.json()["markdown"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_protected_doc_requires_authentication():
|
||||
response = await get_json("/api/v1/docs/zh/backend-collectors")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_without_group_cannot_read_developer_doc():
|
||||
response = await get_json(
|
||||
"/api/v1/docs/zh/backend-collectors",
|
||||
make_user(role="viewer"),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_developer_group_can_read_developer_but_not_admin_doc():
|
||||
user = make_user(role="viewer", groups=["docs_developer"])
|
||||
|
||||
developer_response = await get_json("/api/v1/docs/zh/backend-collectors", user)
|
||||
admin_response = await get_json("/api/v1/docs/zh/backend-system-service-control", user)
|
||||
|
||||
assert developer_response.status_code == 200
|
||||
assert developer_response.json()["access"] == "docs_developer"
|
||||
assert admin_response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_and_super_admin_can_read_admin_docs():
|
||||
admin_response = await get_json(
|
||||
"/api/v1/docs/zh/backend-system-service-control",
|
||||
make_user(role="admin"),
|
||||
)
|
||||
super_admin_response = await get_json(
|
||||
"/api/v1/docs/zh/backend-system-service-control",
|
||||
make_user(role="super_admin"),
|
||||
)
|
||||
|
||||
assert admin_response.status_code == 200
|
||||
assert super_admin_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_language_slug_and_path_traversal_do_not_read_files():
|
||||
bad_lang = await get_json("/api/v1/docs/fr/quickstart")
|
||||
bad_slug = await get_json("/api/v1/docs/zh/not-a-doc")
|
||||
traversal = await get_json("/api/v1/docs/zh/..%2Fmanual")
|
||||
|
||||
assert bad_lang.status_code == 404
|
||||
assert bad_slug.status_code == 404
|
||||
assert traversal.status_code == 404
|
||||
44
backend/tests/test_layers.py
Normal file
44
backend/tests/test_layers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1 import layers
|
||||
|
||||
|
||||
def test_layer_guard_requires_bbox():
|
||||
try:
|
||||
layers._parse_layer_bbox("")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
else:
|
||||
raise AssertionError("Expected missing bbox to fail")
|
||||
|
||||
|
||||
def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit():
|
||||
geojson = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [121.0, 31.0]},
|
||||
"properties": {"id": "inside"},
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [10.0, 10.0]},
|
||||
"properties": {"id": "outside"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = layers._guard_geojson_layer(
|
||||
geojson,
|
||||
bbox=(120.0, 30.0, 122.0, 32.0),
|
||||
zoom=2,
|
||||
limit=6000,
|
||||
)
|
||||
|
||||
assert result["returned_count"] == 1
|
||||
assert result["visible_count"] == 1
|
||||
assert result["features"][0]["properties"]["id"] == "inside"
|
||||
assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT
|
||||
assert result["diagnostics"]["limit_clamped"] is True
|
||||
assert result["diagnostics"]["degraded"] is True
|
||||
957
backend/tests/test_location_pipeline.py
Normal file
957
backend/tests/test_location_pipeline.py
Normal file
@@ -0,0 +1,957 @@
|
||||
"""Tests for the shared location resolution pipeline.
|
||||
|
||||
Validates the abstraction itself: the protocol contract, the orchestrator,
|
||||
each built-in resolver, and the pluggability promise (a custom resolver can
|
||||
be slotted in without touching consumers).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.location import (
|
||||
InheritFromAnotherEntityResolver,
|
||||
LocationCandidate,
|
||||
LocationPipeline,
|
||||
LocationQuery,
|
||||
NominatimResolver,
|
||||
RegistryResolver,
|
||||
ResolverOutput,
|
||||
SourceCoordinatesResolver,
|
||||
)
|
||||
from app.schemas.ai import SituationalAnalysisResponse
|
||||
import app.services.location.llm_fallback as llm_fallback
|
||||
from app.services.location.llm_fallback import collect_llm_location_fallback_candidate
|
||||
|
||||
|
||||
# ── Test fixtures ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_registry(tmp_path: Path) -> Path:
|
||||
payload = {
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "Test Site Alpha",
|
||||
"aliases": ["alpha", "alpha-one", "Acme HQ"],
|
||||
"operator": "Acme Networks",
|
||||
"site": "Acme HQ",
|
||||
"city": "Lyon",
|
||||
"country": "France",
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "site",
|
||||
"confidence": 0.92,
|
||||
"source_note": "Test fixture",
|
||||
"verified_at": "2026-05-08",
|
||||
},
|
||||
{
|
||||
"canonical_name": "Test Site Bravo",
|
||||
"aliases": ["bravo"],
|
||||
"operator": "Acme Networks",
|
||||
"site": "Bravo POP",
|
||||
"city": "Berlin",
|
||||
"country": "Germany",
|
||||
"latitude": 52.52,
|
||||
"longitude": 13.405,
|
||||
"precision": "city",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
],
|
||||
"city_fallbacks": [
|
||||
{
|
||||
"city": "Bhutan-Capital",
|
||||
"country": "Bhutan",
|
||||
"latitude": 27.4728,
|
||||
"longitude": 89.639,
|
||||
"precision": "city",
|
||||
"confidence": 0.5,
|
||||
}
|
||||
],
|
||||
}
|
||||
path = tmp_path / "registry.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ── SourceCoordinatesResolver ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_passes_through_valid_coordinates():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
query = LocationQuery(
|
||||
name="Acme HQ",
|
||||
source_latitude=45.0,
|
||||
source_longitude=4.0,
|
||||
country="France",
|
||||
)
|
||||
output = resolver.resolve(query)
|
||||
assert len(output.candidates) == 1
|
||||
candidate = output.candidates[0]
|
||||
assert candidate.latitude == 45.0
|
||||
assert candidate.longitude == 4.0
|
||||
assert candidate.precision == "precise"
|
||||
assert candidate.source == "source_coordinates"
|
||||
assert candidate.needs_confirmation is False
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_skips_zero_coordinates():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="X", source_latitude=0.0, source_longitude=0.0)
|
||||
)
|
||||
assert output.candidates == ()
|
||||
|
||||
|
||||
def test_source_coordinates_resolver_skips_when_missing():
|
||||
resolver = SourceCoordinatesResolver()
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
|
||||
|
||||
# ── RegistryResolver ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_registry_resolver_matches_alias(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="alpha", country="France")
|
||||
)
|
||||
candidates = list(output.candidates)
|
||||
assert candidates, "should match registry entry"
|
||||
assert any(c.matched_location_name == "Test Site Alpha" for c in candidates)
|
||||
alpha = next(c for c in candidates if c.matched_location_name == "Test Site Alpha")
|
||||
assert alpha.precision == "site"
|
||||
assert alpha.confidence == pytest.approx(0.92)
|
||||
assert alpha.needs_confirmation is True
|
||||
assert alpha.location_verified_at is None
|
||||
|
||||
|
||||
def test_registry_resolver_filters_country_mismatch(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
# alpha is in France; query says Spain → should reject
|
||||
output = resolver.resolve(
|
||||
LocationQuery(name="alpha", country="Spain")
|
||||
)
|
||||
assert all(
|
||||
c.matched_location_name != "Test Site Alpha" for c in output.candidates
|
||||
)
|
||||
|
||||
|
||||
def test_registry_resolver_emits_city_fallback_candidate(tmp_registry):
|
||||
resolver = RegistryResolver(registry_path=tmp_registry)
|
||||
resolver.reload()
|
||||
output = resolver.resolve(
|
||||
LocationQuery(city="Bhutan-Capital", country="Bhutan")
|
||||
)
|
||||
candidates = list(output.candidates)
|
||||
assert candidates, "city fallback should fire"
|
||||
assert any(c.source == "local_registry_city" for c in candidates)
|
||||
|
||||
|
||||
# ── NominatimResolver ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nominatim_resolver_calls_geocoder_with_plan_queries():
|
||||
calls = []
|
||||
|
||||
def fake_geocoder(query: str):
|
||||
calls.append(query)
|
||||
return {
|
||||
"lat": "12.34",
|
||||
"lon": "56.78",
|
||||
"display_name": "Test City, Country",
|
||||
"address": {"city": "Test City", "country": "Country"},
|
||||
}
|
||||
|
||||
def plan(query: LocationQuery):
|
||||
return [
|
||||
("primary query", ("name",)),
|
||||
("secondary query", ("city",)),
|
||||
]
|
||||
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=plan,
|
||||
geocoder=fake_geocoder,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X", country="Country"))
|
||||
assert calls == ["primary query", "secondary query"]
|
||||
assert output.attempted_queries == ("primary query", "secondary query")
|
||||
assert len(output.candidates) == 2
|
||||
assert all(c.precision == "city" for c in output.candidates)
|
||||
assert all(c.needs_confirmation for c in output.candidates)
|
||||
|
||||
|
||||
def test_nominatim_resolver_skips_when_geocoder_returns_none():
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=lambda q: [("only", ("name",))],
|
||||
geocoder=lambda q: None,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
assert output.attempted_queries == ("only",)
|
||||
|
||||
|
||||
def test_nominatim_resolver_swallows_exceptions_per_query():
|
||||
def boom(query):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
resolver = NominatimResolver(
|
||||
query_plan_builder=lambda q: [("a", ()), ("b", ())],
|
||||
geocoder=boom,
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == ()
|
||||
assert output.attempted_queries == ("a", "b")
|
||||
|
||||
|
||||
# ── InheritFromAnotherEntityResolver ────────────────────────────────
|
||||
|
||||
|
||||
def test_inherit_resolver_returns_provided_candidate():
|
||||
sentinel = LocationCandidate(
|
||||
latitude=10.0,
|
||||
longitude=20.0,
|
||||
display_name="Inherited",
|
||||
precision="city",
|
||||
confidence=0.7,
|
||||
query="inherit::test",
|
||||
source="inherited",
|
||||
source_note=None,
|
||||
matched_fields=("collector",),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
resolver = InheritFromAnotherEntityResolver(
|
||||
source_lookup=lambda q: sentinel
|
||||
)
|
||||
output = resolver.resolve(LocationQuery(name="X"))
|
||||
assert output.candidates == (sentinel,)
|
||||
|
||||
|
||||
def test_inherit_resolver_skips_when_lookup_returns_none():
|
||||
resolver = InheritFromAnotherEntityResolver(source_lookup=lambda q: None)
|
||||
assert resolver.resolve(LocationQuery(name="X")).candidates == ()
|
||||
|
||||
|
||||
# ── LocationPipeline orchestration ──────────────────────────────────
|
||||
|
||||
|
||||
def test_pipeline_aggregates_candidates_across_resolvers(tmp_registry):
|
||||
pipeline = LocationPipeline(
|
||||
[
|
||||
SourceCoordinatesResolver(),
|
||||
RegistryResolver(registry_path=tmp_registry),
|
||||
NominatimResolver(
|
||||
query_plan_builder=lambda q: [("nominatim attempt", ("name",))],
|
||||
geocoder=lambda q: {
|
||||
"lat": "1.0",
|
||||
"lon": "2.0",
|
||||
"display_name": "Online City",
|
||||
"address": {"city": "Online City", "country": "France"},
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
pipeline.resolvers[1].reload()
|
||||
candidates, attempted = pipeline.collect_candidates(
|
||||
LocationQuery(
|
||||
name="alpha",
|
||||
country="France",
|
||||
source_latitude=44.0,
|
||||
source_longitude=5.0,
|
||||
)
|
||||
)
|
||||
sources = {c.source for c in candidates}
|
||||
assert "source_coordinates" in sources
|
||||
assert "local_registry" in sources
|
||||
assert "nominatim_online_geocode" in sources
|
||||
assert "nominatim attempt" in attempted
|
||||
|
||||
|
||||
def test_pipeline_dedupes_by_source_and_coordinates():
|
||||
same = LocationCandidate(
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
display_name="dup",
|
||||
precision="city",
|
||||
confidence=0.5,
|
||||
query="x",
|
||||
source="dup_source",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
class _DupResolver:
|
||||
name = "dup_source"
|
||||
|
||||
def resolve(self, query):
|
||||
return ResolverOutput(candidates=(same, same))
|
||||
|
||||
pipeline = LocationPipeline([_DupResolver()])
|
||||
candidates, _ = pipeline.collect_candidates(LocationQuery(name="X"))
|
||||
assert len(candidates) == 1
|
||||
|
||||
|
||||
def test_registry_short_aliases_do_not_match_inside_larger_tokens(tmp_path: Path):
|
||||
registry_path = tmp_path / "registry.json"
|
||||
registry_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"locations": [
|
||||
{
|
||||
"canonical_name": "Aurora",
|
||||
"aliases": ["Aurora", "ANL"],
|
||||
"site": "DOE/SC/Argonne National Laboratory",
|
||||
"country": "United States",
|
||||
"city": "Lemont",
|
||||
"latitude": 41.713,
|
||||
"longitude": -87.982,
|
||||
"precision": "site",
|
||||
},
|
||||
{
|
||||
"canonical_name": "Venado",
|
||||
"aliases": ["Venado"],
|
||||
"site": "DOE/NNSA/LANL",
|
||||
"country": "United States",
|
||||
"city": "Los Alamos",
|
||||
"latitude": 35.8443,
|
||||
"longitude": -106.2872,
|
||||
"precision": "site",
|
||||
},
|
||||
],
|
||||
"city_fallbacks": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
resolver = RegistryResolver(registry_path=registry_path)
|
||||
resolver.reload()
|
||||
|
||||
output = resolver.resolve(
|
||||
LocationQuery(
|
||||
name="Venado",
|
||||
country="United States",
|
||||
extra={"site": "DOE/NNSA/LANL"},
|
||||
)
|
||||
)
|
||||
|
||||
assert len(output.candidates) == 1
|
||||
assert output.candidates[0].matched_location_name == "Venado"
|
||||
|
||||
|
||||
def test_pipeline_resolve_best_returns_highest_priority():
|
||||
online = LocationCandidate(
|
||||
latitude=10.0,
|
||||
longitude=20.0,
|
||||
display_name="online",
|
||||
precision="city",
|
||||
confidence=0.9,
|
||||
query="x",
|
||||
source="nominatim_online_geocode",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=True,
|
||||
)
|
||||
source = LocationCandidate(
|
||||
latitude=11.0,
|
||||
longitude=21.0,
|
||||
display_name="src",
|
||||
precision="precise",
|
||||
confidence=1.0,
|
||||
query="x",
|
||||
source="source_coordinates",
|
||||
source_note=None,
|
||||
matched_fields=(),
|
||||
needs_confirmation=False,
|
||||
)
|
||||
|
||||
class _StubResolver:
|
||||
def __init__(self, c, name):
|
||||
self._c = c
|
||||
self.name = name
|
||||
|
||||
def resolve(self, query):
|
||||
return ResolverOutput(candidates=(self._c,))
|
||||
|
||||
pipeline = LocationPipeline(
|
||||
[
|
||||
_StubResolver(online, "online"),
|
||||
_StubResolver(source, "src"),
|
||||
]
|
||||
)
|
||||
result = pipeline.resolve_best(LocationQuery(name="X"))
|
||||
assert result.location is source, "source_coordinates should beat nominatim"
|
||||
|
||||
|
||||
def test_pipeline_returns_diagnostic_when_nothing_resolves():
|
||||
pipeline = LocationPipeline([SourceCoordinatesResolver()])
|
||||
result = pipeline.resolve_best(LocationQuery(name="X", country="Bhutan"))
|
||||
assert result.location is None
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.country == "Bhutan"
|
||||
|
||||
|
||||
def test_pluggability_custom_resolver_works_without_changing_pipeline():
|
||||
"""Validates the abstraction promise: a new algorithm = a new class."""
|
||||
|
||||
class _PeeringDBStubResolver:
|
||||
name = "fake_peeringdb"
|
||||
|
||||
def resolve(self, query):
|
||||
asn = (query.extra or {}).get("asn")
|
||||
if asn != 174:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(
|
||||
candidates=(
|
||||
LocationCandidate(
|
||||
latitude=1.0,
|
||||
longitude=2.0,
|
||||
display_name="Cogent HQ",
|
||||
precision="site",
|
||||
confidence=0.8,
|
||||
query=f"peeringdb::{asn}",
|
||||
source="peeringdb_stub",
|
||||
source_note="Stub for testing",
|
||||
matched_fields=("asn",),
|
||||
needs_confirmation=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
pipeline = LocationPipeline([_PeeringDBStubResolver()])
|
||||
candidates, _ = pipeline.collect_candidates(
|
||||
LocationQuery(name="X", extra={"asn": 174})
|
||||
)
|
||||
assert len(candidates) == 1
|
||||
assert candidates[0].source == "peeringdb_stub"
|
||||
|
||||
|
||||
# ── LLM fallback helper ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeAIProviderClient:
|
||||
def __init__(self, content: str | list[str]):
|
||||
self.contents = content if isinstance(content, list) else [content]
|
||||
self.calls = 0
|
||||
|
||||
async def analyze(self, payload, request_id=None):
|
||||
self.calls += 1
|
||||
content = self.contents[min(self.calls - 1, len(self.contents) - 1)]
|
||||
return SituationalAnalysisResponse(
|
||||
provider="test",
|
||||
model="test-model",
|
||||
content=content,
|
||||
raw_response={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_returns_candidate_from_strict_json():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 45.764,
|
||||
"longitude": 4.8357,
|
||||
"precision": "city",
|
||||
"confidence": 0.74,
|
||||
"city": "Lyon",
|
||||
"region": "Auvergne-Rhone-Alpes",
|
||||
"country": "France",
|
||||
"matched_location_name": "Lyon, France",
|
||||
"evidence": ["operator and city point to Lyon"],
|
||||
"reasoning_summary": "Best supported city-level match.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(
|
||||
name="Mystery GPU Cluster",
|
||||
city="Lyon",
|
||||
country="France",
|
||||
extra={"operator": "Mystery Operator"},
|
||||
),
|
||||
entity_type="compute_center",
|
||||
attempted_queries=("Mystery Operator, Lyon, France",),
|
||||
)
|
||||
|
||||
assert client.calls == 1
|
||||
assert result.failure_reason is None
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Mystery GPU Cluster"]
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.source == "llm_location_factcheck"
|
||||
assert candidate.needs_confirmation is True
|
||||
assert candidate.precision == "city"
|
||||
assert candidate.city == "Lyon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_common_precision_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"candidate": {
|
||||
"latitude": 43.2389,
|
||||
"longitude": 76.8897,
|
||||
"precision": "city-level",
|
||||
"confidence": "0.68",
|
||||
"city": "Almaty",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Almaty, Kazakhstan",
|
||||
"evidence": ["NITEC context points to Almaty"],
|
||||
"reasoning_summary": "City-level fallback.",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].precision == "city"
|
||||
assert result.candidates[0].confidence >= 0.55
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_lat_lng_aliases():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"lat": 51.1694,
|
||||
"lng": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].latitude == pytest.approx(51.1694)
|
||||
assert result.candidates[0].longitude == pytest.approx(71.4491)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_when_coordinates_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "51.1694",
|
||||
"lon": "71.4491",
|
||||
"display_name": "Astana, Kazakhstan",
|
||||
"address": {"city": "Astana", "country": "Kazakhstan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Official source",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is in Astana.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.latitude == pytest.approx(51.1694)
|
||||
assert candidate.longitude == pytest.approx(71.4491)
|
||||
assert "Nominatim city fallback" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_matched_location_without_city(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if "Falun" not in query:
|
||||
return None
|
||||
return {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Dalarna County, Sweden",
|
||||
"address": {"city": "Falun", "state": "Dalarna County", "country": "Sweden"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"precision": "city",
|
||||
"confidence": 0.64,
|
||||
"country": "Sweden",
|
||||
"matched_location_name": "Falun, Sweden",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Credible public source",
|
||||
"source_type": "news",
|
||||
"entity_match": True,
|
||||
"text": "DeepL Mercury supercomputer is located in Falun.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Falun"
|
||||
assert candidate.country == "瑞典"
|
||||
assert candidate.latitude == pytest.approx(60.6065)
|
||||
assert candidate.longitude == pytest.approx(15.6355)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_repairs_non_json_answer(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"TAIPEI-1 appears to be located in Taipei, Taiwan, based on NVIDIA context.",
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.62,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 appears to be located in Taipei.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "City-level location extracted from prose.",
|
||||
}
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Taipei"
|
||||
assert result.candidates[0].source == "llm_location_factcheck"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_accepts_taipei_name_hint_with_weak_wording(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"precision": "city",
|
||||
"confidence": 0.43,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "NVIDIA context",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"text": "TAIPEI-1 points to Taipei city-level placement.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Weak city-level evidence, but the entity name and geography align.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.confidence >= 0.55
|
||||
breakdown = candidate.suggested_registry_entry["llm_score_breakdown"]
|
||||
assert breakdown["weak_evidence_penalty"] <= 0.15
|
||||
assert breakdown["conflict_penalty"] == 0
|
||||
assert breakdown["name_location_hint"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_geocodes_city_from_entity_name_when_llm_unparseable(monkeypatch):
|
||||
def _fake_geocode(query):
|
||||
if query != "Taipei, 中国(台湾)":
|
||||
return None
|
||||
return {
|
||||
"lat": "25.033",
|
||||
"lon": "121.5654",
|
||||
"display_name": "Taipei, Taiwan",
|
||||
"address": {"city": "Taipei", "country": "Taiwan"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(llm_fallback, "_geocode_llm_city", _fake_geocode)
|
||||
client = _FakeAIProviderClient(["not a location answer", "still not json"])
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="TAIPEI-1", country="中国(台湾)"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Taipei"
|
||||
assert candidate.latitude == pytest.approx(25.033)
|
||||
assert candidate.longitude == pytest.approx(121.5654)
|
||||
assert "Entity name city hint" in candidate.source_note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_extracts_city_from_non_json_when_repair_fails(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
llm_fallback,
|
||||
"_geocode_llm_city",
|
||||
lambda query: {
|
||||
"lat": "60.6065",
|
||||
"lon": "15.6355",
|
||||
"display_name": "Falun, Sweden",
|
||||
"address": {"city": "Falun", "country": "Sweden"},
|
||||
},
|
||||
)
|
||||
client = _FakeAIProviderClient(
|
||||
[
|
||||
"DeepL Mercury 超級電腦位於瑞典的 法倫 (Falun)。",
|
||||
"still not json",
|
||||
]
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="DeepL Mercury", country="Sweden"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert client.calls == 2
|
||||
assert result.failure_reason is None
|
||||
assert result.candidates[0].city == "Falun"
|
||||
assert result.candidates[0].needs_confirmation is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_combines_model_score_with_evidence_score():
|
||||
client = _FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Kazakhstan National Supercomputing Center",
|
||||
"url": "https://example.test/alem-cloud",
|
||||
"source_type": "official",
|
||||
"entity_match": True,
|
||||
"text": "Alem.Cloud is located in Astana.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Evidence supports city-level location but not exact facility coordinates.",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=client,
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.failure_reason is None
|
||||
candidate = result.candidates[0]
|
||||
assert candidate.city == "Astana"
|
||||
assert candidate.confidence >= 0.55
|
||||
assert candidate.suggested_registry_entry["llm_model_confidence"] == pytest.approx(0.38)
|
||||
assert candidate.suggested_registry_entry["llm_combined_confidence"] == pytest.approx(
|
||||
candidate.confidence
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_low_combined_score():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 51.1694,
|
||||
"longitude": 71.4491,
|
||||
"precision": "city",
|
||||
"confidence": 0.38,
|
||||
"city": "Astana",
|
||||
"country": "Kazakhstan",
|
||||
"matched_location_name": "Astana, Kazakhstan",
|
||||
"evidence": ["some page mentions Kazakhstan"],
|
||||
"reasoning_summary": "Weak and ambiguous city evidence.",
|
||||
"ambiguity": "weak city evidence",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="Alem.Cloud", country="Kazakhstan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "combined evidence score" in result.failure_reason
|
||||
assert "below minimum 0.55" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_rejects_explicit_conflicts():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps(
|
||||
{
|
||||
"latitude": 25.033,
|
||||
"longitude": 121.5654,
|
||||
"precision": "city",
|
||||
"confidence": 0.70,
|
||||
"city": "Taipei",
|
||||
"country": "Taiwan",
|
||||
"matched_location_name": "Taipei, Taiwan",
|
||||
"evidence": [
|
||||
{
|
||||
"source": "Conflicting source",
|
||||
"source_type": "generic",
|
||||
"entity_match": True,
|
||||
"has_conflict": True,
|
||||
"text": "One source says Taipei, another contradicts it.",
|
||||
}
|
||||
],
|
||||
"reasoning_summary": "Conflicting evidence prevents confirmation.",
|
||||
}
|
||||
)
|
||||
),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "conflict=" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"not json",
|
||||
json.dumps({"latitude": 0, "longitude": 0, "precision": "city", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "country", "confidence": 0.9}),
|
||||
json.dumps({"latitude": 45, "longitude": 4, "precision": "city", "confidence": 0.2}),
|
||||
],
|
||||
)
|
||||
async def test_llm_location_fallback_rejects_unsafe_outputs(content):
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(content),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert result.failure_reason
|
||||
assert result.attempted_queries == ["llm_factcheck:compute_center:Unsafe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_failure_explains_rejection_reason():
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=_FakeAIProviderClient(
|
||||
json.dumps({
|
||||
"latitude": 45,
|
||||
"longitude": 4,
|
||||
"precision": "region",
|
||||
"confidence": 0.9,
|
||||
})
|
||||
),
|
||||
query=LocationQuery(name="Unsafe", country="France"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "precision" in result.failure_reason
|
||||
assert "region" in result.failure_reason
|
||||
242
backend/tests/test_motion_agent.py
Normal file
242
backend/tests/test_motion_agent.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from motion_agent.cameras import (
|
||||
MotionAgentCameraError,
|
||||
MotionAgentDependencyError,
|
||||
UrlCameraInput,
|
||||
UrlCameraSpec,
|
||||
UsbCameraInput,
|
||||
UsbCameraSpec,
|
||||
)
|
||||
import motion_agent.cameras as motion_cameras
|
||||
from motion_agent.config import MotionAgentConfig
|
||||
from motion_agent.events import GestureEvent, HeartbeatEvent, SkeletonEvent, SkeletonJoint
|
||||
from motion_agent.recognizer import GestureObservation
|
||||
from motion_agent.server import MotionAgentServer
|
||||
from motion_agent.state import GestureStateMachine
|
||||
from motion_agent import cli as motion_cli
|
||||
|
||||
|
||||
def test_gesture_event_serializes_stable_protocol_fields():
|
||||
event = GestureEvent(
|
||||
gesture="rotate_left",
|
||||
confidence=0.91,
|
||||
intensity=0.75,
|
||||
timestamp_ms=1000,
|
||||
seq=7,
|
||||
mode="single",
|
||||
)
|
||||
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "gesture"
|
||||
assert payload["gesture"] == "rotate_left"
|
||||
assert payload["phase"] == "discrete"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["intensity"] == 0.75
|
||||
assert payload["timestamp_ms"] == 1000
|
||||
assert payload["seq"] == 7
|
||||
assert payload["source"] == "motion-agent"
|
||||
assert payload["mode"] == "single"
|
||||
assert payload["payload"] == {}
|
||||
|
||||
|
||||
def test_state_machine_ignores_low_confidence_observations():
|
||||
state = GestureStateMachine(confidence_threshold=0.8, cooldown_ms=400)
|
||||
|
||||
event = state.accept(
|
||||
GestureObservation(
|
||||
gesture="confirm",
|
||||
confidence=0.79,
|
||||
intensity=1,
|
||||
timestamp_ms=1000,
|
||||
)
|
||||
)
|
||||
|
||||
assert event is None
|
||||
|
||||
|
||||
def test_state_machine_applies_per_gesture_cooldown():
|
||||
state = GestureStateMachine(confidence_threshold=0.7, cooldown_ms=400)
|
||||
|
||||
first = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.9, intensity=0.8, timestamp_ms=1000)
|
||||
)
|
||||
repeated = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1200)
|
||||
)
|
||||
later = state.accept(
|
||||
GestureObservation("rotate_right", confidence=0.95, intensity=0.9, timestamp_ms=1500)
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.seq == 1
|
||||
assert repeated is None
|
||||
assert later is not None
|
||||
assert later.seq == 2
|
||||
|
||||
|
||||
def test_motion_server_status_includes_dry_run_camera_and_heartbeat():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
status = json.loads(server.status_event().to_json())
|
||||
heartbeat = json.loads(HeartbeatEvent(timestamp_ms=123).to_json())
|
||||
|
||||
assert status["type"] == "status"
|
||||
assert status["camera_count"] == 1
|
||||
assert status["active_camera_ids"] == ["dry-run:null-camera"]
|
||||
assert status["recognizer"] == "dry-run"
|
||||
assert heartbeat == {
|
||||
"timestamp_ms": 123,
|
||||
"source": "motion-agent",
|
||||
"type": "heartbeat",
|
||||
}
|
||||
|
||||
|
||||
def test_skeleton_event_serializes_without_raw_image_fields():
|
||||
event = SkeletonEvent(
|
||||
joints=[SkeletonJoint("left_wrist", 0.42, 0.61, 0.98)],
|
||||
bones=[("left_shoulder", "left_elbow"), ("left_elbow", "left_wrist")],
|
||||
matched_gesture="rotate_left",
|
||||
confidence=0.91,
|
||||
camera_id="usb:0",
|
||||
timestamp_ms=1000,
|
||||
mode="single",
|
||||
)
|
||||
|
||||
payload = json.loads(event.to_json())
|
||||
|
||||
assert payload["type"] == "skeleton"
|
||||
assert payload["matched_gesture"] == "rotate_left"
|
||||
assert payload["confidence"] == 0.91
|
||||
assert payload["camera_id"] == "usb:0"
|
||||
assert payload["joints"] == [
|
||||
{"id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98}
|
||||
]
|
||||
assert payload["bones"] == [["left_shoulder", "left_elbow"], ["left_elbow", "left_wrist"]]
|
||||
assert "image" not in payload
|
||||
assert "frame" not in payload
|
||||
|
||||
|
||||
def test_dry_run_recognizer_produces_debug_skeleton():
|
||||
server = MotionAgentServer(MotionAgentConfig(dry_run=True))
|
||||
|
||||
skeleton = server.recognizer.debug_skeleton(
|
||||
None,
|
||||
camera_id="dry-run:null-camera",
|
||||
mode="single",
|
||||
)
|
||||
|
||||
assert skeleton is not None
|
||||
assert skeleton.type == "skeleton"
|
||||
assert skeleton.camera_id == "dry-run:null-camera"
|
||||
assert skeleton.joints
|
||||
assert skeleton.bones
|
||||
|
||||
|
||||
class ServerRecognizerStub:
|
||||
name = "stub"
|
||||
|
||||
def recognize(self, frame):
|
||||
_ = frame
|
||||
return None
|
||||
|
||||
def debug_skeleton(self, frame, **kwargs):
|
||||
_ = frame, kwargs
|
||||
return None
|
||||
|
||||
|
||||
def test_motion_server_prefers_camera_urls_over_usb_indexes():
|
||||
server = MotionAgentServer(
|
||||
MotionAgentConfig(
|
||||
dry_run=False,
|
||||
camera_indexes=(0,),
|
||||
camera_urls=("rtsp://camera.example/live", "http://camera.example/video"),
|
||||
),
|
||||
recognizer=ServerRecognizerStub(),
|
||||
)
|
||||
|
||||
assert [camera.camera_id for camera in server.cameras] == ["url:0", "url:1"]
|
||||
assert all(isinstance(camera, UrlCameraInput) for camera in server.cameras)
|
||||
|
||||
|
||||
def test_usb_camera_reports_missing_opencv_as_readable_dependency_error(monkeypatch):
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
original_exists = motion_cameras.Path.exists
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "cv2":
|
||||
raise ImportError("cv2 missing")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
monkeypatch.setattr(
|
||||
motion_cameras.Path,
|
||||
"exists",
|
||||
lambda self: True if str(self) in {"/dev", "/dev/video0"} else original_exists(self),
|
||||
)
|
||||
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
||||
|
||||
with pytest.raises(MotionAgentDependencyError, match="Add opencv-python with uv"):
|
||||
camera.open()
|
||||
|
||||
|
||||
def test_usb_camera_reports_missing_device_before_opencv_noise(monkeypatch):
|
||||
original_exists = motion_cameras.Path.exists
|
||||
|
||||
monkeypatch.setattr(
|
||||
motion_cameras.Path,
|
||||
"exists",
|
||||
lambda self: True if str(self) == "/dev" else False if str(self) == "/dev/video0" else original_exists(self),
|
||||
)
|
||||
camera = UsbCameraInput(UsbCameraSpec(index=0))
|
||||
|
||||
with pytest.raises(MotionAgentCameraError, match="/dev/video0"):
|
||||
camera.open()
|
||||
|
||||
|
||||
def test_url_camera_reports_unreachable_stream(monkeypatch):
|
||||
class BrokenCapture:
|
||||
def __init__(self, _url):
|
||||
pass
|
||||
|
||||
def isOpened(self):
|
||||
return False
|
||||
|
||||
class Cv2Stub:
|
||||
VideoCapture = BrokenCapture
|
||||
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "cv2":
|
||||
return Cv2Stub()
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
camera = UrlCameraInput(UrlCameraSpec(url="rtsp://camera.example/live"))
|
||||
|
||||
with pytest.raises(MotionAgentCameraError, match="Unable to open camera URL"):
|
||||
camera.open()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_motion_agent_cli_reports_dependency_error_without_traceback(monkeypatch, capsys):
|
||||
class BrokenServer:
|
||||
def __init__(self, _config):
|
||||
raise MotionAgentDependencyError("missing cv stack")
|
||||
|
||||
monkeypatch.setattr(motion_cli, "MotionAgentServer", BrokenServer)
|
||||
|
||||
exit_code = await motion_cli.async_main([])
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert exit_code == 2
|
||||
assert "Motion agent failed: missing cv stack" in captured.err
|
||||
assert "Traceback" not in captured.err
|
||||
106
backend/tests/test_otp_service.py
Normal file
106
backend/tests/test_otp_service.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Unit tests for app.services.otp using an in-memory Redis fake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import otp
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal subset of redis-py used by services.otp."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, tuple[Any, float | None]] = {}
|
||||
|
||||
def _expired(self, key: str) -> bool:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return True
|
||||
_, expires = item
|
||||
if expires is not None and expires <= time.time():
|
||||
self._store.pop(key, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: Any, ex: int | None = None) -> None:
|
||||
expires = time.time() + ex if ex else None
|
||||
self._store[key] = (value, expires)
|
||||
|
||||
def get(self, key: str) -> Any:
|
||||
if self._expired(key):
|
||||
return None
|
||||
return self._store[key][0]
|
||||
|
||||
def ttl(self, key: str) -> int:
|
||||
if self._expired(key):
|
||||
return -2
|
||||
_, expires = self._store[key]
|
||||
if expires is None:
|
||||
return -1
|
||||
return max(int(expires - time.time()), 0)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._store.pop(key, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis(monkeypatch):
|
||||
fake = FakeRedis()
|
||||
monkeypatch.setattr(otp, "redis_client", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_issue_code_returns_six_digits(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
assert len(code) == 6
|
||||
assert code.isdigit()
|
||||
|
||||
|
||||
def test_verify_code_succeeds_and_consumes(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
with pytest.raises(otp.OtpExpired):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_verify_code_rejects_wrong_code(fake_redis):
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
with pytest.raises(otp.OtpInvalid):
|
||||
otp.verify_code("alice@example.com", "register", "000000")
|
||||
|
||||
|
||||
def test_verify_code_locks_after_max_attempts(fake_redis):
|
||||
code = otp.issue_code("alice@example.com", "register")
|
||||
for _ in range(otp.MAX_ATTEMPTS):
|
||||
with pytest.raises(otp.OtpInvalid):
|
||||
otp.verify_code("alice@example.com", "register", "000000")
|
||||
# After max attempts the next call should raise OtpAttemptsExceeded and clear the code.
|
||||
with pytest.raises(otp.OtpAttemptsExceeded):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
with pytest.raises(otp.OtpExpired):
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_issue_code_enforces_resend_cooldown(fake_redis):
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
with pytest.raises(otp.OtpResendRateLimited) as excinfo:
|
||||
otp.issue_code("alice@example.com", "register")
|
||||
assert excinfo.value.retry_after_seconds > 0
|
||||
|
||||
|
||||
def test_issue_code_emails_are_case_insensitive(fake_redis):
|
||||
code = otp.issue_code("Alice@Example.com", "register")
|
||||
otp.verify_code("alice@example.com", "register", code)
|
||||
|
||||
|
||||
def test_purposes_are_isolated(fake_redis):
|
||||
register_code = otp.issue_code("alice@example.com", "register")
|
||||
reset_code = otp.issue_code("alice@example.com", "reset_password")
|
||||
assert register_code != reset_code
|
||||
otp.verify_code("alice@example.com", "register", register_code)
|
||||
# Reset code should still be valid after consuming the register code.
|
||||
otp.verify_code("alice@example.com", "reset_password", reset_code)
|
||||
209
backend/tests/test_settings_ai_provider.py
Normal file
209
backend/tests/test_settings_ai_provider.py
Normal file
@@ -0,0 +1,209 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import settings as settings_api
|
||||
from app.api.v1.settings import (
|
||||
AIProviderIntegrationUpdate,
|
||||
OCRIntegrationUpdate,
|
||||
_build_ai_provider_payload,
|
||||
_build_ocr_payload,
|
||||
_mask_secret,
|
||||
_normalize_ai_provider_payload,
|
||||
_normalize_ocr_payload,
|
||||
_resolve_provider_api_key,
|
||||
get_runtime_ai_provider_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_ai_provider_env_file(monkeypatch, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
monkeypatch.setattr(settings_api, "AI_PROVIDER_ENV_FILE", env_file)
|
||||
return env_file
|
||||
|
||||
|
||||
def test_legacy_ai_provider_payload_maps_to_provider_config():
|
||||
payload = _normalize_ai_provider_payload(
|
||||
{
|
||||
"provider": "openai",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.example/v1",
|
||||
"model": "gpt-test",
|
||||
"api_key": "old-openai-key",
|
||||
"max_tokens": 2048,
|
||||
"anthropic_version": "2023-06-01",
|
||||
}
|
||||
)
|
||||
|
||||
assert payload["default_provider"] == "openai"
|
||||
assert payload["providers"]["openai"]["api_key"] == "old-openai-key"
|
||||
assert payload["providers"]["openai"]["model"] == "gpt-test"
|
||||
assert payload["providers"]["openai"]["base_url"] == "https://api.openai.example/v1"
|
||||
|
||||
|
||||
def test_provider_key_prefers_specific_env_file_key(isolated_ai_provider_env_file):
|
||||
isolated_ai_provider_env_file.write_text(
|
||||
"OPENAI_API_KEY=openai-env-file-key\nAI_API_KEY=generic-env-file-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
||||
|
||||
assert value == "openai-env-file-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_provider_key_falls_back_to_generic_ai_api_key(isolated_ai_provider_env_file):
|
||||
isolated_ai_provider_env_file.write_text(
|
||||
"AI_API_KEY=generic-env-file-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_provider_api_key("openai", {"api_key": ""})
|
||||
|
||||
assert value == "generic-env-file-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_mask_secret_without_prefix_is_fully_masked():
|
||||
assert _mask_secret("plainsecret")["preview"] == "***********"
|
||||
assert _mask_secret("sk-prefixed")["preview"] == "sk-********"
|
||||
|
||||
|
||||
def test_build_payload_updates_only_selected_provider_key():
|
||||
current = {
|
||||
"ai_provider": {
|
||||
"default_provider": "openai",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-old",
|
||||
"api_key": "openai-old-key",
|
||||
"max_tokens": 4096,
|
||||
"anthropic_version": "2023-06-01",
|
||||
},
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"api_key": "minimax-old-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = AIProviderIntegrationUpdate(
|
||||
provider="openai",
|
||||
provider_api="openai-completions",
|
||||
base_url="https://api.openai.com/v1",
|
||||
model="gpt-new",
|
||||
api_key="openai-new-key",
|
||||
max_tokens=8192,
|
||||
)
|
||||
|
||||
payload = _build_ai_provider_payload(current, update)
|
||||
|
||||
assert payload["default_provider"] == "openai"
|
||||
assert payload["providers"]["openai"]["api_key"] == "openai-new-key"
|
||||
assert payload["providers"]["openai"]["model"] == "gpt-new"
|
||||
assert payload["providers"]["minimax"]["api_key"] == "minimax-old-key"
|
||||
|
||||
|
||||
def test_build_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"ai_provider": {
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-old-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = AIProviderIntegrationUpdate(
|
||||
provider="openai",
|
||||
provider_api="openai-completions",
|
||||
base_url="https://api.openai.com/v1",
|
||||
model="gpt-test",
|
||||
api_key="sk-*********",
|
||||
)
|
||||
|
||||
payload = _build_ai_provider_payload(current, update)
|
||||
|
||||
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
|
||||
|
||||
|
||||
def test_normalize_ocr_payload_adds_defaults():
|
||||
payload = _normalize_ocr_payload({})
|
||||
|
||||
assert payload["enabled"] is False
|
||||
assert payload["provider"] == "paddleocr"
|
||||
assert payload["languages"] == ["zh", "en"]
|
||||
assert payload["output_format"] == "markdown"
|
||||
|
||||
|
||||
def test_build_ocr_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"ocr": {
|
||||
"enabled": True,
|
||||
"provider": "custom",
|
||||
"base_url": "http://localhost:8020",
|
||||
"api_key": "ocr-old-secret",
|
||||
}
|
||||
}
|
||||
update = OCRIntegrationUpdate(
|
||||
enabled=True,
|
||||
provider="custom",
|
||||
base_url="http://localhost:8020",
|
||||
api_key="**************",
|
||||
model="ocr-model",
|
||||
languages=["zh", "en"],
|
||||
timeout_seconds=45,
|
||||
max_file_size_mb=50,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
payload = _build_ocr_payload(current, update)
|
||||
|
||||
assert payload["api_key"] == "ocr-old-secret"
|
||||
assert payload["model"] == "ocr-model"
|
||||
assert payload["output_format"] == "json"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
|
||||
record = SimpleNamespace(
|
||||
payload={
|
||||
"ai_provider": {
|
||||
"default_provider": "minimax",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider": "openai",
|
||||
"api_key": "openai-key",
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"minimax": {
|
||||
"provider": "minimax",
|
||||
"api_key": "minimax-key",
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://api.minimaxi.com/anthropic",
|
||||
"model": "MiniMax-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def fake_get_setting_record(_db, category):
|
||||
assert category == "external_integrations"
|
||||
return record
|
||||
|
||||
monkeypatch.setattr(settings_api, "get_setting_record", fake_get_setting_record)
|
||||
|
||||
runtime_config = await get_runtime_ai_provider_config(object())
|
||||
|
||||
assert runtime_config["llm_config"]["provider"] == "minimax"
|
||||
assert runtime_config["llm_config"]["api_key"] == "minimax-key"
|
||||
assert runtime_config["llm_config"]["model"] == "MiniMax-test"
|
||||
86
backend/tests/test_settings_smtp.py
Normal file
86
backend/tests/test_settings_smtp.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Unit tests for SMTP settings helpers in app.api.v1.settings."""
|
||||
|
||||
from app.api.v1.settings import (
|
||||
SMTPSettingsUpdate,
|
||||
_build_smtp_payload,
|
||||
_serialize_smtp_payload,
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_masks_password_and_reports_configured():
|
||||
serialized = _serialize_smtp_payload(
|
||||
{
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "noreply@example.com",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"from_name": "Planet",
|
||||
"use_tls": False,
|
||||
"use_starttls": True,
|
||||
"timeout_seconds": 20,
|
||||
}
|
||||
)
|
||||
assert serialized["configured"] is True
|
||||
assert serialized["password"]["configured"] is True
|
||||
assert "secret" not in serialized["password"]["preview"]
|
||||
|
||||
|
||||
def test_serialize_marks_unconfigured_when_host_missing():
|
||||
serialized = _serialize_smtp_payload(
|
||||
{
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"from_address": "",
|
||||
}
|
||||
)
|
||||
assert serialized["configured"] is False
|
||||
assert serialized["password"]["configured"] is False
|
||||
|
||||
|
||||
def test_build_payload_preserves_password_when_placeholder_submitted():
|
||||
current = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"username": "noreply@example.com",
|
||||
"password": "super-secret",
|
||||
"from_address": "noreply@example.com",
|
||||
"from_name": "Planet",
|
||||
"use_tls": False,
|
||||
"use_starttls": True,
|
||||
"timeout_seconds": 20,
|
||||
}
|
||||
preview = _serialize_smtp_payload(current)["password"]["preview"]
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
username="noreply@example.com",
|
||||
password=preview,
|
||||
from_address="noreply@example.com",
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == "super-secret"
|
||||
|
||||
|
||||
def test_build_payload_replaces_password_when_new_value_submitted():
|
||||
current = {"password": "old", "host": "", "port": 587, "from_address": ""}
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
password="new-secret",
|
||||
from_address="noreply@example.com",
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == "new-secret"
|
||||
|
||||
|
||||
def test_build_payload_clears_password_when_requested():
|
||||
current = {"password": "old"}
|
||||
update = SMTPSettingsUpdate(
|
||||
host="smtp.example.com",
|
||||
port=587,
|
||||
from_address="noreply@example.com",
|
||||
clear_password=True,
|
||||
)
|
||||
merged = _build_smtp_payload(current, update)
|
||||
assert merged["password"] == ""
|
||||
@@ -523,22 +523,115 @@ def test_convert_vessels_to_geojson_dedupes_mmsi_rows():
|
||||
|
||||
|
||||
@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):
|
||||
@@ -552,75 +645,39 @@ 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_vessels_geojson_merges_raw_and_legacy_sources(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(
|
||||
visualization,
|
||||
"get_aggregated_vessels",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.9,
|
||||
"lon": 10.7,
|
||||
"received_at": now,
|
||||
"name": "AISSTREAM SHIP",
|
||||
"vessel_type_name": "Cargo",
|
||||
"source_summary": {"aisstream_vessels": {"message_types": ["PositionReport"]}},
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
rows = [
|
||||
(
|
||||
VesselPosition(mmsi=1, lat=60.0, lon=10.8, received_at=now),
|
||||
VesselStatic(mmsi=1, name="LEGACY DUP", vessel_type_name="Cargo"),
|
||||
),
|
||||
(
|
||||
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now),
|
||||
VesselStatic(mmsi=2, name="BARENTSWATCH ONLY", vessel_type_name="Passenger"),
|
||||
),
|
||||
]
|
||||
|
||||
class _Result:
|
||||
def all(self):
|
||||
return rows
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _Result()
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
async def test_legacy_vessels_geojson_rejects_even_with_bbox():
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/vessels")
|
||||
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 == 200
|
||||
data = response.json()
|
||||
names = {feature["properties"]["mmsi"]: feature["properties"]["name"] for feature in data["features"]}
|
||||
assert data["count"] == 2
|
||||
assert names == {1: "AISSTREAM SHIP", 2: "BARENTSWATCH ONLY"}
|
||||
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 1
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 410
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -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
|
||||
@@ -353,3 +814,304 @@ async def test_visualization_geo_summary_returns_counts(monkeypatch):
|
||||
assert stats["bgp_collector_count"] == 2
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_location_endpoint_returns_candidates_for_known_record(monkeypatch):
|
||||
def _fake_ror(query):
|
||||
assert query == "Oak Ridge National Laboratory"
|
||||
return {
|
||||
"id": "https://ror.org/01qz5mb56",
|
||||
"names": [
|
||||
{"types": ["ror_display"], "value": "Oak Ridge National Laboratory"}
|
||||
],
|
||||
"locations": [
|
||||
{
|
||||
"geonames_id": 4646571,
|
||||
"geonames_details": {
|
||||
"name": "Oak Ridge",
|
||||
"country_subdivision_name": "Tennessee",
|
||||
"country_name": "United States",
|
||||
"lat": 36.01036,
|
||||
"lng": -84.26964,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", _fake_ror)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
|
||||
target_record = _build_record(
|
||||
record_id=42,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Oak Ridge National Laboratory", "rmax": 1102000.0},
|
||||
)
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _ScalarResult([target_record])
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/visualization/compute-centers/top500-42/collect-location",
|
||||
json={
|
||||
"name": "Frontier",
|
||||
"operator": "Oak Ridge National Laboratory",
|
||||
"country": "United States",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["candidates"], "must include candidates"
|
||||
best = body["best_candidate"]
|
||||
assert best["precision"] in {"precise", "site", "city"}
|
||||
assert best["source"] == "ror_organization_registry"
|
||||
assert best["needs_confirmation"] is True
|
||||
assert best["matched_fields"], "matched_fields must be populated"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_location_endpoint_returns_failure_reason(monkeypatch):
|
||||
monkeypatch.setattr(compute_center_locations, "_lookup_ror_organization", lambda _query: None)
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
async def execute(self, _query):
|
||||
return _ScalarResult([])
|
||||
|
||||
async def override_get_db():
|
||||
yield _FakeSession()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/visualization/compute-centers/epoch-mystery-99/collect-location",
|
||||
json={
|
||||
"name": "Mystery Cluster",
|
||||
"operator": "Mystery Operator",
|
||||
"country": "Bhutan",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is False
|
||||
assert body["failure_reason"]
|
||||
assert body["candidates"] == []
|
||||
assert body["attempted_queries"], "must include attempted queries"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_location_endpoint_upserts_and_geojson_can_render():
|
||||
target_record = _build_record(
|
||||
record_id=52,
|
||||
source="epoch_ai_gpu",
|
||||
data_type="gpu_cluster",
|
||||
name="Saved Cluster",
|
||||
country="United States",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"value": "1200", "unit": "TFlop/s"},
|
||||
)
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
class _Scalars:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def first(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
return _Scalars(self._rows)
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self):
|
||||
self.saved = []
|
||||
|
||||
async def execute(self, _query):
|
||||
if self.saved:
|
||||
return _ScalarResult(self.saved)
|
||||
return _ScalarResult([target_record])
|
||||
|
||||
async def scalar(self, _query):
|
||||
return None
|
||||
|
||||
def add(self, record):
|
||||
self.saved.append(record)
|
||||
|
||||
async def commit(self):
|
||||
return None
|
||||
|
||||
async def refresh(self, _record):
|
||||
return None
|
||||
|
||||
fake_session = _FakeSession()
|
||||
|
||||
async def override_get_db():
|
||||
yield fake_session
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/visualization/compute-centers/epoch_ai_gpu-52/location",
|
||||
json={
|
||||
"source": "epoch_ai_gpu",
|
||||
"name": "Saved Cluster",
|
||||
"latitude": 35.1495,
|
||||
"longitude": -90.049,
|
||||
"precision": "city",
|
||||
"confidence": 0.72,
|
||||
"location_source": "ror_organization_registry",
|
||||
"source_note": "Selected by user",
|
||||
"raw_payload": {"source": "ror_organization_registry"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert fake_session.saved
|
||||
|
||||
payload = convert_compute_centers_to_geojson([target_record])
|
||||
assert len(payload["features"]) == 1
|
||||
feature = payload["features"][0]
|
||||
assert feature["geometry"]["coordinates"] == [-90.049, 35.1495]
|
||||
assert feature["properties"]["location_source"] == "stored_compute_center_location"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
compute_center_locations.set_compute_center_location_cache({})
|
||||
|
||||
|
||||
def test_resolution_chain_orders_source_coords_first(monkeypatch):
|
||||
def _explode(_query):
|
||||
raise AssertionError("source coords must short-circuit before online geocoding")
|
||||
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", _explode)
|
||||
record = _build_record(
|
||||
record_id=20,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Frontier",
|
||||
country="United States",
|
||||
city="Oak Ridge",
|
||||
latitude=35.93,
|
||||
longitude=-84.31,
|
||||
metadata={"organization": "ORNL"},
|
||||
)
|
||||
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||
assert result.is_resolved
|
||||
assert result.location.location_precision == "precise"
|
||||
assert result.location.location_source == "source_coordinates"
|
||||
|
||||
|
||||
def test_no_country_centroid_or_major_compute_city_fallback(monkeypatch):
|
||||
monkeypatch.setattr(compute_center_locations, "_geocode_online", lambda _query: None)
|
||||
record = _build_record(
|
||||
record_id=21,
|
||||
source="top500",
|
||||
data_type="supercomputer",
|
||||
name="Phantom System",
|
||||
country="France",
|
||||
city="",
|
||||
latitude=0.0,
|
||||
longitude=0.0,
|
||||
metadata={"organization": "Phantom Operator"},
|
||||
)
|
||||
result = compute_center_locations.resolve_compute_center_location_full(record, record.extra_data)
|
||||
assert result.location is None, "must NOT fall back to country centroid or hashed major city"
|
||||
assert result.diagnostic is not None
|
||||
assert result.diagnostic.failure_reason
|
||||
|
||||
|
||||
def test_repository_has_no_forbidden_precision_tokens():
|
||||
"""Static guard: forbidden fallback strategies must not regress into the codebase.
|
||||
|
||||
Each forbidden token may appear at most once per target file, and only inside
|
||||
the FORBIDDEN_PRECISIONS guard list (so we still reject them at runtime).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
backend_root = Path(__file__).resolve().parents[1]
|
||||
forbidden_tokens = (
|
||||
"country_centroid",
|
||||
"country_major_compute_city",
|
||||
"estimated_country",
|
||||
)
|
||||
targets = [
|
||||
backend_root / "app" / "services" / "compute_center_locations.py",
|
||||
backend_root / "app" / "api" / "v1" / "visualization.py",
|
||||
]
|
||||
for target in targets:
|
||||
text = target.read_text(encoding="utf-8")
|
||||
for token in forbidden_tokens:
|
||||
occurrences = text.count(token)
|
||||
assert occurrences <= 1, (
|
||||
f"{token} appears {occurrences} times in {target}; "
|
||||
"should only appear in FORBIDDEN_PRECISIONS guard list."
|
||||
)
|
||||
if occurrences == 1:
|
||||
assert "FORBIDDEN_PRECISIONS" in text, (
|
||||
f"{token} appears in {target} outside the FORBIDDEN_PRECISIONS guard"
|
||||
)
|
||||
|
||||
261
backend/tests/test_web_search_tools.py
Normal file
261
backend/tests/test_web_search_tools.py
Normal file
@@ -0,0 +1,261 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1 import settings as settings_api
|
||||
from app.api.v1.settings import (
|
||||
WebSearchIntegrationUpdate,
|
||||
_build_web_search_payload,
|
||||
_mask_secret,
|
||||
_normalize_web_search_payload,
|
||||
_resolve_web_search_api_key,
|
||||
)
|
||||
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
|
||||
from app.services.ai_tools.web_search import WebSearchClient
|
||||
from app.services.credential_guides import generate_credential_guide
|
||||
from app.services.location.llm_fallback import (
|
||||
collect_llm_location_fallback_candidate,
|
||||
collect_location_search_evidence,
|
||||
)
|
||||
from app.services.location.models import LocationQuery
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_web_search_env_files(monkeypatch, tmp_path):
|
||||
env_file = tmp_path / ".env"
|
||||
monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,))
|
||||
return env_file
|
||||
|
||||
|
||||
def test_normalize_web_search_payload_adds_default_provider():
|
||||
payload = _normalize_web_search_payload({})
|
||||
|
||||
assert payload["default_provider"] == "tavily"
|
||||
assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com"
|
||||
|
||||
|
||||
def test_web_search_key_prefers_provider_env(isolated_web_search_env_files):
|
||||
isolated_web_search_env_files.write_text(
|
||||
"TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
value, source = _resolve_web_search_api_key("tavily", {"api_key": ""})
|
||||
|
||||
assert value == "tavily-env-key"
|
||||
assert source == "env_file"
|
||||
|
||||
|
||||
def test_build_web_search_payload_keeps_saved_key_when_preview_submitted():
|
||||
current = {
|
||||
"web_search": {
|
||||
"default_provider": "tavily",
|
||||
"providers": {
|
||||
"tavily": {
|
||||
"provider": "tavily",
|
||||
"api_key": "tvly-old-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
update = WebSearchIntegrationUpdate(
|
||||
enabled=True,
|
||||
provider="tavily",
|
||||
base_url="https://api.tavily.com",
|
||||
api_key=_mask_secret("tvly-old-secret")["preview"],
|
||||
)
|
||||
|
||||
payload = _build_web_search_payload(current, update)
|
||||
|
||||
assert payload["enabled"] is True
|
||||
assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tavily_adapter_normalizes_results(monkeypatch):
|
||||
config = WebSearchConfig(
|
||||
enabled=True,
|
||||
default_provider="tavily",
|
||||
provider="tavily",
|
||||
providers={
|
||||
"tavily": WebSearchProviderConfig(
|
||||
provider="tavily",
|
||||
base_url="https://api.tavily.com",
|
||||
api_key="key",
|
||||
)
|
||||
},
|
||||
)
|
||||
client = WebSearchClient(config)
|
||||
|
||||
async def fake_request_json(*args, **kwargs):
|
||||
return {
|
||||
"query": "Alem.Cloud",
|
||||
"results": [
|
||||
{
|
||||
"title": "Alem.Cloud official",
|
||||
"url": "https://example.test/alem",
|
||||
"content": "Alem.Cloud is in Astana.",
|
||||
"score": 0.9,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
||||
|
||||
results = await client.search("Alem.Cloud")
|
||||
|
||||
assert results[0].source_provider == "tavily"
|
||||
assert results[0].url == "https://example.test/alem"
|
||||
assert "Astana" in results[0].snippet
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_searxng_adapter_allows_empty_api_key(monkeypatch):
|
||||
config = WebSearchConfig(
|
||||
enabled=True,
|
||||
default_provider="searxng",
|
||||
provider="searxng",
|
||||
providers={
|
||||
"searxng": WebSearchProviderConfig(
|
||||
provider="searxng",
|
||||
base_url="http://localhost:8080",
|
||||
api_key="",
|
||||
)
|
||||
},
|
||||
)
|
||||
client = WebSearchClient(config)
|
||||
|
||||
async def fake_request_json(*args, **kwargs):
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"title": "TAIPEI-1",
|
||||
"url": "https://example.test/taipei",
|
||||
"content": "TAIPEI-1 is in Taipei.",
|
||||
"score": 2,
|
||||
"engine": "duckduckgo",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(client, "_request_json", fake_request_json)
|
||||
|
||||
results = await client.search("TAIPEI-1")
|
||||
|
||||
assert results[0].source_provider == "searxng"
|
||||
assert results[0].metadata["engine"] == "duckduckgo"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch):
|
||||
class EmptySearchClient:
|
||||
async def search(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
result = await collect_location_search_evidence(
|
||||
web_search_client=EmptySearchClient(),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
)
|
||||
|
||||
assert result.evidence == []
|
||||
assert "no usable" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_location_fallback_skips_when_search_evidence_empty():
|
||||
class ExplodingAIClient:
|
||||
async def analyze(self, *_args, **_kwargs):
|
||||
raise AssertionError("LLM should not be called without evidence")
|
||||
|
||||
result = await collect_llm_location_fallback_candidate(
|
||||
provider_client=ExplodingAIClient(),
|
||||
query=LocationQuery(name="TAIPEI-1", country="Taiwan"),
|
||||
entity_type="compute_center",
|
||||
search_evidence=[],
|
||||
)
|
||||
|
||||
assert result.candidates == []
|
||||
assert "no WebSearch evidence" in result.failure_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_guide_keeps_default_without_search_evidence():
|
||||
class EmptySearchClient:
|
||||
async def search(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
class ExplodingAIClient:
|
||||
async def analyze(self, *_args, **_kwargs):
|
||||
raise AssertionError("AI should not be called without search evidence")
|
||||
|
||||
async def fake_get_store(_db):
|
||||
return None, {}
|
||||
|
||||
import app.services.credential_guides as credential_guides
|
||||
|
||||
original = credential_guides._get_guide_store
|
||||
credential_guides._get_guide_store = fake_get_store
|
||||
try:
|
||||
guide = await generate_credential_guide(
|
||||
object(),
|
||||
"barentswatch",
|
||||
ExplodingAIClient(),
|
||||
EmptySearchClient(),
|
||||
)
|
||||
finally:
|
||||
credential_guides._get_guide_store = original
|
||||
|
||||
assert guide["source"] == "default"
|
||||
assert guide["verification_status"] == "unverified_no_search_evidence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_guide_uses_search_evidence(monkeypatch):
|
||||
class SearchClient:
|
||||
async def search(self, *args, **kwargs):
|
||||
from app.services.ai_tools.schemas import SearchEvidence
|
||||
|
||||
return [
|
||||
SearchEvidence(
|
||||
title="Official docs",
|
||||
url="https://docs.example.test",
|
||||
snippet="Create an AIS client.",
|
||||
source_provider="tavily",
|
||||
)
|
||||
]
|
||||
|
||||
class AIClient:
|
||||
async def analyze(self, payload):
|
||||
assert payload.context["search_evidence"]
|
||||
return SimpleNamespace(content="## Generated\n\nSources included.")
|
||||
|
||||
saved = {}
|
||||
|
||||
async def fake_get_store(_db):
|
||||
return None, saved
|
||||
|
||||
async def fake_save(db, provider, title, markdown, **metadata):
|
||||
return {
|
||||
"provider": provider,
|
||||
"title": title,
|
||||
"markdown": markdown,
|
||||
"source": "ai",
|
||||
**metadata,
|
||||
}
|
||||
|
||||
import app.services.credential_guides as credential_guides
|
||||
|
||||
monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store)
|
||||
monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save)
|
||||
|
||||
guide = await generate_credential_guide(
|
||||
object(),
|
||||
"barentswatch",
|
||||
AIClient(),
|
||||
SearchClient(),
|
||||
)
|
||||
|
||||
assert guide["source"] == "ai"
|
||||
assert guide["verification_status"] == "verified_with_search_evidence"
|
||||
assert guide["sources"][0]["url"] == "https://docs.example.test"
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
import importlib
|
||||
|
||||
from app.core.websocket.manager import ConnectionManager
|
||||
from app.core.websocket.broadcaster import DataBroadcaster
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
@@ -44,3 +46,93 @@ async def test_disconnect_removes_channel_subscriptions():
|
||||
|
||||
assert socket.sent == []
|
||||
assert "dashboard" not in manager.channel_subscriptions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_subscribers_receive_only_matching_bbox_updates():
|
||||
manager = ConnectionManager()
|
||||
oslo_socket = FakeWebSocket()
|
||||
bergen_socket = FakeWebSocket()
|
||||
|
||||
await manager.connect(oslo_socket, "user-1")
|
||||
await manager.connect(bergen_socket, "user-2")
|
||||
manager.subscribe_vessels(
|
||||
oslo_socket,
|
||||
{"bbox": [10, 59, 11, 60], "zoom": 12, "limit": 1000},
|
||||
)
|
||||
manager.subscribe_vessels(
|
||||
bergen_socket,
|
||||
{"bbox": [5, 60, 6, 61], "zoom": 12, "limit": 1000},
|
||||
)
|
||||
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"vessels": [
|
||||
{"mmsi": 1, "lat": 59.9, "lon": 10.7},
|
||||
{"mmsi": 2, "lat": 60.3, "lon": 5.3},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert oslo_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]
|
||||
assert bergen_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 2, "lat": 60.3, "lon": 5.3}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_broadcast_removes_slow_connections():
|
||||
manager = ConnectionManager()
|
||||
|
||||
class BrokenWebSocket(FakeWebSocket):
|
||||
async def send_json(self, message):
|
||||
raise RuntimeError("client is gone")
|
||||
|
||||
socket = BrokenWebSocket()
|
||||
await manager.connect(socket, "user-1")
|
||||
manager.subscribe_vessels(socket, {"bbox": [10, 59, 11, 60], "zoom": 12})
|
||||
|
||||
await manager.broadcast_vessels({"vessels": [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]})
|
||||
|
||||
assert socket not in manager.vessel_subscriptions
|
||||
|
||||
|
||||
def test_vessel_subscription_rejects_large_bbox():
|
||||
manager = ConnectionManager()
|
||||
|
||||
with pytest.raises(ValueError, match="bbox is too large"):
|
||||
manager.subscribe_vessels(FakeWebSocket(), {"bbox": [-180, -90, 180, 90], "zoom": 1})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_broadcast_vessels(payload):
|
||||
sent.append(payload)
|
||||
|
||||
broadcaster_module = importlib.import_module("app.core.websocket.broadcaster")
|
||||
monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels)
|
||||
broadcaster = DataBroadcaster()
|
||||
broadcaster.enqueue_vessel_update(
|
||||
{
|
||||
"source": "aisstream_vessels",
|
||||
"vessels": [
|
||||
{"mmsi": 1, "lat": 59.0, "lon": 10.0},
|
||||
{"mmsi": 1, "lat": 59.1, "lon": 10.1},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
await broadcaster.flush_vessel_updates()
|
||||
|
||||
assert len(sent) == 1
|
||||
assert sent[0]["vessels"] == [
|
||||
{
|
||||
"mmsi": 1,
|
||||
"lat": 59.1,
|
||||
"lon": 10.1,
|
||||
"source": "aisstream_vessels",
|
||||
"action": "upsert",
|
||||
"created": None,
|
||||
}
|
||||
]
|
||||
|
||||
@@ -8,6 +8,80 @@ 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
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
This file contains Planet-specific documentation coverage rules. Documentation skills and agents should read this file before deciding which docs to update. Keep tool-specific workflow in skills; keep product and repository rules here.
|
||||
|
||||
## Audience Routing (mandatory)
|
||||
|
||||
Before deciding scope, classify the change by who performs the action:
|
||||
|
||||
- **Browser/UI end user** (login, account settings, configuring collectors or AI via UI, using Earth/Console pages): update `docs/technical/{zh,en}/manual.md` and `quickstart.md` only. Never put shell commands, log file paths, `planet.sh`, Docker operations, or `netsh portproxy` rules into these files.
|
||||
- **Operations / deployment / on-call** (`planet.sh`, log paths, SMTP fallbacks like `createuser`, LAN/portproxy, env-var tuning, troubleshooting order, Bun build conventions): update `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`). Never put UI button labels or screenshots into these files.
|
||||
- **Second-party developers** (component context, render order, internal pipelines): update the existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
|
||||
|
||||
If the same action has both a UI and a CLI path (e.g. user creation), describe the UI path in `manual.md` and the CLI path in `ops-runbook.md`, and cross-link them with a single sentence each.
|
||||
|
||||
## Scope Rules
|
||||
|
||||
- User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`.
|
||||
|
||||
@@ -25,11 +25,17 @@
|
||||
- [earth-real-terrain-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-real-terrain-plan.md)
|
||||
- [earth-news-source-configuration-and-collector-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-source-configuration-and-collector-plan.md)
|
||||
- [earth-news-cruise-summary-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-news-cruise-summary-plan.md)
|
||||
- [Earth 动作捕捉手势控制计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-capture-gesture-control-plan.md)
|
||||
- [Earth 动捕交互语义 V2 计划](/home/ray/dev/linkong/planet/docs/plans/earth-motion-gesture-interaction-v2-plan.md)
|
||||
- [Earth Presentation 解耦架构计划](/home/ray/dev/linkong/planet/docs/plans/earth-presentation-decoupled-architecture-plan.md)
|
||||
- [earth-vessel-rendering-performance-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-rendering-performance-plan.md)
|
||||
- [AIS 多源采集、冲突记录与聚合接口计划](/home/ray/dev/linkong/planet/docs/plans/earth-vessel-ais-aggregation-plan.md)
|
||||
- [earth-interactable-layer-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-interactable-layer-plan.md)
|
||||
- [frontend-public-docs-site-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-public-docs-site-plan.md)
|
||||
- [Docs Gatekeeper 鉴权系统计划](/home/ray/dev/linkong/planet/docs/plans/docs-gatekeeper-auth-plan.md)
|
||||
- [Location Resolver 共享管线计划](/home/ray/dev/linkong/planet/docs/plans/location-resolver-shared-pipeline-plan.md)
|
||||
- [frontend-ai-playground-development-plan.md](/home/ray/dev/linkong/planet/docs/plans/frontend-ai-playground-development-plan.md)
|
||||
- [Lightweight Agent Orchestrator 与 WebSearch 证据层计划](/home/ray/dev/linkong/planet/docs/plans/agents-light-orchestrator-websearch-plan.md)
|
||||
- [ue5-mvp-fused-plan.md](/home/ray/dev/linkong/planet/docs/plans/ue5-mvp-fused-plan.md)
|
||||
|
||||
不适合放入这里的内容:
|
||||
|
||||
618
docs/plans/agents-light-orchestrator-websearch-plan.md
Normal file
618
docs/plans/agents-light-orchestrator-websearch-plan.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# Lightweight Agent Orchestrator and WebSearch Evidence Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Planet should not turn `aiprovider` into a general-purpose agent runtime.
|
||||
|
||||
`aiprovider` should remain the model gateway:
|
||||
|
||||
- provider compatibility
|
||||
- protocol adaptation
|
||||
- model authentication
|
||||
- request and response normalization
|
||||
|
||||
Agent behavior belongs in the backend, where Planet already owns business state,
|
||||
permissions, persistence, evidence records, and operator workflows.
|
||||
|
||||
The recommended direction is a lightweight backend Agent Orchestrator with a
|
||||
controlled tool layer. The first version should use fixed workflows instead of a
|
||||
free-form tool-calling loop.
|
||||
|
||||
|
||||
## Architecture Decision
|
||||
|
||||
Use this boundary:
|
||||
|
||||
```text
|
||||
aiprovider = model adapter only
|
||||
backend Agent = task orchestration + tools + evidence + policy + business rules
|
||||
```
|
||||
|
||||
This keeps model transport separate from Planet-specific behavior. It also lets
|
||||
OpenAI, MiniMax, Anthropic-compatible providers, Ollama, and later providers all
|
||||
reuse the same backend tools.
|
||||
|
||||
Recommended module shape:
|
||||
|
||||
```text
|
||||
backend/app/services/
|
||||
ai/
|
||||
agent_orchestrator.py
|
||||
tool_registry.py
|
||||
prompts.py
|
||||
schemas.py
|
||||
ai_tools/
|
||||
web_search.py
|
||||
web_fetch.py
|
||||
geo_resolve.py
|
||||
internal_data_query.py
|
||||
incident_query.py
|
||||
evidence_store.py
|
||||
situation/
|
||||
bgp_analyzer.py
|
||||
risk_scoring.py
|
||||
event_correlator.py
|
||||
alert_policy.py
|
||||
|
||||
aiprovider/
|
||||
provider_service.py
|
||||
main.py
|
||||
```
|
||||
|
||||
|
||||
## Phase 1: Controlled Workflow Agent
|
||||
|
||||
The first implementation should not be a full OpenClaw/Codex-style agent loop.
|
||||
Planet's immediate needs are better served by explicit workflows:
|
||||
|
||||
1. `tutorial_refresh`
|
||||
2. `geo_correction`
|
||||
3. `situation_brief`
|
||||
|
||||
Each workflow should:
|
||||
|
||||
1. collect evidence with backend tools
|
||||
2. normalize and store evidence
|
||||
3. call `AIProviderClient` through the configured global provider/model/key
|
||||
4. validate the result with Pydantic schemas
|
||||
5. return a proposal, candidate, or brief instead of directly mutating critical state
|
||||
|
||||
For location correction, the flow should be:
|
||||
|
||||
```text
|
||||
object name / type / current coordinate / description
|
||||
-> web_search
|
||||
-> web_fetch for selected results
|
||||
-> geo_resolve for city/site coordinates
|
||||
-> LLM structured extraction
|
||||
-> schema validation and confidence scoring
|
||||
-> pending review candidate
|
||||
```
|
||||
|
||||
The LLM output must be constrained to a schema such as:
|
||||
|
||||
```json
|
||||
{
|
||||
"object_id": "string",
|
||||
"object_type": "datacenter|ixp|submarine_cable|asn|city|facility|satellite",
|
||||
"current_location": {
|
||||
"lat": 0,
|
||||
"lon": 0
|
||||
},
|
||||
"suggested_location": {
|
||||
"lat": 0,
|
||||
"lon": 0
|
||||
},
|
||||
"confidence": 0.82,
|
||||
"reason": "short evidence-backed explanation",
|
||||
"evidence": [
|
||||
{
|
||||
"title": "source title",
|
||||
"url": "https://example.com/source",
|
||||
"quote": "short supporting excerpt",
|
||||
"retrieved_at": "2026-05-10T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"needs_human_review": true
|
||||
}
|
||||
```
|
||||
|
||||
The LLM may generate a suggestion, but it must not directly write final
|
||||
coordinates into the dimension tables.
|
||||
|
||||
|
||||
## Phase 2: Backend Tool Registry
|
||||
|
||||
Add a small Python tool interface in the backend:
|
||||
|
||||
```python
|
||||
class ToolResult(BaseModel):
|
||||
ok: bool
|
||||
data: Any = None
|
||||
error: str | None = None
|
||||
evidence: list[dict] = []
|
||||
```
|
||||
|
||||
Register tools through a backend registry:
|
||||
|
||||
```text
|
||||
web_search
|
||||
web_fetch
|
||||
geo_resolve
|
||||
internal_data_query
|
||||
incident_query
|
||||
evidence_store
|
||||
```
|
||||
|
||||
Do not put WebSearch inside `aiprovider`.
|
||||
|
||||
Reasons:
|
||||
|
||||
- search is a business tool, not a model-provider feature
|
||||
- search evidence must be stored and audited by the backend
|
||||
- different LLM providers should share the same search pipeline
|
||||
- Planet may switch between Tavily, Brave, Exa, SearXNG, or MiniMax MCP without
|
||||
changing model transport
|
||||
|
||||
The first WebSearch implementation should be an HTTP evidence provider. Tavily is
|
||||
the recommended first default because it is simple to call from the existing
|
||||
`httpx` backend stack and returns LLM/RAG-friendly search results. The interface
|
||||
should remain provider-neutral so Brave, Exa, SearXNG, or MiniMax MCP can be
|
||||
added later.
|
||||
|
||||
WebSearch configuration should live under PostgreSQL `system_settings` with the
|
||||
rest of external integrations:
|
||||
|
||||
```text
|
||||
external_integrations.web_search
|
||||
enabled
|
||||
provider
|
||||
api_key
|
||||
base_url
|
||||
max_results
|
||||
timeout_seconds
|
||||
```
|
||||
|
||||
Secret resolution should follow the existing settings pattern:
|
||||
|
||||
1. saved PostgreSQL secret
|
||||
2. provider-specific environment variable, for example `TAVILY_API_KEY`
|
||||
3. generic fallback `WEB_SEARCH_API_KEY`
|
||||
|
||||
### Common WebSearch Providers
|
||||
|
||||
The first implementation should model WebSearch as a provider-specific adapter
|
||||
behind one internal interface:
|
||||
|
||||
```text
|
||||
SearchEvidenceProvider.search(query, max_results, domains, freshness_days)
|
||||
-> list[SearchEvidence]
|
||||
```
|
||||
|
||||
Recommended provider ids and environment variables:
|
||||
|
||||
| Provider | Provider id | Env key | Default base URL | Primary use |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Tavily | `tavily` | `TAVILY_API_KEY` | `https://api.tavily.com` | Default hosted search for agent/RAG style results |
|
||||
| Brave Search API | `brave` | `BRAVE_SEARCH_API_KEY` | `https://api.search.brave.com` | Independent web index and low-level SERP results |
|
||||
| SerpAPI | `serpapi` | `SERPAPI_API_KEY` | `https://serpapi.com` | Search-engine-backed SERP data with engine options |
|
||||
| Exa | `exa` | `EXA_API_KEY` | `https://api.exa.ai` | Neural/semantic web search and result contents |
|
||||
| Firecrawl Search / Scrape | `firecrawl` | `FIRECRAWL_API_KEY` | `https://api.firecrawl.dev` | Search plus page scrape/markdown extraction |
|
||||
| SearXNG | `searxng` | optional `SEARXNG_API_KEY` | self-hosted instance URL | Self-hosted metasearch when external search APIs are undesirable |
|
||||
|
||||
The normalized configuration should support per-provider defaults while keeping
|
||||
one active provider:
|
||||
|
||||
```text
|
||||
external_integrations.web_search
|
||||
enabled: true
|
||||
default_provider: tavily
|
||||
providers:
|
||||
tavily:
|
||||
base_url: https://api.tavily.com
|
||||
api_key: <secret>
|
||||
max_results: 5
|
||||
search_depth: basic
|
||||
include_answer: false
|
||||
include_raw_content: false
|
||||
brave:
|
||||
base_url: https://api.search.brave.com
|
||||
api_key: <secret>
|
||||
endpoint_path: /res/v1/web/search
|
||||
max_results: 5
|
||||
serpapi:
|
||||
base_url: https://serpapi.com
|
||||
api_key: <secret>
|
||||
endpoint_path: /search.json
|
||||
engine: google
|
||||
max_results: 5
|
||||
exa:
|
||||
base_url: https://api.exa.ai
|
||||
api_key: <secret>
|
||||
endpoint_path: /search
|
||||
max_results: 5
|
||||
include_text: false
|
||||
firecrawl:
|
||||
base_url: https://api.firecrawl.dev
|
||||
api_key: <secret>
|
||||
search_path: /v2/search
|
||||
scrape_path: /v2/scrape
|
||||
max_results: 5
|
||||
scrape_formats: [markdown]
|
||||
searxng:
|
||||
base_url: http://localhost:8080
|
||||
api_key: <optional secret>
|
||||
endpoint_path: /
|
||||
max_results: 5
|
||||
categories: general
|
||||
engines: []
|
||||
```
|
||||
|
||||
Adapter notes:
|
||||
|
||||
- Tavily should call `/search` and normalize title, URL, snippet/content, score,
|
||||
and optional raw content.
|
||||
- Brave should call `/res/v1/web/search` and map web results into the same
|
||||
`SearchEvidence` shape.
|
||||
- SerpAPI should call `/search.json`, pass `engine`, and normalize organic
|
||||
results. Search-engine-specific fields should remain in provider metadata.
|
||||
- Exa should call `/search`; optional result text should be treated as fetched
|
||||
content only when enabled.
|
||||
- Firecrawl can be used both as `web_search` and `web_fetch`: `/v2/search`
|
||||
returns result URLs/descriptions and may include scrape options, while
|
||||
`/v2/scrape` can produce markdown for a selected URL.
|
||||
- SearXNG should query the configured instance with `q` and `format=json`.
|
||||
Public instances should not be assumed reliable for production; a controlled
|
||||
self-hosted instance is preferred.
|
||||
|
||||
The settings UI should expose only provider, base URL, key, max results, and a
|
||||
test button in the first version. Provider-specific advanced fields can stay
|
||||
collapsed or backend-only until a real workflow needs them.
|
||||
|
||||
### Frontend Configuration Window
|
||||
|
||||
Add a WebSearch configuration panel to the existing settings page, next to the
|
||||
LLM provider configuration. It should behave like the current AI provider secret
|
||||
controls: clear configured state, masked preview, explicit show/hide, test
|
||||
connection, and save feedback.
|
||||
|
||||
First-version visible fields:
|
||||
|
||||
```text
|
||||
WebSearch Provider
|
||||
API Base URL
|
||||
API Key
|
||||
Max Results
|
||||
Timeout Seconds
|
||||
Enable WebSearch
|
||||
Test Connection
|
||||
Save
|
||||
```
|
||||
|
||||
Provider dropdown options:
|
||||
|
||||
```text
|
||||
Tavily
|
||||
Brave Search API
|
||||
SerpAPI
|
||||
Exa
|
||||
Firecrawl Search / Scrape
|
||||
SearXNG
|
||||
```
|
||||
|
||||
Field behavior:
|
||||
|
||||
- Switching provider loads that provider's saved config and masked key preview.
|
||||
- Empty key input means keep the existing saved or environment key.
|
||||
- Typing a new key replaces only the selected provider's key.
|
||||
- Show key reveals the full current input value when the backend reveal endpoint
|
||||
allows it; hide key returns to the prefix-preserving masked preview.
|
||||
- The configured badge should only show `已配置` or `未配置`, not repeat the
|
||||
masked key text.
|
||||
- `Test Connection` sends the current unsaved draft to the backend and should
|
||||
not require a separate save first.
|
||||
- A successful test may save the draft as the new WebSearch default only if the
|
||||
API endpoint is explicitly designed to mirror the AI provider test behavior.
|
||||
Otherwise, test should be read-only and the Save button should persist.
|
||||
- Save success and test success must show visible feedback. Failures should show
|
||||
provider-specific but secret-safe error messages.
|
||||
|
||||
Provider-specific UI hints:
|
||||
|
||||
| Provider | UI hint |
|
||||
| --- | --- |
|
||||
| Tavily | Good default for agent/RAG style search. |
|
||||
| Brave Search API | Uses Brave's independent search index. |
|
||||
| SerpAPI | Supports search-engine-specific parameters such as `engine`. |
|
||||
| Exa | Good for semantic search and optional result text. |
|
||||
| Firecrawl | Can search and scrape pages into markdown. |
|
||||
| SearXNG | Requires a reachable self-hosted or trusted instance URL. |
|
||||
|
||||
Advanced fields can live in a collapsed section:
|
||||
|
||||
```text
|
||||
Endpoint Path
|
||||
Search Depth
|
||||
Engine
|
||||
Categories
|
||||
Engines
|
||||
Include Raw Content
|
||||
Scrape Formats
|
||||
Domain Allowlist
|
||||
```
|
||||
|
||||
The first version should keep the UI conservative. It should not expose every
|
||||
provider knob until backend workflows use those knobs.
|
||||
|
||||
### Web Fetch and Page Extraction
|
||||
|
||||
`web_fetch` is separate from `web_search`. Search finds candidate URLs; fetch
|
||||
turns selected pages into clean, citable evidence.
|
||||
|
||||
Recommended extraction chain:
|
||||
|
||||
```text
|
||||
1. plain httpx fetch
|
||||
2. trafilatura extraction for static HTML
|
||||
3. readability extraction as secondary cleanup
|
||||
4. Playwright fetch only for allowlisted JS-heavy pages
|
||||
5. Firecrawl scrape as hosted fallback when configured
|
||||
```
|
||||
|
||||
Implementation guidance:
|
||||
|
||||
- Use `trafilatura` as the first local extractor because it is Python-native and
|
||||
matches the backend stack.
|
||||
- Prefer a Python readability implementation for local cleanup. Do not introduce
|
||||
a Node-only readability dependency for backend fetch.
|
||||
- Use Playwright sparingly for JavaScript-rendered pages. It should have domain
|
||||
allowlists, low concurrency, strict timeouts, response size limits, and no
|
||||
automatic form submission or login behavior.
|
||||
- Store `content_hash`, `retrieved_at`, final URL, title, extracted text
|
||||
preview, and extractor name in `ai_evidence`.
|
||||
- Keep short quotes for UI review, but do not store huge page bodies directly in
|
||||
every task record. Large extracted content should be truncated or stored once
|
||||
by hash.
|
||||
|
||||
The local/self-hosted stack should look like this:
|
||||
|
||||
```text
|
||||
SearXNG
|
||||
-> SearchEvidence URLs
|
||||
-> httpx fetch
|
||||
-> trafilatura / readability
|
||||
-> Playwright only when static extraction fails and the domain is allowed
|
||||
-> normalized evidence
|
||||
-> LLM structured output through AIProviderClient
|
||||
```
|
||||
|
||||
This route gives Planet a lower-cost and more controllable search path, while
|
||||
hosted providers remain available when search quality or maintenance effort
|
||||
matters more than self-hosting.
|
||||
|
||||
|
||||
## Phase 3: Limited Agent Loop
|
||||
|
||||
After the fixed workflows are stable, the backend can add a limited agent loop:
|
||||
|
||||
```text
|
||||
LLM sees an allowed tool list
|
||||
-> LLM requests a tool call
|
||||
-> backend validates and executes the tool
|
||||
-> tool result is added to context
|
||||
-> LLM continues
|
||||
-> final structured output after at most N steps
|
||||
```
|
||||
|
||||
Guardrails:
|
||||
|
||||
- max tool steps: 3 to 5
|
||||
- only read-only tools may run automatically
|
||||
- writes go to pending review first
|
||||
- all web evidence must be persisted
|
||||
- all final outputs must pass schema validation
|
||||
- prompts must include explicit evidence boundaries
|
||||
|
||||
Permission levels:
|
||||
|
||||
```text
|
||||
L0: pure analysis, no tools
|
||||
L1: read-only tools, web_search / web_fetch / internal_query
|
||||
L2: proposal generation, write pending review records
|
||||
L3: low-risk notifications and briefs
|
||||
L4: database mutation or alert triggering, human confirmation required
|
||||
```
|
||||
|
||||
|
||||
## Situational Awareness Boundary
|
||||
|
||||
Planet's situational-awareness layer should not rely on the LLM as the primary
|
||||
risk engine.
|
||||
|
||||
Use deterministic analysis for:
|
||||
|
||||
- anomaly type
|
||||
- affected prefixes
|
||||
- affected ASNs
|
||||
- geographic scope
|
||||
- duration
|
||||
- severity score
|
||||
- confidence
|
||||
- related events
|
||||
- raw evidence
|
||||
|
||||
Use the LLM for:
|
||||
|
||||
- readable summaries
|
||||
- risk explanation
|
||||
- likely impact narrative
|
||||
- next recommended actions
|
||||
- missing data requests
|
||||
|
||||
In short:
|
||||
|
||||
```text
|
||||
deterministic services compute the score
|
||||
LLM explains the evidence and options
|
||||
```
|
||||
|
||||
Proactive alerts should be triggered by deterministic rules or scheduled jobs,
|
||||
then optionally summarized by the Agent Orchestrator.
|
||||
|
||||
|
||||
## Persistence Model
|
||||
|
||||
Add lightweight persistence for auditability:
|
||||
|
||||
```text
|
||||
ai_tasks
|
||||
id
|
||||
task_type
|
||||
status
|
||||
input_json
|
||||
output_json
|
||||
model
|
||||
created_at
|
||||
finished_at
|
||||
error
|
||||
|
||||
ai_evidence
|
||||
id
|
||||
task_id
|
||||
source_type
|
||||
title
|
||||
url
|
||||
snippet
|
||||
content_hash
|
||||
retrieved_at
|
||||
credibility_score
|
||||
|
||||
ai_briefs
|
||||
id
|
||||
brief_type
|
||||
severity
|
||||
title
|
||||
summary
|
||||
evidence_ids
|
||||
related_entity_ids
|
||||
created_at
|
||||
acknowledged_at
|
||||
|
||||
ai_location_suggestions
|
||||
id
|
||||
object_type
|
||||
object_id
|
||||
old_lat
|
||||
old_lon
|
||||
new_lat
|
||||
new_lon
|
||||
confidence
|
||||
reason
|
||||
evidence_ids
|
||||
status
|
||||
```
|
||||
|
||||
The tables can be introduced incrementally. The first implementation may start
|
||||
with `ai_tasks` and `ai_evidence`, then add specialized tables when the UI needs
|
||||
review queues and acknowledgement state.
|
||||
|
||||
|
||||
## MVP Scope
|
||||
|
||||
The MVP should deliver three fixed capabilities:
|
||||
|
||||
### 1. Tutorial Refresh
|
||||
|
||||
Input:
|
||||
|
||||
- provider or tutorial topic
|
||||
- current tutorial text
|
||||
- known stale point, when available
|
||||
|
||||
Tools:
|
||||
|
||||
- `web_search`
|
||||
- `web_fetch`
|
||||
|
||||
Output:
|
||||
|
||||
- updated Markdown
|
||||
- source list
|
||||
- verification status
|
||||
|
||||
### 2. Geo Correction
|
||||
|
||||
Input:
|
||||
|
||||
- object id
|
||||
- object name
|
||||
- object type
|
||||
- current coordinates
|
||||
- source description
|
||||
|
||||
Tools:
|
||||
|
||||
- `web_search`
|
||||
- `web_fetch`
|
||||
- `geo_resolve`
|
||||
|
||||
Output:
|
||||
|
||||
- `LocationCorrection` JSON
|
||||
- evidence list
|
||||
- pending review candidate
|
||||
|
||||
### 3. Situation Brief
|
||||
|
||||
Input:
|
||||
|
||||
- anomaly event
|
||||
- deterministic findings
|
||||
- internal data summary
|
||||
|
||||
Tools:
|
||||
|
||||
- `internal_data_query`
|
||||
- optional `web_search`
|
||||
|
||||
Output:
|
||||
|
||||
- `SituationBrief` JSON
|
||||
- risk explanation
|
||||
- recommended actions
|
||||
- missing evidence list
|
||||
|
||||
|
||||
## Test Plan
|
||||
|
||||
Backend tests:
|
||||
|
||||
- WebSearch settings persist to `system_settings` and mask secrets in API responses.
|
||||
- Env fallback resolves provider-specific keys before `WEB_SEARCH_API_KEY`.
|
||||
- WebSearch provider normalizes success, empty results, 401, 429, and timeout responses.
|
||||
- `tutorial_refresh` uses evidence when available and marks output unverified when no evidence exists.
|
||||
- `geo_correction` returns pending review candidates and never writes final coordinates directly.
|
||||
- `situation_brief` accepts deterministic findings and returns schema-valid summaries.
|
||||
- Agent outputs fail closed when schema validation fails.
|
||||
|
||||
Frontend tests:
|
||||
|
||||
- WebSearch settings card shows configured state, masked key, connection test result, and save feedback.
|
||||
- Candidate review UI can display evidence links and pending location suggestions.
|
||||
- Situation brief UI can show evidence-backed summaries without exposing raw secrets.
|
||||
|
||||
Regression tests:
|
||||
|
||||
- existing `aiprovider` status and analysis calls remain unchanged
|
||||
- current LLM provider configuration remains the global model source
|
||||
- location pipeline tests continue to pass
|
||||
- datasource credential guide tests continue to pass
|
||||
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `aiprovider` remains model-adapter-only.
|
||||
- Backend tools are implemented directly in Python first; MCP support is optional and later.
|
||||
- Search is evidence collection, not model transport.
|
||||
- Writes to important domain tables require human confirmation.
|
||||
- Deterministic analysis owns risk scores; LLM output is explanatory and evidence-backed.
|
||||
146
docs/plans/data-products-layer-guard-redesign-plan.md
Normal file
146
docs/plans/data-products-layer-guard-redesign-plan.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# 数据产品流水线、数据源批量运维与抗击穿图层接口计划
|
||||
|
||||
## Summary
|
||||
|
||||
前端展示分两类数据:
|
||||
|
||||
- **图层数据**:按 viewport、bbox、zoom、limit 返回,可降级、截断、缓存,用来保护服务器。
|
||||
- **聚合面板统计**:必须是全量统计,不受当前 viewport 限制,但不能实时扫全表;通过产品状态表或预计算统计提供。
|
||||
|
||||
也就是说:地图上低 zoom 可以只画摘要或局部数据,但面板里的“总船只数、总海缆数、BGP 活跃事件数、卫星数”等应该代表全局数据产品状态。
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- 已新增 `POST /api/v1/datasources/trigger-batch`,支持按选中 `source_ids` 或筛选条件批量触发,并返回 `triggered/skipped/failed`。
|
||||
- 已改造 `/datasources` 页面,支持产品域、层级、启用状态、最近执行状态、是否已有数据和关键词筛选,并支持复选框批量采集。
|
||||
- 已新增 `/api/v1/data-products` 和 `/api/v1/data-products/{product_id}/status`,聚合面板可以读取全量/全局统计口径。
|
||||
- 已新增 `/api/v1/layers/*` 受控图层接口骨架,要求 `bbox/zoom/limit`,响应包含 `visible_count/returned_count/diagnostics`。
|
||||
- 非船只图层当前先复用已有 GeoJSON 转换再做保护层;下一步应把 cables/BGP/satellites 的 bbox 过滤继续下推到各自产品查询,避免转换前仍加载过多候选。
|
||||
|
||||
## Key Changes
|
||||
|
||||
- 新增数据产品状态/统计层:
|
||||
- 每个产品维护全量统计:总实体数、活跃数、最近更新时间、使用源、缺失源、冲突数、构建状态。
|
||||
- 统计在采集成功或产品投影完成后更新,不在用户打开页面时临时全表聚合。
|
||||
- 前端聚合面板统一读取产品统计接口,而不是从图层返回量推断总数。
|
||||
- 新增接口:
|
||||
- `GET /api/v1/data-products`
|
||||
- `GET /api/v1/data-products/{product_id}/status`
|
||||
- `GET /api/v1/layers/{product}/...`
|
||||
- `POST /api/v1/datasources/trigger-batch`
|
||||
- `/layers/*` 只负责可视化数据:
|
||||
- 支持 bbox、zoom、limit、since。
|
||||
- 可以返回 `degraded`、`truncated`、`cache_hit`。
|
||||
- 返回 `visible_count` 和 `returned_count`,但不作为全量统计来源。
|
||||
- `/data-products/*/status` 负责全量统计:
|
||||
- 返回 `total_count`、`active_count`、`source_counts`、`last_built_at`、`health`。
|
||||
- 数据来自预计算状态或轻量索引统计。
|
||||
- 即使图层降级,统计也保持全量口径。
|
||||
|
||||
## Product Processing
|
||||
|
||||
- 船只:
|
||||
- 图层:bbox snapshot + WS 聚合流,受限返回。
|
||||
- 统计:全量唯一 MMSI、最近窗口活跃 MMSI、AISStream/BarentsWatch/source counts。
|
||||
- 海缆:
|
||||
- 图层:viewport 内 cable segments/landing points,低 zoom 可简化路线。
|
||||
- 统计:全量 cable count、landing point count、relation count、graph 构建状态。
|
||||
- 处理:专用 cable graph assembler,区分路线源、登陆点源、关系源、补充源,不使用统一字段融合函数。
|
||||
- BGP:
|
||||
- 图层:active incidents/anomalies/collectors,按窗口和 limit 返回。
|
||||
- 统计:全量活跃事件、最近 24h/7d 事件数、collector 数、incident/anomaly 分布。
|
||||
- 处理:专用事件流水线,区分 observation、anomaly、incident、geo hint、infrastructure inference。
|
||||
- 卫星:
|
||||
- 图层:可见卫星或受控 limit。
|
||||
- 统计:全量卫星数、最新 TLE epoch、源覆盖情况。
|
||||
- 处理:按 NORAD id 生成轨道快照,TLE epoch 最新优先。
|
||||
|
||||
## Data Source Page
|
||||
|
||||
- 增加筛选:
|
||||
- 产品类型、启用/禁用、最近成功/失败/运行中/未执行、已采集/未采集、凭证状态、文本搜索。
|
||||
- 增加复选框批量操作:
|
||||
- 批量启用、禁用、采集、强制采集。
|
||||
- 一键采集改为:采集全部启用源、采集筛选结果、采集选中源。
|
||||
- 后端 batch 逻辑:
|
||||
- 禁用源 skipped。
|
||||
- 运行中源按 force 处理。
|
||||
- 单个失败不影响其他源。
|
||||
- 返回 `triggered`、`skipped`、`failed`,并包含每个 source 的原因和 task_id。
|
||||
|
||||
## Protection Rules
|
||||
|
||||
- 所有 `/layers/*` 接口必须有保护层:
|
||||
- limit clamp。
|
||||
- bbox/zoom 校验。
|
||||
- 低 zoom 降级。
|
||||
- 短 TTL 缓存。
|
||||
- 慢查询超时。
|
||||
- diagnostics 返回降级原因。
|
||||
- 全量统计不走图层查询:
|
||||
- 不允许为了面板统计在请求时 `.all()` 全量加载。
|
||||
- 统计由采集/投影任务异步更新。
|
||||
- 统计缺失时返回 `unknown` 或 `stale`,不触发重型实时计算。
|
||||
- 缓存失效规则:
|
||||
- 采集成功后失效对应产品缓存。
|
||||
- 海缆 graph cache 在路线、登陆点或关系源成功采集后失效。
|
||||
- BGP incident/anomaly 生成后失效 BGP layer cache。
|
||||
- 船只实时流使用短 TTL 或 viewport 级缓存,不清全局缓存。
|
||||
- 接口观测:
|
||||
- 记录每个 layer endpoint 的耗时、返回数量、是否降级、是否缓存命中、limit 是否被 clamp。
|
||||
- 对高频 viewport 请求增加简单 per-IP 或 per-user rate limit。
|
||||
|
||||
## Frontend UX
|
||||
|
||||
- 数据源页:
|
||||
- 顶部统计可作为快捷筛选入口:全部、启用、禁用、运行中、失败、未采集。
|
||||
- 表格左侧增加复选框。
|
||||
- 工具栏显示“已选择 N 个”,并提供批量按钮。
|
||||
- 筛选结果和选中结果分清楚,避免误触发全部源。
|
||||
- 批量采集完成后弹出摘要:触发、跳过、失败数量,可展开查看原因。
|
||||
- 设置/配置页:
|
||||
- “采集器设置”改为“数据产品配置”。
|
||||
- 产品内按源角色分组展示,而不是简单列出 collector。
|
||||
- 海缆显示路线源、登陆点源、关系源、补充源。
|
||||
- BGP 显示实时观测源、历史回填源、地理 hint 源、检测输出。
|
||||
- 船只显示实时 AIS、轮询 AIS、自定义补充源。
|
||||
- Earth 图层交互:
|
||||
- 聚合面板统计读取 `/data-products/*/status`,保持全量口径。
|
||||
- 图层面板展示当前图层是否降级、截断、缓存命中。
|
||||
- 对象详情展示来源证据:
|
||||
- 船只:字段来源、冲突。
|
||||
- 海缆:路线源、登陆点源、关系源。
|
||||
- BGP:事件证据、分组依据、地理推断依据。
|
||||
- 产品 degraded 时仍显示可用部分,并提示缺失源角色。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 图层接口:
|
||||
- 大 limit 被 clamp。
|
||||
- 低 zoom 降级。
|
||||
- 大数据集不全量内存过滤。
|
||||
- diagnostics 正确说明截断、缓存、降级。
|
||||
- 全量统计:
|
||||
- 面板统计不受 bbox 影响。
|
||||
- 图层返回 1000 条时,产品统计仍显示全量总数。
|
||||
- 统计陈旧时返回 `stale=true` 和 `last_built_at`。
|
||||
- 采集成功后对应产品统计刷新。
|
||||
- 数据源批量:
|
||||
- 筛选、选中、批量采集行为正确。
|
||||
- skipped/failed/triggered 分组正确。
|
||||
- 禁用源在 batch 中被 skipped。
|
||||
- 运行中源按 force 参数处理。
|
||||
- batch 单源失败不阻断整体。
|
||||
- 产品处理:
|
||||
- 海缆缺 relation 时产品状态 degraded,但 cable layer 可用。
|
||||
- BGP observation 不直接变成前端 marker,必须经过 anomaly/incident 投影。
|
||||
- 船只 bbox snapshot 和 WS 节流继续有效。
|
||||
- 卫星列表不返回无限轨道点。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- 前端聚合面板以后只读 `/data-products/*/status`。
|
||||
- 地图图层只读 `/layers/*`。
|
||||
- 统计可以短暂 stale,但不能因实时全量统计击穿服务器。
|
||||
- 保留现有 collector,不为了重构而删除 BarentsWatch 或其他源。
|
||||
- 当前开发阶段允许前端从旧 `/visualization/geo/*` 迁移到 `/layers/*`。
|
||||
116
docs/plans/docs-audience-split-plan.md
Normal file
116
docs/plans/docs-audience-split-plan.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 文档受众分层重构计划
|
||||
|
||||
**状态**:待实施
|
||||
**创建日期**:2026-05-12
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.claude/commands/docs.md`,让以后写文档时自动按受众归档。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `manual.md` 把客户实际使用和开发/运维操作混在一份文档里:开头 200 多行讲的是 `planet.sh start/stop/restart/log/health/createuser/--allow-lan`、AI Provider 镜像构建、`netsh portproxy` 和故障排查顺序,后面才进入 Earth、控制台、AI、Docs 这些客户真正会用到的功能。
|
||||
|
||||
客户读到一半会被 shell 命令吓住,开发者想找运维细节又要在大段 UI 操作里翻。`documentation-coverage-rules.md` 现在也没有受众分层规则,未来文档继续混着写。
|
||||
|
||||
本计划假定客户已经能拿到账号登录使用 — 注册/验证流程本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。
|
||||
|
||||
## 新的文档地形
|
||||
|
||||
| 文档 | 受众 | Gatekeeper 组 | 范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| `manual.md` (zh+en) | 纯客户/最终用户 | `public` | 注册、登录、账户、设置 UI、collector 配置、AI 配置、Console 页面、Earth、Docs 浏览 |
|
||||
| `quickstart.md` (zh+en) | 纯客户 | `public` | "我刚拿到 Planet 怎么开始用" — 打开 URL → 注册 → 验证 → 登录 → 第一次配置 |
|
||||
| `ops-runbook.md` (zh+en, **新增**) | 运维/部署人员 | `docs_admin` | `planet.sh` 完整命令、健康检查、日志位置、LAN/portproxy、故障排查顺序、createuser CLI、Bun 构建约定 |
|
||||
| `ops-planet-sh-startup.md` (已存在) | 运维 | `docs_admin` | 启动性能、AI Provider 镜像、`PLANET_LOAD_ZSHRC_ENV` 深度调优 — 保持不动 |
|
||||
| 现有 `*-context.md` / `backend-*.md` | 二次开发者 | `docs_developer` | 保持现状 |
|
||||
|
||||
`backend-system-service-control.md` 偏后端服务控制原理,**不**和 `ops-runbook.md` 重复 — runbook 讲"运维要敲什么命令",service-control 讲"后端怎么实现服务管控"。
|
||||
|
||||
## manual.md 重写后的章节顺序(客户旅程)
|
||||
|
||||
1. **欢迎与入口** — Planet 是什么、几个入口(Earth 公开 / Console 需登录 / Docs / API)
|
||||
2. **注册账户** — 打开 `/login` → 点"注册" → 填用户名/邮箱/密码 → 收邮件 → 输入 6 位验证码 → 登录
|
||||
3. **登录与找回密码** — 登录页、忘记密码流程
|
||||
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
|
||||
5. **Console 总览** — 左侧菜单结构、各路由用途
|
||||
6. **配置数据采集器** — `/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR)
|
||||
8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件)
|
||||
9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组
|
||||
10. **数据探索** — `/datasources`、`/data`、`/bgp`、`/alerts/*`
|
||||
11. **AI 测试台** — `/ai?tab=playground`
|
||||
12. **Earth 公开页面** — 现 manual.md 的 Earth 章节原样保留(图层、图例、搜索、位置候选、设置、视角、动捕、巡航、移动端)
|
||||
13. **Docs 文档站** — 当前 Docs 章节保留(权限组说明)
|
||||
|
||||
不再出现:`planet.sh`、`./planet.sh log`、`netsh portproxy`、`source ~/.zshrc && bun run build`、"故障排查顺序"、"开发命令约定"。
|
||||
|
||||
## quickstart.md 重写
|
||||
|
||||
当前 quickstart 假设读者会自己 `git clone` 然后 `./planet.sh start`,这是给开发者看的。改为:
|
||||
|
||||
- 打开管理员给你的 URL
|
||||
- 注册账号 + 邮箱验证
|
||||
- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 看 Earth
|
||||
|
||||
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
|
||||
|
||||
## ops-runbook.md 内容大纲
|
||||
|
||||
抽自现 manual.md,重新组织:
|
||||
|
||||
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123`、`linkong/12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`)
|
||||
2. 启停与按模块重启 — `start/stop/restart` 及 `-b -f -a -d`
|
||||
3. 健康检查 — `./planet.sh health`
|
||||
4. 日志 — `./planet.sh log` 及 `-f -b -a`,日志文件路径
|
||||
5. 创建用户(CLI 兜底)— `./planet.sh createuser`;说明这是公开注册不可用(SMTP 未配置)时的兜底
|
||||
6. 局域网/WSL 访问 — `--allow-lan`、`netsh portproxy`、防火墙
|
||||
7. AI Provider 环境变量与构建 — `aiprovider/.env`、`~/.zshrc`、`PLANET_LOAD_ZSHRC_ENV`
|
||||
8. 故障排查顺序 — 现 manual 末尾那段,原样搬来
|
||||
9. 开发命令约定 — Bun、`bun run build`、为什么不用 npm
|
||||
|
||||
## documentation-coverage-rules.md 增量
|
||||
|
||||
在现有"覆盖清单"末尾新增一段:
|
||||
|
||||
> **受众分层(强制)**
|
||||
>
|
||||
> - 客户/最终用户能在浏览器里完成的操作 → 只写到 `manual.md` / `quickstart.md`
|
||||
> - 需要 SSH/shell/Docker/`planet.sh`/日志文件路径/端口转发 → 只写到 `ops-runbook.md`(或现有 `ops-*.md`),**禁止**出现在 manual/quickstart
|
||||
> - 同一动作两种入口(如"创建用户"既能 UI 也能 CLI)→ UI 路径写 manual.md,CLI 路径写 ops-runbook.md,互相用一句话相互引用
|
||||
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
|
||||
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
|
||||
|
||||
## .claude/commands/docs.md 增量
|
||||
|
||||
在 "Step 2 — Decide Scope" 后插一段:
|
||||
|
||||
> **Document Audience Routing (Planet)**
|
||||
>
|
||||
> 在 Planet 仓库内,写文档前先判断动作的执行者:
|
||||
>
|
||||
> - 浏览器 UI 用户 → `docs/technical/{zh,en}/manual.md` / `quickstart.md`
|
||||
> - shell/容器/运维 → `docs/technical/{zh,en}/ops-runbook.md` 或现有 `ops-*.md`
|
||||
> - 二次开发者 → 现有 `*-context.md` / `backend-*.md`
|
||||
>
|
||||
> 永远不要把 shell 命令、日志路径、Docker 操作写进 manual/quickstart;永远不要把 UI 截图/按钮路径写进 ops-*。
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
- `docs/technical/zh/manual.md` & `en/manual.md` — 重写
|
||||
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
|
||||
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
|
||||
- `docs/documentation-coverage-rules.md` — 加受众分层段
|
||||
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
|
||||
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook` 到 `DOCS_METADATA`(`docs_admin` 组)
|
||||
|
||||
## 依赖
|
||||
|
||||
manual.md 的"注册账户"和"登录与找回密码"两章需要前后端注册/验证流程已经落地,否则文档会描述不存在的功能。注册功能本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。建议先实现注册再重写 manual,避免文档与代码错位。
|
||||
|
||||
## 验证
|
||||
|
||||
- `rg -n 'planet\.sh' docs/technical/zh/manual.md docs/technical/en/manual.md docs/technical/zh/quickstart.md docs/technical/en/quickstart.md` 应该为空
|
||||
- `rg -n '注册账户|register|邮箱验证' docs/technical/zh/manual.md docs/technical/en/manual.md` 应该有命中
|
||||
- `rg -n 'planet\.sh' docs/technical/zh/ops-runbook.md docs/technical/en/ops-runbook.md` 应该有命中
|
||||
- `frontend/src/pages/Docs/docs-content.ts` 中 `ops-runbook` 出现且分组为 `docs_admin`
|
||||
- zh/en manual 章节标题对齐(按 `documentation-coverage-rules.md` 现有要求)
|
||||
- Docs 站点访问:未登录看 manual/quickstart 正常;非 `docs_admin` 用户看不到 `ops-runbook`;`admin` 能看到
|
||||
92
docs/plans/docs-gatekeeper-auth-plan.md
Normal file
92
docs/plans/docs-gatekeeper-auth-plan.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Docs Gatekeeper 鉴权系统计划
|
||||
|
||||
**状态**:已实现,当前行为见 [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md)
|
||||
**创建日期**:2026-05-08
|
||||
**核心目标**:把 `/docs` 从前端公开打包 Markdown 改成后端受控读取,并通过用户 Gatekeeper 权限组划分公开文档、用户文档、开发文档和管理/运维文档。
|
||||
|
||||
## 背景
|
||||
|
||||
当前 Docs 页面通过前端 `import.meta.glob(...?raw)` 把 `docs/technical/{zh,en}` 中注册过的 Markdown 直接打进前端 bundle。即使在前端隐藏目录或增加路由守卫,受保护 Markdown 仍可能出现在构建产物中,无法形成真正鉴权。
|
||||
|
||||
本阶段需要把文档正文读取迁到后端,并让后端根据当前用户身份返回可见目录和正文。Earth 仍保持公开访问,其它控制台模块暂不改变既有鉴权。
|
||||
|
||||
## 鉴权模型
|
||||
|
||||
保留现有 `users.role`,新增 `gatekeeper_groups` 作为可叠加的权限组。`role` 继续用于控制台和系统操作;Gatekeeper 只负责 Docs 等内容权限。
|
||||
|
||||
默认权限:
|
||||
|
||||
| 身份 | 默认 Docs 能力 |
|
||||
| --- | --- |
|
||||
| 未登录访客 | `public` |
|
||||
| 普通登录用户 | `public`,以及用户被分配的 Gatekeeper 组 |
|
||||
| `admin` | `docs_admin`,并隐含 `docs_developer` / `docs_user` |
|
||||
| `super_admin` | 全部 Docs 权限 |
|
||||
|
||||
Gatekeeper 组:
|
||||
|
||||
- `docs_user`:登录用户操作类文档。
|
||||
- `docs_developer`:开发、前端、后端、Earth 实现文档。
|
||||
- `docs_admin`:运维、服务控制、凭证、环境变量和敏感操作文档。
|
||||
|
||||
## 初步文档划分
|
||||
|
||||
`public`:
|
||||
|
||||
- `README.md`
|
||||
- `quickstart.md`
|
||||
- `manual.md`
|
||||
|
||||
`docs_developer`:
|
||||
|
||||
- `earth-frontend-context.md`
|
||||
- `earth-interactable-usage.md`
|
||||
- `earth-layer-style-reference.md`
|
||||
- `earth-render-layer-order.md`
|
||||
- `earth-satellite-footprint-policy.md`
|
||||
- `earth-bgp-context.md`
|
||||
- `earth-news-live-streams-collector-format.md`
|
||||
- `earth-toolbar-overlay-coordination.md`
|
||||
- `frontend-admin-frontend-context.md`
|
||||
- `frontend-layout-guidelines.md`
|
||||
- `backend-collectors.md`
|
||||
- `datasource-collector-settings-connectivity.md`
|
||||
- `backend-datasources-api-performance.md`
|
||||
- `agents-aiprovider.md`
|
||||
|
||||
`docs_admin`:
|
||||
|
||||
- `backend-system-service-control.md`
|
||||
- `ops-docker-compose-buildx-upgrade.md`
|
||||
- `ops-planet-sh-startup.md`
|
||||
|
||||
## 实施要点
|
||||
|
||||
后端新增:
|
||||
|
||||
- `GET /api/v1/docs/catalog`:返回当前用户可见文档目录;未登录只返回 `public`。
|
||||
- `GET /api/v1/docs/{lang}/{slug}`:返回单篇 Markdown;未登录访问受保护文档返回 `401`,已登录无权限返回 `403`。
|
||||
- 服务端维护文档 metadata 白名单,禁止任意路径读取。
|
||||
|
||||
用户管理新增:
|
||||
|
||||
- `users.gatekeeper_groups` JSON 字段。
|
||||
- 用户列表、创建和编辑支持展示/配置 Gatekeeper 权限组。
|
||||
- 只有 `super_admin` 能编辑 Gatekeeper 权限组。
|
||||
|
||||
前端 Docs 改造:
|
||||
|
||||
- 移除 Markdown raw import 作为正文来源。
|
||||
- 从后端 catalog 构建目录和搜索记录。
|
||||
- 从后端 content API 加载正文。
|
||||
- 对 `401` 显示登录入口,对 `403` 显示无权限提示。
|
||||
|
||||
## 验证
|
||||
|
||||
- 未登录用户只能看到和读取 `public` 文档。
|
||||
- 未登录直接访问受保护文档返回 `401` 并显示登录提示。
|
||||
- 无 Gatekeeper 组的普通用户访问开发文档返回 `403`。
|
||||
- `docs_developer` 用户能读开发文档,不能读管理/运维文档。
|
||||
- `admin` 和 `super_admin` 能读管理/运维文档。
|
||||
- 未知 slug、未知语言和路径穿越字符串不能读取文件。
|
||||
- 前端构建产物不再包含受保护 Markdown raw import 生成的文档模块。
|
||||
252
docs/plans/earth-mobile-center-country-highlight-plan.md
Normal file
252
docs/plans/earth-mobile-center-country-highlight-plan.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Earth Mobile Center Country Highlight Plan
|
||||
|
||||
## Goal
|
||||
|
||||
移动端打开 Earth 国界图层后,用屏幕中心,也就是当前镜头正对的地球表面位置,自动识别所在国家,并高亮该国家国界。
|
||||
|
||||
桌面端仍保持现有 hover 行为。移动端不引入新的国界渲染体系,而是复用已有 `country-boundaries.js` 的 GeoJSON 命中和 hover 高亮能力。
|
||||
|
||||
## Criteria for success
|
||||
|
||||
1. 移动端 `layout-mode-mobile` 下,国界图层开启后,屏幕中心所在国家会自动高亮。
|
||||
2. 移动端旋转、缩放、巡航或自动旋转地球时,高亮会跟随镜头中心更新。
|
||||
3. 屏幕中心落在海洋或没有命中地球时,国家高亮会清除。
|
||||
4. 国界图层关闭时,不执行中心国家识别,也不显示残留高亮。
|
||||
5. 桌面端 pointer hover 行为保持不变。
|
||||
6. 移动端抽屉、搜索、设置、媒体、详情等前景 UI 打开时,不因为用户操作 UI 产生明显误高亮或抖动。
|
||||
7. 中心识别有节流或状态缓存,不把 GeoJSON point-in-polygon 检测放到无条件每帧高频执行。
|
||||
8. 实现后能通过本地静态检查或前端构建,并用移动端 viewport 手动或 Playwright 验证核心场景。
|
||||
|
||||
## Existing pieces
|
||||
|
||||
当前项目已经具备大部分基础能力:
|
||||
|
||||
- [frontend/public/earth/js/country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
||||
- `updateCountryBoundaryHover(coords)`:根据 `{ lat, lon }` 命中国家并更新高亮线。
|
||||
- `clearCountryBoundaryHover()`:清除当前 hover 高亮。
|
||||
- `getShowCountryBoundaries()`:判断国界线图层是否可见。
|
||||
- [frontend/public/earth/js/utils.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/utils.js)
|
||||
- `screenToEarthCoords(clientX, clientY, camera, earth, domElement)`:屏幕坐标 raycast 到地球表面。
|
||||
- `vector3ToLatLon(vector)`:地球本地坐标转经纬度。
|
||||
- [frontend/public/earth/js/constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
- `COUNTRY_BOUNDARY_CONFIG` 已定义普通国界线和 hover 国界线样式。
|
||||
- 移动端布局状态已经通过 `layout-mode-mobile` body class 区分。
|
||||
|
||||
因此本需求的核心不是新增图层,而是补一个移动端中心取点控制器。
|
||||
|
||||
## Non-goals
|
||||
|
||||
- 不改变桌面端 hover 交互。
|
||||
- 不替换 `countries-admin0.min.geojson` 数据源。
|
||||
- 不新增后端 API。
|
||||
- 不把国家面填充做成新的 selected country 面状 shader。
|
||||
- 不为移动端增加永久准星 UI,除非后续产品明确需要视觉准星。
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. Add a small mobile center hover controller
|
||||
|
||||
新增一个轻量函数,建议放在现有主循环附近或单独模块,例如:
|
||||
|
||||
```text
|
||||
frontend/public/earth/js/mobile-center-country-highlight.js
|
||||
```
|
||||
|
||||
建议导出:
|
||||
|
||||
```js
|
||||
updateMobileCenterCountryHighlight({
|
||||
camera,
|
||||
earth,
|
||||
renderer,
|
||||
now,
|
||||
isBlocked,
|
||||
});
|
||||
|
||||
clearMobileCenterCountryHighlight();
|
||||
```
|
||||
|
||||
职责:
|
||||
|
||||
1. 判断是否处于移动端。
|
||||
2. 判断国界图层是否开启。
|
||||
3. 判断当前是否被移动端前景 UI 阻塞。
|
||||
4. 对 renderer canvas 中心点做 raycast。
|
||||
5. 命中地球后转经纬度。
|
||||
6. 调用 `updateCountryBoundaryHover({ lat, lon })`。
|
||||
7. 无命中或禁用时调用 `clearCountryBoundaryHover()`。
|
||||
|
||||
### 2. Use canvas center, not window center
|
||||
|
||||
中心点应基于 renderer canvas rect 计算:
|
||||
|
||||
```js
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const clientX = rect.left + rect.width / 2;
|
||||
const clientY = rect.top + rect.height / 2;
|
||||
```
|
||||
|
||||
这样在移动端安全区、地址栏变化、viewport resize 或 canvas 非全屏时仍然准确。
|
||||
|
||||
### 3. Convert center point into country hover coords
|
||||
|
||||
复用已有工具:
|
||||
|
||||
```js
|
||||
const point = screenToEarthCoords(clientX, clientY, camera, earth, renderer.domElement);
|
||||
if (!point) {
|
||||
clearCountryBoundaryHover();
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = vector3ToLatLon(point);
|
||||
updateCountryBoundaryHover(coords);
|
||||
```
|
||||
|
||||
注意:`screenToEarthCoords` 返回的是 earth local point,符合 `vector3ToLatLon` 的输入语义。
|
||||
|
||||
### 4. Gate updates by mobile and foreground UI state
|
||||
|
||||
建议新增一个本地判断函数:
|
||||
|
||||
```js
|
||||
function isMobileCenterCountryHighlightBlocked() {
|
||||
return (
|
||||
!document.body.classList.contains("layout-mode-mobile") ||
|
||||
document.body.classList.contains("earth-search-open") ||
|
||||
document.body.classList.contains("earth-settings-open") ||
|
||||
document.body.classList.contains("earth-media-open") ||
|
||||
document.body.classList.contains("earth-info-open")
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
如果移动端抽屉只是半收起、且没有覆盖中心视野,可以继续允许中心高亮。若实际体验里抽屉展开会遮挡中心点,再把 drawer open 状态纳入阻塞条件。
|
||||
|
||||
### 5. Throttle and cache center updates
|
||||
|
||||
GeoJSON polygon 命中不应该无条件每帧执行。
|
||||
|
||||
第一版建议:
|
||||
|
||||
- `throttleMs = 120`
|
||||
- 缓存上次经纬度,中心点变化小于 `0.05` 度时跳过。
|
||||
- 禁用、切回桌面、图层关闭、UI 阻塞时立即清除一次高亮。
|
||||
|
||||
伪代码:
|
||||
|
||||
```js
|
||||
if (now - lastUpdateAt < 120) return;
|
||||
if (Math.abs(coords.lat - lastLat) < 0.05 && Math.abs(coords.lon - lastLon) < 0.05) return;
|
||||
```
|
||||
|
||||
### 6. Wire into the Earth animation loop
|
||||
|
||||
在 [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 的动画循环中调用:
|
||||
|
||||
```js
|
||||
updateMobileCenterCountryHighlight({
|
||||
camera,
|
||||
earth,
|
||||
renderer,
|
||||
now: performance.now(),
|
||||
isBlocked: isMobileCenterCountryHighlightBlocked(),
|
||||
});
|
||||
```
|
||||
|
||||
这样自动旋转、手势旋转、缩放和巡航都会自然更新。
|
||||
|
||||
### 7. Keep desktop hover unchanged
|
||||
|
||||
桌面 pointer hover 仍然走当前逻辑。
|
||||
|
||||
移动端中心高亮只在 `layout-mode-mobile` 下生效,不应该监听 pointer move,也不应该抢占 desktop hover 状态。
|
||||
|
||||
### 8. Optional visual tuning
|
||||
|
||||
第一版复用:
|
||||
|
||||
- `COUNTRY_BOUNDARY_CONFIG.hoverLineColor`
|
||||
- `COUNTRY_BOUNDARY_CONFIG.hoverLineOpacity`
|
||||
- `COUNTRY_BOUNDARY_CONFIG.hoverGlowOpacity`
|
||||
|
||||
如果移动端体验太强,可以后续加独立配置:
|
||||
|
||||
```js
|
||||
mobileCenterHoverLineOpacity
|
||||
mobileCenterHoverGlowOpacity
|
||||
```
|
||||
|
||||
但第一版不建议过早分叉样式。
|
||||
|
||||
## Verification
|
||||
|
||||
### Static checks
|
||||
|
||||
1. `npm` 前端构建或现有 lint/typecheck 命令通过。
|
||||
2. `rg` 确认新增函数只在移动端路径调用,不影响桌面 pointer hover。
|
||||
3. `git diff --stat` 和目标文件 diff 确认改动范围集中。
|
||||
|
||||
### Manual mobile checks
|
||||
|
||||
使用移动端 viewport,例如 390x844:
|
||||
|
||||
1. 打开 Earth。
|
||||
2. 开启国界图层。
|
||||
3. 转动地球到中国、美国、澳大利亚等大块陆地区域,确认中心国家国界高亮。
|
||||
4. 转动到太平洋或印度洋,确认高亮消失。
|
||||
5. 缩放地球,确认高亮仍跟随中心点。
|
||||
6. 打开移动端搜索、设置、媒体或详情面板,确认没有明显误高亮或抖动。
|
||||
7. 切回桌面 viewport,确认 hover 仍由鼠标位置控制。
|
||||
|
||||
### Playwright smoke check
|
||||
|
||||
如果已有 Playwright 流程,建议补一个移动端 smoke:
|
||||
|
||||
1. 设置 viewport 为手机尺寸。
|
||||
2. 打开 Earth 页面。
|
||||
3. 开启国界图层。
|
||||
4. 等待国界数据加载。
|
||||
5. 截图确认中心附近国家边界有 hover 高亮线。
|
||||
|
||||
这个 smoke 不必断言具体国家名称,因为当前功能核心是视觉高亮;更稳定的自动化可以后续通过暴露 debug state 实现。
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
### Polygon hit cost too高
|
||||
|
||||
风险:移动端设备上频繁 `featureContains` 可能带来卡顿。
|
||||
|
||||
缓解:
|
||||
|
||||
- 使用 `120ms` 节流。
|
||||
- 经纬度变化小于阈值时跳过。
|
||||
- 后续如仍慢,再为 GeoJSON features 预计算 bbox,先 bbox 粗筛再 point-in-polygon。
|
||||
|
||||
### UI blocking state 不完整
|
||||
|
||||
风险:某些移动端前景 UI 没有对应 body class,中心点被遮挡但高亮仍更新。
|
||||
|
||||
缓解:
|
||||
|
||||
- 第一版覆盖现有主要 class。
|
||||
- 验证时记录遗漏项,补充到 `isMobileCenterCountryHighlightBlocked()`。
|
||||
|
||||
### Desktop hover 被移动端状态污染
|
||||
|
||||
风险:移动端中心高亮和桌面 hover 共用 `_hoveredFeature` 状态。
|
||||
|
||||
缓解:
|
||||
|
||||
- 只在 `layout-mode-mobile` 下运行中心高亮。
|
||||
- 切出 mobile 或图层关闭时调用一次 `clearCountryBoundaryHover()`。
|
||||
- 不改 `updateCountryBoundaryHover()` 的语义。
|
||||
|
||||
## Milestones
|
||||
|
||||
1. 设计落地:完成本 plan,明确目标和验收标准。
|
||||
2. 最小实现:新增移动端中心取点 controller,并接入 animation loop。
|
||||
3. 性能保护:加入节流、经纬度阈值和禁用态清理。
|
||||
4. 验证:本地构建通过,移动端 viewport 手动检查通过。
|
||||
5. 调优:根据截图或真机体验微调阻塞条件和节流阈值。
|
||||
|
||||
191
docs/plans/earth-motion-capture-gesture-control-plan.md
Normal file
191
docs/plans/earth-motion-capture-gesture-control-plan.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Earth Motion Capture Gesture Control Plan
|
||||
|
||||
## Goal
|
||||
|
||||
为 Planet Earth 大屏和未来 3D 展示增加一套解耦的动作捕捉手势控制能力。实时输入分成两条路线:网页端可直接通过浏览器 `getUserMedia` 在本机识别;高级设备可继续使用本机 Motion Capture Edge Agent。两条路线都只输出轻量语义事件,客户端负责把“手势事件”映射到“具体交互函数”。
|
||||
|
||||
首版面向两颗 Logitech C1000 RGB 摄像头,但必须保持单摄像头兼容。后续任何 USB 摄像头、手机摄像头、RTSP/HTTP/WebRTC 视频源都应通过输入适配器接入,而不是改 Earth 渲染端。
|
||||
|
||||
## Architecture
|
||||
|
||||
实时链路分两种 provider,但进入 Earth 后协议一致:
|
||||
|
||||
```text
|
||||
Browser camera -> browser-local recognizer -> Motion Provider events -> Earth control functions
|
||||
Camera(s)/RTSP/HTTP -> Local Motion Capture Agent -> local WebSocket -> Motion Provider events -> Earth control functions
|
||||
```
|
||||
|
||||
关键原则:
|
||||
|
||||
- 实时控制不经过 SaaS 云端。
|
||||
- 实时控制不复用现有新闻、RSS、聚合数据接口。
|
||||
- 浏览器 provider 和 Agent provider 都不向云端上传视频帧,只输出低带宽语义事件。
|
||||
- Web/3D 客户端只消费统一事件并执行映射,不把具体输入源写进 Earth 交互逻辑。
|
||||
- 双摄首版用于冗余和稳定性,不承诺完整 3D 姿态重建。
|
||||
|
||||
## Motion Providers
|
||||
|
||||
Earth 使用统一 Motion Provider 抽象:
|
||||
|
||||
- `browser_camera`:默认 provider。使用 `getUserMedia` 获取摄像头,在浏览器本地加载 MediaPipe Tasks Vision,输出 `gesture` / `skeleton` / `status` 事件。适合 SaaS、WSL、Windows 浏览器、大屏演示和“不安装 app”的用户。
|
||||
- `motion_agent`:连接本地 Agent WebSocket。适合双摄、USB index、RTSP/HTTP 视频源、边缘设备和客户端集成。
|
||||
|
||||
设置项保存在 `planet.earth.settings.v2.shared.motionProvider`。`?motionProvider=browser` 强制浏览器摄像头,`?motionProvider=agent` 或 `?motionAgent=ws://...` 强制 Motion Agent。
|
||||
|
||||
## Motion Capture Agent
|
||||
|
||||
Agent 是本地 Edge 服务,职责包括:
|
||||
|
||||
- 读取摄像头:默认 USB index,支持单摄、双摄和未来 URL 视频源。
|
||||
- 运行识别:首版使用 OpenCV + MediaPipe;识别引擎藏在接口后,未来可替换为 ONNX、TensorRT、C++ 或 Rust worker。
|
||||
- 输出事件:通过 WebSocket 推送 `gesture`、`status`、`heartbeat`。
|
||||
- 控制节流:负责置信度阈值、防抖、冷却时间和连续手势限频。
|
||||
- 健康状态:报告摄像头数量、当前模式、识别 FPS、最近手势和错误。
|
||||
- 明确失败:缺少 CV 依赖、摄像头打不开、无可用输入时给出可读错误。
|
||||
|
||||
Python 不应成为性能瓶颈:重计算在 OpenCV/MediaPipe 原生代码中完成,Python 只做编排、状态机和事件推送。事件消息通常小于 1KB,频率不超过 20Hz。
|
||||
|
||||
## Event Protocol
|
||||
|
||||
本地默认地址:
|
||||
|
||||
```text
|
||||
ws://127.0.0.1:8765/ws/gestures
|
||||
```
|
||||
|
||||
事件类型:
|
||||
|
||||
- `gesture`
|
||||
- `status`
|
||||
- `heartbeat`
|
||||
|
||||
手势语义:
|
||||
|
||||
- `rotate_left`:左挥手,地球向左旋转。
|
||||
- `rotate_right`:右挥手,地球向右旋转。
|
||||
- `zoom_in`:双手张开,地球放大。
|
||||
- `zoom_out`:双手合拢,地球缩小。
|
||||
- `confirm`:握拳或确认动作,触发当前交互确认。
|
||||
|
||||
最小事件字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "gesture",
|
||||
"gesture": "rotate_left",
|
||||
"phase": "discrete",
|
||||
"confidence": 0.92,
|
||||
"intensity": 0.8,
|
||||
"timestamp_ms": 1770000000000,
|
||||
"seq": 42,
|
||||
"source": "motion-agent",
|
||||
"mode": "single",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Earth Client Integration
|
||||
|
||||
Earth 前端新增 motion-control adapter:
|
||||
|
||||
- 连接本地 Agent WebSocket。
|
||||
- 处理断线、重连、心跳和状态。
|
||||
- 过滤低置信度事件。
|
||||
- 将手势映射到 Earth 控制函数。
|
||||
- Agent 离线时不影响普通鼠标、触摸、巡航和图层交互。
|
||||
|
||||
Earth 端只暴露最小动作入口:
|
||||
|
||||
- `applyMotionRotate(direction, intensity)`
|
||||
- `applyMotionZoom(direction, intensity)`
|
||||
- `applyMotionConfirm()`
|
||||
|
||||
动作捕捉不直接操作 Three.js 内部对象,也不修改图层业务模块。
|
||||
|
||||
## SaaS Strategy
|
||||
|
||||
未来网页端做成 SaaS 后,默认实时手势链路仍在浏览器本地完成,不走云端 RPC。高级现场设备可选本地 Agent:
|
||||
|
||||
```text
|
||||
Browser SaaS page -> getUserMedia -> browser-local recognizer
|
||||
Browser SaaS page -> local secure bridge -> Local Motion Capture Agent (advanced)
|
||||
Cloud SaaS -> config/auth/status only
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 云端 RPC 会增加网络 RTT 和抖动。
|
||||
- 上传摄像头帧有隐私和带宽风险。
|
||||
- 大屏交互需要稳定体感延迟,云端只适合做配置、授权、设备状态和审计。
|
||||
|
||||
浏览器摄像头要求 HTTPS 或 localhost。Agent 模式在本地部署可使用 `ws://127.0.0.1:8765`;生产 HTTPS SaaS 若要接 Agent,需要补 `wss://127.0.0.1` 或等价本地安全桥接,避免浏览器混合内容限制。
|
||||
|
||||
## Latency Budget
|
||||
|
||||
目标体感延迟:
|
||||
|
||||
- 摄像头采集:16-33ms。
|
||||
- 识别:8-25ms。
|
||||
- 状态机:小于 2ms。
|
||||
- 本地 WebSocket:1-5ms。
|
||||
- 浏览器渲染:约 16ms。
|
||||
|
||||
实验室目标:从动作被识别到 Earth 响应 p95 小于 50ms;摄像头到画面响应端到端小于 120ms。
|
||||
|
||||
## Implementation Milestones
|
||||
|
||||
1. 保存本计划并注册到 `docs/plans/README.md`。
|
||||
2. 新增独立 motion agent 包,提供 CLI、配置、摄像头输入抽象、事件模型和 WebSocket server。
|
||||
3. 新增手势状态机,支持阈值、防抖、冷却和限频。
|
||||
4. 新增 Earth motion-control provider manager,默认接浏览器摄像头 provider,可切换到 Motion Agent provider。
|
||||
5. 增加 Agent 单元测试、协议测试和前端 adapter 静态验证。
|
||||
6. 更新中英文用户手册和 Earth 前端开发上下文。
|
||||
|
||||
## Debug Mode Addition
|
||||
|
||||
**当前状态**:Browser Camera provider 会在调试面板中显示本地 `<video>` 预览并叠加骨架;`只显示骨骼` 可关闭视频底图。Motion Agent provider 仍只发送 `skeleton` 事件,不传原始摄像头帧。
|
||||
|
||||
Earth 设置中增加“动捕调试模式” switch,并增加“动捕输入源”选择。开启后,Earth 会启动当前 provider 并显示独立 HUD 调试面板。Browser Camera 模式下调试面板可以显示本机浏览器视频预览;Motion Agent 模式下只画归一化骨架点和关节连线,不传原始摄像头画面。
|
||||
|
||||
Motion Agent 增加 `skeleton` 事件:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "skeleton",
|
||||
"camera_id": "usb:0",
|
||||
"matched_gesture": "rotate_left",
|
||||
"confidence": 0.91,
|
||||
"joints": [{ "id": "left_wrist", "x": 0.42, "y": 0.61, "confidence": 0.98 }],
|
||||
"bones": [["left_shoulder", "left_elbow"]]
|
||||
}
|
||||
```
|
||||
|
||||
调试颜色约定:
|
||||
|
||||
- 未匹配动作:红色骨架。
|
||||
- 已匹配动作:绿色骨架,并显示匹配到的动作名。
|
||||
|
||||
权限先预留 `data-gatekeeper-permission="earth.motion_debug"` 标记,后续由 Gatekeeper 决定 switch 是否可见/可用。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Agent 单元测试:
|
||||
- 事件模型可序列化。
|
||||
- 低置信度手势被忽略。
|
||||
- 冷却期内重复手势被忽略。
|
||||
- 冷却后新手势可再次输出。
|
||||
- 无摄像头/缺依赖时错误可读。
|
||||
- Agent 协议测试:
|
||||
- `gesture`、`status`、`heartbeat` 字段稳定。
|
||||
- WebSocket 广播只发送语义事件。
|
||||
- Earth 前端验证:
|
||||
- motion-control provider manager 能消费浏览器 provider 和 Agent provider 的 mock 消息。
|
||||
- browser provider 在 mock `getUserMedia` 成功时进入 active 状态。
|
||||
- browser provider 在权限拒绝、无摄像头或非安全上下文时给出可读错误。
|
||||
- `skeleton` 事件能触发 `earth:motion-debug-frame`。
|
||||
- Agent 离线时不抛异常。
|
||||
- `rotate_left/right`、`zoom_in/out`、`confirm` 映射到 Earth 动作函数。
|
||||
- 文档验证:
|
||||
- 计划文档存在。
|
||||
- `docs/plans/README.md` 有入口。
|
||||
- 中英文使用说明不互相矛盾。
|
||||
66
docs/plans/earth-motion-gesture-interaction-v2-plan.md
Normal file
66
docs/plans/earth-motion-gesture-interaction-v2-plan.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Earth Motion Gesture Interaction V2 Plan
|
||||
|
||||
**状态**:已实现主体交互,并按实测调整。当前浏览器识别保留右手导航、头部切目标、左手上下切动捕图层、双手张开/收拢缩放;双手上举确认暂时关闭。Motion 目标展示已改为 `CruiseSequencer` + `PresentationController` 的 persistent 展示。
|
||||
|
||||
## Summary
|
||||
|
||||
把动捕从“几个单点手势触发函数”升级为一套更像大屏遥控器的交互层:右手负责地球导航,头部负责候选切换,左手上下切换动捕候选图层,双手负责缩放,调试面板支持“只显示骨骼”和暂停匹配。进入动捕模式后,Earth 自动软选中屏幕中心附近的正面可交互目标;确认动作预留为把目标升级为锁定,并用巡航/引导线式详情打开,不再模拟鼠标点击。
|
||||
|
||||
## Key Changes
|
||||
|
||||
- 手势语义 v1 固定为稳健小集:
|
||||
- 修正当前左右挥手语义反向问题:手势名以用户感知方向为准,provider 层输出正确 `rotate_left` / `rotate_right`。
|
||||
- 右手左/右/上/下挥控制地球水平/垂直旋转,新增 `rotate_up`、`rotate_down`。
|
||||
- 双手张开/靠近明确映射为 `zoom_in` / `zoom_out`。
|
||||
- 头往左/右歪新增 `focus_prev` / `focus_next`,在当前自动候选目标之间切换。
|
||||
- 左手上/下挥新增 `layer_prev` / `layer_next`,切换当前动捕候选图层并聚焦该图层最近目标。
|
||||
- 双手确认手势暂时关闭,避免与缩放和站姿误触混淆;协议仍保留 `confirm`。
|
||||
|
||||
- Motion Provider / Protocol:
|
||||
- 扩展 `MOTION_GESTURES`,新增 `rotate_up`、`rotate_down`、`focus_prev`、`focus_next`、`layer_prev`、`layer_next`。
|
||||
- Browser Camera provider 扩展 pose joints,保留肩/肘/腕,增加头部关键点,用于判断头歪。
|
||||
- 右手作为导航手;左手独立控制动捕候选图层。
|
||||
- 每类手势使用独立阈值和 cooldown,避免缩放/确认/旋转互相误触。
|
||||
|
||||
- Earth 交互层:
|
||||
- Motion adapter 支持水平/垂直旋转和 focus 切换 callback。
|
||||
- 进入动捕模式后,周期性从可交互对象中选出屏幕中心最近、位于地球正面的候选。
|
||||
- 软选中目标独立于 `lockedObject`,用 hover/linked 视觉态展示,不立即打开详情。
|
||||
- `focus_prev` / `focus_next` 在候选列表中切换;列表按屏幕中心距离、正面可见性、当前图层可见性排序。
|
||||
- `confirm` 预留为将软选中目标升级为 locked,并打开引导线详情;若没有候选,显示状态提示。
|
||||
|
||||
- 调试面板:
|
||||
- 在动捕 HUD / drawer 内增加“只显示骨骼”开关。
|
||||
- 增加“停止匹配动作”开关:暂停 gesture 执行,但不关闭摄像头预览或骨架绘制。
|
||||
- 设置持久化到 `planet.earth.settings.v2.shared.motionDebugSkeletonOnly`。
|
||||
- 开启后 canvas 不绘制视频帧,只绘制深色背景 + 红/绿骨骼线;摄像头仍继续用于识别。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Browser provider 单元测试:
|
||||
- 右手左/右挥输出的 `rotate_left` / `rotate_right` 与用户语义一致。
|
||||
- 右手上/下挥输出 `rotate_up` / `rotate_down`。
|
||||
- 双手张开输出 `zoom_in`,双手靠近输出 `zoom_out`。
|
||||
- 头部左右倾斜输出 `focus_prev` / `focus_next`。
|
||||
- 双手确认动作暂时不会触发。
|
||||
|
||||
- Motion adapter 测试:
|
||||
- 新增 gesture 能通过 `normalizeGestureMessage`。
|
||||
- `rotate_up/down` 调用垂直旋转逻辑。
|
||||
- `focus_prev/focus_next` 调用候选切换 callback。
|
||||
- `confirm` 在协议层保持兼容;浏览器 provider 当前不主动发出。
|
||||
|
||||
- Earth 前端验证:
|
||||
- 开启动捕模式后,屏幕中心附近正面目标自动软选中。
|
||||
- 头歪能在候选之间切换。
|
||||
- 左手上下切换图层后会在新图层中选择最近目标并展示 persistent 引导线详情。
|
||||
- 右手上下挥能旋转到南北方向目标。
|
||||
- “只显示骨骼”开关持久化,刷新后状态保持。
|
||||
- `bun --check` 覆盖新增/修改 Earth JS 模块,现有 motion tests 全绿。
|
||||
|
||||
## Assumptions
|
||||
|
||||
- v1 采用“右手导航、头部切候选、左手切图层、双手缩放”的交互模型;确认手势保留协议但暂时关闭浏览器识别。
|
||||
- 自动选中是 soft focus,不覆盖现有 mouse locked selection;只有 `confirm` 才真正锁定目标。
|
||||
- 骨骼-only 只影响调试画面,不关闭摄像头、不影响识别。
|
||||
- Motion Agent 协议可以接收新增 gesture 名;旧 agent 只发旧 gesture 时仍兼容。
|
||||
67
docs/plans/earth-presentation-decoupled-architecture-plan.md
Normal file
67
docs/plans/earth-presentation-decoupled-architecture-plan.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Earth Presentation Decoupled Architecture Plan
|
||||
|
||||
## Goal
|
||||
|
||||
把 Earth 页面里的“详情卡片、连接器、隐藏策略、跟随更新”从具体业务交互里拆出来,形成统一的 Presentation 层。第一阶段只迁 Motion 动捕展示,修复卡片被鼠标移动误隐藏、连接器 interactable 端不贴合本体的问题;BGP/News 巡航保持现状,避免改变原有轮播体验。
|
||||
|
||||
## Current Issues
|
||||
|
||||
- Motion 展示复用了巡航卡片,但隐藏判断仍散落在 `main.js` 的 hover/mousemove 分支里,导致鼠标移动时卡片可能被 `hideInfoCard()` 清掉。
|
||||
- Motion 连接器 source 端目前主要使用屏幕点坐标,缺少本体视觉边界,线无法稳定贴住 marker、卫星或海缆本体。
|
||||
- 卡片、连接器、目标本体和生命周期策略耦合在 adapter 内,不利于后续把点击详情、动捕、巡航统一管理。
|
||||
|
||||
## Phase 1 Scope
|
||||
|
||||
- 新增 `PresentationController`。
|
||||
- Motion 使用 `PresentationController` 管理卡片、连接器和 persistent 生命周期。
|
||||
- BGP/News adapter 不迁移,继续使用现有 `CruiseSequencer`、卡片位置、线动画和 dwell/advance 行为。
|
||||
- InfoCard 和 CalloutConnector 继续作为底层 renderer,不重写 UI。
|
||||
|
||||
## Presentation Interface
|
||||
|
||||
`PresentationController.present(request)` 接收:
|
||||
|
||||
- `id`: presentation 唯一 id。
|
||||
- `owner`: `motion | cruise | click | hover`。
|
||||
- `card`: 提供 `render({ reveal })` 和 `hide()`。
|
||||
- `connector`: 提供 `sourceProvider`、`targetProvider`、`options`,由 controller 调用 `createConnectorPath()` 和 `connector.render()`。
|
||||
- `lifetime`: `persistent | timeout | sequenced`,Motion 默认 `persistent`。
|
||||
- `onDismiss(reason)`: 替换、关闭、停止等清理回调。
|
||||
|
||||
`PresentationController.update()` 每帧重算 active connector 的 source/target anchor。`dismiss(reason)` 统一清理卡片、连接器和计时器。
|
||||
|
||||
## Motion Integration
|
||||
|
||||
- Motion adapter 不再直接管理 `showInfoCard + connector.render + hideInfoCard`。
|
||||
- Motion request 使用 `owner: "motion"` 和 `lifetime: { mode: "persistent" }`。
|
||||
- Motion 切目标时替换当前 presentation。
|
||||
- Motion 关闭、页面销毁或用户关闭展示时 dismiss。
|
||||
- Motion source anchor 使用视觉近似矩形:
|
||||
- BGP / compute / vessel marker: 投影中心 + marker 尺寸近似。
|
||||
- satellite: 当前卫星位置 + point size 近似。
|
||||
- cable: localCenter + 小矩形近似。
|
||||
|
||||
## Cruise Compatibility
|
||||
|
||||
- BGP/News 第一阶段不迁移。
|
||||
- `CruiseSequencer` 的 `auto_advance` 不改。
|
||||
- 原巡航的 dwell、hide、advance、卡片固定锚点、连接器动画时序不改。
|
||||
- 后续迁移 BGP/News 前必须先补回归测试,再只替换渲染层,不改排序、聚焦和时序。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- `presentation-controller.test.js`
|
||||
- `persistent` 不自动隐藏。
|
||||
- `timeout` 按配置隐藏。
|
||||
- 新 presentation 替换旧 presentation,并触发旧 `onDismiss("replace")`。
|
||||
- `dismiss(reason)` 清理卡片、连接器、计时器。
|
||||
- `update()` 重新获取 source/target anchor 并重绘 connector。
|
||||
- Motion 手动验证:
|
||||
- Motion 展示后移动鼠标,卡片不消失。
|
||||
- Motion 切目标后旧卡片和旧线被替换。
|
||||
- 卡片拖动、窗口 resize、地球旋转、卫星移动时 connector 两端跟随。
|
||||
- source 端贴近 interactable 视觉边缘。
|
||||
- 巡航回归:
|
||||
- BGP/News 自动轮播、dwell、隐藏、进入下一条不变。
|
||||
- 移动端 popup/drawer 行为不变。
|
||||
|
||||
@@ -176,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
|
||||
@@ -317,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 和异常位置保护。
|
||||
@@ -342,13 +344,13 @@ 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` 全量返回。
|
||||
|
||||
### v3.1 — 聚合完整性修复(v4 前置)
|
||||
### v3.1 — 聚合完整性修复(已被新快照接口取代)
|
||||
|
||||
目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。
|
||||
原目标是先保证“所有已采集到的船都能显示”,BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。开发期产品尚未上线后,决策调整为直接淘汰 legacy 船只表兜底:船只快照只读取 `ais_raw_observations` 聚合结果,旧 `vessel_position + vessel_static` 不再合并进 `/api/v1/vessels/snapshot`。
|
||||
|
||||
当前风险是 `/geo/vessels` 只要 raw observation 聚合返回非空,就直接使用 raw 聚合结果,不再补读兼容层 `vessel_position + vessel_static`。如果 raw observation 中只存在 AISStream 的几百艘船,或 BarentsWatch 历史数据没有完整回填到 raw 层,最终 Earth 就会只显示 AISStream 子集。
|
||||
因此以下 legacy merge 要求作废,保留在文档中只作为历史决策记录:
|
||||
|
||||
1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。
|
||||
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。
|
||||
@@ -416,7 +418,7 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100
|
||||
- `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`,并在 `/geo/vessels` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。
|
||||
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)
|
||||
@@ -460,8 +462,8 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100
|
||||
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。
|
||||
- 同一时间窗口内多来源相近轨迹点只展示一个点。
|
||||
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
|
||||
- raw observation 聚合结果和 legacy latest position 结果会按 MMSI 合并,BarentsWatch-only 船只不会因为 AISStream 子集存在而消失。
|
||||
- 不传 `limit` 或传 `limit=0` 时,`/geo/vessels` 全量返回合并后的船只集合。
|
||||
- `/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` 等身份编号在前端不显示千分位符。
|
||||
|
||||
@@ -216,7 +216,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再
|
||||
|
||||
### 1. 请求视口范围
|
||||
|
||||
前端请求 `/api/v1/visualization/geo/vessels` 时带上当前视口 `bbox`,减少无关船只。
|
||||
前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox`、`zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。
|
||||
|
||||
### 2. 后端排序策略
|
||||
|
||||
|
||||
127
docs/plans/location-resolver-shared-pipeline-plan.md
Normal file
127
docs/plans/location-resolver-shared-pipeline-plan.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Location Resolver Shared Pipeline Plan
|
||||
|
||||
**状态**:已实现,当前用户流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md),开发接口见 [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md)。
|
||||
|
||||
## Goal
|
||||
|
||||
把"给定一条记录,决定它的 lat/lon"这件事抽象成一条统一的可插拔管线,让算力中心、BGP 观测站、BGP 事件——以及未来任何需要位置估算的实体——共用同一套接口。新算法(peeringdb 设施查询、IXP 表、用户认领的精确点位等)通过实现一个 Resolver 类即可挂入,不需要改任何上层调用方。
|
||||
|
||||
## Background
|
||||
|
||||
### 实施前现状
|
||||
|
||||
- **算力中心** (`backend/app/services/compute_center_locations.py`) 早期曾使用源坐标 → 本地 JSON 注册表 → 城市兜底 → Nominatim 在线地理编码。后续为避免硬编码位置污染事实链路,算力中心本地注册表已移除;主地图只使用源坐标,手动候选采集使用 ROR 和 Nominatim。
|
||||
- **BGP 观测站** (`collectors/bgp_common.py:RIPE_RIS_COLLECTOR_COORDS`) 是一张写死的字典,26 个 RIPE RIS collector 的城市级坐标。新增 collector / 升级到设施级精度都得改 Python。
|
||||
- **BGP 事件**继承所属 collector 的城市级坐标(`BGPObservation.collector_geo`)。
|
||||
- 用户原本以为 BGP 观测站位置是通过 iptoasn 推断的——其实 iptoasn 只用于前缀级国家归属(`bgp_enrichment.py`),不影响 marker 坐标。
|
||||
|
||||
### 痛点
|
||||
|
||||
1. 算力中心那条 4 层链路写死在算力中心模块里,BGP 想用得复制一遍。
|
||||
2. 三类实体各走各的坐标策略,缺统一抽象。
|
||||
3. 未来要插更精的算法(peeringdb / IXP / 用户认领),现在没有挂入点。
|
||||
|
||||
## Design
|
||||
|
||||
### 接口契约
|
||||
|
||||
`backend/app/services/location/`:
|
||||
|
||||
- `models.py` —— `LocationQuery`(输入)、`LocationCandidate`(候选)、`ResolverOutput`(单 resolver 输出)、`ResolutionResult`/`ResolutionDiagnostic`(管线最终结果)
|
||||
- `pipeline.py` —— `LocationResolver` Protocol、`LocationPipeline` 编排器
|
||||
- `resolvers/source_coordinates.py` —— 记录自带 lat/lon 时直通
|
||||
- `resolvers/registry.py` —— 本地 JSON 注册表(locations + city_fallbacks),按别名得分
|
||||
- `resolvers/nominatim.py` —— 通用 Nominatim 客户端(rate-limited + LRU 缓存)+ 可注入 query plan
|
||||
- `resolvers/inherit.py` —— 从外部回调取候选(事件继承 collector 用)
|
||||
- `text.py` —— 文本规范化共享工具
|
||||
|
||||
核心 Protocol:
|
||||
|
||||
```python
|
||||
class LocationResolver(Protocol):
|
||||
name: str
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||
```
|
||||
|
||||
`LocationPipeline.collect_candidates()` 跑全部 resolver,聚合所有候选,按 `(source_rank, precision_rank, -confidence)` 排序去重;`resolve_best()` 选 top 候选。
|
||||
|
||||
### 各领域管线
|
||||
|
||||
```python
|
||||
# compute_center_locations.py(重构后,公共 API 不变)
|
||||
COMPUTE_CENTER_PIPELINE = LocationPipeline([
|
||||
SourceCoordinatesResolver(),
|
||||
])
|
||||
|
||||
COMPUTE_CENTER_COLLECTION_PIPELINE = LocationPipeline([
|
||||
SourceCoordinatesResolver(),
|
||||
ROROrganizationResolver(),
|
||||
NominatimResolver(query_plan_builder=_compute_center_query_plan,
|
||||
geocoder=lambda q: _geocode_online(q)),
|
||||
])
|
||||
|
||||
# bgp_collector_locations.py(新)
|
||||
BGP_COLLECTOR_PIPELINE = LocationPipeline([
|
||||
SourceCoordinatesResolver(),
|
||||
StoredCollectorLocationResolver(),
|
||||
])
|
||||
|
||||
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline([
|
||||
SourceCoordinatesResolver(),
|
||||
NominatimResolver(query_plan_builder=_bgp_collector_query_plan,
|
||||
geocoder=lambda q: _geocode_online(q)),
|
||||
])
|
||||
|
||||
# bgp_event_locations.py(新)
|
||||
BGP_EVENT_PIPELINE = LocationPipeline([
|
||||
SourceCoordinatesResolver(),
|
||||
InheritFromAnotherEntityResolver(source_lookup=_inherit_from_owning_collector),
|
||||
# 占位:将来插 ASNFacilityResolver / PrefixGeoResolver
|
||||
])
|
||||
```
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
1. **算力中心公共 API 完全不变**:`resolve_compute_center_location()`、`collect_location_candidates()`、`ComputeCenterLocation` dataclass、`_geocode_online` 模块级符号都保留,前端 / 上层调用方零改动;现有 19 个回归测试全绿。
|
||||
2. **`_geocode_online` 用 lambda 晚绑定**:`NominatimResolver(geocoder=lambda q: _geocode_online(q))` 能让测试 `monkeypatch.setattr(module, "_geocode_online", fake)` 继续生效。
|
||||
3. **`RIPE_RIS_COLLECTOR_COORDS` 自动从 DB-backed cache 重建**:启动时 seed/refresh `bgp_collector_locations` 维表,再原地刷新旧 `{rrcXX → {city, country, lat, lon}}` 字典。下游消费者(`bgp_collectors.py`、序列化、detector)不动即可获得新元数据。
|
||||
4. **修复隐藏 bug**:BGP collector 不再通过 registry/operator 模糊匹配晋升候选,避免 `operator="RIPE NCC"` 让每个事件都落到 `rrc00`。
|
||||
5. **事件继承走严格名字查询**:事件继承不跑 collector 的完整 pipeline,改成直接查 DB-backed cache。"改进位置"用户触发流程只跑源坐标和在线地理编码候选。
|
||||
|
||||
## Files
|
||||
|
||||
### 新增
|
||||
- `backend/app/services/location/__init__.py`
|
||||
- `backend/app/services/location/models.py`
|
||||
- `backend/app/services/location/pipeline.py`
|
||||
- `backend/app/services/location/text.py`
|
||||
- `backend/app/services/location/resolvers/__init__.py`
|
||||
- `backend/app/services/location/resolvers/source_coordinates.py`
|
||||
- `backend/app/services/location/resolvers/registry.py`
|
||||
- `backend/app/services/location/resolvers/nominatim.py`
|
||||
- `backend/app/services/location/resolvers/inherit.py`
|
||||
- `backend/app/services/bgp_collector_locations.py`
|
||||
- `backend/app/services/bgp_event_locations.py`
|
||||
- `backend/app/models/bgp_collector_location.py`
|
||||
- `backend/tests/test_location_pipeline.py`(16 用例)
|
||||
- `backend/tests/test_bgp_collector_locations.py`(11 用例)
|
||||
|
||||
### 修改
|
||||
- `backend/app/services/compute_center_locations.py` —— 改为薄包装
|
||||
- `backend/app/services/collectors/bgp_common.py` —— 删除写死字典,改调 `resolve_bgp_event_geo_dict()`
|
||||
- `backend/app/api/v1/bgp.py` —— 新增 `POST /api/v1/bgp/collectors/{collector_id}/collect-location`
|
||||
- `frontend/public/earth/js/info-card.js` —— `renderComputeCenterCollectSection` → `renderLocationCollectSection`,BGP collector 走通用化路径
|
||||
- `frontend/public/earth/js/compute-centers.js` —— 新增通用 `collectLocationCandidates(endpoint, payload)`
|
||||
- `frontend/public/earth/js/main.js` —— `previewComputeCenterCandidate` → `previewLocationCandidate`,事件名改为 `earth:preview-location-candidate`
|
||||
|
||||
## Verification
|
||||
|
||||
- `uv run pytest backend/tests/test_visualization_compute_centers.py` —— 19 个用例全绿(公共 API 未改)
|
||||
- `uv run pytest backend/tests/test_location_pipeline.py backend/tests/test_bgp_collector_locations.py` —— 16 + 11 用例全绿
|
||||
- 抽象可插拔性测试:`test_pluggability_custom_resolver_works_without_changing_pipeline` —— 临时实现 `_PeeringDBStubResolver` 直接接入 `LocationPipeline`,验证管线不需要改一行就能识别新 source
|
||||
|
||||
## Out of scope
|
||||
|
||||
- 持久化用户认领的精确坐标(写回 JSON 注册表)—— `suggested_registry_entry` 字段已就绪,工作流单独立项
|
||||
- 真正实现 `ASNFacilityResolver` / `PrefixGeoResolver` —— 接口已留好,具体算法(peeringdb / IXP 表 / iptoasn 升级)单独立项
|
||||
- 算力中心 / 观测站 marker 合并避让 —— 上一轮已用 `SURFACE_AVOIDANCE_PROFILES.city` + halo 收敛解决
|
||||
155
docs/plans/user-registration-email-verification-plan.md
Normal file
155
docs/plans/user-registration-email-verification-plan.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# 用户公开注册与邮箱验证计划
|
||||
|
||||
**状态**:待实施
|
||||
**创建日期**:2026-05-12
|
||||
**核心目标**:给 Planet 增加公开注册流程 + 邮箱验证码 + 忘记密码,让客户无需管理员介入就能开通账号;同时把 SMTP 邮件作为可复用基础服务接入 `/settings`。
|
||||
|
||||
## 背景
|
||||
|
||||
当前认证只暴露 `/auth/login`、`/auth/refresh`、`/auth/logout`、`/auth/me`(`backend/app/api/v1/auth.py`),账号只能由 `super_admin` 在 `/users` 后台创建。User 模型 `backend/app/models/user.py` 没有 `email_verified` 字段,仓库也没有任何 SMTP/邮件发送基础设施。
|
||||
|
||||
客户旅程想从"打开浏览器→注册→验证→登录"开始走(见 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md)),就必须先把这条链路在代码里跑通。
|
||||
|
||||
注册策略(已确认):
|
||||
|
||||
- 开放公开注册,任何人可在 `/register` 自助开通
|
||||
- 默认角色 `viewer`
|
||||
- 邮箱验证后立即可登录(无需管理员审批)
|
||||
- 验证仅走 SMTP 邮件,6 位数字码,10 分钟 TTL
|
||||
|
||||
## 数据模型
|
||||
|
||||
`backend/app/models/user.py` 加两列:
|
||||
|
||||
```python
|
||||
email_verified = Column(Boolean, default=False, nullable=False)
|
||||
pending_email = Column(String(255), nullable=True) # 改邮箱时临时落地待验证地址
|
||||
```
|
||||
|
||||
迁移路径:仓库目前没看到 alembic 目录,沿用 `backend/app/db/session.py` 的初始化风格在启动时跑 `ALTER TABLE users ADD COLUMN IF NOT EXISTS ...`。先确认是否存在 alembic,若有则正规迁移。
|
||||
|
||||
不另建 `verification_codes` 表 — OTP 走 **Redis**(系统已有 Redis,token blacklist 也走 Redis):
|
||||
|
||||
```
|
||||
key: otp:{purpose}:{email} purpose ∈ {register, verify_email, reset_password}
|
||||
value: { code_hash: bcrypt, attempts: int, issued_at: ts }
|
||||
TTL: 600 秒
|
||||
```
|
||||
|
||||
`{purpose}:{email}` 同时配一个限流键 `otp_rate:{purpose}:{email}`,TTL 60 秒,用于"60 秒内禁止重发"。
|
||||
|
||||
## 服务拆分
|
||||
|
||||
按项目 `services/` 单职责风格拆两个:
|
||||
|
||||
**`backend/app/services/otp.py`**(通用 OTP 原语,未来 2FA / 手机号验证可直接复用):
|
||||
|
||||
```python
|
||||
async def issue_code(email: str, purpose: OtpPurpose) -> str # 生成 6 位、写 Redis、返回明码(调用方负责送达)
|
||||
async def verify_code(email: str, purpose: OtpPurpose, code: str) -> bool # 校验并消耗
|
||||
async def check_resend_allowed(email: str, purpose: OtpPurpose) -> None # 抛 RateLimited 异常
|
||||
```
|
||||
|
||||
- 6 位数字,密码学随机
|
||||
- Redis 存 `bcrypt(code)`,不存明码
|
||||
- 校验失败计数 ≥ 5 直接失效该 key
|
||||
- 重发触发即失效旧 code
|
||||
|
||||
**`backend/app/services/email.py`**(通用 SMTP 发送,告警/摘要等后续可复用):
|
||||
|
||||
```python
|
||||
async def send_email(to: str, subject: str, html: str, text: str | None = None) -> None
|
||||
async def send_verification_email(to: str, code: str, purpose: OtpPurpose) -> None # 模板封装
|
||||
```
|
||||
|
||||
- 用 `aiosmtplib` 异步发送
|
||||
- 从 `system_settings` 的 `smtp` 命名空间读配置(host/port/username/password/from/use_tls)
|
||||
- 未配置抛 `EmailNotConfiguredError`
|
||||
- 模板用简单 HTML + 纯文本双段,按 `purpose` 切换文案
|
||||
|
||||
编排("签码 → 发邮件")在 `api/v1/auth.py` 端点里调两个服务,不在 service 内互相调用,保持单测可单独 mock。
|
||||
|
||||
## 后端端点
|
||||
|
||||
新增到 `backend/app/api/v1/auth.py`:
|
||||
|
||||
| 端点 | 入参 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `POST /auth/register` | `username, email, password` | 用户名/邮箱查重 → 写 User `is_active=True, email_verified=False, role="viewer"` → 调 `otp.issue_code(email, "register")` → 调 `email.send_verification_email` |
|
||||
| `POST /auth/verify-email` | `email, code` | `otp.verify_code` → 置 `email_verified=True` → 直接返回 access/refresh token |
|
||||
| `POST /auth/resend-code` | `email, purpose` | `check_resend_allowed` → `issue_code` → `send_verification_email` |
|
||||
| `POST /auth/forgot-password` | `email` | 即便邮箱不存在也返回 200(防枚举);存在则签 `reset_password` 码并发邮件 |
|
||||
| `POST /auth/reset-password` | `email, code, new_password` | `verify_code(..., "reset_password")` → `user.set_password(new_password)` |
|
||||
|
||||
`/auth/login` 改造:邮箱未验证用户登录返回 `403 { code: "EMAIL_NOT_VERIFIED", email }`,前端拿到后跳验证页。
|
||||
|
||||
## SMTP 设置
|
||||
|
||||
复用 `backend/app/api/v1/settings.py` 现有 setting store,新增 `smtp` 命名空间:
|
||||
|
||||
- `smtp_host`、`smtp_port`、`smtp_username`、`smtp_password`、`smtp_from`、`smtp_from_name`、`smtp_use_tls`
|
||||
- 密码走与 collector 凭证相同的加密路径(看 `backend/app/services/` 是否已有 `credentials_encryption` 之类工具,若有直接复用)
|
||||
- `POST /settings/smtp/test` — 用当前未保存的入参试发一封到指定地址,不落库
|
||||
|
||||
未配置 SMTP 时 `/auth/register` 应返回明确错误 `503 { code: "EMAIL_PROVIDER_NOT_CONFIGURED" }`,提示管理员先去 `/settings` 配 SMTP 或用 `./planet.sh createuser` 兜底。
|
||||
|
||||
## 前端
|
||||
|
||||
**新页面**:
|
||||
|
||||
- `frontend/src/pages/Register/Register.tsx` — 两步表单:(1) 用户名/邮箱/密码 (2) 6 位验证码;60s 重发冷却;验证成功写 token,自动跳 `/admin`
|
||||
- `frontend/src/pages/VerifyEmail/VerifyEmail.tsx` — 给登录拦截 `EMAIL_NOT_VERIFIED` 时落地的页,仅"输码 + 重发"
|
||||
- `frontend/src/pages/ForgotPassword/ForgotPassword.tsx` — 两步:(1) 输邮箱 (2) 输码 + 新密码
|
||||
|
||||
**改动**:
|
||||
|
||||
- `frontend/src/pages/Login/Login.tsx` — 表单下加"注册账号"、"忘记密码"链接;接 `EMAIL_NOT_VERIFIED` 跳 `/verify-email`
|
||||
- `frontend/src/pages/Settings/Settings.tsx` — 新增 SMTP 子 tab(host/port/username/password/from/TLS + 测试发送按钮),用工作区里新建的 `frontend/src/components/ConnectionTestInput/` 做连通测试输入
|
||||
- 路由表(`frontend/src/App.tsx` 或 `frontend/src/router/*`)— 加 `/register`、`/forgot-password`、`/verify-email`
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
后端:
|
||||
|
||||
- `backend/app/models/user.py` — 加字段
|
||||
- `backend/app/schemas/user.py` — 新增 `UserRegister`、`VerifyCode`、`ResetPasswordRequest` schema
|
||||
- `backend/app/api/v1/auth.py` — 新端点 + 登录校验
|
||||
- `backend/app/services/otp.py` *(新)*
|
||||
- `backend/app/services/email.py` *(新)*
|
||||
- `backend/app/api/v1/settings.py` — SMTP 命名空间 + 测试发送
|
||||
- `backend/app/core/config.py` — SMTP 默认值/特性开关
|
||||
- `backend/app/db/session.py` — DDL 兜底(若无 alembic)
|
||||
|
||||
前端:
|
||||
|
||||
- `frontend/src/pages/Register/Register.tsx` *(新)*
|
||||
- `frontend/src/pages/VerifyEmail/VerifyEmail.tsx` *(新)*
|
||||
- `frontend/src/pages/ForgotPassword/ForgotPassword.tsx` *(新)*
|
||||
- `frontend/src/pages/Login/Login.tsx`
|
||||
- `frontend/src/pages/Settings/Settings.tsx`
|
||||
- 路由文件
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 后端:User 模型字段 + DDL 兜底
|
||||
2. 后端:`services/otp.py`(先纯单测跑通)
|
||||
3. 后端:`services/email.py`(用 MailHog 本地试发)
|
||||
4. 后端:`/auth/register` + `/auth/verify-email` + `/auth/resend-code` + 登录拦截
|
||||
5. 后端:`/auth/forgot-password` + `/auth/reset-password`
|
||||
6. 后端:`/settings/smtp` 命名空间 + 测试发送
|
||||
7. 前端:`Settings.tsx` 加 SMTP 子 tab
|
||||
8. 前端:Register / VerifyEmail / ForgotPassword 页 + Login 入口
|
||||
|
||||
文档同步在 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md) 落地。
|
||||
|
||||
## 验证
|
||||
|
||||
- **后端单测**(仿 `backend/tests/test_settings_ai_provider.py`):
|
||||
- 注册端点用户名/邮箱查重
|
||||
- OTP 过期、错码计数、重发限流
|
||||
- 邮箱未验证用户登录返回 `EMAIL_NOT_VERIFIED`
|
||||
- SMTP 未配置时注册端点返回 `EMAIL_PROVIDER_NOT_CONFIGURED`
|
||||
- 忘记密码对不存在邮箱仍返回 200
|
||||
- **后端集测**:本机起 MailHog 或 Mailtrap,把 SMTP 指到上面,跑 register → 收码 → verify → login 一遍
|
||||
- **前端**:`source ~/.zshrc && bun run build`;启 dev server 走 `/register` → `/verify-email` → `/admin` 全流程,再试 `/forgot-password`
|
||||
- **手测**:新邮箱注册 → 收码 → 输错 → 重发 → 输对 → 登录 → 改密码 → 用新密码再登;管理员在 `/settings` 改 SMTP → 测试发送
|
||||
@@ -25,8 +25,13 @@ What belongs here:
|
||||
|
||||
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): The shortest path to getting Planet running from scratch
|
||||
- [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): Complete usage guide for the console, `planet.sh`, Earth, and Docs
|
||||
- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md): Central troubleshooting entry for Windows / WSL, ports, dependencies, motion capture, credentials, and Docs permissions
|
||||
- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md): Collect and preview coordinate candidates for compute centers and BGP collectors on Earth
|
||||
- [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md): Data source catalog, collector settings, connectivity validation, and BarentsWatch credentials
|
||||
- [Shared Location Resolution Pipeline Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-development.md): Backend location resolver / pipeline interfaces, registries, and extension points
|
||||
- [Docs Gatekeeper Development Guide](/home/ray/dev/linkong/planet/docs/technical/en/docs-gatekeeper-development.md): Backend Docs catalog, Markdown content loading, and Gatekeeper permission groups
|
||||
- [Earth Interactable Usage](/home/ray/dev/linkong/planet/docs/technical/en/earth-interactable-usage.md): API, lifecycle, and integration examples for Earth surface icon Interactable
|
||||
- [Earth Toolbar and Overlay Coordination](/home/ray/dev/linkong/planet/docs/technical/en/earth-toolbar-overlay-coordination.md): Closing matrix and integration rules for toolbar buttons, search, settings, news, and layer overlays
|
||||
|
||||
What does not belong here:
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@ The recommended default is:
|
||||
- business-level request shaping
|
||||
- stable `/api/v1/ai/...` endpoints
|
||||
- internal service-to-service authentication toward `aiprovider`
|
||||
- reading the default provider, model, and per-provider keys saved in Settings, then overriding `aiprovider` `.env` defaults through internal headers
|
||||
|
||||
`aiprovider` is responsible for:
|
||||
|
||||
- model protocol adaptation
|
||||
- provider selection by `.env`
|
||||
- provider selection by `.env` when no backend override headers are present
|
||||
- timeout and lightweight retry
|
||||
- request tracing via `X-Request-ID`
|
||||
|
||||
@@ -85,6 +86,18 @@ Optional tracing header:
|
||||
|
||||
The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response.
|
||||
|
||||
### Settings API
|
||||
|
||||
The AI settings page uses:
|
||||
|
||||
- `GET /api/v1/settings/integrations`
|
||||
- `PUT /api/v1/settings/integrations`
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
|
||||
These endpoints require an authenticated user. The `secrets` endpoint is only used when the settings page reveals a key or token; hiding the field restores the masked preview.
|
||||
|
||||
### AI provider internal API
|
||||
|
||||
Internal-only endpoints:
|
||||
@@ -172,6 +185,75 @@ Both services also return:
|
||||
|
||||
## Configuration
|
||||
|
||||
### Runtime Configuration Flow
|
||||
|
||||
The backend Settings system owns the global LLM default. The runtime flow is:
|
||||
|
||||
1. Frontend or application code calls a `backend` `/api/v1/ai/...` endpoint.
|
||||
2. `backend` reads `category = external_integrations` from the PostgreSQL `system_settings` table.
|
||||
3. `payload.ai_provider.default_provider` selects the active provider.
|
||||
4. `payload.ai_provider.providers[provider]` supplies that provider's `api_key`, `provider_api`, `base_url`, `model`, `max_tokens`, and `anthropic_version`.
|
||||
5. `backend` converts those values to internal headers such as `X-AI-Provider`, `X-AI-Provider-API`, `X-AI-Base-URL`, `X-AI-API-Key`, and `X-AI-Model`.
|
||||
6. `aiprovider` uses those headers to override its `.env` defaults before calling the real model vendor.
|
||||
|
||||
After the AI settings page saves a new default provider/model/key, Playground, alert briefs, datasource mapping generation, and other backend AI calls all use that same default.
|
||||
|
||||
#### Persistence Shape
|
||||
|
||||
AI settings are persisted in PostgreSQL, not a JSON file. The core payload shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"ai_provider": {
|
||||
"service_url": "http://localhost:8010",
|
||||
"service_token": "",
|
||||
"default_provider": "openai",
|
||||
"providers": {
|
||||
"openai": {
|
||||
"provider_api": "openai-completions",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-5.1",
|
||||
"api_key": "<saved secret>",
|
||||
"max_tokens": 4096,
|
||||
"anthropic_version": "2023-06-01"
|
||||
},
|
||||
"minimax": {
|
||||
"provider_api": "anthropic-messages",
|
||||
"base_url": "https://api.minimaxi.com/anthropic",
|
||||
"model": "MiniMax-M2.7",
|
||||
"api_key": "<saved secret>",
|
||||
"max_tokens": 1200,
|
||||
"anthropic_version": "2023-06-01"
|
||||
}
|
||||
},
|
||||
"timeout_seconds": 60,
|
||||
"retry_attempts": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Legacy single-slot settings are mapped to `providers[provider]` on read and are written back in the new shape on save.
|
||||
|
||||
#### Key Fallback
|
||||
|
||||
Each provider has its own key slot. Resolution order is:
|
||||
|
||||
1. `providers[provider].api_key` in PostgreSQL
|
||||
2. the provider-specific variable in `aiprovider/.env`, such as `OPENAI_API_KEY`, `MINIMAX_API_KEY`, or `ANTHROPIC_API_KEY`
|
||||
3. the generic `AI_API_KEY` in `aiprovider/.env`
|
||||
|
||||
`.env` is only a fallback. After the settings page saves successfully, or after the connection test succeeds, PostgreSQL becomes the global default source.
|
||||
|
||||
#### Settings Page Behavior
|
||||
|
||||
- The Provider select controls the global default provider.
|
||||
- The model select saves the default model for the selected provider.
|
||||
- The LLM API Key field shows a masked preview while hidden; keys with a `-` prefix keep the prefix, for example `sk-********`, and keys without a prefix are fully masked.
|
||||
- Clicking the eye icon fetches and displays the full plaintext value; hiding restores the masked preview.
|
||||
- `Save AI Configuration` saves the current form as the global default.
|
||||
- `Test Connection` uses the current form for a real model-chain test, then saves it as the global default only when the test succeeds.
|
||||
- Leaving a key field empty keeps the old key; it does not delete it.
|
||||
|
||||
### Backend
|
||||
|
||||
Recommended backend `.env`:
|
||||
@@ -208,6 +290,18 @@ AI_HTTP_RETRY_ATTEMPTS=2
|
||||
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
|
||||
```
|
||||
|
||||
Optional provider-specific keys:
|
||||
|
||||
```env
|
||||
MINIMAX_API_KEY=sk-cp-xxxxx
|
||||
OPENAI_API_KEY=sk-xxxxx
|
||||
ANTHROPIC_API_KEY=sk-ant-xxxxx
|
||||
DEEPSEEK_API_KEY=sk-xxxxx
|
||||
DASHSCOPE_API_KEY=sk-xxxxx
|
||||
MOONSHOT_API_KEY=sk-xxxxx
|
||||
OPENROUTER_API_KEY=sk-or-xxxxx
|
||||
```
|
||||
|
||||
### OpenAI-compatible example
|
||||
|
||||
```env
|
||||
|
||||
@@ -87,6 +87,10 @@ async def run(self, db):
|
||||
| BarentsWatch AIS | vessel | AIS vessel positions, speed, heading, MMSI, and related fields | Collector settings |
|
||||
| AISStream Vessels | vessel_ais | AIS WebSocket realtime stream, written to the raw observation layer and displayed through aggregation | Collector settings |
|
||||
|
||||
AIS vessel collectors use a different persistence path from regular `CollectedData` collectors. BarentsWatch, AISStream, and custom `vessel_ais` sources write into the AIS raw observation layer first, then the aggregation service merges those observations into the GeoJSON and detail payloads used by the Earth vessel layer. This preserves source, transport, field conflicts, and observation time instead of letting one realtime source overwrite the final display table.
|
||||
|
||||
TOP500 and Epoch AI compute sources do not always provide usable coordinates. The unified Earth compute-center endpoint uses only valid source-provided coordinates or `compute_center_locations` dimension-table coordinates during the main map startup path; records without coordinates are returned as `unresolved` instead of being rendered from a local registry, country centroid, or guessed city. When users manually collect candidates, the backend queries ROR and Nominatim/OpenStreetMap from source fields; accepted candidates are saved into `compute_center_locations` and rendered from that table on the next layer refresh.
|
||||
|
||||
## IV. Data Format (stored in CollectedData table)
|
||||
|
||||
```python
|
||||
@@ -213,13 +217,169 @@ backend/app/services/collectors/
|
||||
├── epoch_ai.py # Epoch AI collector
|
||||
├── huggingface.py # HuggingFace collector
|
||||
├── peeringdb.py # PeeringDB collector
|
||||
└── telegeraphy.py # TeleGeography submarine cable collector
|
||||
├── telegeraphy.py # TeleGeography submarine cable collector
|
||||
├── vessel_ais.py # BarentsWatch AIS vessel collector
|
||||
└── aisstream.py # AISStream WebSocket vessel collector
|
||||
|
||||
backend/app/services/
|
||||
├── custom_datasource_runtime.py # Custom REST / WebSocket mapping runtime
|
||||
├── datasource_mapping.py # Deterministic field mapping and target writes
|
||||
├── vessel_ais_aggregation.py # AIS raw observation writes and aggregate reads
|
||||
├── vessel_aggregation_strategy.py # Multi-source field selection, freshness fallback, and conflict records
|
||||
└── vessel_enrichment.py # Vessel profile enrichment cache
|
||||
|
||||
backend/app/models/
|
||||
└── collected_data.py # Unified data model
|
||||
├── collected_data.py # Unified data model
|
||||
└── vessel_enrichment.py # Vessel enrichment cache
|
||||
```
|
||||
|
||||
## IX. Data Usage
|
||||
## IX. Credentialed Collectors
|
||||
|
||||
Some collectors require external service credentials:
|
||||
|
||||
| Collector | Credential provider | Credential sources |
|
||||
| --- | --- | --- |
|
||||
| `barentswatch_vessels` | `barentswatch` | Console collector settings, environment variables, `~/.zshrc` |
|
||||
| `aisstream_vessels` | `aisstream` | Console collector settings, environment variables, `~/.zshrc` for connectivity checks; save it in collector settings or inject it into the backend environment for collection |
|
||||
| `spacetrack_tle` | `spacetrack` | Environment variables, `~/.zshrc` |
|
||||
|
||||
### BarentsWatch AIS
|
||||
|
||||
BarentsWatch AIS credential resolution is centralized in:
|
||||
|
||||
- [barentswatch.py](/home/ray/dev/linkong/planet/backend/app/services/barentswatch.py)
|
||||
|
||||
`VesselAISCollector` only collects and transforms AIS data. It no longer reads environment variables or builds token requests directly. It uses:
|
||||
|
||||
- `resolve_barentswatch_config()`
|
||||
- `fetch_barentswatch_access_token()`
|
||||
|
||||
Resolution priority:
|
||||
|
||||
1. `DataSourceConfig.auth_config`
|
||||
2. `DataSourceConfig.config`
|
||||
3. Environment variables
|
||||
4. `~/.zshrc`
|
||||
|
||||
Supported variables:
|
||||
|
||||
```bash
|
||||
export BARENTSWATCH_CLIENT_ID="..."
|
||||
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
Historical misspellings are also supported:
|
||||
|
||||
```bash
|
||||
export BARRENTSWATCH_CLIENT_ID="..."
|
||||
export BARRENTSWATCH_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
Connectivity validation requests `https://id.barentswatch.no/connect/token` for an access token with `scope=ais`, then requests the AIS endpoint with `Authorization: Bearer <token>`.
|
||||
|
||||
### AISStream Realtime Vessels
|
||||
|
||||
AISStream uses the `wss://stream.aisstream.io/v0/stream` WebSocket endpoint. Its default runtime is a long-lived realtime collector rather than the traditional REST pattern of one request, progress to 100%, then completion.
|
||||
|
||||
Runtime configuration:
|
||||
|
||||
- `api_key`: read first from `DataSourceConfig.auth_config.api_key` or `config.api_key`; it can also come from the backend process environment variable `AISSTREAM_API_KEY`.
|
||||
- `bounding_boxes`: AISStream subscription bounds. The default example is global `[[[-90, -180], [90, 180]]]`; demos and production runs should usually start with a smaller area.
|
||||
- `message_types`: defaults to `PositionReport` and `ShipStaticData`.
|
||||
- `streaming_enabled`: enables long-lived streaming by default; disabling it falls back to batch-style `fetch -> transform -> save`.
|
||||
- `streaming_max_messages`: test-only stop limit. Non-zero values stop the stream after the requested number of messages.
|
||||
- `reconnect_delay_seconds` and `receive_timeout_seconds`: control reconnect delay and idle receive waits.
|
||||
|
||||
State semantics:
|
||||
|
||||
- `connecting`: connecting to AISStream.
|
||||
- `streaming`: receiving realtime messages; `records_processed` means messages seen, usually without a fixed total or percentage.
|
||||
- `reconnecting`: upstream or network interruption; the collector records `AISSourceHealth` and waits before reconnecting.
|
||||
- `stopped` / `cancelled`: stopped by a test limit or user action.
|
||||
|
||||
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
|
||||
|
||||
### AIS Raw Observations And Aggregation
|
||||
|
||||
AIS observations do not directly replace final vessel records. They are first saved as raw observations:
|
||||
|
||||
- `source` records the origin, such as `barentswatch_vessels`, `aisstream_vessels`, or a custom source name.
|
||||
- `delivery_mode` captures realtime quality; `realtime_stream` outranks `polling`.
|
||||
- `transport` records `websocket` or `http`.
|
||||
- Dynamic fields such as position, speed, and course are selected by freshness and source priority.
|
||||
- Static fields prefer non-empty values; conflicting candidates are recorded for detail and diagnostics views.
|
||||
|
||||
Earth vessel rendering now consumes the bounded snapshot endpoint and realtime delta channel:
|
||||
|
||||
```http
|
||||
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
|
||||
```
|
||||
|
||||
`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It reads only aggregated `ais_raw_observations`; it no longer merges legacy `vessel_position` / `vessel_static` rows. The old `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`.
|
||||
|
||||
Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"data": {
|
||||
"channel": "vessels",
|
||||
"bbox": [120.8, 30.7, 122.1, 31.8],
|
||||
"zoom": 12,
|
||||
"limit": 1000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The backend stores lightweight subscription filters per connection and only sends vessel updates that match the subscriber bbox. Collector broadcasts enter a 1-second throttle queue; within each flush window, only the latest update per MMSI is retained.
|
||||
|
||||
### Layer APIs And Global Stats
|
||||
|
||||
Earth is moving to two API families:
|
||||
|
||||
```http
|
||||
GET /api/v1/data-products
|
||||
GET /api/v1/data-products/{product_id}/status
|
||||
GET /api/v1/layers/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/layers/cables?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/layers/landing-points?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/layers/satellites?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/layers/bgp/anomalies?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/layers/bgp/incidents?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
```
|
||||
|
||||
`/api/v1/data-products/*` is for aggregate panels and keeps a global statistics scope independent of the map bbox. `/api/v1/layers/*` is for map rendering, requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`; low zoom falls back to a smaller response cap and reports `degraded`, `truncated`, `limit_clamped`, and `stats_scope=viewport` in `diagnostics`. Non-vessel layers currently reuse the existing GeoJSON converters before the guard layer; future product-specific queries can push bbox filtering deeper.
|
||||
|
||||
## X. Collector Settings And Connectivity Validation
|
||||
|
||||
The console "Collector Settings" page owns endpoint, headers, timeouts, retries, and credentials for all built-in collectors. Connectivity is derived by the backend checksum rather than by frontend button styling:
|
||||
|
||||
- endpoint
|
||||
- auth type
|
||||
- headers
|
||||
- config
|
||||
- credential provider
|
||||
- credential fingerprint
|
||||
|
||||
Related APIs:
|
||||
|
||||
```http
|
||||
GET /api/v1/datasources/configs/all
|
||||
POST /api/v1/datasources/configs/builtin/connection-status
|
||||
POST /api/v1/datasources/configs/builtin/connect
|
||||
POST /api/v1/settings/integrations/barentswatch/connect
|
||||
GET /api/v1/settings/credential-guides/{provider}
|
||||
POST /api/v1/settings/credential-guides/{provider}/generate
|
||||
POST /api/v1/settings/credential-guides/{provider}/reset
|
||||
```
|
||||
|
||||
See [Collector Settings and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
|
||||
|
||||
## XI. Data Usage
|
||||
|
||||
Collected data ultimately:
|
||||
|
||||
@@ -227,7 +387,7 @@ Collected data ultimately:
|
||||
2. **Situational analysis** — global compute distribution statistics and growth trends
|
||||
3. **Alert system** — detects changes to important nodes
|
||||
|
||||
## X. Collector Registration
|
||||
## XII. Collector Registration
|
||||
|
||||
Collectors are automatically registered at application startup:
|
||||
|
||||
@@ -249,7 +409,7 @@ collector_registry.register(TeleGeographyCableSystemCollector())
|
||||
|
||||
**Core file**: `backend/app/services/collectors/registry.py`
|
||||
|
||||
## XI. Triggering Collection
|
||||
## XIII. Triggering Collection
|
||||
|
||||
### Method 1: Scheduled
|
||||
|
||||
@@ -263,4 +423,12 @@ curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
Batch collection uses:
|
||||
|
||||
```http
|
||||
POST /api/v1/datasources/trigger-batch
|
||||
```
|
||||
|
||||
The request body may pass `source_ids` for selected rows. Without `source_ids`, the backend filters by `product`, `module`, `is_active`, `run_status`, `collected`, `credential_status`, and `q`. The endpoint skips disabled sources, sources already running without `force`, and sources still inside their frequency window, then returns `triggered`, `skipped`, and `failed` groups.
|
||||
|
||||
**Core file**: `backend/app/api/v1/datasources.py`
|
||||
|
||||
99
docs/technical/en/backend-datasources-api-performance.md
Normal file
99
docs/technical/en/backend-datasources-api-performance.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# DataSources List API Performance Optimization
|
||||
|
||||
## Background
|
||||
|
||||
`GET /api/v1/datasources` is the core API for the Data Sources page. Slow responses directly block page rendering.
|
||||
|
||||
## Query Path Before Optimization
|
||||
|
||||
`_load_datasource_list_context` used to run these queries sequentially:
|
||||
|
||||
| Order | Function | Query | Bottleneck |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `_load_latest_running_tasks` | `collection_tasks` window query; stale check depends on this result | Must be serial |
|
||||
| 2 | `_load_latest_completed_tasks` | `collection_tasks` window query for latest completed tasks | Serial wait |
|
||||
| 3 | `_load_datasource_data_counts` | `COUNT(*) GROUP BY source` on `collected_data` | Slow full-table scan |
|
||||
| 4 | `_load_datasource_endpoint_overrides` | Simple `datasource_configs` SELECT | Serial wait |
|
||||
|
||||
## Phase 1: Parallelization
|
||||
|
||||
The independent queries 2, 3, and 4 were moved to `asyncio.gather` with separate sessions:
|
||||
|
||||
```python
|
||||
async def _fetch_completed():
|
||||
async with async_session_factory() as s:
|
||||
return await _load_latest_completed_tasks(s, datasource_ids)
|
||||
|
||||
async def _fetch_counts():
|
||||
async with async_session_factory() as s:
|
||||
return await _load_datasource_data_counts(s, sources)
|
||||
|
||||
async def _fetch_overrides():
|
||||
async with async_session_factory() as s:
|
||||
return await _load_datasource_endpoint_overrides(s, sources)
|
||||
|
||||
completed_tasks, data_counts, endpoint_overrides = await asyncio.gather(
|
||||
_fetch_completed(), _fetch_counts(), _fetch_overrides(),
|
||||
)
|
||||
```
|
||||
|
||||
SQLAlchemy `AsyncSession` does not support concurrent use from multiple coroutines, so every parallel branch needs its own session.
|
||||
|
||||
## Phase 2: Remove Heavy Queries
|
||||
|
||||
### Remove `_load_datasource_data_counts`
|
||||
|
||||
`data_count` was only used by the frontend to show an edge-case `(0 records)` hint in the latest collection column. It was not worth keeping a `COUNT(*) GROUP BY` full-table scan.
|
||||
|
||||
- Frontend `(0 records)` display logic was removed.
|
||||
- `data_count` was removed from the `BuiltInDataSource` interface.
|
||||
|
||||
### Remove `_load_latest_completed_tasks`
|
||||
|
||||
`last_status` and `last_run_at` are already written to the `DataSource` model when collectors finish, so the list endpoint no longer needs to join `collection_tasks`:
|
||||
|
||||
```python
|
||||
# Before: completed_tasks query required
|
||||
last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None)
|
||||
last_status = datasource.last_status or (last_task.status if last_task else None)
|
||||
|
||||
# After: read model fields directly
|
||||
last_run_at = datasource.last_run_at
|
||||
last_status = datasource.last_status
|
||||
```
|
||||
|
||||
`last_records_processed` was removed as well because it came from completed task rows and is not displayed in the list.
|
||||
|
||||
## Query Path After Optimization
|
||||
|
||||
```text
|
||||
datasources SELECT -> required primary data
|
||||
_load_latest_running_tasks -> required for running state and stale check
|
||||
_load_datasource_endpoint_overrides -> required for endpoint overrides and collector settings display
|
||||
```
|
||||
|
||||
The endpoint now runs three queries instead of five. The last two run sequentially because running tasks are needed for stale checks and endpoint overrides are lightweight.
|
||||
|
||||
## Frontend `triggerDatasource` Double Refresh Fix
|
||||
|
||||
`triggerDatasource` previously called `fetchData()` twice:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
} else {
|
||||
window.setTimeout(() => { fetchData() }, 800)
|
||||
}
|
||||
fetchData()
|
||||
|
||||
// After: mutually exclusive
|
||||
if (res.data.task_id) {
|
||||
fetchData()
|
||||
} else {
|
||||
window.setTimeout(fetchData, 800)
|
||||
}
|
||||
```
|
||||
|
||||
## Related Files
|
||||
|
||||
- [datasources.py](/home/ray/dev/linkong/planet/backend/app/api/v1/datasources.py): `_load_datasource_list_context`, `list_datasources`
|
||||
- [DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx): `BuiltInDataSource`, `triggerDatasource`
|
||||
@@ -62,7 +62,7 @@ File:
|
||||
Current behavior:
|
||||
|
||||
- The `collector_credentials` tab is displayed as "Collector Settings".
|
||||
- A select lists all built-in collectors.
|
||||
- A select lists built-in collectors and supports maintaining custom supplemental sources that merge into built-in data.
|
||||
- The only button beside the select is a plug icon for health checks.
|
||||
- Status tags below the select show:
|
||||
- `Credentials required` / `No credentials required`
|
||||
@@ -72,6 +72,8 @@ Current behavior:
|
||||
- Whether the endpoint is overridden
|
||||
- Collectors that require credentials place the credential card above base configuration.
|
||||
- Collectors without credentials only show base configuration.
|
||||
- The AISStream collector uses WebSocket semantics: connecting, streaming, reconnecting, or stopped. It does not use a fixed completion percentage.
|
||||
- Custom source editing lives in collector settings. The data source catalog keeps overview, run controls, and read-only drawers.
|
||||
|
||||
The connection button uses an inline Tabler-style plug icon with `plug-connected` semantics, avoiding the older refresh icon for a connection action.
|
||||
|
||||
@@ -284,6 +286,110 @@ Normalization:
|
||||
- Vessel type usually comes from lower-frequency `ShipStaticData.Type`; the backend maps AIS numeric type codes to Cargo / Tanker / Passenger / Fishing / Military.
|
||||
- If a vessel has not yet produced a static message, its aggregated type can still be `Other`; v5 vessel profile enrichment is planned to fill that gap.
|
||||
|
||||
Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key.
|
||||
|
||||
Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while the `aisstream_vessels` collector is `streaming` / `connected` and its message count plus `last_seen_at` keep advancing.
|
||||
|
||||
The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call:
|
||||
|
||||
```http
|
||||
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
|
||||
```
|
||||
|
||||
That endpoint reads local aggregated `ais_raw_observations` only. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI.
|
||||
|
||||
## Custom REST / WebSocket Mapping Runtime
|
||||
|
||||
Files:
|
||||
|
||||
- [custom_datasource_runtime.py](/home/ray/dev/linkong/planet/backend/app/services/custom_datasource_runtime.py)
|
||||
- [datasource_mapping.py](/home/ray/dev/linkong/planet/backend/app/services/datasource_mapping.py)
|
||||
|
||||
Custom sources are supplemental inputs for existing target schemas, not isolated data islands. The most complete target today is `vessel_ais`: a custom REST or WebSocket source is mapped deterministically, written into AIS raw observations, and then pushed to Earth through the `vessels` WebSocket channel.
|
||||
|
||||
### Configuration Semantics
|
||||
|
||||
Important fields:
|
||||
|
||||
- `source_type`: `rest` / `http` / `websocket` / `ws`.
|
||||
- `endpoint`: REST uses `http(s)://`; WebSocket uses `ws(s)://`.
|
||||
- `auth_type`: `none`, `bearer`, `api_key`, or `basic`.
|
||||
- `headers`: static request headers.
|
||||
- `auth_config`: token, API key, or basic username/password; API keys can be sent by header or query.
|
||||
- `config.target_schema`: for example `vessel_ais`.
|
||||
- `config.delivery_mode`: REST defaults to `polling`; WebSocket defaults to `realtime_stream`.
|
||||
- `config.merge_target_source`: records which built-in source this custom source supplements, such as `barentswatch_vessels`.
|
||||
|
||||
The REST runner supports:
|
||||
|
||||
- `GET` / `POST`
|
||||
- query params
|
||||
- JSON body
|
||||
- headers and auth injection
|
||||
- active mapping writes into the target schema
|
||||
|
||||
The WebSocket runner supports:
|
||||
|
||||
- endpoint format validation
|
||||
- headers and auth injection
|
||||
- optional `ws_subscribe_message`
|
||||
- `ws_message_path` / `ws_items_path` extraction
|
||||
- reconnects
|
||||
- `debug_max_messages` debug limits
|
||||
- background stream start / stop / status
|
||||
|
||||
Related APIs:
|
||||
|
||||
```http
|
||||
POST /api/v1/datasources/custom/sample
|
||||
GET /api/v1/datasources/target-schemas
|
||||
POST /api/v1/datasources/{config_id}/run-mapped
|
||||
POST /api/v1/datasources/{config_id}/stop-mapped
|
||||
GET /api/v1/datasources/{config_id}/mapped-status
|
||||
DELETE /api/v1/datasources/configs/{config_id}?delete_mappings=true&delete_source_data=true
|
||||
```
|
||||
|
||||
`run-mapped?background=true` only matters for WebSocket sources and starts a background stream. REST sources remain one-shot collection runs.
|
||||
|
||||
### Delete And Data Cleanup
|
||||
|
||||
Deleting a custom source has three levels:
|
||||
|
||||
- Delete configuration only: preserve mapping and historical data.
|
||||
- Delete configuration and mapping: also delete mapping templates for that config.
|
||||
- Delete configuration, mapping, and source data: delete that source's `collected_data`, `ais_raw_observations`, and `ais_source_health`.
|
||||
|
||||
When deleted `vessel_ais` source data affects Earth, the backend broadcasts `reload_required` on the `vessels` channel so Earth reloads aggregated vessels. Legacy `vessel_position` rows are not deleted by custom source because that table cannot safely attribute rows back to a custom source.
|
||||
|
||||
### Local AIS Mock WebSocket
|
||||
|
||||
File:
|
||||
|
||||
- [mock-ais-ws-server.ts](/home/ray/dev/linkong/planet/scripts/mock-ais-ws-server.ts)
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bun run mock:ais-ws
|
||||
```
|
||||
|
||||
The mock service continuously sends AIS-like JSON to validate the chain: WebSocket custom source -> mapping -> AIS raw observation -> `vessels` channel -> Earth vessel upsert. Typical config:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_type": "websocket",
|
||||
"endpoint": "ws://localhost:8787",
|
||||
"config": {
|
||||
"target_schema": "vessel_ais",
|
||||
"delivery_mode": "realtime_stream",
|
||||
"merge_target_source": "barentswatch_vessels",
|
||||
"ws_message_path": "$.data",
|
||||
"ws_items_path": "$.vessels[*]",
|
||||
"ws_reconnect": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Credential Guide
|
||||
|
||||
File:
|
||||
@@ -345,6 +451,7 @@ Added coverage:
|
||||
Credential providers currently supported:
|
||||
|
||||
- `barentswatch`
|
||||
- `aisstream`
|
||||
- `spacetrack`
|
||||
|
||||
Other collectors with `requires_credentials=true` return that their credential chain has not been wired yet, and the frontend shows `Unavailable`.
|
||||
|
||||
116
docs/technical/en/docs-gatekeeper-development.md
Normal file
116
docs/technical/en/docs-gatekeeper-development.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Docs Gatekeeper Development Guide
|
||||
|
||||
Docs Gatekeeper moves `/docs` from "bundle all Markdown into the frontend" to "return catalog and content from the backend according to permissions." Its goal is to keep public manuals, user docs, developer docs, and admin/ops docs in one searchable Docs page while making every protected Markdown body pass through a server-side whitelist and authorization check.
|
||||
|
||||
For the user workflow, see the Docs section in [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md).
|
||||
|
||||
## Authorization Model
|
||||
|
||||
Docs uses two permission layers:
|
||||
|
||||
- `users.role`: preserved for console/system permissions.
|
||||
- `users.gatekeeper_groups`: Docs content permission groups.
|
||||
|
||||
Groups:
|
||||
|
||||
| Group | Purpose |
|
||||
| --- | --- |
|
||||
| `docs_user` | User-operation docs |
|
||||
| `docs_developer` | Earth, frontend, backend, collector, and AI Provider development docs |
|
||||
| `docs_admin` | Service control, operations, environment, and sensitive-operation docs |
|
||||
|
||||
Inheritance:
|
||||
|
||||
- Anonymous users can only read `public`.
|
||||
- `docs_developer` includes `docs_user`.
|
||||
- `docs_admin` includes `docs_developer` and `docs_user`.
|
||||
- `admin` and `super_admin` receive all Docs permissions by default.
|
||||
|
||||
## Backend Entry Points
|
||||
|
||||
Files:
|
||||
|
||||
- [docs.py](/home/ray/dev/linkong/planet/backend/app/api/v1/docs.py)
|
||||
- [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py)
|
||||
- [user.py](/home/ray/dev/linkong/planet/backend/app/models/user.py)
|
||||
- [users.py](/home/ray/dev/linkong/planet/backend/app/api/v1/users.py)
|
||||
|
||||
APIs:
|
||||
|
||||
```http
|
||||
GET /api/v1/docs/catalog
|
||||
GET /api/v1/docs/{lang}/{slug}
|
||||
```
|
||||
|
||||
`catalog` returns only documents visible to the current user. The content endpoint validates language, slug, and file existence through the metadata whitelist before checking access:
|
||||
|
||||
- Anonymous protected-doc request: `401`.
|
||||
- Authenticated but insufficient permissions: `403`.
|
||||
- Unknown language, unknown slug, or missing file: `404`.
|
||||
|
||||
Markdown bodies can only come from whitelisted files under `docs/technical/{zh,en}/`; arbitrary path reads are not allowed.
|
||||
|
||||
## Metadata Source
|
||||
|
||||
Server-side metadata lives in [docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/app/services/docs_gatekeeper.py):
|
||||
|
||||
```python
|
||||
DocsMetadata(
|
||||
"manual.md",
|
||||
"manual",
|
||||
"public",
|
||||
"Manual",
|
||||
2,
|
||||
"Planet 使用手册",
|
||||
"Planet Manual",
|
||||
)
|
||||
```
|
||||
|
||||
When adding a public technical doc:
|
||||
|
||||
- Add both Chinese and English Markdown files.
|
||||
- Add filename, slug, access, group, order, and titles to server `DOCS_METADATA`.
|
||||
- Add matching metadata to frontend [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts) so navigation titles and sorting stay aligned.
|
||||
- Update `docs/technical/zh/README.md` and `docs/technical/en/README.md` when the document should be discoverable from the README.
|
||||
|
||||
## User Management
|
||||
|
||||
The `users` table has `gatekeeper_groups JSONB DEFAULT '[]'`. Startup [session.py](/home/ray/dev/linkong/planet/backend/app/db/session.py) applies `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for existing local databases.
|
||||
|
||||
The user API:
|
||||
|
||||
- Writes `gatekeeper_groups` during user creation.
|
||||
- Validates group names on update: only `docs_user`, `docs_developer`, and `docs_admin` are accepted.
|
||||
- Allows only `super_admin` to modify Gatekeeper groups.
|
||||
|
||||
Frontend [Users.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Users/Users.tsx) displays group tags and provides a multi-select in the edit form. Non-`super_admin` users see the field disabled, and submission removes `gatekeeper_groups` before sending.
|
||||
|
||||
## Frontend Docs Loading
|
||||
|
||||
Files:
|
||||
|
||||
- [Docs.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/Docs.tsx)
|
||||
- [docs-content.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-content.ts)
|
||||
- [docs-search.ts](/home/ray/dev/linkong/planet/frontend/src/pages/Docs/docs-search.ts)
|
||||
|
||||
Key changes:
|
||||
|
||||
- Remove `import.meta.glob(...?raw)` as the Markdown content source.
|
||||
- Load `/api/v1/docs/catalog` to build the visible navigation.
|
||||
- Load `/api/v1/docs/{lang}/{slug}` for document bodies.
|
||||
- Index search only across currently visible docs, loading Markdown from the backend as needed.
|
||||
- Show login state for `401`, permission state for `403`, and unavailable-doc state for `404`.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Relevant tests:
|
||||
|
||||
- [test_docs_gatekeeper.py](/home/ray/dev/linkong/planet/backend/tests/test_docs_gatekeeper.py)
|
||||
|
||||
Tests should cover:
|
||||
|
||||
- Anonymous users only see public docs.
|
||||
- Protected content returns `401` or `403` appropriately.
|
||||
- `docs_developer` can read developer docs but not admin docs.
|
||||
- `admin` and `super_admin` can read admin docs.
|
||||
- Unknown slugs, unknown languages, and path traversal strings cannot read files.
|
||||
@@ -81,7 +81,38 @@ Responsibilities:
|
||||
- Status message
|
||||
- Tooltip / error / cleanup logic
|
||||
|
||||
### 5. Globe and Terrain
|
||||
### 5. Motion Capture Control Adapter
|
||||
|
||||
- [motion-control.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-control.js)
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- Act as the Motion Provider manager for both `browser_camera` and `motion_agent`.
|
||||
- Use browser `getUserMedia` plus local MediaPipe recognition by default; advanced setups can connect to the local Motion Capture Agent WebSocket.
|
||||
- Handle browser camera permission/secure-context errors, plus Agent disconnects, reconnects, `status`, and `heartbeat` messages.
|
||||
- Filter low-confidence and overly repeated gesture events.
|
||||
- Map `rotate_left`, `rotate_right`, `rotate_up`, `rotate_down`, `zoom_in`, `zoom_out`, `focus_prev`, `focus_next`, `layer_prev`, `layer_next`, and `confirm` to the action entry points exposed by `main.js`.
|
||||
- Parse `skeleton` debug events and dispatch `earth:motion-debug-frame`.
|
||||
|
||||
Gesture recognition may run locally in the browser or inside the local Agent, but neither path sends realtime camera frames to the SaaS cloud. `main.js` exposes rotation, zoom, target focus, layer switching, and confirm entry points, plus a `window.__planetEarth.motion` debug entry. The adapter starts only when `?motion=1` is present, browser local storage contains `planet-earth-motion-control-enabled=true`, or Earth settings enable Motion Debug Mode.
|
||||
|
||||
[motion-debug-panel.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-debug-panel.js) owns the debug panel. It listens for `earth:motion-debug-frame` and draws normalized skeleton joints and bones on a canvas. The Browser Camera provider also emits `earth:motion-debug-video-source` with the local `<video>` element so the panel can show a local preview behind the skeleton; `shared.motionDebugSkeletonOnly` switches the panel back to skeleton-only rendering. `Stop Matching Gestures` dispatches `earth:motion-recognition-pause`, which suppresses gesture execution while video and skeleton drawing continue. Unmatched skeletons are red; matched gestures turn green and display the gesture name. Settings are persisted under `shared.motionDebugEnabled`, `shared.motionProvider`, and `shared.motionDebugSkeletonOnly` in `planet.earth.settings.v2`, and both the switch and provider selector reserve `data-gatekeeper-permission="earth.motion_debug"`.
|
||||
|
||||
The Browser Camera provider's gesture pipeline lives in `recognizeGesture()` inside [motion-browser-provider.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/motion-browser-provider.js). Detectors are evaluated in this order, first match wins:
|
||||
|
||||
1. **`getZoomTrend` (trend zoom)** — derived from the per-frame change in `Math.abs(rightWrist.x - leftWrist.x)`. Both wrists must cross the noise floor (`ZOOM_TREND_MIN_WRIST_DELTA = 0.010`) and stay within `ZOOM_TREND_HEIGHT_TOLERANCE` of each other vertically. Growing span → `zoom_in`, shrinking span → `zoom_out`. Trend has highest priority so mid-motion frames cannot be hijacked by the layer/focus/rotate detectors.
|
||||
2. **`getZoomHoldPose` (sustained zoom)** — after motion stops, keeps emitting `zoom_in` while the wrists stay at chest level or above with span > `ZOOM_HOLD_SPREAD_FACTOR × shoulderWidth` (default 1.30), and `zoom_out` while elbows sit visibly outward and span < `ZOOM_HOLD_CLOSE_FACTOR × shoulderWidth` (default 0.85).
|
||||
3. **layer / focus** — left-wrist raise + vertical motion fires `layer_prev/next`; head tilt fires `focus_prev/next`.
|
||||
4. **`getRightArmPattern` (single-arm rotate)** — only considered when both `!isZoomCandidatePose(...)` and `isLeftArmAtRest(...)` hold. `isLeftArmAtRest` requires the left wrist to hang clearly below the shoulder line (≥ 0.13) and both left elbow and left wrist to stay near the body — any ambiguous left-arm posture (mid-spread, raised, held at chest) blocks single-arm rotate.
|
||||
|
||||
Two non-obvious decisions worth preserving:
|
||||
|
||||
- **Mirror-safe**: all zoom checks use `Math.abs(rightWrist.x - leftWrist.x)` and never rely on per-side x direction. `getUserMedia` returns the raw camera feed without horizontal flip, so a subject's anatomical left arm appears on the image right. A direction-based detector (e.g. "left wrist moves left, right wrist moves right") inverts on non-mirrored feeds — span-based detection is invariant.
|
||||
- **Continuous vs. discrete**: `rotate`, `layer`, `focus`, and `confirm` go through `applyPoseLatch`, which emits each gesture once until the pose returns to neutral (one wave = one rotation step). Zoom intentionally bypasses the latch and re-matches every frame; downstream `GESTURE_POLICIES.zoom_in/out.cooldownMs = 120` rate-limits to ~8 emits/sec, so holding a spread pose keeps zooming in until the user changes their pose. Do not reuse the latch for zoom — that semantic difference is the point.
|
||||
|
||||
[presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) is the new Presentation layer. In the first stage only Motion uses it: `motion-cruise-adapter.js` uses a persistent presentation that reuses the cruise fixed-card placement and connector, but mouse movement does not auto-hide the card. The connector recalculates source and target anchors every frame so dragged cards, globe rotation, and moving targets stay connected. BGP/News still use the existing `CruiseSequencer` auto-advance path to preserve the old cruise experience.
|
||||
|
||||
### 6. Globe and Terrain
|
||||
|
||||
- [earth.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/earth.js)
|
||||
- [terrain.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/terrain.js)
|
||||
@@ -92,14 +123,14 @@ Responsibilities:
|
||||
- Real terrain mesh
|
||||
- Terrain tile fetch, decode, displacement, and shading
|
||||
|
||||
### 6. Layer Modules
|
||||
### 7. Layer Modules
|
||||
|
||||
- [satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
- [cables.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cables.js)
|
||||
- [vessels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/vessels.js)
|
||||
- [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js)
|
||||
- [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
||||
- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js)
|
||||
- [compute-centers.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/compute-centers.js) renders supercomputer and GPU-cluster markers. The backend renders compute centers only from source-provided coordinates or `compute_center_locations` dimension-table coordinates during startup; manual candidate collection can query ROR and Nominatim/OpenStreetMap, and the layer keeps the `?` badge for unconfirmed positions while the details card shows precision, confidence, source notes, and verification date.
|
||||
- [country-boundaries.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/country-boundaries.js)
|
||||
|
||||
Each module is responsible for its own:
|
||||
@@ -109,29 +140,43 @@ Each module is responsible for its own:
|
||||
- State tracking (loaded, visible, hover, locked)
|
||||
- Self-cleanup (dispose on scene destroy)
|
||||
|
||||
`tv.js` owns the live / aggregation-news tabs inside `media-panel`. Toolbar open and tab-switch actions write back through `earth:tv-visibility-change` and `earth:tv-tab-change`: panel visibility remains viewport-scoped at `views.<scope>.panelVisibility.media-panel`, while the active tab is stored at `shared.mediaPanelActiveTab`. Refreshing the page therefore restores the user's last live/news state. Temporary hides from `closeTransientMobileOverlays()` carry `persist:false` and do not overwrite the preference.
|
||||
|
||||
The compute-center layer row has a notification badge for GeoJSON `unresolved` records. The badge means "no trustworthy coordinates, cannot render on the globe"; it is different from the `?` marker drawn on already positioned but unconfirmed compute centers. Clicking the badge opens a fixed info card beside the layer panel. Row-level `采集` fetches candidates only. Header-level `一键采用` processes the queue top-to-bottom, saves the highest-confidence valid candidate, removes successful rows, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch ends, `earth:compute-center-location-saved` refreshes the real layer.
|
||||
|
||||
Location candidate state in the details card is cached in [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) by `entityType:entityId`. If the user closes the details card or unresolved queue and reopens the same compute center / BGP collector, previously collected candidates and status text are restored. Header-level `一键采用` prefers cached candidates, avoiding repeated online geocoding or LLM factcheck calls. After a location is saved, that entity's candidate list is cleared to a "refreshing layer" status so stale candidates do not keep misleading the user.
|
||||
|
||||
The `预览 / 保存` buttons on each candidate row use a single delegated `click` handler per candidate root (the `[data-collect-cache-key]` block in the details card, or `[data-unresolved-item]` in the unresolved queue), guarded by a `data-candidate-actions-bound` flag so it cannot be double-bound. Direct `pointerup` / `click` listeners on individual buttons and overlapping delegated handlers were removed. Candidate objects are no longer JSON-stringified into an HTML attribute and parsed back; buttons only carry `data-candidate-index`, and the handler resolves the candidate object from a module-level `Map` keyed by cache-key. This removes the entire class of failures caused by HTML entity escaping of `&` / `<` / `"` in candidate fields. Clicking `预览` dispatches `earth:preview-location-candidate`; `main.js`'s `previewLocationCandidate()` calls `showComputeCenterLocationPreview()`, which attaches a hollow breathing-ring sprite pair at the candidate coordinates (visually mirroring the BGP event ring) and focuses the camera on the candidate. Previewing another candidate replaces the ring; saving clears it and `spawnSavedComputeCenterLocation()` immediately spawns the formal compute-center interactable. Note that `main.js` has no module-level `earth` variable — every location-save / preview handler must call `const earth = getEarth();` first, otherwise the event handler throws a `ReferenceError` that the surrounding `.catch` swallows, producing the failure mode where the button "does nothing".
|
||||
|
||||
The `earth:compute-center-location-saved` reconciliation pipeline is deliberately silent on background-refresh failures. `spawnComputeCenterAfterLocationSave()` already presents the success toast and locked state; `refreshComputeCentersAfterLocationSave()` only reloads backend data when the scene is ready and no longer emits its own `已保存` toast. `handleComputeCenterLocationSaved()` runs refresh in the background after a successful spawn; only when spawn returns `null` (scene not ready) or throws does refresh take over the success toast. A refresh error is only `console.warn`'d — it must never surface as a `保存失败` message, because the save itself succeeded and the refresh is a follow-up sync.
|
||||
|
||||
### AIS Vessel Layer
|
||||
|
||||
The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap.
|
||||
The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection.
|
||||
|
||||
The legacy `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend no longer merges legacy `vessel_position` / `vessel_static` rows into vessel snapshots, so the frontend must not depend on old BarentsWatch-only fallback rows.
|
||||
|
||||
The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport.
|
||||
|
||||
Vessel color and vessel type text must use the same normalized classification. `vessels.js` derives `type` from both `vessel_type_name` and the AIS numeric `vessel_type` code; that `type` drives marker color. It also derives `vessel_type_display`, which `main.js` uses for the info card, hover summary, and search result subtitle. Do not make the info card read only the raw `vessel_type_name`, because AISStream can provide a numeric type while the raw name is still `Other`.
|
||||
|
||||
AISStream `PositionReport` messages commonly carry live position and `MetaData.ShipName`, while vessel type usually comes from lower-frequency `ShipStaticData.Type`. The backend normalizes `MetaData.ShipName` into the vessel name and maps numeric type codes into Cargo / Tanker / Passenger / Fishing / Military where available. Missing type detail should wait for a static AIS message or the planned vessel profile enrichment; the frontend should not invent a more specific type.
|
||||
|
||||
### 7. HUD Panels and Search
|
||||
### 8. HUD Panels and Search
|
||||
|
||||
- [hud-panels.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/hud-panels.js)
|
||||
- [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
|
||||
- [search.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/search.js)
|
||||
- [legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js)
|
||||
|
||||
### 8. Cruise Mode
|
||||
### 9. Cruise Mode
|
||||
|
||||
- [cruise-sequencer.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/cruise-sequencer.js)
|
||||
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||
|
||||
The cruise sequencer handles generic logic: current target, queue order, camera focus, and dwell / hide / switch. Business modules supply target queues and content — they should not contain camera control logic.
|
||||
|
||||
### 9. Constants
|
||||
### 10. Constants
|
||||
|
||||
- [constants.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/constants.js)
|
||||
|
||||
@@ -154,6 +199,8 @@ Layer toggle buttons use `data-status-target` attributes to link button state to
|
||||
|
||||
This is the canonical way to synchronize button visual state with actual layer state. Do not maintain separate boolean flags for button display.
|
||||
|
||||
Terrain should not block startup when it is not the restored visible layer. After deferred layer visibility settings are applied, `controls.js` schedules `scheduleTerrainPrefetch()` only when HD texture is enabled, terrain is not ready, and no prefetch is already running. The prefetch uses `setTimeout` plus `requestIdleCallback` so cloud, HD texture, and startup layer work keep first-screen priority.
|
||||
|
||||
## Current Settings Persistence
|
||||
|
||||
Earth settings are stored in `localStorage`. The key is typically a namespaced string defined in `constants.js`. `controls.js` handles read, write, and reset.
|
||||
@@ -171,6 +218,8 @@ Settings that affect visual layers (terrain opacity, day/night mode, satellite d
|
||||
|
||||
When HD texture is off, terrain is temporarily hidden and its state is remembered. When HD texture comes back on, terrain restores its prior visibility.
|
||||
|
||||
Terrain tile fetching is batched. `terrain.js` deduplicates required Terrarium tile keys and sends chunks sized by `TERRAIN_CONFIG.batchRequestSize` to `/api/v1/visualization/terrain/terrarium/batch`. The backend proxies S3 Terrarium tiles with an in-memory LRU cache, per-batch deduplication, and bounded concurrency. The single tile endpoint remains for fallback paths and browser cache semantics.
|
||||
|
||||
## Current High-Frequency Risk Points
|
||||
|
||||
### 1. Visual State and Business State Out of Sync
|
||||
|
||||
86
docs/technical/en/earth-toolbar-overlay-coordination.md
Normal file
86
docs/technical/en/earth-toolbar-overlay-coordination.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Earth Toolbar And Overlay Coordination
|
||||
|
||||
This document describes the current coordination rules between the Earth toolbar buttons and the search panel, settings modal, news/live panel, and layer panel. Use this matrix when changing interactions, adding buttons, or adjusting panels so one action does not close an unrelated overlay.
|
||||
|
||||
Related entries:
|
||||
|
||||
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
|
||||
- [Frontend Layout Guidelines](/home/ray/dev/linkong/planet/docs/technical/en/frontend-layout-guidelines.md)
|
||||
|
||||
## Toolbar Button Directory
|
||||
|
||||
The toolbar is marked by `.earth-toolbar-btn` in [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html):
|
||||
|
||||
| ID | Title | Type | Overlay / action |
|
||||
|----|-------|------|------------------|
|
||||
| `layer-action` | Layers | Overlay toggle | HUD panel `layer-toggles` on desktop / mobile drawer `layers` card |
|
||||
| `search-action` | Search | Overlay toggle | Search panel on desktop / mobile drawer `search` card |
|
||||
| `rotate-toggle` | Auto rotate | Standalone toggle | No overlay |
|
||||
| `toggle-tv` | News live | Overlay toggle | Media panel `media-panel` with TV and News tabs |
|
||||
| `reload-data` | Reload data | Standalone action | No overlay |
|
||||
| `zoom-trigger` | Zoom control | Floating menu | Zoom floating menu |
|
||||
| `settings-trigger` | Settings | Overlay toggle | Settings modal on desktop / mobile drawer `settings` card |
|
||||
| `reset-view` | Reset view | Standalone action | No overlay |
|
||||
| `layout-toggle` | Maximize layout | Standalone toggle | No overlay |
|
||||
|
||||
## Shared Coordination Entry Point
|
||||
|
||||
[controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) is the shared coordinator for deciding what should close when an overlay opens.
|
||||
|
||||
Every path that opens a fullscreen-style overlay calls `closeTransientMobileOverlays({ except })`, where `except` names the overlay that should stay open:
|
||||
|
||||
```js
|
||||
closeTransientMobileOverlays({ except: "search" });
|
||||
closeTransientMobileOverlays({ except: "settings" });
|
||||
closeTransientMobileOverlays({ except: "media" });
|
||||
closeTransientMobileOverlays({ except: "layer-toggles" });
|
||||
```
|
||||
|
||||
Current `except` values are `"search"`, `"settings"`, `"media"`, `"layer-toggles"`, or omitted to close all transient overlays.
|
||||
|
||||
## Close Matrix
|
||||
|
||||
`close` means the overlay closes; `keep` means it remains open.
|
||||
|
||||
| Action | Search | Settings | Mobile layers drawer | News/live |
|
||||
|--------|:------:|:--------:|:--------------------:|:---------:|
|
||||
| Open search (`except: "search"`) | self | close | close | keep |
|
||||
| Open settings (`except: "settings"`) | close | self | close | keep |
|
||||
| Open news/live (`except: "media"`) | close | close | close | self |
|
||||
| Open mobile layers (`except: "layer-toggles"`) | close | close | self | close |
|
||||
| Close all (`except: null`) | close | close | close | close |
|
||||
|
||||
Examples:
|
||||
|
||||
- Clicking toolbar Settings closes search and the mobile layer drawer, but keeps news/live open.
|
||||
- Clicking toolbar Layers on mobile opens the `layers` drawer and closes search, settings, and news.
|
||||
- Clicking News Live closes search, settings, and the layer drawer, then toggles the media panel.
|
||||
|
||||
## Design Rules
|
||||
|
||||
1. **Floating menus such as `zoom-trigger` are not overlays.** They use `bindFloatingMenu` and are managed separately by `closeFloatingMenus()`. Opening any overlay first closes floating menus.
|
||||
2. **Desktop `layer-toggles` is a persistent HUD panel.** `closeTransientMobileOverlays` only closes it when `activeMobileDrawerId === "layer-toggles"`, so desktop search, settings, and news do not disturb the layer panel.
|
||||
3. **News/live is independent from settings.** Users often adjust collector settings while watching news, so opening settings does not close the media panel. This became an invariant after the May 2026 coordination patch.
|
||||
4. **Search and news are both primary information overlays.** Search opens without closing news, and news opens without closing search. If product direction changes, update both sides in `closeTransientMobileOverlays` so the matrix stays symmetric.
|
||||
5. **Mobile drawers are fullscreen-focus states.** Any mobile drawer, whether layers, search, or settings, uses `setMobileDrawerState` and closes other overlays.
|
||||
6. **Escape has a fixed close order.** See [controls.js::setupKeyboardControls](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js): search, settings, mobile drawer, floating menu, toolbar hub, locked object.
|
||||
|
||||
## Adding A Button Or Overlay
|
||||
|
||||
1. Add the button in the `.earth-toolbar` container in [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html), using the existing `floating-btn liquid-glass-surface earth-toolbar-btn` class pattern.
|
||||
2. Decide whether it is a standalone action, a floating menu, or a mutually coordinated overlay.
|
||||
3. For a coordinated overlay, call `closeTransientMobileOverlays({ except: "<your-key>" })` when opening it.
|
||||
4. Add the reciprocal close branch inside `closeTransientMobileOverlays`, so other overlays can close yours.
|
||||
5. If the new overlay should coexist with an existing overlay, exclude that peer on both sides of the matrix.
|
||||
6. Add an Escape close path in `setupKeyboardControls`.
|
||||
7. On mobile, use `setMobileDrawerState({ open: true, card: "<your-card>" })` for drawer-style panels.
|
||||
|
||||
## Current Implementation Locations
|
||||
|
||||
- Coordinator: [controls.js::closeTransientMobileOverlays](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- Settings overlay: [controls.js::openSettingsModal / closeSettingsModal](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- Search overlay: [controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js), imported from the search module
|
||||
- News/live overlay: [tv.js::setTVPanelVisible](/home/ray/dev/linkong/planet/frontend/public/earth/js/tv.js), with the News tab in [news.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/news.js)
|
||||
- Mobile layer drawer: [controls.js::setMobileDrawerState](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- Floating menu: [controls.js::bindFloatingMenu](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js)
|
||||
- Toolbar DOM: [index.html](/home/ray/dev/linkong/planet/frontend/public/earth/index.html)
|
||||
328
docs/technical/en/faq.md
Normal file
328
docs/technical/en/faq.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# FAQ
|
||||
|
||||
This page collects common troubleshooting paths for local startup, Windows / WSL, dependencies, motion capture, credentials, and Docs permissions. Deeper background stays in the topic-specific docs; this page focuses on what to check first and which command to run.
|
||||
|
||||
## Startup and Ports
|
||||
|
||||
### What should I do when the backend port is already in use?
|
||||
|
||||
The error usually looks like:
|
||||
|
||||
```text
|
||||
Backend address is already in use: 0.0.0.0:8000 / 127.0.0.1:8000 / [::1]:8000
|
||||
Address already in use
|
||||
```
|
||||
|
||||
First try:
|
||||
|
||||
```bash
|
||||
./planet.sh restart -b
|
||||
```
|
||||
|
||||
If the port remains occupied, start on a different backend port:
|
||||
|
||||
```bash
|
||||
./planet.sh start -b 8001
|
||||
```
|
||||
|
||||
In WSL, the listener may be on the Windows side rather than a Linux process. A common diagnostic line looks like:
|
||||
|
||||
```text
|
||||
Windows listener: 0.0.0.0:8000 pid=4700 process=svchost.exe services=iphlpsvc
|
||||
```
|
||||
|
||||
`iphlpsvc` is the Windows IP Helper service. It often hosts IPv6, tunneling, proxying, port forwarding, WSL, or developer-tool networking features. Do not start by killing that `svchost.exe`; first check whether an old portproxy rule owns the port.
|
||||
|
||||
From Administrator PowerShell, inspect portproxy rules:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy show all
|
||||
```
|
||||
|
||||
If you see `0.0.0.0:8000` or `listenport=8000`, delete that rule:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
|
||||
```
|
||||
|
||||
If there is no portproxy rule, confirm which services are hosted by that PID:
|
||||
|
||||
```powershell
|
||||
netstat -ano | findstr :8000
|
||||
tasklist /svc /fi "PID eq 4700"
|
||||
```
|
||||
|
||||
For temporary troubleshooting, you can stop IP Helper from Administrator PowerShell:
|
||||
|
||||
```powershell
|
||||
Stop-Service iphlpsvc
|
||||
```
|
||||
|
||||
This may affect networking, proxying, or forwarding features. Do not disable it long-term unless you know why it is safe. If the Windows forwarding rule must stay, use a different Planet backend port.
|
||||
|
||||
If the script prints `failed-stop-service` or `failed-stop-process`, the current shell does not have permission to clear the Windows listener. Startup stops immediately instead of launching the backend into the same port conflict.
|
||||
|
||||
### Which startup flags change default ports?
|
||||
|
||||
| Service | Default port | Flag |
|
||||
| --- | --- | --- |
|
||||
| Frontend | `3000` | `-f <port>` |
|
||||
| Backend | `8000` | `-b <port>` |
|
||||
| AI Provider | `8010` | `-a <port>` |
|
||||
| Motion Agent | `8765` | `--motion-agent-port <port>` |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
./planet.sh start -f 3001 -b 8001 -a 8101
|
||||
```
|
||||
|
||||
## Windows / WSL / LAN
|
||||
|
||||
### LAN access does not work on Windows / WSL. What should I check?
|
||||
|
||||
Check in this order before changing firewall rules:
|
||||
|
||||
```bash
|
||||
# In WSL or the shell running Planet
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Then verify from Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
curl http://localhost:3000
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
If both localhost checks pass but a phone or another computer cannot connect, start with LAN enabled:
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
The flag must be written as `--allow-lan`. `allowlan` or `--allowlan` is not recognized by the startup script. If Planet is already running and you only need to reopen the frontend on the LAN, restart the frontend explicitly:
|
||||
|
||||
```bash
|
||||
./planet.sh restart -f 3000 --allow-lan
|
||||
```
|
||||
|
||||
If `ss -ltnp` shows the frontend listening on `0.0.0.0:3000`, but `Test-NetConnection <Windows LAN IP> -Port 3000` still fails from Windows PowerShell, the problem is usually Windows-side forwarding or firewall policy rather than Vite or `.zshrc`.
|
||||
|
||||
For traditional WSL NAT networking, configure portproxy and firewall from Administrator PowerShell:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
|
||||
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
|
||||
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
|
||||
```
|
||||
|
||||
If `wslinfo --networking-mode` prints `mirrored`, also check Hyper-V firewall. Even when ordinary Windows Firewall rules exist, Hyper-V firewall can still block external devices from reaching WSL. From Administrator PowerShell, allow the required ports:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
|
||||
New-NetFirewallHyperVRule -Name "Planet-Backend-8000" -DisplayName "Planet Backend 8000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 8000 -Action Allow
|
||||
```
|
||||
|
||||
Use these commands to inspect the current Hyper-V firewall state:
|
||||
|
||||
```powershell
|
||||
Get-NetFirewallHyperVVMSetting -Name "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
|
||||
Get-NetFirewallHyperVRule -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}"
|
||||
```
|
||||
|
||||
LAN devices should open the Windows LAN IP, for example `http://<Windows LAN IP>:3000/earth`, not the internal WSL IP.
|
||||
|
||||
### How do `--allow-lan` and the Motion Agent LAN URL fit together?
|
||||
|
||||
`--allow-lan` binds the frontend, backend, and optional Motion Agent to `0.0.0.0`. If a remote browser needs to connect to the display machine's Motion Agent, pass the Agent URL explicitly:
|
||||
|
||||
```text
|
||||
http://<LAN_IP>:3000/earth?motion=1&motionProvider=agent&motionAgent=ws://<LAN_IP>:8765/ws/gestures
|
||||
```
|
||||
|
||||
Browser Camera mode does not need a `motionAgent` URL.
|
||||
|
||||
## Dependencies and Environment Variables
|
||||
|
||||
### Why should I use `uv` instead of `pip`?
|
||||
|
||||
Planet manages Python dependencies through `uv` and `pyproject.toml`. Avoid `pip install` in the project environment, because it can diverge from the lock file and startup scripts.
|
||||
|
||||
For Motion Agent live dependencies, use:
|
||||
|
||||
```bash
|
||||
uv add mediapipe opencv-python
|
||||
```
|
||||
|
||||
`planet.sh start --motion-agent` checks and installs those live dependencies automatically. To disable auto-install:
|
||||
|
||||
```bash
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
|
||||
```
|
||||
|
||||
### Why should I use `bun` instead of `npm run`?
|
||||
|
||||
The frontend runtime is Bun. This avoids WSL / Windows mixed-path issues that can happen when npm invokes `cmd.exe`.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev
|
||||
bun run build
|
||||
```
|
||||
|
||||
If a non-interactive shell cannot find `bun`, `planet.sh` searches the current PATH, `~/.bun/bin`, zsh config, and PowerShell command resolution.
|
||||
|
||||
### When does `planet.sh` read environment variables from `.zshrc`?
|
||||
|
||||
By default, `planet.sh` statically parses simple lines in `~/.zshrc`:
|
||||
|
||||
```bash
|
||||
export KEY=value
|
||||
KEY=value
|
||||
```
|
||||
|
||||
This avoids slow shell themes, plugins, and interactive initialization. For complex shell expansion, opt in to source mode:
|
||||
|
||||
```bash
|
||||
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
|
||||
```
|
||||
|
||||
To ignore `~/.zshrc` while troubleshooting:
|
||||
|
||||
```bash
|
||||
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
|
||||
```
|
||||
|
||||
Never put real secret values in docs or commits; documentation should only mention variable names and purposes.
|
||||
|
||||
## Motion Capture / Cameras
|
||||
|
||||
### Does Browser Camera mode need the `motionAgent` parameter?
|
||||
|
||||
No. Browser Camera mode uses webpage `getUserMedia` and runs recognition locally in the browser.
|
||||
|
||||
Recommended URL:
|
||||
|
||||
```text
|
||||
/earth?motion=1&motionProvider=browser
|
||||
```
|
||||
|
||||
You can also open Earth settings, enable Motion Debug Mode, and select Browser Camera as the input source. The page must run on HTTPS or localhost, and the user must grant browser camera permission.
|
||||
|
||||
### When do I need Motion Agent?
|
||||
|
||||
Use Motion Agent for:
|
||||
|
||||
- dual USB cameras
|
||||
- RTSP / HTTP camera streams
|
||||
- edge devices or client integration
|
||||
- a standalone local recognition service
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent
|
||||
./planet.sh start --motion-agent --motion-agent-camera-indexes 0,1
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls rtsp://example/live
|
||||
./planet.sh start --motion-agent --motion-agent-dry-run
|
||||
```
|
||||
|
||||
`--motion-agent-dry-run` is only for protocol and frontend connection testing; it does not open cameras.
|
||||
|
||||
### Why does WSL not find my camera?
|
||||
|
||||
Windows cameras usually do not appear inside WSL as `/dev/video*`. Check first:
|
||||
|
||||
```bash
|
||||
ls /dev/video*
|
||||
```
|
||||
|
||||
If no device appears, use Browser Camera for ordinary web demos. For Agent live mode, use an RTSP / HTTP camera URL:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
|
||||
```
|
||||
|
||||
USB passthrough into WSL is an advanced path. The script does not silently downgrade missing-camera live mode to dry-run.
|
||||
|
||||
## Docker / AI Provider
|
||||
|
||||
### Why does changing the AI key, base URL, or model not rebuild the image?
|
||||
|
||||
Keys, base URLs, and model names are runtime configuration. They do not require a Docker image rebuild. Restart AI Provider:
|
||||
|
||||
```bash
|
||||
./planet.sh restart -a
|
||||
```
|
||||
|
||||
The first Docker build may be slow because of image layers or `uv sync` dependency downloads. Later builds reuse `.dockerignore`, BuildKit, and uv cache.
|
||||
|
||||
### What should I do when Docker health checks fail?
|
||||
|
||||
Start with:
|
||||
|
||||
```bash
|
||||
./planet.sh health
|
||||
```
|
||||
|
||||
Then inspect logs:
|
||||
|
||||
```bash
|
||||
./planet.sh log
|
||||
```
|
||||
|
||||
If only AI Provider is unhealthy, restart just that service:
|
||||
|
||||
```bash
|
||||
./planet.sh restart -a
|
||||
```
|
||||
|
||||
## Datasource and Collector Credentials
|
||||
|
||||
### Connectivity validation passes, but collection cannot read credentials. Why?
|
||||
|
||||
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Settings -> Collector Settings, especially for AISStream's long-lived WebSocket collector.
|
||||
|
||||
If `AISSTREAM_API_KEY` only lives in `~/.zshrc`, confirm the backend process actually inherited it. Otherwise validation may pass while the collector runtime has no key.
|
||||
|
||||
### Where should BarentsWatch / AISStream credentials live?
|
||||
|
||||
For temporary debugging, environment variables or `~/.zshrc` are fine:
|
||||
|
||||
```bash
|
||||
export AISSTREAM_API_KEY="..."
|
||||
export BARENTSWATCH_CLIENT_ID="..."
|
||||
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
For stable operation, save credentials in Collector Settings so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
|
||||
|
||||
## Docs / Permissions
|
||||
|
||||
### Why can I not see some Docs pages?
|
||||
|
||||
Docs visibility is controlled by Gatekeeper groups:
|
||||
|
||||
- Quickstart, Manual, FAQ, and other basic docs are public.
|
||||
- Development docs usually require `docs_developer`.
|
||||
- Operations and service-control docs usually require `docs_admin`.
|
||||
- `admin` and `super_admin` have Docs access by default; ordinary users need groups assigned from the Users page.
|
||||
|
||||
## Earth Common Tasks
|
||||
|
||||
### Why did collecting a location candidate not write anything?
|
||||
|
||||
Collecting and saving are two separate actions. Candidates can be previewed on Earth first. A candidate is written only after clicking Save or using the unresolved list's one-click adopt flow.
|
||||
|
||||
Compute-center saves write to `compute_center_locations` and refresh the layer. Records with no candidate stay in the unresolved list; Planet does not fabricate a location from a country center or hard-coded hint.
|
||||
|
||||
### Why does Motion Debug not show camera video?
|
||||
|
||||
With the Browser Camera source, the debug panel shows the local browser camera preview and draws the skeleton over it. If `Skeleton Only` is enabled, the video preview is hidden and the panel keeps only the dark canvas plus red/green skeleton.
|
||||
|
||||
With the Motion Agent source, the Agent WebSocket sends normalized joints, bones, and matched gestures only. It does not stream raw camera frames to Earth, which keeps privacy risk, bandwidth, and latency lower. In that mode the panel is a skeleton debug view rather than a video stream.
|
||||
@@ -32,7 +32,7 @@ Current admin-related routes:
|
||||
- `/alerts/bgp`
|
||||
- `/alerts/situational`
|
||||
- `/bgp`
|
||||
- `/playground`
|
||||
- `/ai`
|
||||
- `/settings`
|
||||
|
||||
`/earth` is a standalone display page and is not part of the console shell.
|
||||
@@ -155,6 +155,7 @@ Purpose:
|
||||
- Renders Markdown content for `/docs`
|
||||
- Supports headings, lists, blockquotes, code blocks, tables, and basic inline formatting
|
||||
- Code blocks and tables reuse `Scrollbar` so horizontal content does not blow out the docs page
|
||||
- Docs content is returned by backend `/api/v1/docs/...` endpoints according to Gatekeeper permissions; the frontend only renders content visible to the current user
|
||||
|
||||
Current constraints:
|
||||
|
||||
@@ -162,7 +163,25 @@ Current constraints:
|
||||
- Internal document links should be converted to `/docs/:slug` through `transformLink`
|
||||
- Heading anchors are injected through `getHeadingId`, keeping route state outside the renderer
|
||||
|
||||
### 6. `TableActions`
|
||||
### 6. `ConnectionTestInput`
|
||||
|
||||
File:
|
||||
|
||||
- [ConnectionTestInput.tsx](/home/ray/dev/linkong/planet/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx)
|
||||
|
||||
Purpose:
|
||||
|
||||
- Console form fields that combine an endpoint/Base URL value with a connection check
|
||||
- Connection-test entry points for AI Provider and WebSearch
|
||||
- Future collector configuration fields should reuse it when the test action belongs inside the input
|
||||
|
||||
Current constraints:
|
||||
|
||||
- The input suffix shows a single plug/connector icon, not an adjacent text button
|
||||
- Disabled integrations must grey out both the input and its connection-test action
|
||||
- The component only combines the input and action; callers still own form state, loading, disabled state, and the request itself
|
||||
|
||||
### 7. `TableActions`
|
||||
|
||||
File:
|
||||
|
||||
@@ -190,11 +209,32 @@ Responsibilities:
|
||||
|
||||
- Token
|
||||
- Current user
|
||||
- Gatekeeper groups
|
||||
- Login / logout
|
||||
|
||||
`App.tsx` uses it to decide whether to redirect to the login page.
|
||||
`App.tsx` uses it to decide whether to redirect to the login page. `/docs` remains a public route, but the backend decides the visible catalog and content from the token; anonymous visitors only receive public docs.
|
||||
|
||||
### 2. Business Data Gateway
|
||||
### 2. AI
|
||||
|
||||
File:
|
||||
|
||||
- [AISettings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/AISettings/AISettings.tsx)
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings`
|
||||
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating
|
||||
- The `工具` tab first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR
|
||||
- WebSearch configuration includes provider, search key, base URL, timeout, result count, and advanced provider options
|
||||
- OCR configuration includes provider, Base URL, API key, model/engine, languages, timeout, file-size limit, and output format
|
||||
- The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging
|
||||
- The page reuses the Settings single-screen tabs, panel card, and internal scrolling style
|
||||
- AI Provider and WebSearch connection tests use `ConnectionTestInput`, with the connector icon fixed at the end of the Base URL input; when WebSearch is disabled, every configuration field and the test entry point are greyed out except the switch
|
||||
|
||||
Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`.
|
||||
Legacy `/playground` should redirect to `/ai?tab=playground`.
|
||||
|
||||
### 3. Business Data Gateway
|
||||
|
||||
AI / situational awareness related services are currently in:
|
||||
|
||||
|
||||
223
docs/technical/en/location-pipeline-development.md
Normal file
223
docs/technical/en/location-pipeline-development.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Shared Location Resolution Pipeline Development Guide
|
||||
|
||||
`backend/app/services/location/` is the shared abstraction for any "given a record, decide its lat/lon" workflow. Compute centers, BGP collectors, and BGP events now run on this pipeline. Future entities such as satellite ground stations, user-claimed points, and IXP facilities should plug in here instead of creating another geocoding path.
|
||||
|
||||
For the user workflow, see [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md).
|
||||
|
||||
## Design Goals
|
||||
|
||||
Historically compute centers had their own four-tier chain, BGP collectors used a hard-coded dictionary, and BGP events inherited collector coordinates. These implementations did not share code, and new algorithms had no stable insertion point.
|
||||
|
||||
The refactored rules:
|
||||
|
||||
- Share the `LocationResolver` protocol and `LocationPipeline` orchestrator.
|
||||
- Domain modules only build `LocationQuery` and choose resolver order.
|
||||
- New algorithms join by adding resolver classes, without changing ingestion, API, or frontend envelopes.
|
||||
- Earth renders only city-level or better locations.
|
||||
- Local JSON registries are not runtime candidate sources for compute centers or BGP collectors; persisted location facts live in database dimension tables.
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class LocationQuery:
|
||||
name: str | None
|
||||
aliases: tuple[str, ...]
|
||||
city: str | None
|
||||
country: str | None
|
||||
region: str | None
|
||||
source_latitude: float | None
|
||||
source_longitude: float | None
|
||||
extra: Mapping[str, Any]
|
||||
```
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class LocationCandidate:
|
||||
latitude: float
|
||||
longitude: float
|
||||
display_name: str
|
||||
precision: str
|
||||
confidence: float
|
||||
source: str
|
||||
needs_confirmation: bool
|
||||
matched_fields: tuple[str, ...]
|
||||
suggested_registry_entry: dict | None
|
||||
```
|
||||
|
||||
```python
|
||||
class LocationResolver(Protocol):
|
||||
name: str
|
||||
def resolve(self, query: LocationQuery) -> ResolverOutput: ...
|
||||
```
|
||||
|
||||
`LocationPipeline.collect_candidates()` returns sorted candidates plus `attempted_queries`; `resolve_best()` returns the best candidate with diagnostics. The default sort key ranks source, precision, and confidence, then deduplicates candidates with the same source and rounded coordinates.
|
||||
|
||||
## Built-In Resolvers
|
||||
|
||||
| Resolver | File | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `SourceCoordinatesResolver` | `resolvers/source_coordinates.py` | Emits `precision="precise"` when the record already has lat/lon |
|
||||
| `RegistryResolver` | `resolvers/registry.py` | Legacy generic resolver; current compute-center and BGP runtime paths do not use it to generate candidates |
|
||||
| `NominatimResolver` | `resolvers/nominatim.py` | Runs a domain query plan against Nominatim with LRU cache and rate limiting |
|
||||
| `InheritFromAnotherEntityResolver` | `resolvers/inherit.py` | Wraps an externally resolved entity location as a candidate |
|
||||
| `LocationLLMFallback` | `location/llm_fallback.py` | Generates a confirmation-required candidate through the current default AI Provider when user-triggered collection has no regular candidates |
|
||||
|
||||
Nominatim is the geocoding service in the OpenStreetMap ecosystem. Given a place name, city, country, organization, or facility query, it returns possible coordinates, a display name, and structured address fields. It is useful for turning city/facility text into candidate coordinates, but it is not an authoritative fact registry and can match same-name places or broad administrative areas. Planet therefore treats Nominatim output as confirmation-required candidates and uses it with caching and rate limiting.
|
||||
|
||||
`RegistryResolver` remains available for future controlled import scenarios, but it should not be reconnected as a hard-coded hint source for compute centers or BGP. Matching common fields such as `operator` or `city` was the main reason multiple entities could collapse onto the same point.
|
||||
|
||||
## Current Domain Pipelines
|
||||
|
||||
### Compute Centers
|
||||
|
||||
Entry points:
|
||||
|
||||
- [compute_center_locations.py](/home/ray/dev/linkong/planet/backend/app/services/compute_center_locations.py)
|
||||
|
||||
Resolver order:
|
||||
|
||||
```python
|
||||
SourceCoordinatesResolver()
|
||||
StoredComputeCenterLocationResolver()
|
||||
```
|
||||
|
||||
The main map startup path is source coordinates first, then the database-backed current-location table. The table is `compute_center_locations`, keyed by `(source, source_id)`, and stores manually accepted locations or true coordinates migrated from source records. `init_db()` only migrates source records that already contain real coordinates; it does not import old hard-coded hints and does not run ROR, Nominatim, or LLM geocoding during startup.
|
||||
|
||||
Candidate collection is intentionally separate from rendering. `collect_location_candidates()` builds ROR and Nominatim/OpenStreetMap queries from source fields, but it does not emit the current `compute_center_locations` row as a candidate. If those regular candidates are empty, the API layer calls `LocationLLMFallback` through the current default AI Provider and only returns `source="llm_location_factcheck"` candidates with `needs_confirmation=true`. LLM candidates use a combined threshold made from the model self-score plus backend evidence scoring; when the LLM provides a credible city/country but no coordinates, the backend may fill city-level coordinates through Nominatim without increasing the evidence score. After a user accepts a candidate, the save endpoint upserts it into the dimension table; the next map refresh renders it through `StoredComputeCenterLocationResolver`.
|
||||
|
||||
`resolve_compute_center_location()`, `resolve_compute_center_location_full()`, and `collect_location_candidates()` remain the domain API. `visualization.py` consumes that API and no longer owns coordinate hints, country-centroid fallbacks, or Nominatim details.
|
||||
|
||||
GeoJSON output includes only `RENDERABLE_PRECISIONS`. Unresolved records are returned in `unresolved` with `failure_reason`, `attempted_queries`, `source_id`, `record_id`, and related diagnostics.
|
||||
|
||||
### BGP Collectors
|
||||
|
||||
Entry points:
|
||||
|
||||
- [bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_collector_locations.py)
|
||||
- [bgp_collector_location.py](/home/ray/dev/linkong/planet/backend/app/models/bgp_collector_location.py)
|
||||
|
||||
Resolver order:
|
||||
|
||||
```python
|
||||
SourceCoordinatesResolver()
|
||||
StoredCollectorLocationResolver()
|
||||
NominatimResolver(_bgp_collector_query_plan)
|
||||
```
|
||||
|
||||
The 23 RIPE RIS collector coordinates moved from the old table into the `bgp_collector_locations` dimension table with `source=legacy_seed` and `needs_confirmation=true`. The legacy dictionary is still maintained from the DB-backed cache for compatibility; manual candidate collection uses stored site/city/country as context but does not emit stored rows as candidates. If Nominatim cannot produce a city-level candidate, the collection endpoint uses the current default AI Provider as an LLM factcheck fallback and returns a confirmation-required candidate instead of saving automatically.
|
||||
|
||||
### BGP Events
|
||||
|
||||
Entry point:
|
||||
|
||||
- [bgp_event_locations.py](/home/ray/dev/linkong/planet/backend/app/services/bgp_event_locations.py)
|
||||
|
||||
Resolver order:
|
||||
|
||||
```python
|
||||
SourceCoordinatesResolver()
|
||||
InheritFromAnotherEntityResolver(_inherit_from_owning_collector)
|
||||
```
|
||||
|
||||
Event inheritance uses a strict owning-collector lookup and does not run the full fuzzy collector registry. Future ASN facility, PrefixGeo, or PeeringDB resolvers can be inserted after inheritance.
|
||||
|
||||
## API Envelope
|
||||
|
||||
```http
|
||||
POST /api/v1/visualization/compute-centers/{source_id}/collect-location
|
||||
POST /api/v1/visualization/compute-centers/{source_id}/location
|
||||
POST /api/v1/bgp/collectors/{collector_id}/collect-location
|
||||
```
|
||||
|
||||
Both `collect-location` endpoints return the same envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"candidates": [],
|
||||
"best_candidate": {},
|
||||
"attempted_queries": [],
|
||||
"context": {}
|
||||
}
|
||||
```
|
||||
|
||||
The LLM fallback only runs inside user-triggered `collect-location` requests, and only after regular candidates are empty. It does not run during `/geo/compute-centers` startup rendering, scheduled collection, or batch persistence, and it never writes directly to `compute_center_locations` or `bgp_collector_locations`. Internally it is no longer a single "strict JSON or fail" step. It first asks the LLM to factcheck the location; if the answer is not JSON, it makes a second normalization request that may only extract facts from the original text; if that still fails, it conservatively extracts a city/country pair from the prose. The backend then performs coordinate filling, combined scoring, and candidate creation through one shared path.
|
||||
|
||||
This lets an answer such as "DeepL Mercury is in Falun, Sweden" become a city-level candidate after backend Nominatim coordinate filling, and lets a prose first answer be normalized into JSON on the second pass. Regardless of the path, only `precise`, `site`, or `city` precision with non-zero coordinates and a sufficient combined score is converted to a candidate. Failed, low-score, country-only, or cityless responses stay as diagnostics.
|
||||
|
||||
The LLM-provided `confidence` is only the model's self-score. The backend recomputes a combined score and uses that value as the candidate `confidence`:
|
||||
|
||||
```text
|
||||
combined =
|
||||
0.25 * model_confidence
|
||||
+ source_quality
|
||||
+ entity_match
|
||||
+ geography_match
|
||||
+ precision_quality
|
||||
+ name_location_hint
|
||||
- conflict_penalty
|
||||
- weak_evidence_penalty
|
||||
```
|
||||
|
||||
Current component caps: authoritative/government/academic evidence can add up to `0.35`, reputable databases or news up to `0.25`, generic web evidence up to `0.15`; evidence that clearly names the queried entity can add `0.25`; city+country geography match adds `0.20`, country-only match adds `0.05`; precision adds `precise=0.15`, `site=0.12`, or `city=0.08`; `name_location_hint` adds signal when the entity name and candidate city overlap, such as `TAIPEI-1` and `Taipei`; explicit conflicts can subtract up to `0.45`; weak-evidence wording can subtract up to `0.30`, capped at `0.15` when entity and city/country match and no conflict is present. Candidates below `0.55` are rejected. This lets cases such as Alem.Cloud and TAIPEI-1 recover from a low model self-score when entity and city evidence align, while genuinely weak or conflicting evidence still fails.
|
||||
|
||||
`POST /api/v1/visualization/compute-centers/{source_id}/location` upserts the candidate selected by the frontend into `compute_center_locations`. Manual saves default to `needs_confirmation=false`, `verification_status="verified"`, and a `verified_at` timestamp. Future automated staging can pass `needs_confirmation=true` explicitly.
|
||||
|
||||
The frontend [info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js) renders the shared candidate list and preview events. The compute-center layer button shows an `unresolved` badge; clicking it opens the unresolved queue. Row-level `采集` only fetches candidates. Header-level `一键采用` walks the queue top-to-bottom, picks the highest-confidence candidate with valid coordinates, saves it, removes the row, renumbers the list, and dispatches `earth:compute-center-unresolved-count-change` so the badge updates immediately. When the batch finishes, `earth:compute-center-location-saved` refreshes the real layer.
|
||||
|
||||
If the remaining records have no city-level candidates, the batch must not invent coordinates. The UI keeps those rows and shows the backend `failure_reason` plus attempted queries.
|
||||
|
||||
## Adding A Resolver
|
||||
|
||||
A resolver only needs `name` and `resolve()`, returning `ResolverOutput`.
|
||||
|
||||
```python
|
||||
class PeeringDBFacilityResolver:
|
||||
name = "peeringdb_facility"
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def resolve(self, query):
|
||||
asn = query.extra.get("origin_asn")
|
||||
if not asn:
|
||||
return ResolverOutput()
|
||||
return ResolverOutput(candidates=tuple(
|
||||
LocationCandidate(
|
||||
latitude=f.latitude,
|
||||
longitude=f.longitude,
|
||||
display_name=f.name,
|
||||
precision="site",
|
||||
confidence=0.78,
|
||||
query=f"peeringdb::{asn}",
|
||||
source=self.name,
|
||||
source_note=f"PeeringDB facility for AS{asn}",
|
||||
matched_fields=("origin_asn",),
|
||||
needs_confirmation=False,
|
||||
city=f.city,
|
||||
country=f.country,
|
||||
)
|
||||
for f in self._client.facilities_for_asn(asn)
|
||||
))
|
||||
```
|
||||
|
||||
Wire it in:
|
||||
|
||||
```python
|
||||
BGP_EVENT_PIPELINE = LocationPipeline([
|
||||
SourceCoordinatesResolver(),
|
||||
InheritFromAnotherEntityResolver(source_lookup=...),
|
||||
PeeringDBFacilityResolver(client=peeringdb_client),
|
||||
])
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Relevant tests:
|
||||
|
||||
- [test_location_pipeline.py](/home/ray/dev/linkong/planet/backend/tests/test_location_pipeline.py)
|
||||
- [test_bgp_collector_locations.py](/home/ray/dev/linkong/planet/backend/tests/test_bgp_collector_locations.py)
|
||||
- [test_visualization_compute_centers.py](/home/ray/dev/linkong/planet/backend/tests/test_visualization_compute_centers.py)
|
||||
|
||||
Coverage focuses on resolver pluggability, registry alias guards, BGP collector legacy dictionary compatibility, compute-center public API compatibility, and non-renderable locations being returned as `unresolved`.
|
||||
133
docs/technical/en/location-pipeline-user.md
Normal file
133
docs/technical/en/location-pipeline-user.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Earth Location Candidate Collection User Guide
|
||||
|
||||
Location candidate collection helps fill or verify coordinates for compute centers and BGP collectors on Earth. Users do not type coordinates by hand; the backend ranks source coordinates, open organization-registry results, online geocoding results, and, when needed, LLM factcheck fallback results into a previewable candidate list.
|
||||
|
||||
## Supported Entities
|
||||
|
||||
Currently supported:
|
||||
|
||||
- Compute centers: TOP500 supercomputers and Epoch AI GPU clusters.
|
||||
- BGP collectors: RIPE RIS `rrcXX` collectors.
|
||||
|
||||
BGP events inherit the location of their owning collector. Events do not have a separate collection button yet; future ASN facility, prefix geography, or PeeringDB resolvers should use the same pipeline.
|
||||
|
||||
## What Users See
|
||||
|
||||
Clicking a compute center or BGP collector on Earth opens a detail card with location fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| Location precision | Precise coordinates, site-level, city-level, or unconfirmed |
|
||||
| Location source | Source coordinates, ROR organization registry, Nominatim online search, LLM factcheck fallback, or stored BGP collector locations |
|
||||
| Location confidence | Relative confidence reported by the backend resolver |
|
||||
| Verification status | Confirmed, estimated, or online result pending confirmation |
|
||||
| Resolution reason | Why the location was selected |
|
||||
| Matched location name | Canonical name from an open source, online result, or stored collector location |
|
||||
| Verified at | Verification date for confirmed locations; online candidates are usually empty |
|
||||
|
||||
Nominatim here means the online geocoding service from the OpenStreetMap ecosystem. It converts place names, cities, countries, organizations, or campus/facility queries into possible coordinate candidates, but it can match same-name places or broad administrative areas. The UI therefore treats these results as pending confirmation.
|
||||
|
||||
Compute-center GeoJSON no longer renders country centroids, unknown locations, or `[0, 0]` placeholders. Records that cannot reach city-level precision are returned in the endpoint's `unresolved` list and can be improved through candidate collection.
|
||||
|
||||
A compute center with a `?` marker on Earth is not unresolved. It already has coordinates, but the coordinates still need confirmation, either because `needs_confirmation=true` or because the source is online geocoding. Truly unresolved records have no trustworthy coordinates and are therefore absent from the globe.
|
||||
|
||||
## Collect Candidates
|
||||
|
||||
1. Open `http://localhost:3000/earth`.
|
||||
2. Enable the `Compute centers` or `BGP observation` layer.
|
||||
3. Click an object to open its detail card.
|
||||
4. Click `自动采集坐标候选` or `重新自动采集坐标`.
|
||||
5. Wait for up to five candidates to appear.
|
||||
6. Click `预览` on a candidate row; Earth flies to that latitude and longitude.
|
||||
|
||||
Candidate rows show:
|
||||
|
||||
- Candidate name.
|
||||
- Precision: precise, site, or city.
|
||||
- Resolver source.
|
||||
- Confidence.
|
||||
- Coordinates.
|
||||
|
||||
Clicking `保存` on a candidate row writes the selected compute-center candidate into the location dimension table. After the save succeeds, the compute-center layer refreshes; if the record was previously in the unresolved queue, the unresolved count decreases.
|
||||
|
||||
## Unresolved Queue And Adopt All
|
||||
|
||||
The notification badge on the compute-center layer row shows the current unresolved count. Clicking it opens a fixed queue beside the layer panel:
|
||||
|
||||
1. The queue contains only compute centers without trustworthy coordinates.
|
||||
2. Row-level `采集` calls the candidate endpoint and shows up to five previewable candidates.
|
||||
3. Header-level `一键采用` walks the list from top to bottom, chooses the highest-confidence candidate with valid coordinates, and saves it.
|
||||
4. Each successful save immediately removes that row, renumbers the remaining rows, and updates the badge count.
|
||||
5. When the batch completes, the frontend refreshes the compute-center layer so UI state and backend state converge.
|
||||
|
||||
If a record has no saveable candidate, the system does not invent a country centroid, vendor headquarters, or hard-coded hint. The row stays in the queue with the backend failure reason and attempted queries so an operator can supply better evidence later.
|
||||
|
||||
## Backend APIs
|
||||
|
||||
The frontend buttons call:
|
||||
|
||||
```http
|
||||
POST /api/v1/visualization/compute-centers/{source_id}/collect-location
|
||||
POST /api/v1/visualization/compute-centers/{source_id}/location
|
||||
POST /api/v1/bgp/collectors/{collector_id}/collect-location
|
||||
```
|
||||
|
||||
Both `collect-location` endpoints use the same response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"candidates": [],
|
||||
"best_candidate": {},
|
||||
"attempted_queries": [],
|
||||
"context": {}
|
||||
}
|
||||
```
|
||||
|
||||
When regular candidates are empty, the endpoint asks the current default AI Provider for one LLM factcheck fallback. LLM candidates always require human confirmation and are never saved automatically; only strict JSON results with city-or-better precision, non-zero coordinates, and sufficient confidence appear in the candidate list. When no candidate reaches city-level precision, `success` is `false` and the response includes `failure_reason`, `llm_failure_reason`, and attempted queries. This helps distinguish missing source fields, open-source gaps, online geocoding misses, and unusable LLM responses.
|
||||
|
||||
## Registry Maintenance
|
||||
|
||||
Compute centers and BGP collectors no longer maintain local candidate registries. Compute-center accepted locations are stored in the `compute_center_locations` database dimension table keyed by `(source, source_id)`. BGP collector current locations are stored in the `bgp_collector_locations` database dimension table; the old RIPE RIS city-level coordinates are used only as initialization seed data and still require confirmation.
|
||||
|
||||
For compute centers, prefer maintaining:
|
||||
|
||||
- `source` / `source_id`: for example `top500` + `top500_50`.
|
||||
- `name` / `operator` / `site`.
|
||||
- `city` / `country`.
|
||||
- `latitude` / `longitude`.
|
||||
- `precision`: `precise`, `site`, or `city`.
|
||||
- `confidence`: confidence from 0 to 1.
|
||||
- `location_source` / `source_url` / `source_note` / `raw_payload`: evidence source.
|
||||
- `needs_confirmation` / `verification_status` / `verified_at`: manual verification status and date.
|
||||
|
||||
For BGP collectors, prefer maintaining:
|
||||
|
||||
- `collector_id`: for example `rrc12`.
|
||||
- `site` / `operator`: site and operator.
|
||||
- `city` / `country` / `region`.
|
||||
- `latitude` / `longitude`.
|
||||
- `precision`: `precise`, `site`, or `city`.
|
||||
- `confidence`: confidence from 0 to 1.
|
||||
- `source` / `source_url` / `raw_payload`: evidence source.
|
||||
- `verification_status` / `verified_at`: manual verification status and date.
|
||||
|
||||
If only the city is known, use city-level precision. Do not enter a precise-looking coordinate that has not been verified.
|
||||
|
||||
## Common Questions
|
||||
|
||||
### Why are some compute centers missing on Earth?
|
||||
|
||||
Earth only renders coordinates that reach city-level precision or better. If source data, verified storage, and online geocoding all fail, the record is returned as `unresolved` instead of being rendered at a misleading country center or `[0, 0]`.
|
||||
|
||||
### Why do online results need confirmation?
|
||||
|
||||
Nominatim/OpenStreetMap results may match same-name cities, organizations, or campuses. They are useful for previewing candidates, but should be manually confirmed before being persisted as verified locations.
|
||||
|
||||
### Can the LLM fallback change the map directly?
|
||||
|
||||
No. The LLM runs only after a user clicks candidate collection and regular sources have no candidates. It returns confirmation-required candidates only. Earth startup GeoJSON, scheduled collection, and batch rendering do not call the LLM automatically; a location affects future rendering only after a user saves the candidate into the dimension table.
|
||||
|
||||
### Why do BGP events no longer all land in Amsterdam?
|
||||
|
||||
The old behavior could match common fields like `operator="RIPE NCC"` and incorrectly promote `rrc00`. BGP event inheritance now uses a strict owning-collector lookup in the DB-backed cache instead of registry fuzzy matching.
|
||||
@@ -1,593 +1,332 @@
|
||||
# Planet Manual
|
||||
|
||||
This manual is for daily use, demos, development integration, and local operations. It covers four core entry points:
|
||||
This manual is for Planet end users. Starting from the browser, it covers account registration, login, configuring collectors, configuring AI, using Earth and the console, and reading the docs site. Every action happens in a browser.
|
||||
|
||||
- `planet.sh`: local start, stop, restart, health check, and log access
|
||||
- Earth: public 3D situational awareness page
|
||||
- Console: admin backend (login required)
|
||||
- Docs: public developer documentation and manual
|
||||
|
||||
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md).
|
||||
If you are responsible for deployment or on-call duty, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead — it covers shell commands, log paths, and CLI fallbacks for user creation.
|
||||
|
||||
## Entry Overview
|
||||
|
||||
After a default startup, the common URLs are:
|
||||
|
||||
| Name | URL | Login Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness |
|
||||
| Docs | `http://localhost:3000/docs` | No | Developer docs, technical reference, usage manual |
|
||||
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
|
||||
| AI Playground | `http://localhost:3000/playground` | Yes | AI Provider status and debugging |
|
||||
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
|
||||
| Earth | `http://<host>/earth` | No | Public 3D situational awareness page |
|
||||
| Docs | `http://<host>/docs` | Partly | Public docs need no login; developer/ops docs need Gatekeeper groups |
|
||||
| Register / Login / Forgot Password | `/register`, `/login`, `/forgot-password` | No | Self-serve account creation and recovery |
|
||||
| Console | `http://<host>/admin` | Yes | Data, collectors, alerts, AI, users, settings |
|
||||
| AI | `http://<host>/ai` | Yes | Model providers, tools, testbench |
|
||||
| Backend API Docs | `http://<host>:8000/docs` | Depends | FastAPI / OpenAPI |
|
||||
|
||||
## planet.sh
|
||||
URLs below use the local default `http://localhost:3000`. Replace the prefix with your deployment URL in production.
|
||||
|
||||
`planet.sh` is the main control script for local development and demos. Use it to manage services rather than manually starting frontend, backend, database, and AI Provider separately.
|
||||
## Register an Account
|
||||
|
||||
### Start
|
||||
1. Open `http://localhost:3000/login` and click "Register" under the form.
|
||||
2. On `/register`, fill in:
|
||||
- **Username**: 3–50 characters, used to log in
|
||||
- **Email**: receives the verification code; editable later in account settings
|
||||
- **Password**: at least 8 characters
|
||||
3. After submission you are taken to the verify page. A 6-digit code is sent to your email. It expires in 10 minutes.
|
||||
4. Enter the code and click "Verify and Sign In". On success the system stores a session and sends you to the console.
|
||||
|
||||
```bash
|
||||
./planet.sh start
|
||||
```
|
||||
If no email arrives within 60 seconds:
|
||||
|
||||
Default behavior:
|
||||
- Check spam, promotions, and any enterprise mail gateway
|
||||
- The "Resend Code" button shows a 60-second countdown; you can resend after it ends
|
||||
- After 5 wrong attempts the code is invalidated; you must resend a new one
|
||||
|
||||
- Starts PostgreSQL and Redis
|
||||
- Starts AI Provider
|
||||
- Starts the backend API
|
||||
- Starts the frontend Vite dev server
|
||||
- Outputs Earth, console, Playground, and backend API doc URLs
|
||||
If you see "Email service not configured", the administrator has not yet set up SMTP. Ask the administrator to fill SMTP at `/settings -> SMTP Email`.
|
||||
|
||||
Specify custom ports:
|
||||
The default role for a self-registered user is `viewer`, which can sign in and view public content. To see collector / user / settings pages, ask an `admin` or `super_admin` to promote your role at `/users`.
|
||||
|
||||
```bash
|
||||
./planet.sh start -b 8001 -f 3001 -a 8101
|
||||
```
|
||||
## Sign In and Recover Password
|
||||
|
||||
Parameters:
|
||||
### Sign In
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `-b <port>` | Backend port |
|
||||
| `-f <port>` | Frontend port |
|
||||
| `-a <port>` | AI Provider port |
|
||||
| `--allow-lan` | Enable LAN access |
|
||||
| `--verbose` | Show more command output during execution |
|
||||
Open `/login`, enter username and password. On success you are taken to `/admin`.
|
||||
|
||||
### AI Provider Environment and Builds
|
||||
If you see "Email not verified", the page automatically redirects to `/verify-email` — follow the prompts to enter the code.
|
||||
|
||||
AI Provider runtime configuration can live in `aiprovider/.env` or in matching variables in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the container at startup.
|
||||
### Forgot Password
|
||||
|
||||
Changing model, API key, or base URL does not rebuild the image. Restart only AI Provider to pick up runtime configuration changes:
|
||||
1. On `/login`, click "Forgot Password?", or open `/forgot-password` directly.
|
||||
2. Enter your registered email and click "Send Code". The same confirmation is shown regardless of whether the email is registered (to avoid enumeration).
|
||||
3. After receiving the code, enter it together with a new password (≥ 8 characters) and click "Reset Password".
|
||||
4. The system sends you back to `/login` — sign in with the new password.
|
||||
|
||||
```bash
|
||||
./planet.sh restart -a
|
||||
```
|
||||
## Account Settings
|
||||
|
||||
For complex shell expansion in `~/.zshrc`, opt in explicitly:
|
||||
Click your username at the top-right of the console to open account settings:
|
||||
|
||||
```bash
|
||||
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
|
||||
```
|
||||
- Change password: enter current password + new password
|
||||
- Change email: the system sends a verification code to the new address; the change applies only after verification
|
||||
- View Gatekeeper groups: lists current groups (`docs_user` / `docs_developer` / `docs_admin`)
|
||||
- Log out: clears the current session
|
||||
|
||||
To ignore `~/.zshrc` during troubleshooting:
|
||||
## Console Overview
|
||||
|
||||
```bash
|
||||
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
|
||||
```
|
||||
|
||||
The AI Provider Docker build context is intentionally limited to the files required by the service, and `uv sync` uses a BuildKit cache mount so dependency downloads are reused after the first build.
|
||||
|
||||
### Stop
|
||||
|
||||
```bash
|
||||
./planet.sh stop
|
||||
```
|
||||
|
||||
Stops:
|
||||
|
||||
- Backend
|
||||
- AI Provider
|
||||
- Frontend
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
|
||||
### Restart
|
||||
|
||||
Full restart:
|
||||
|
||||
```bash
|
||||
./planet.sh restart
|
||||
```
|
||||
|
||||
Per-module restart:
|
||||
|
||||
```bash
|
||||
./planet.sh restart -b
|
||||
./planet.sh restart -f
|
||||
./planet.sh restart -a
|
||||
./planet.sh restart -d
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| `-b` | Backend only |
|
||||
| `-f` | Frontend only |
|
||||
| `-a` | AI Provider only |
|
||||
| `-d` | Database only |
|
||||
|
||||
Per-module restarts are preferred during development — they avoid interrupting unrelated services.
|
||||
|
||||
### Create User
|
||||
|
||||
```bash
|
||||
./planet.sh createuser
|
||||
```
|
||||
|
||||
Used to create a console login account before first use. The script interactively prompts for username, password, and role.
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
./planet.sh health
|
||||
```
|
||||
|
||||
Checks:
|
||||
|
||||
- `planet_*` container status
|
||||
- Backend `/health`
|
||||
- AI Provider `/health`
|
||||
- Frontend reachability
|
||||
|
||||
If something shows offline, check the corresponding logs first.
|
||||
|
||||
### Logs
|
||||
|
||||
Recent logs:
|
||||
|
||||
```bash
|
||||
./planet.sh log
|
||||
```
|
||||
|
||||
Follow logs:
|
||||
|
||||
```bash
|
||||
./planet.sh log -f
|
||||
./planet.sh log -b
|
||||
./planet.sh log -a
|
||||
```
|
||||
|
||||
| Flag | Log source |
|
||||
| --- | --- |
|
||||
| `-f` / `--frontend` | `/tmp/planet_frontend.log` |
|
||||
| `-b` / `--backend` | `/tmp/planet_backend.log` |
|
||||
| `-a` / `--ai-provider` | `planet_aiprovider` container logs |
|
||||
|
||||
### LAN Access
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan
|
||||
```
|
||||
|
||||
Useful for:
|
||||
|
||||
- Starting in WSL, accessing from Windows browser
|
||||
- Demos on phone or tablet
|
||||
- Another machine on the same LAN accessing the same dev instance
|
||||
|
||||
After starting, check your firewall and WSL network forwarding if access fails.
|
||||
|
||||
## Earth
|
||||
|
||||
Earth is the public 3D situational awareness page, accessed at:
|
||||
|
||||
```text
|
||||
http://localhost:3000/earth
|
||||
```
|
||||
|
||||
It is a standalone frontend. The actual page lives at:
|
||||
|
||||
- `frontend/public/earth/index.html`
|
||||
- `frontend/public/earth/js/`
|
||||
- `frontend/public/earth/css/`
|
||||
|
||||
The React route `/earth` simply hosts it in an iframe.
|
||||
|
||||
### Main Uses
|
||||
|
||||
Earth is used to observe in a single globe view:
|
||||
|
||||
- BGP events, anomalies, and situational posture
|
||||
- Satellites and orbital trails
|
||||
- Submarine cables and landing points
|
||||
- Compute centers
|
||||
- AIS vessels
|
||||
- Border lines, grid lines, HD texture, cloud layer, terrain
|
||||
- Live news streams and situational news
|
||||
- Search and focused object details
|
||||
|
||||
### Layer Control
|
||||
|
||||
The right-side layer panel toggles visualization layers on or off.
|
||||
|
||||
Common layers include:
|
||||
|
||||
- Grid lines
|
||||
- Border lines
|
||||
- HD texture
|
||||
- Atmospheric cloud layer
|
||||
- Submarine cables
|
||||
- Compute centers
|
||||
- BGP observation
|
||||
- AIS vessels
|
||||
- Satellites
|
||||
- Orbital trails
|
||||
- Terrain
|
||||
|
||||
Some layers have dependencies:
|
||||
|
||||
- Terrain requires HD texture
|
||||
- Trails require Satellites
|
||||
- When HD texture is off, the globe shows the base map and edge glow effect
|
||||
|
||||
### Legend
|
||||
|
||||
The lower-left legend follows the currently focused or enabled layer.
|
||||
|
||||
Current legend modes include:
|
||||
|
||||
- Cables
|
||||
- Satellites
|
||||
- Border lines
|
||||
- Compute centers
|
||||
- BGP
|
||||
- AIS vessels
|
||||
|
||||
AIS vessel legend entries are grouped by vessel type: cargo, tanker, passenger, fishing, military, anchored/slow, and other. Triangle markers represent moving vessels; dots represent anchored or slow vessels.
|
||||
|
||||
### Search
|
||||
|
||||
Earth search finds current globe objects, such as:
|
||||
|
||||
- Submarine cables
|
||||
- Landing points
|
||||
- Satellites
|
||||
- Compute centers
|
||||
- BGP events
|
||||
- BGP collectors
|
||||
|
||||
Search results can be used to quickly locate objects and open their details.
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel contains:
|
||||
|
||||
- Rotation mode / cruise mode
|
||||
- Cruise modules: BGP, News
|
||||
- Satellite display style: self-glow, real ground footprint
|
||||
- Day/night mode
|
||||
- Panel visibility toggles
|
||||
- Globe default size
|
||||
- Terrain opacity
|
||||
- Reset settings
|
||||
|
||||
These settings are stored in browser local storage. They revert to defaults if you switch browsers or clear site data.
|
||||
|
||||
### View Controls
|
||||
|
||||
Earth supports mouse, touchpad, and touchscreen interaction.
|
||||
|
||||
Common controls:
|
||||
|
||||
| Action | Result |
|
||||
| --- | --- |
|
||||
| Left-button drag | Rotates the globe |
|
||||
| One-finger drag | Rotates the globe on touch devices |
|
||||
| Mouse wheel | Zooms the view in or out |
|
||||
| Two-finger pinch | Zooms the view on touch devices |
|
||||
| Zoom buttons | Adjust zoom in fixed steps |
|
||||
| Click the zoom percent | Resets to the default zoom |
|
||||
|
||||
When zooming, the top capsule briefly shows the current zoom level, for example `Zoom 180%`. This indicates view zoom only, not data loading progress. Loading status takes priority and will not be interrupted by zoom feedback.
|
||||
|
||||
Drag sensitivity adjusts automatically based on the current zoom. Around the default view it keeps the normal rotation feel; when zoomed in, dragging becomes progressively finer for inspecting a region, vessel, satellite, or BGP event; when zoomed out, dragging is slightly faster for global browsing.
|
||||
|
||||
### Cruise Mode
|
||||
|
||||
Cruise mode makes Earth automatically cycle through focus targets.
|
||||
|
||||
Current cruise modules:
|
||||
|
||||
- BGP
|
||||
- News
|
||||
|
||||
Suitable for demos, monitoring displays, or unattended presentations.
|
||||
|
||||
### Mobile
|
||||
|
||||
Earth has a mobile drawer layout. On small screens:
|
||||
|
||||
- Layer controls open in a mobile drawer
|
||||
- Search, settings, and details use mobile panels
|
||||
- Main interactions remain centered on globe object clicks, search, and layer toggles
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Earth Won't Open
|
||||
|
||||
Check whether the frontend is online:
|
||||
|
||||
```bash
|
||||
./planet.sh health
|
||||
./planet.sh log -f
|
||||
```
|
||||
|
||||
If the frontend port is not `3000`, use the actual port shown at startup.
|
||||
|
||||
#### Layer Has No Data
|
||||
|
||||
Check the backend and data sources:
|
||||
|
||||
```bash
|
||||
./planet.sh health
|
||||
./planet.sh log -b
|
||||
```
|
||||
|
||||
Then open the console and check:
|
||||
|
||||
- `/datasources`
|
||||
- `/data`
|
||||
- `/bgp`
|
||||
|
||||
#### Satellites, BGP, or Cables Load Slowly
|
||||
|
||||
These layers may depend on backend APIs, external data sources, or first-run collection tasks. Wait for startup tasks to finish before checking logs and console data source status.
|
||||
|
||||
## Console
|
||||
|
||||
Console entry point:
|
||||
|
||||
```text
|
||||
http://localhost:3000/admin
|
||||
```
|
||||
|
||||
The console requires login. Create a user first if this is your first time:
|
||||
|
||||
```bash
|
||||
./planet.sh createuser
|
||||
```
|
||||
|
||||
### Page Structure
|
||||
|
||||
The console uses React + Ant Design, with a left-side menu organized by work domain.
|
||||
|
||||
Common pages:
|
||||
The console at `http://localhost:3000/admin` is built with React + Ant Design. The left menu is organized by work domain.
|
||||
|
||||
| Page | Route | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | `/admin` | System overview |
|
||||
| Earth | `/earth` | Opens the public Earth page |
|
||||
| Data Sources | `/datasources` | View data sources and trigger collection |
|
||||
| Collected Data | `/data` | View collected data |
|
||||
| BGP Observation | `/bgp` | BGP situational data |
|
||||
| Earth | `/earth` | Open the public Earth page |
|
||||
| Datasources | `/datasources` | Source directory and collection triggers |
|
||||
| Collected Data | `/data` | Data already ingested |
|
||||
| BGP | `/bgp` | BGP situational view |
|
||||
| System Alerts | `/alerts/system` | System-level alerts |
|
||||
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
|
||||
| Situational Alerts | `/alerts/situational` | Situational assessment alerts |
|
||||
| AI Playground | `/playground` | AI Provider debugging |
|
||||
| System Logs | `/logs` | View system logs (typically super admin only) |
|
||||
| Users | `/users` | User management |
|
||||
| Settings | `/settings` | System config and TV live stream sources |
|
||||
| Situational Alerts | `/alerts/situational` | Situational analysis alerts |
|
||||
| AI | `/ai` | Model providers, tools, testbench |
|
||||
| Logs | `/logs` | Usually visible only to super admin |
|
||||
| Users | `/users` | Create/delete users, change roles/groups |
|
||||
| Settings | `/settings` | System, SMTP, TV, collectors |
|
||||
|
||||
### Data Sources
|
||||
Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups.
|
||||
|
||||
`/datasources` shows collection sources and triggers collection. It is now a data source directory that lists built-in and custom sources in one table.
|
||||
## Configure Data Collectors
|
||||
|
||||
Common operations:
|
||||
`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials.
|
||||
|
||||
- View data source status
|
||||
- Trigger collection
|
||||
- View recent collection tasks
|
||||
- Open the read-only detail drawer for endpoint, headers, runtime config, and built-in/custom source type
|
||||
Steps:
|
||||
|
||||
If a category of objects is missing on Earth, start here to confirm the data source is available.
|
||||
1. Pick a collector in the dropdown.
|
||||
2. Inspect status tags:
|
||||
- `No credentials` / `Credentials required`
|
||||
- Owning module
|
||||
- `Enabled` / `Disabled`
|
||||
- `Unchecked` / `Reachable` / `Unreachable`
|
||||
3. Click the plug icon next to the dropdown to run a health check. On success the status becomes `Reachable`.
|
||||
4. Edit endpoint, headers, timeout, retries; click save.
|
||||
|
||||
The data source name opens an information drawer only. Endpoint, credentials, headers, and custom source configuration are maintained under `/settings` collector settings.
|
||||
For free collectors without credentials, the health check hits the endpoint directly. For credential-bearing collectors it runs the credential flow. If credentials or endpoint changed since the last successful check, click connect again.
|
||||
|
||||
When collection tasks are running, the progress area shows a clickable `Collecting N` pill. Clicking it opens a modal with each running task's phase, progress, and processed count.
|
||||
"Connected" means either: data was successfully collected with the current config, or the connect button passed validation with the current config.
|
||||
|
||||
### Collected Data
|
||||
### BarentsWatch AIS Credentials
|
||||
|
||||
`/data` shows the collected data table.
|
||||
|
||||
Useful for diagnosing:
|
||||
|
||||
- Whether data has entered the system
|
||||
- Whether data update times match expectations
|
||||
- Whether a data source produced valid records
|
||||
|
||||
### BGP Observation
|
||||
|
||||
`/bgp` is the BGP-focused page.
|
||||
|
||||
It complements the BGP layer on Earth:
|
||||
|
||||
- Earth emphasizes spatial posture and visual focus
|
||||
- The console BGP page emphasizes lists, status, details, and assessment
|
||||
|
||||
### Alerts
|
||||
|
||||
Alert entry points:
|
||||
|
||||
- `/alerts/system`
|
||||
- `/alerts/bgp`
|
||||
- `/alerts/situational`
|
||||
|
||||
Used to view system, network, and situational alerts.
|
||||
|
||||
### System Settings
|
||||
|
||||
`/settings` manages system-level configuration.
|
||||
|
||||
Current common uses:
|
||||
|
||||
- System settings
|
||||
- TV live stream source configuration
|
||||
- Collector settings
|
||||
- External integrations and AI Provider configuration
|
||||
|
||||
Available configuration depends on the current user's role.
|
||||
|
||||
#### Collector Settings
|
||||
|
||||
`/settings?tab=collector_credentials` is currently displayed as Collector Settings. It manages connection settings for all collectors, not only credentials.
|
||||
|
||||
Use it to:
|
||||
|
||||
1. Select a collector from the dropdown.
|
||||
2. Review tags such as `Requires credentials`, module, enabled state, and `Unchecked` / `Available` / `Unavailable`.
|
||||
3. Click the plug icon next to the selector to run a health check.
|
||||
4. Edit endpoint, request headers, timeout, and retry settings.
|
||||
5. Save the collector settings.
|
||||
|
||||
Free collectors are checked by requesting their endpoint directly. Credentialed collectors use their credential provider. If endpoint or credential fingerprint changes after the last successful validation, the collector must be checked again.
|
||||
|
||||
The system treats a collector as connected when the current configuration has either collected data successfully or passed the manual connection check.
|
||||
|
||||
#### BarentsWatch AIS Credentials
|
||||
|
||||
`BarentsWatch AIS` is a credentialed built-in collector. Its credential card appears above the basic configuration card.
|
||||
|
||||
Configured fields:
|
||||
`BarentsWatch AIS` is a credential-required built-in collector. Selecting it surfaces the credential section above the base configuration:
|
||||
|
||||
- `Client ID`
|
||||
- `Client Secret`
|
||||
- `Endpoint`
|
||||
|
||||
If a secret is already configured, the input shows a masked preview. Keeping that preview unchanged preserves the stored secret; entering a new value replaces it.
|
||||
If a secret was saved previously, the input shows a masked preview. Saving while keeping the preview unchanged preserves the original secret; entering a new secret overwrites it.
|
||||
|
||||
BarentsWatch AIS credentials can be read from:
|
||||
When the connection fails, the page opens a credential guide. You can:
|
||||
|
||||
1. Collector settings saved in the console.
|
||||
2. Backend environment variables:
|
||||
- `BARENTSWATCH_CLIENT_ID`
|
||||
- `BARENTSWATCH_CLIENT_SECRET`
|
||||
- historical spellings: `BARRENTSWATCH_CLIENT_ID`, `BARRENTSWATCH_CLIENT_SECRET`
|
||||
3. matching `export` lines in `~/.zshrc`.
|
||||
- View the default guide
|
||||
- Click "Guide not helpful" to ask AI Provider to regenerate from the default prompt
|
||||
- Click "Reset" to restore the default guide
|
||||
|
||||
If connection fails, the page opens the credential guide. The guide can be regenerated through AI Provider or reset to the default guide. The default guide points users to the official BarentsWatch tutorial and emphasizes selecting `AIS - API`, not the regular `BarentsWatch - API`.
|
||||
The default guide follows the BarentsWatch official tutorial and reminds you to choose `AIS - API` for Live AIS.
|
||||
|
||||
### System Logs
|
||||
### AISStream Realtime Vessels
|
||||
|
||||
`/logs` views system logs. If the menu item is not visible, the current user likely lacks the required role.
|
||||
`AISStream Realtime Vessels` is the global AIS WebSocket collector. A passing connection test only confirms API key + endpoint format. Actual global vessel data requires the backend `aisstream_vessels` collector to stay connected and write to `ais_raw_observations`.
|
||||
|
||||
Common troubleshooting sequence:
|
||||
Steps:
|
||||
|
||||
```bash
|
||||
./planet.sh health
|
||||
./planet.sh log
|
||||
```
|
||||
1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
2. Fill the AISStream API Key
|
||||
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
|
||||
4. Click the plug icon to test; confirm it reports `Reachable`
|
||||
5. Save collector settings
|
||||
6. Trigger the `aisstream_vessels` collector from the collection scheduler
|
||||
7. Watch the `AISStream Runtime` panel:
|
||||
- `streaming` / `connected` means the live stream is being consumed
|
||||
- `messages this round` should keep growing
|
||||
- `disconnected` with `ConnectionResetError` means the upstream or network dropped; re-trigger or wait for reconnect
|
||||
|
||||
Then open `/logs` for more structured runtime information.
|
||||
## Configure AI Credentials
|
||||
|
||||
## Docs
|
||||
`/ai?tab=providers` is the AI management entry. Two key sub-tabs:
|
||||
|
||||
Public documentation site:
|
||||
- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test
|
||||
- `Tools`: a dropdown for specific tools — currently WebSearch and OCR
|
||||
|
||||
```text
|
||||
http://localhost:3000/docs
|
||||
```
|
||||
### Model Providers
|
||||
|
||||
Current public content comes from:
|
||||
Providers and models accept presets or arbitrary custom IDs. Common fields:
|
||||
|
||||
```text
|
||||
docs/technical/zh/ (Chinese)
|
||||
docs/technical/en/ (English)
|
||||
```
|
||||
- Provider: e.g. `minimax`, `openai`, `anthropic`, `ollama`
|
||||
- Protocol: `OpenAI Chat Completions` / `Anthropic Messages` / `Ollama Generate`
|
||||
- Base URL: model API URL
|
||||
- Default Model: e.g. `gpt-5.1`, `MiniMax-M2.7`
|
||||
- API Key: stored on save; displayed masked afterwards
|
||||
- Max Tokens, Anthropic Version: keep defaults if unsure
|
||||
- Timeout / Retry: timeout and retry attempts
|
||||
|
||||
Docs supports:
|
||||
The plug icon at the end of the Base URL input runs a connection test. A passing test echoes the model's short reply.
|
||||
|
||||
- Category navigation
|
||||
- Markdown rendering
|
||||
- Tables and code blocks
|
||||
- In-document table of contents
|
||||
- Local search
|
||||
- Internal links between technical documents
|
||||
### Tools
|
||||
|
||||
When adding a new technical document, check:
|
||||
- **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out
|
||||
- **OCR**: provider, base URL, API key, model/engine, recognition languages, timeout, max file size, output format
|
||||
|
||||
- Does it have a clear top-level heading
|
||||
- Does it need to be added to the `/docs` manual category and ordering
|
||||
- Does it contain information that should not be publicly displayed
|
||||
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
|
||||
## Development Command Conventions
|
||||
## System Settings
|
||||
|
||||
Frontend commands must use Bun:
|
||||
`/settings` manages system-level configuration. Sub-tabs:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
bun install
|
||||
bun run dev
|
||||
bun run build
|
||||
```
|
||||
- **System Display**: name, refresh interval, retention, max concurrent tasks
|
||||
- **Notifications**: alert email switch, recipient, critical/warning/daily summary
|
||||
- **Security**: session timeout, max login attempts, password policy
|
||||
- **SMTP Email**: outgoing email used by registration and password reset (visible to `admin` / `super_admin` only)
|
||||
- **TV Livestream**: TV source management
|
||||
- **AI / WebSearch / OCR**: see above
|
||||
|
||||
Do not use `npm run ...`. The project uses Bun in WSL / Windows mixed environments to avoid Node/npm path compatibility issues.
|
||||
### SMTP Email Settings
|
||||
|
||||
Verify the frontend build:
|
||||
Public registration and verification codes depend on this section. An `admin` or `super_admin` opens `/settings -> SMTP Email` and fills:
|
||||
|
||||
```bash
|
||||
source ~/.zshrc && bun run build
|
||||
```
|
||||
- SMTP host, port
|
||||
- Username, password
|
||||
- From address (required), from name
|
||||
- STARTTLS (typical for port 587) or implicit TLS (port 465)
|
||||
- Timeout in seconds
|
||||
|
||||
## Troubleshooting Order
|
||||
Save, then click "Send Test Email" and enter a recipient address to verify delivery. Once that works, regular users can self-register at `/register`.
|
||||
|
||||
When something goes wrong, follow this sequence:
|
||||
Leaving the masked password preview unchanged keeps the original password. Enter a new value to replace it.
|
||||
|
||||
1. Check service status:
|
||||
## User Management (Admins)
|
||||
|
||||
```bash
|
||||
./planet.sh health
|
||||
```
|
||||
`/users` is `super_admin`-only for create/delete. The page supports:
|
||||
|
||||
2. Check recent logs:
|
||||
- Listing users (username, email, role, active, email verified)
|
||||
- Creating users (equivalent to public registration but skips email verification — administrator vouching)
|
||||
- Changing roles: `viewer` / `operator` / `admin` / `super_admin`
|
||||
- Editing Gatekeeper groups: `docs_user` / `docs_developer` / `docs_admin`, controlling which docs are visible
|
||||
- Disabling / enabling accounts
|
||||
|
||||
```bash
|
||||
./planet.sh log
|
||||
```
|
||||
To let a regular user read developer or operations docs, add `docs_developer` or `docs_admin` at `/users`.
|
||||
|
||||
3. Check per-module logs:
|
||||
## Data Exploration
|
||||
|
||||
```bash
|
||||
./planet.sh log -f
|
||||
./planet.sh log -b
|
||||
./planet.sh log -a
|
||||
```
|
||||
- `/datasources`: source directory. It can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
|
||||
- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records"
|
||||
- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth
|
||||
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts
|
||||
|
||||
4. Restart only the affected module:
|
||||
## AI Testbench
|
||||
|
||||
```bash
|
||||
./planet.sh restart -f
|
||||
./planet.sh restart -b
|
||||
./planet.sh restart -a
|
||||
```
|
||||
`/ai?tab=playground` is for real-pipeline debugging:
|
||||
|
||||
5. If database or cache is abnormal, restart the database:
|
||||
- Pick the active provider
|
||||
- Run preset requests or custom prompts
|
||||
- Watch AI Provider status and response
|
||||
|
||||
```bash
|
||||
./planet.sh restart -d
|
||||
```
|
||||
The legacy link `/playground` redirects here.
|
||||
|
||||
6. If still unrecovered, do a full restart:
|
||||
## Earth Public Page
|
||||
|
||||
```bash
|
||||
./planet.sh restart
|
||||
```
|
||||
Earth at `http://localhost:3000/earth` is the public 3D situational page; no login required. The React route `/earth` wraps a standalone frontend (in `frontend/public/earth/`) via iframe.
|
||||
|
||||
### Primary Uses
|
||||
|
||||
A single globe view of: BGP events and observations, satellites and tracks, cables and landing points, compute centers, country boundaries / graticules / high-res tiles / clouds / terrain, news live streams and situational news, search and focus details.
|
||||
|
||||
### Layer Control
|
||||
|
||||
The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, tracks, terrain.
|
||||
|
||||
Dependencies:
|
||||
|
||||
- Terrain depends on high-res tiles
|
||||
- Tracks depend on satellites
|
||||
- With high-res tiles disabled, the globe shows the base map with edge highlighting
|
||||
|
||||
### Legend
|
||||
|
||||
The bottom-left legend follows the focused or enabled layer. Covered: cables, satellites, country boundaries, compute centers, BGP, AIS vessels.
|
||||
|
||||
AIS vessel legend colors by type: cargo, tanker, passenger, fishing, military, moored/slow, other. Triangles indicate moving vessels; dots indicate moored or slow targets.
|
||||
|
||||
### Search
|
||||
|
||||
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
|
||||
|
||||
### Coordinate Candidate Collection
|
||||
|
||||
Compute center and BGP observer detail cards support automatic coordinate-candidate collection. Click the object then use "Collect Coordinate Candidates" or "Re-collect Coordinates". The backend assembles candidates from source coordinates, public-org registry APIs, and online geocoders. When regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback. BGP observers' stored coordinates only fill query context; they are not returned as candidates.
|
||||
|
||||
Candidates preview on Earth directly. Saving a compute-center candidate writes to the `compute_center_locations` dimension table and refreshes the layer immediately. The notification badge at the top-left of the compute-center layer shows the unresolved count; clicking it opens the queue, supports single collection, or "Adopt All" to save the top-confidence candidates from top to bottom. Records without candidates stay in the queue rather than being faked to country centroids. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md).
|
||||
|
||||
### Settings
|
||||
|
||||
The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset.
|
||||
|
||||
These settings live in browser local storage; switching browsers or clearing site data resets them.
|
||||
|
||||
### View Controls
|
||||
|
||||
| Action | Effect |
|
||||
| --- | --- |
|
||||
| Mouse drag | Rotate the globe |
|
||||
| Single-finger drag | Touch rotate |
|
||||
| Mouse wheel | Zoom in/out |
|
||||
| Pinch | Touch zoom |
|
||||
| Zoom button | Stepped zoom |
|
||||
| Click zoom percentage | Reset to default zoom |
|
||||
|
||||
A small pill at the top briefly shows the current zoom while zooming. This is not a data loading indicator; if data is loading, the loading state takes precedence.
|
||||
|
||||
Drag sensitivity adjusts to zoom: near the default it is normal; zoomed in it is finer for targeted inspection; zoomed out it is slightly faster for global browsing.
|
||||
|
||||
### Motion Capture
|
||||
|
||||
Earth supports motion capture. Two live inputs:
|
||||
|
||||
- **Browser Camera** (default): uses `getUserMedia` in the page. No install needed but the page must run on HTTPS or localhost, and the user must grant camera permission
|
||||
- **Motion Agent**: camera/RTSP/HTTP → local agent → local WebSocket → Earth. Used for dual cameras, USB index, phone/IP camera streams
|
||||
|
||||
Enable via the settings toggle "Motion Debug Mode", or with URL parameter `?motion=1`. Motion Agent defaults to `ws://127.0.0.1:8765/ws/gestures`; override with `motionAgent`. You can also pin the input with `?motion=1&motionProvider=browser` or `?motion=1&motionProvider=agent`.
|
||||
|
||||
Neither mode uploads camera frames or live gestures; neither reuses the news/RSS aggregation API.
|
||||
|
||||
Gesture semantics:
|
||||
|
||||
| Event | Effect |
|
||||
| --- | --- |
|
||||
| `rotate_left/right/up/down` | Rotate accordingly |
|
||||
| `zoom_in/out` | Zoom |
|
||||
| `focus_prev/next` | Cycle focusable targets within the current layer |
|
||||
| `layer_prev/next` | Switch layer and pan to nearest target |
|
||||
| `confirm` | Confirm the current selection |
|
||||
|
||||
In the debug panel: the browser camera input shows the live preview with skeleton overlay; Motion Agent sends only normalized skeleton events, never raw frames. "Skeleton Only" hides the video and keeps just the skeleton; "Stop Matching" pauses gesture firing while keeping preview and skeleton. Unmatched skeleton is red; matched turns green and shows the action name.
|
||||
|
||||
### Cruise Mode
|
||||
|
||||
Cruise mode auto-rotates focused targets. Current modules: BGP, news, compute centers, vessels, cables, satellites. Suitable for demos, control rooms, and unattended displays.
|
||||
|
||||
### Mobile
|
||||
|
||||
Mobile uses a drawer layout: layer control moves into a drawer; search/settings/details use mobile panels. Main interaction is still object tap, search, and layer toggles.
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **Earth does not open**: confirm the frontend is online; if not on port `3000`, use the port printed by the startup log
|
||||
- **Layers have no data**: open `/datasources` to check source status, collected-record state, and the latest run result; then `/data` or `/bgp` for records
|
||||
- **Satellites / BGP / cables load slowly**: those layers depend on backend APIs and external data sources; the first load waits for startup tasks
|
||||
|
||||
## Docs Site
|
||||
|
||||
Docs at `http://localhost:3000/docs` are served by the backend with access control, not bundled into the frontend build.
|
||||
|
||||
Anonymous visitors see only `public` docs: README, Quickstart, Manual, FAQ, Earth Location Candidate Collection User Guide. Authenticated users with Gatekeeper groups see more:
|
||||
|
||||
- `docs_user`: end-user operational docs
|
||||
- `docs_developer`: Earth, frontend, backend, collectors, AI Provider development docs
|
||||
- `docs_admin`: service control, operations, environment variables, sensitive operations (including the Ops Runbook)
|
||||
|
||||
`admin` has `docs_admin` by default; `super_admin` has all docs permissions. Gatekeeper groups are managed at `/users`.
|
||||
|
||||
Docs supports: category navigation, Markdown rendering, tables and code blocks, in-doc table of contents, search across currently visible docs, internal links between technical docs.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md)
|
||||
- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
|
||||
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
|
||||
- [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
|
||||
- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
|
||||
- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
|
||||
- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md)
|
||||
- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md)
|
||||
- [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)
|
||||
|
||||
291
docs/technical/en/ops-planet-sh-startup.md
Normal file
291
docs/technical/en/ops-planet-sh-startup.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# `planet.sh` Startup Performance Optimization
|
||||
|
||||
## Background
|
||||
|
||||
`planet.sh` manages start, stop, restart, health checks, and logs for all local services. The previous implementation had several startup issues:
|
||||
|
||||
1. AI Provider rebuilt every time, even when code had not changed.
|
||||
2. Port cleanup could wait up to 45 seconds.
|
||||
3. Port bind detection used a Python subprocess, adding about 300 ms per call.
|
||||
4. Plain `restart` and `restart -b` behaved differently.
|
||||
|
||||
## Issue 1: AI Provider Rebuilt Every Time
|
||||
|
||||
### Root Cause
|
||||
|
||||
The build stamp file lived under `/tmp/`. After WSL or Linux restart, `/tmp` is cleared, so the `stamp_non_empty` condition failed and the script decided to rebuild:
|
||||
|
||||
```bash
|
||||
# All three conditions had to be true to skip rebuild
|
||||
image_exists AND stamp_non_empty AND fingerprint_match
|
||||
```
|
||||
|
||||
### Fix
|
||||
|
||||
The stamp file moved to a persistent cache path:
|
||||
|
||||
```bash
|
||||
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
|
||||
```
|
||||
|
||||
Writing the stamp creates the directory first:
|
||||
|
||||
```bash
|
||||
write_ai_provider_build_stamp() {
|
||||
mkdir -p "$(dirname "$AI_PROVIDER_BUILD_STAMP_FILE")"
|
||||
compute_ai_provider_build_fingerprint > "$AI_PROVIDER_BUILD_STAMP_FILE"
|
||||
}
|
||||
```
|
||||
|
||||
### Faster Fingerprint
|
||||
|
||||
The previous implementation tarred the whole `aiprovider/` directory before hashing, which could take seconds in large trees. The new version uses `find + stat` and reads only file metadata:
|
||||
|
||||
```bash
|
||||
compute_ai_provider_build_fingerprint() {
|
||||
find aiprovider \
|
||||
-type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '.env' \
|
||||
! -name '.env.*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '*.pyo' \
|
||||
| LC_ALL=C sort \
|
||||
| xargs -r stat --format="%Y %s %n" 2>/dev/null
|
||||
sha256sum docker-compose.yml docker-compose.simple.yml 2>/dev/null
|
||||
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py" 2>/dev/null
|
||||
}
|
||||
```
|
||||
|
||||
This is roughly 10 times faster for many-small-file workloads while preserving the same practical rebuild signal. `.env` and `.env.*` are excluded because runtime model, key, and Base URL changes should not force an image rebuild.
|
||||
|
||||
### Docker Build Context
|
||||
|
||||
AI Provider only needs root `pyproject.toml`, `uv.lock`, and `aiprovider/` source code. Sending the entire repository as Docker build context wastes time on frontend assets, PDFs, historical data, and Unreal files.
|
||||
|
||||
The root `.dockerignore` now narrows the context:
|
||||
|
||||
```dockerignore
|
||||
**
|
||||
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!aiprovider/
|
||||
!aiprovider/**
|
||||
|
||||
aiprovider/.env
|
||||
aiprovider/.env.*
|
||||
!aiprovider/.env.example
|
||||
```
|
||||
|
||||
The Dockerfile copies only AI Provider inputs:
|
||||
|
||||
```dockerfile
|
||||
COPY pyproject.toml uv.lock /app/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
COPY aiprovider /app/aiprovider
|
||||
```
|
||||
|
||||
`uv sync` uses a BuildKit cache mount. The first build may still depend on network speed, but later builds reuse `/root/.cache/uv`.
|
||||
|
||||
### Runtime Configuration
|
||||
|
||||
Before starting AI Provider, `planet.sh` generates a temporary env-file and passes it to Compose or the manual `docker run` fallback. Configuration priority:
|
||||
|
||||
1. `aiprovider/.env`
|
||||
2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc`
|
||||
|
||||
The default parser is static and only covers AI Provider, image, and proxy variables. It avoids executing interactive shell initialization. Complex shell expansion can be enabled explicitly:
|
||||
|
||||
```bash
|
||||
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
|
||||
```
|
||||
|
||||
To ignore personal shell config during debugging:
|
||||
|
||||
```bash
|
||||
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
|
||||
```
|
||||
|
||||
### Skip-Rebuild Behavior
|
||||
|
||||
When the fingerprint matches, the script skips `docker compose build` and starts the existing container:
|
||||
|
||||
```bash
|
||||
docker start planet_aiprovider
|
||||
```
|
||||
|
||||
`docker stop` stops the container without deleting the image. `cleanup_exit_containers` removes exited containers but not images, so the next `docker start` can reuse the existing image.
|
||||
|
||||
## Issue 2: Slow Port Cleanup
|
||||
|
||||
### Cause
|
||||
|
||||
`wait_for_port_release` could wait up to 45 seconds by default: 15 attempts times 3 seconds.
|
||||
|
||||
### Fix
|
||||
|
||||
Background process cleanup now uses a 3-second timeout: TERM, 1.5 seconds, KILL, 1.5 seconds.
|
||||
|
||||
```bash
|
||||
PORT_RELEASE_ATTEMPTS=15
|
||||
PORT_RELEASE_INTERVAL=0.2
|
||||
|
||||
wait_for_port_release "$port" 15 0.2
|
||||
```
|
||||
|
||||
`wait_for_port_release` accepts optional parameters so different situations can choose different timeouts.
|
||||
|
||||
## Issue 3: Port Detection Used Python
|
||||
|
||||
### Cause
|
||||
|
||||
`can_bind_port` used `python3 -c "import socket..."`; each call cost about 300 ms.
|
||||
|
||||
### Fix
|
||||
|
||||
Prefer system tools and keep Python as a fallback:
|
||||
|
||||
```bash
|
||||
can_bind_port() {
|
||||
local port="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE ":${port}$"
|
||||
return
|
||||
fi
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
[ -z "$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null)" ]
|
||||
return
|
||||
fi
|
||||
python3 - "$port" <<'PY'
|
||||
import sys, socket
|
||||
p = int(sys.argv[1])
|
||||
s = socket.socket()
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
s.bind(("", p)); s.close(); sys.exit(0)
|
||||
except OSError:
|
||||
sys.exit(1)
|
||||
PY
|
||||
}
|
||||
```
|
||||
|
||||
Frontend startup now has an additional pre-start cleanup retry layer:
|
||||
|
||||
- `PORT_PRESTART_RETRIES`: defaults to 3 attempts.
|
||||
- `PORT_PRESTART_RETRY_INTERVAL`: defaults to 2 seconds.
|
||||
|
||||
`kill_port_if_requested()` first cleans listener PIDs visible in the current environment. It only checks for Windows-side listeners when the script detects WSL, no local listener PID is visible, and the port still cannot bind. In that WSL-only path it tries to stop the owning Windows service or force-stop the owning process through PowerShell. If permissions are missing, or a system service such as `iphlpsvc` refuses to stop, the script prints the Windows listener details and stops startup immediately instead of launching the service into the same port error. Non-WSL environments do not run the Windows cleanup path. At that point, use Administrator PowerShell to clear the portproxy/service ownership, or choose another port.
|
||||
|
||||
## Issue 4: `restart` Behavior
|
||||
|
||||
Before the stamp path fix:
|
||||
|
||||
- `restart -b`: stop all services, check fingerprint, rebuild only when needed, then start.
|
||||
- plain `restart`: stop all services, then often rebuild AI Provider because `/tmp` lost the stamp.
|
||||
|
||||
After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`.
|
||||
|
||||
## Optional Motion Agent Startup
|
||||
|
||||
`planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable.
|
||||
|
||||
Start it with:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
- `--motion-agent` / `-m`: start or restart the Motion Agent for this command.
|
||||
- `--motion-agent-port <port>`: override the default WebSocket port `8765`.
|
||||
- `--motion-agent-camera-indexes <indexes>`: override auto-detected camera indexes, for example `0` or `0,1`. The same can be provided through `MOTION_AGENT_CAMERA_INDEXES=0,1`.
|
||||
- `--motion-agent-camera-urls <urls>`: use RTSP/HTTP camera streams, useful for WSL, phone cameras, or network cameras. The same can be provided through `MOTION_AGENT_CAMERA_URLS=...`.
|
||||
- `--motion-agent-dry-run`: start only the protocol service without opening cameras or loading CV dependencies; useful for Web client debugging.
|
||||
|
||||
Non-dry-run live mode checks `mediapipe` and `opencv-python` before startup. If the current `.venv` is missing them, the script automatically runs:
|
||||
|
||||
```bash
|
||||
uv add mediapipe opencv-python
|
||||
```
|
||||
|
||||
To disable startup-time auto-install:
|
||||
|
||||
```bash
|
||||
PLANET_MOTION_AGENT_AUTO_INSTALL=0 ./planet.sh start --motion-agent
|
||||
```
|
||||
|
||||
Live mode auto-detects `/dev/video*` and passes the first two indexes to the Motion Agent. In WSL, Windows cameras usually do not appear as `/dev/video*` automatically. Check available devices first:
|
||||
|
||||
```bash
|
||||
ls /dev/video*
|
||||
```
|
||||
|
||||
To override auto-detection, pass indexes explicitly:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-indexes 1,2
|
||||
```
|
||||
|
||||
In WSL, the more general path is to connect a phone or network camera through an RTSP/HTTP stream:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://192.168.1.20:8080/video
|
||||
```
|
||||
|
||||
If WSL has no `/dev/video*` and no `--motion-agent-camera-urls` is provided, live startup stops and prints guidance instead of silently falling back to dry-run. Choose one of:
|
||||
|
||||
```bash
|
||||
./planet.sh start --motion-agent --motion-agent-camera-urls http://<phone-ip>:8080/video
|
||||
./planet.sh start --motion-agent --motion-agent-dry-run
|
||||
```
|
||||
|
||||
Automatic dry-run fallback only happens when `PLANET_MOTION_AGENT_WSL_ALLOW_DRY_RUN_FALLBACK=1` is explicitly set.
|
||||
|
||||
Environment-variable startup is also supported:
|
||||
|
||||
```bash
|
||||
PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
./planet.sh log -m
|
||||
```
|
||||
|
||||
To expose it together with the frontend on the LAN:
|
||||
|
||||
```bash
|
||||
./planet.sh start --allow-lan --motion-agent
|
||||
```
|
||||
|
||||
In this mode the Motion Agent binds `0.0.0.0`, and startup output prints both the local WebSocket URL and the recommended LAN WebSocket URL. When opening Earth from another LAN browser, point `motionAgent` at the display machine:
|
||||
|
||||
```text
|
||||
http://<LAN_IP>:3000/earth?motion=1&motionAgent=ws://<LAN_IP>:8765/ws/gestures
|
||||
```
|
||||
|
||||
`./planet.sh stop` also stops a script-managed Motion Agent. `./planet.sh health` reports its online/offline status. The Earth page still requires `?motion=1` or browser local storage to enable the Web client connection explicitly.
|
||||
|
||||
For ordinary web, WSL, or no-install demo scenarios, you can skip Motion Agent entirely: choose the `Browser Camera` input source in Earth settings and enable Motion Debug Mode. This route uses browser `getUserMedia`, so the page must run on HTTPS or localhost and the user must grant camera permission.
|
||||
|
||||
## Other Cleanup
|
||||
|
||||
Two redundant `sleep 3` waits were removed because health checks already cover the same readiness:
|
||||
|
||||
- `start_backend_service`: post-database-health-check sleep.
|
||||
- `restart_database_service`: post-restart sleep.
|
||||
|
||||
## Related Files
|
||||
|
||||
- [planet.sh](/home/ray/dev/linkong/planet/planet.sh)
|
||||
- [.dockerignore](/home/ray/dev/linkong/planet/.dockerignore)
|
||||
- [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile)
|
||||
- [docker-compose.yml](/home/ray/dev/linkong/planet/docker-compose.yml)
|
||||
- [docker-compose.simple.yml](/home/ray/dev/linkong/planet/docker-compose.simple.yml)
|
||||
- [compute_aiprovider_dependency_fingerprint.py](/home/ray/dev/linkong/planet/scripts/compute_aiprovider_dependency_fingerprint.py)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user