release: bump version to 0.59.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-16 05:02:05 +08:00
parent 93eb41a9f7
commit 9b913a3b83
86 changed files with 3645 additions and 1198 deletions

View File

@@ -341,6 +341,7 @@ async def collect_bgp_collector_location(
provider_client=provider_client,
query=query,
entity_type="bgp_collector",
db=db,
attempted_queries=attempted_queries,
search_evidence=search_result.evidence,
)

View File

@@ -13,11 +13,6 @@ import httpx
from app.core.target_schema_registry import get_target_schema, list_target_schemas
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.core.earth_boundary_defaults import (
EARTH_BOUNDARY_DEFAULT_SOURCES,
EARTH_BOUNDARY_SOURCE_MAPPING,
default_earth_boundary_config,
)
from app.db.session import get_db
from app.models.user import User
from app.models.datasource_config import DataSourceConfig
@@ -27,6 +22,7 @@ from app.models.vessel import AISRawObservation, AISSourceHealth
from app.core.security import get_current_user
from app.core.cache import cache
from app.core.time import to_iso8601_utc
from app.ai_tasks.prompts import get_effective_prompt
from app.schemas.ai import SituationalAnalysisRequest
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.datasource_mapping import (
@@ -46,6 +42,8 @@ from app.services.custom_datasource_runtime import (
stop_custom_stream,
test_websocket_config,
)
DATASOURCE_MAPPING_PROMPT_KEY = "datasource.mapping"
from app.services.datasource_connectivity import (
get_builtin_connection_status,
save_connectivity_success,
@@ -56,22 +54,12 @@ from app.services.datasource_connectivity import (
router = APIRouter()
def _default_builtin_config(name: str) -> dict[str, Any]:
if name in {"earth_admin0_boundaries", "earth_coastline", "earth_claim_lines"}:
return default_earth_boundary_config(name)
if name == "earth_boundary_tiles":
return {
"timeout": 30,
"retry": 0,
"input_mode": "latest_collected_earth_boundary_sources",
}
return {"timeout": 30, "retry": 3}
def _default_builtin_source_type(name: str) -> str:
if name == "aisstream_vessels":
return "websocket"
if name == "earth_boundary_tiles":
return "internal"
return "http"
@@ -400,7 +388,7 @@ async def list_all_datasources(
yaml_url = config.get_yaml_url(name)
db_config = db_configs.get(name)
default_config = _default_builtin_config(name)
default_url = EARTH_BOUNDARY_DEFAULT_SOURCES.get(name, {}).get("endpoint") or yaml_url
default_url = yaml_url
result.append(
{
@@ -783,14 +771,12 @@ async def propose_datasource_mapping(
generated_by = "heuristic"
if payload.use_ai:
try:
prompt = await get_effective_prompt(db, DATASOURCE_MAPPING_PROMPT_KEY)
response = await ai_client.analyze(
SituationalAnalysisRequest(
title=f"Generate datasource mapping for {schema.key}",
objective=(
"Return only JSON for a deterministic mapping DSL. "
"The JSON must contain source.items_path and fields. "
"Do not include prose or code."
),
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
context={
"target_schema": schema.to_dict(),
"sample_payload": redacted_sample,

View File

@@ -35,7 +35,6 @@ PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
("satellites", ("tle", "satellite", "spacetrack", "celestrak")),
("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")),
("earth", ("earth_", "earth_boundary", "boundary", "country boundary", "coastline", "claim")),
("compute", ("top500", "gpu", "supercomputer", "compute")),
("ai", ("huggingface", "epoch_ai")),
("media", ("news", "tv", "live_stream")),

118
backend/app/api/v1/earth.py Normal file
View File

@@ -0,0 +1,118 @@
"""Earth asset management APIs."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token, get_current_user, redis_client
from app.db.session import get_db
from app.models.user import User
from app.services.earth_boundaries import (
EarthBoundaryBuildError,
get_boundary_build_status,
get_boundary_status,
save_boundary_config,
start_boundary_build_job,
)
router = APIRouter()
optional_bearer = HTTPBearer(auto_error=False)
class EarthBoundaryConfigPayload(BaseModel):
config: dict[str, Any] = Field(default_factory=dict)
@router.get("/boundaries/status")
async def get_earth_boundary_status():
return get_boundary_status()
async def _get_optional_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer),
db: AsyncSession = Depends(get_db),
) -> User | None:
if credentials is None:
return None
token = credentials.credentials
if redis_client.sismember("blacklisted_tokens", token):
return None
payload = decode_token(token)
if payload is None or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await db.execute(
text(
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE id = :id"
),
{"id": int(user_id)},
)
row = result.fetchone()
if row is None or not row[5]:
return None
user = User()
user.id = row[0]
user.username = row[1]
user.email = row[2]
user.password_hash = row[3]
user.role = row[4]
user.is_active = row[5]
user.gatekeeper_groups = row[6] or []
return user
def _is_loopback_request(request: Request) -> bool:
host = request.client.host if request.client else ""
return host in {"127.0.0.1", "::1", "localhost"} or host.startswith("127.")
def _require_local_or_user(request: Request, user: User | None) -> None:
if user is not None or _is_loopback_request(request):
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required outside localhost",
)
@router.put("/boundaries/config")
async def update_earth_boundary_config(
payload: EarthBoundaryConfigPayload,
_current_user: User = Depends(get_current_user),
):
try:
return save_boundary_config(payload.config)
except EarthBoundaryBuildError as exc:
raise HTTPException(
status_code=400,
detail={"code": exc.code, "message": str(exc), "details": exc.details},
) from exc
@router.post("/boundaries/build")
async def build_earth_boundary_assets(
request: Request,
current_user: User | None = Depends(_get_optional_current_user),
):
_require_local_or_user(request, current_user)
try:
return await start_boundary_build_job()
except EarthBoundaryBuildError as exc:
raise HTTPException(
status_code=400,
detail={"code": exc.code, "message": str(exc), "details": exc.details},
) from exc
@router.get("/boundaries/build/status")
async def get_earth_boundary_build_status():
return get_boundary_build_status()

View File

@@ -15,6 +15,13 @@ from app.core.time import to_iso8601_utc
from app.core.config import settings as app_settings
from app.core.data_sources import get_data_sources_config
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.ai_tasks.prompts import (
get_effective_prompt,
list_effective_prompts,
reset_prompt_override,
save_prompt_override,
serialize_effective_prompt,
)
from app.db.session import get_db
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
@@ -60,6 +67,7 @@ from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload
router = APIRouter()
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
DEFAULT_SETTINGS = {
"system": {
@@ -251,6 +259,11 @@ class OCRIntegrationUpdate(BaseModel):
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
class AIPromptUpdate(BaseModel):
system_prompt: str = Field(default="", max_length=8000)
prompt: str = Field(min_length=1, max_length=20000)
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
@@ -487,6 +500,10 @@ def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> b
return True
if text == current_preview or text.startswith("••••"):
return True
if "-" in text:
_prefix, masked = text.split("-", 1)
if masked and all(char in {"*", "", " ", "\t"} for char in masked):
return True
return all(char in {"*", "", " ", "\t"} for char in text)
@@ -597,10 +614,12 @@ async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
status_code=400,
detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。",
)
prompt = await get_effective_prompt(None, AI_CONNECTION_TEST_PROMPT_KEY)
analysis_result = await client.analyze(
SituationalAnalysisRequest(
title="保存前完整连接测试",
objective="请用一句话回复连接可用。",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"],
constraints=["回复尽量简短。"],
)
@@ -1212,6 +1231,47 @@ async def get_external_integrations(
return {"integrations": await serialize_external_integrations(db)}
@router.get("/ai-prompts")
async def get_ai_prompts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
prompts = await list_effective_prompts(db)
return {"data": [serialize_effective_prompt(prompt) for prompt in prompts]}
@router.put("/ai-prompts/{task_key}")
async def update_ai_prompt(
task_key: str,
payload: AIPromptUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
prompt = await save_prompt_override(
db,
task_key,
system_prompt=payload.system_prompt,
prompt=payload.prompt,
)
except KeyError:
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
return {"data": serialize_effective_prompt(prompt)}
@router.post("/ai-prompts/{task_key}/reset")
async def reset_ai_prompt(
task_key: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
prompt = await reset_prompt_override(db, task_key)
except KeyError:
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
return {"data": serialize_effective_prompt(prompt)}
@router.get("/integrations/barentswatch/connectivity")
async def get_barentswatch_connectivity(
current_user: User = Depends(get_current_user),
@@ -1294,10 +1354,12 @@ async def connect_ai_provider_integration(
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
prompt = await get_effective_prompt(db, AI_CONNECTION_TEST_PROMPT_KEY)
probe_result = await client.analyze(
SituationalAnalysisRequest(
title="快速连接测试",
objective="Reply OK.",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=[],
constraints=["Output only OK."],
)

View File

@@ -1982,6 +1982,7 @@ async def collect_compute_center_location(
provider_client=provider_client,
query=query,
entity_type="compute_center",
db=db,
attempted_queries=attempted_queries,
search_evidence=search_result.evidence,
)