release: bump version to 0.59.0
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
config/earth-boundary-sources.local.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
@@ -17,9 +17,6 @@ class Settings(BaseSettings):
|
||||
AI_HTTP_RETRY_ATTEMPTS: int = 2
|
||||
AI_MAX_TOKENS: int = 1200
|
||||
AI_ANTHROPIC_VERSION: str = "2023-06-01"
|
||||
AI_ANALYSIS_SYSTEM_PROMPT: str = (
|
||||
"你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。"
|
||||
)
|
||||
|
||||
AI_PROVIDER_SERVICE_TOKEN: str = ""
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ class ProviderService:
|
||||
self.anthropic_version = str(
|
||||
overrides.get("anthropic_version") or settings.AI_ANTHROPIC_VERSION
|
||||
)
|
||||
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
|
||||
|
||||
def get_status(self) -> AIProviderStatusResponse:
|
||||
enabled = self.provider != "disabled"
|
||||
@@ -95,15 +94,20 @@ class ProviderService:
|
||||
prompt = self._build_prompt(payload)
|
||||
|
||||
if self.provider_api == "openai-completions":
|
||||
data = await self._request_openai_compatible(model, prompt)
|
||||
data = await self._request_openai_compatible(model, prompt, payload.system_prompt)
|
||||
content = self._extract_openai_content(data)
|
||||
content_blocks = self._extract_openai_blocks(data)
|
||||
elif self.provider_api == "anthropic-messages":
|
||||
data = await self._request_anthropic_messages(model, prompt, payload.thinking)
|
||||
data = await self._request_anthropic_messages(
|
||||
model,
|
||||
prompt,
|
||||
payload.thinking,
|
||||
payload.system_prompt,
|
||||
)
|
||||
content = self._extract_anthropic_content(data)
|
||||
content_blocks = self._extract_anthropic_blocks(data)
|
||||
elif self.provider_api == "ollama-generate":
|
||||
data = await self._request_ollama(model, prompt)
|
||||
data = await self._request_ollama(model, prompt, payload.system_prompt)
|
||||
content = self._extract_ollama_content(data)
|
||||
content_blocks = self._extract_ollama_blocks(data)
|
||||
else:
|
||||
@@ -139,18 +143,26 @@ class ProviderService:
|
||||
sections.append("约束条件:\n" + "\n".join(f"- {item}" for item in payload.constraints))
|
||||
if payload.context:
|
||||
sections.append(f"附加上下文:\n{payload.context}")
|
||||
sections.append(
|
||||
"请输出: 1) 态势摘要 2) 关键风险 3) 研判依据 4) 建议动作 5) 还缺少的数据。"
|
||||
)
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _request_openai_compatible(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
def _resolve_system_prompt(self, system_prompt: str | None) -> str | None:
|
||||
resolved = str(system_prompt or "").strip()
|
||||
return resolved or None
|
||||
|
||||
async def _request_openai_compatible(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
messages = []
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
messages.append({"role": "system", "content": resolved_system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
request_body = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": self.system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"messages": messages,
|
||||
"temperature": 0.2,
|
||||
"max_tokens": self.max_tokens,
|
||||
}
|
||||
@@ -168,10 +180,10 @@ class ProviderService:
|
||||
model: str,
|
||||
prompt: str,
|
||||
thinking: dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body = {
|
||||
"model": model,
|
||||
"system": self.system_prompt,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -186,6 +198,9 @@ class ProviderService:
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": 0.2,
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
resolved_thinking = self._resolve_anthropic_thinking(thinking)
|
||||
if resolved_thinking:
|
||||
request_body["thinking"] = resolved_thinking
|
||||
@@ -219,20 +234,28 @@ class ProviderService:
|
||||
model: str,
|
||||
prompt: str,
|
||||
thinking: dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request_anthropic_messages(model, prompt, thinking)
|
||||
return await self._request_anthropic_messages(model, prompt, thinking, system_prompt)
|
||||
|
||||
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
|
||||
async def _request_ollama(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_body = {
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"system": self.system_prompt,
|
||||
"prompt": prompt,
|
||||
"options": {
|
||||
"temperature": 0.2,
|
||||
"num_predict": self.max_tokens,
|
||||
},
|
||||
}
|
||||
resolved_system_prompt = self._resolve_system_prompt(system_prompt)
|
||||
if resolved_system_prompt:
|
||||
request_body["system"] = resolved_system_prompt
|
||||
return await self._post(
|
||||
path="/api/generate",
|
||||
headers={
|
||||
|
||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
||||
|
||||
class SituationalAnalysisRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
objective: str = Field(..., min_length=1, max_length=1000)
|
||||
objective: str = Field(..., min_length=1, max_length=20000)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
observations: list[str] = Field(default_factory=list)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
system_prompt: str | None = Field(default=None, max_length=8000)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
2
backend/app/ai_tasks/__init__.py
Normal file
2
backend/app/ai_tasks/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""AI task prompt registry and runtime helpers."""
|
||||
|
||||
74
backend/app/ai_tasks/default_prompts.json
Normal file
74
backend/app/ai_tasks/default_prompts.json
Normal file
@@ -0,0 +1,74 @@
|
||||
[
|
||||
{
|
||||
"key": "earth.news.enrich",
|
||||
"label": "Earth 新闻汉化与定位",
|
||||
"group": "Earth 新闻",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return exactly one strict JSON object with a location object and a localizations object. Infer the most likely physical event location and produce a faithful Simplified Chinese title and summary based only on the supplied RSS headline, description, source, and date."
|
||||
},
|
||||
{
|
||||
"key": "alerts.brief",
|
||||
"label": "系统告警研判",
|
||||
"group": "告警研判",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
|
||||
"prompt": "基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "alerts.situational.brief",
|
||||
"label": "跨模块态势告警研判",
|
||||
"group": "告警研判",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "你是告警研判助手。请基于输入的告警事实、上下文与约束,输出结构化、克制、可执行的值班研判;明确区分事实、推断与建议,不要夸大证据不足的风险。",
|
||||
"prompt": "综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "bgp.brief",
|
||||
"label": "BGP 态势简报",
|
||||
"group": "BGP",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。"
|
||||
},
|
||||
{
|
||||
"key": "location.factcheck.normalize",
|
||||
"label": "位置事实核查结构化",
|
||||
"group": "位置解析",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Convert the supplied location factcheck text into exactly one strict JSON object. Extract only facts present in the text or original query."
|
||||
},
|
||||
{
|
||||
"key": "location.factcheck.resolve",
|
||||
"label": "位置事实核查兜底",
|
||||
"group": "位置解析",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return exactly one JSON object for the most likely physical location. Use only fact-checkable public knowledge; return null fields rather than guessing when evidence is weak."
|
||||
},
|
||||
{
|
||||
"key": "datasource.mapping",
|
||||
"label": "数据源映射生成",
|
||||
"group": "采集配置",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Return only JSON for a deterministic mapping DSL. The JSON must contain source.items_path and fields. Do not include prose or code."
|
||||
},
|
||||
{
|
||||
"key": "credential.guide",
|
||||
"label": "采集器凭据教程",
|
||||
"group": "采集配置",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "生成一份中文采集器凭据配置教程。只能根据 context.search_evidence 中的来源生成教程;如果证据不足,明确说明需要以官方页面为准。"
|
||||
},
|
||||
{
|
||||
"key": "ai.connection_test",
|
||||
"label": "AI Provider 连接测试",
|
||||
"group": "运维测试",
|
||||
"version": "2026-05-16.1",
|
||||
"system_prompt": "",
|
||||
"prompt": "Reply OK."
|
||||
}
|
||||
]
|
||||
182
backend/app/ai_tasks/prompts.py
Normal file
182
backend/app/ai_tasks/prompts.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
AI_PROMPTS_CATEGORY = "ai_prompts"
|
||||
DEFAULT_PROMPTS_PATH = Path(__file__).with_name("default_prompts.json")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AIPromptDefinition:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
version: str
|
||||
system_prompt: str
|
||||
prompt: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EffectiveAIPrompt:
|
||||
key: str
|
||||
label: str
|
||||
group: str
|
||||
version: str
|
||||
default_system_prompt: str
|
||||
default_prompt: str
|
||||
system_prompt: str
|
||||
prompt: str
|
||||
is_custom: bool
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def list_prompt_definitions() -> tuple[AIPromptDefinition, ...]:
|
||||
raw_items = json.loads(DEFAULT_PROMPTS_PATH.read_text(encoding="utf-8"))
|
||||
return tuple(
|
||||
AIPromptDefinition(
|
||||
key=str(item["key"]),
|
||||
label=str(item["label"]),
|
||||
group=str(item["group"]),
|
||||
version=str(item["version"]),
|
||||
system_prompt=str(item.get("system_prompt") or ""),
|
||||
prompt=str(item.get("prompt") or ""),
|
||||
)
|
||||
for item in raw_items
|
||||
)
|
||||
|
||||
|
||||
def get_prompt_definition(task_key: str) -> AIPromptDefinition:
|
||||
for definition in list_prompt_definitions():
|
||||
if definition.key == task_key:
|
||||
return definition
|
||||
raise KeyError(task_key)
|
||||
|
||||
|
||||
async def _get_prompt_setting(db: AsyncSession) -> SystemSetting | None:
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.category == AI_PROMPTS_CATEGORY)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _normalize_overrides(payload: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
||||
raw = (payload or {}).get("overrides")
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): dict(value)
|
||||
for key, value in raw.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
|
||||
|
||||
async def get_prompt_overrides(db: AsyncSession) -> dict[str, dict[str, Any]]:
|
||||
if not hasattr(db, "execute"):
|
||||
return {}
|
||||
setting = await _get_prompt_setting(db)
|
||||
return _normalize_overrides(setting.payload if setting else None)
|
||||
|
||||
|
||||
def _effective_prompt(
|
||||
definition: AIPromptDefinition,
|
||||
override: dict[str, Any] | None,
|
||||
) -> EffectiveAIPrompt:
|
||||
override = override or {}
|
||||
custom_system = override.get("system_prompt")
|
||||
custom_prompt = override.get("prompt")
|
||||
has_custom_system = isinstance(custom_system, str)
|
||||
has_custom_prompt = isinstance(custom_prompt, str)
|
||||
return EffectiveAIPrompt(
|
||||
key=definition.key,
|
||||
label=definition.label,
|
||||
group=definition.group,
|
||||
version=definition.version,
|
||||
default_system_prompt=definition.system_prompt,
|
||||
default_prompt=definition.prompt,
|
||||
system_prompt=custom_system if has_custom_system else definition.system_prompt,
|
||||
prompt=custom_prompt if has_custom_prompt else definition.prompt,
|
||||
is_custom=has_custom_system or has_custom_prompt,
|
||||
updated_at=str(override.get("updated_at") or "") or None,
|
||||
)
|
||||
|
||||
|
||||
async def list_effective_prompts(db: AsyncSession) -> list[EffectiveAIPrompt]:
|
||||
overrides = await get_prompt_overrides(db)
|
||||
return [
|
||||
_effective_prompt(definition, overrides.get(definition.key))
|
||||
for definition in list_prompt_definitions()
|
||||
]
|
||||
|
||||
|
||||
async def get_effective_prompt(db: AsyncSession | None, task_key: str) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
if db is None:
|
||||
return _effective_prompt(definition, None)
|
||||
overrides = await get_prompt_overrides(db)
|
||||
return _effective_prompt(definition, overrides.get(task_key))
|
||||
|
||||
|
||||
async def save_prompt_override(
|
||||
db: AsyncSession,
|
||||
task_key: str,
|
||||
*,
|
||||
system_prompt: str,
|
||||
prompt: str,
|
||||
) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
setting = await _get_prompt_setting(db)
|
||||
payload = dict(setting.payload or {}) if setting else {}
|
||||
overrides = _normalize_overrides(payload)
|
||||
overrides[definition.key] = {
|
||||
"system_prompt": system_prompt,
|
||||
"prompt": prompt,
|
||||
"updated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
payload["overrides"] = overrides
|
||||
if setting is None:
|
||||
setting = SystemSetting(category=AI_PROMPTS_CATEGORY, payload=payload)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.payload = payload
|
||||
await db.commit()
|
||||
return _effective_prompt(definition, overrides[definition.key])
|
||||
|
||||
|
||||
async def reset_prompt_override(db: AsyncSession, task_key: str) -> EffectiveAIPrompt:
|
||||
definition = get_prompt_definition(task_key)
|
||||
setting = await _get_prompt_setting(db)
|
||||
if setting is None:
|
||||
return _effective_prompt(definition, None)
|
||||
payload = dict(setting.payload or {})
|
||||
overrides = _normalize_overrides(payload)
|
||||
overrides.pop(definition.key, None)
|
||||
payload["overrides"] = overrides
|
||||
setting.payload = payload
|
||||
await db.commit()
|
||||
return _effective_prompt(definition, None)
|
||||
|
||||
|
||||
def serialize_effective_prompt(prompt: EffectiveAIPrompt) -> dict[str, Any]:
|
||||
return {
|
||||
"key": prompt.key,
|
||||
"label": prompt.label,
|
||||
"group": prompt.group,
|
||||
"version": prompt.version,
|
||||
"default_system_prompt": prompt.default_system_prompt,
|
||||
"default_prompt": prompt.default_prompt,
|
||||
"system_prompt": prompt.system_prompt,
|
||||
"prompt": prompt.prompt,
|
||||
"is_custom": prompt.is_custom,
|
||||
"updated_at": prompt.updated_at,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from app.api.v1 import (
|
||||
datasource_config,
|
||||
datasources,
|
||||
docs,
|
||||
earth,
|
||||
tasks,
|
||||
dashboard,
|
||||
alerts,
|
||||
@@ -34,6 +35,7 @@ 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(earth.router, prefix="/earth", tags=["earth"])
|
||||
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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
118
backend/app/api/v1/earth.py
Normal 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()
|
||||
@@ -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."],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -257,46 +257,6 @@ DEFAULT_DATASOURCES = {
|
||||
"credential_provider": "aisstream",
|
||||
"credential_status": "supported",
|
||||
},
|
||||
"earth_admin0_boundaries": {
|
||||
"id": 29,
|
||||
"name": "Earth Admin-0 Boundaries",
|
||||
"display_name": "Earth Admin-0 国界源",
|
||||
"module": "L3",
|
||||
"priority": "P1",
|
||||
"frequency_minutes": 10080,
|
||||
"is_free": True,
|
||||
"requires_credentials": False,
|
||||
},
|
||||
"earth_coastline": {
|
||||
"id": 30,
|
||||
"name": "Earth Coastline",
|
||||
"display_name": "Earth 海岸线源",
|
||||
"module": "L3",
|
||||
"priority": "P1",
|
||||
"frequency_minutes": 10080,
|
||||
"is_free": True,
|
||||
"requires_credentials": False,
|
||||
},
|
||||
"earth_claim_lines": {
|
||||
"id": 31,
|
||||
"name": "Earth Claim Lines",
|
||||
"display_name": "Earth 主张线源",
|
||||
"module": "L3",
|
||||
"priority": "P1",
|
||||
"frequency_minutes": 10080,
|
||||
"is_free": True,
|
||||
"requires_credentials": False,
|
||||
},
|
||||
"earth_boundary_tiles": {
|
||||
"id": 32,
|
||||
"name": "Earth PMTiles Builder",
|
||||
"display_name": "Earth PMTiles 构建器",
|
||||
"module": "L3",
|
||||
"priority": "P1",
|
||||
"frequency_minutes": 10080,
|
||||
"is_free": True,
|
||||
"requires_credentials": False,
|
||||
},
|
||||
"media_news_archive": {
|
||||
"id": 33,
|
||||
"name": "Media News Archive",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Default Earth boundary source configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
EARTH_BOUNDARY_SOURCE_MAPPING: dict[str, Any] = {
|
||||
"source": {"items_path": "$.features[*]"},
|
||||
"fields": {
|
||||
"source_id": {"path": "$.properties.id", "type": "string"},
|
||||
"name": {"path": "$.properties.name", "type": "string"},
|
||||
"geometry": {"path": "$.geometry", "type": "object"},
|
||||
"properties": {"path": "$.properties", "type": "object"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
EARTH_BOUNDARY_DEFAULT_SOURCES: dict[str, dict[str, Any]] = {
|
||||
"earth_admin0_boundaries": {
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
||||
"source_kind": "admin0-boundaries",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_coastline": {
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
||||
"source_kind": "coastline",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_claim_lines": {
|
||||
"endpoint": "https://www.arcgis.com/sharing/rest/content/items/faaa1908c3ab43f0823c6fde9f18389c/data",
|
||||
"source_kind": "claim-lines",
|
||||
"license": "CC BY 4.0; source item owner mapmakersami",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def default_earth_boundary_config(name: str) -> dict[str, Any]:
|
||||
source = EARTH_BOUNDARY_DEFAULT_SOURCES.get(name)
|
||||
if not source:
|
||||
return {}
|
||||
return {
|
||||
"method": "GET",
|
||||
"timeout": 120,
|
||||
"retry": 3,
|
||||
"target_schema": "earth_boundary_source",
|
||||
"license": source["license"],
|
||||
"mapping_json": EARTH_BOUNDARY_SOURCE_MAPPING,
|
||||
}
|
||||
@@ -47,18 +47,6 @@ class GenericRecord(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class EarthBoundarySourceRecord(BaseModel):
|
||||
source_id: str | None = None
|
||||
source_kind: str = Field(pattern="^(admin0-boundaries|coastline|claim-lines)$")
|
||||
name: str | None = None
|
||||
geometry: dict[str, Any] | None = None
|
||||
properties: dict[str, Any] = Field(default_factory=dict)
|
||||
artifact_path: str | None = None
|
||||
sha256: str | None = None
|
||||
feature_count: int | None = Field(default=None, ge=0)
|
||||
license: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TargetField:
|
||||
name: str
|
||||
@@ -155,24 +143,6 @@ TARGET_SCHEMAS: dict[str, TargetSchema] = {
|
||||
TargetField("observed_at", "datetime", False, "观测时间", "2026-04-28T00:00:00Z"),
|
||||
),
|
||||
),
|
||||
"earth_boundary_source": TargetSchema(
|
||||
key="earth_boundary_source",
|
||||
label="Earth 边界矢量源",
|
||||
description="Admin-0 国界、海岸线、主张线等 Earth 边界源数据 artifact。",
|
||||
destination="collected_data",
|
||||
model=EarthBoundarySourceRecord,
|
||||
fields=(
|
||||
TargetField("source_id", "string", False, "来源侧 ID", "feature-1"),
|
||||
TargetField("source_kind", "string", True, "边界源类型", "admin0-boundaries"),
|
||||
TargetField("name", "string", False, "记录名称", "China"),
|
||||
TargetField("geometry", "object", False, "GeoJSON geometry", {"type": "Polygon", "coordinates": []}),
|
||||
TargetField("properties", "object", False, "GeoJSON properties", {"ISO_A3": "CHN"}),
|
||||
TargetField("artifact_path", "string", False, "完整源数据本地 artifact 路径", "data/earth-boundary-sources/earth_admin0_boundaries/abc.geojson"),
|
||||
TargetField("sha256", "string", False, "源数据 artifact SHA-256", "abc123"),
|
||||
TargetField("feature_count", "integer", False, "源数据 feature 数", 1),
|
||||
TargetField("license", "string", False, "源数据许可", "ODbL"),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import bindparam, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
@@ -72,6 +72,74 @@ async def seed_default_datasources(session: AsyncSession):
|
||||
await session.commit()
|
||||
|
||||
|
||||
LEGACY_EARTH_BOUNDARY_SOURCES = (
|
||||
"earth_admin0_boundaries",
|
||||
"earth_coastline",
|
||||
"earth_claim_lines",
|
||||
"earth_boundary_tiles",
|
||||
)
|
||||
LEGACY_EARTH_BOUNDARY_DATATYPES = (
|
||||
"earth_boundary_source",
|
||||
"earth_boundary_tiles",
|
||||
)
|
||||
LEGACY_EARTH_BOUNDARY_IDS = (29, 30, 31, 32)
|
||||
|
||||
|
||||
async def purge_legacy_earth_boundary_datasources(session: AsyncSession) -> None:
|
||||
source_names = tuple(LEGACY_EARTH_BOUNDARY_SOURCES)
|
||||
source_ids = tuple(LEGACY_EARTH_BOUNDARY_IDS)
|
||||
data_types = tuple(LEGACY_EARTH_BOUNDARY_DATATYPES)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM datasource_mapping_templates
|
||||
WHERE target_schema IN :data_types
|
||||
OR datasource_config_id IN (
|
||||
SELECT id FROM datasource_configs WHERE name IN :source_names
|
||||
)
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
|
||||
{"source_names": list(source_names), "data_types": list(data_types)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM datasource_configs WHERE name IN :source_names").bindparams(
|
||||
bindparam("source_names", expanding=True)
|
||||
),
|
||||
{"source_names": list(source_names)},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM collected_data
|
||||
WHERE source IN :source_names OR data_type IN :data_types
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("data_types", expanding=True)),
|
||||
{"source_names": list(source_names), "data_types": list(data_types)},
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM data_snapshots
|
||||
WHERE source IN :source_names OR datasource_id IN :source_ids
|
||||
"""
|
||||
).bindparams(bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)),
|
||||
{"source_names": list(source_names), "source_ids": list(source_ids)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM collection_tasks WHERE datasource_id IN :source_ids").bindparams(
|
||||
bindparam("source_ids", expanding=True)
|
||||
),
|
||||
{"source_ids": list(source_ids)},
|
||||
)
|
||||
await session.execute(
|
||||
text("DELETE FROM data_sources WHERE source IN :source_names OR id IN :source_ids").bindparams(
|
||||
bindparam("source_names", expanding=True), bindparam("source_ids", expanding=True)
|
||||
),
|
||||
{"source_names": list(source_names), "source_ids": list(source_ids)},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
DEFAULT_LOGIN_USERS = (
|
||||
{
|
||||
"username": "admin",
|
||||
@@ -203,6 +271,18 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE earth_news_items
|
||||
ADD COLUMN IF NOT EXISTS content_language VARCHAR(32) NOT NULL DEFAULT 'en',
|
||||
ADD COLUMN IF NOT EXISTS localizations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS enrichment_status VARCHAR(80) NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS enrichment_error TEXT,
|
||||
ADD COLUMN IF NOT EXISTS enriched_at TIMESTAMPTZ
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -211,6 +291,22 @@ async def init_db():
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_news_enrichment_status
|
||||
ON earth_news_items (enrichment_status)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_earth_news_enriched_at
|
||||
ON earth_news_items (enriched_at)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
@@ -285,4 +381,5 @@ async def init_db():
|
||||
await seed_default_bgp_collector_locations(session)
|
||||
await seed_compute_center_locations_from_source_coords(session)
|
||||
await seed_default_datasources(session)
|
||||
await purge_legacy_earth_boundary_datasources(session)
|
||||
await ensure_default_admin_user(session)
|
||||
|
||||
@@ -10,6 +10,8 @@ class EarthNewsItem(Base):
|
||||
id = Column(String(160), primary_key=True)
|
||||
title = Column(String(500), nullable=False)
|
||||
summary = Column(Text, nullable=False, default="")
|
||||
content_language = Column(String(32), nullable=False, default="en")
|
||||
localizations = Column(JSON, nullable=False, default=dict)
|
||||
url = Column(Text, nullable=False)
|
||||
source = Column(String(255), nullable=False, default="")
|
||||
feed_name = Column(String(255), nullable=False, default="")
|
||||
@@ -27,6 +29,9 @@ class EarthNewsItem(Base):
|
||||
first_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
last_seen_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
enrichment_status = Column(String(80), nullable=False, default="pending", index=True)
|
||||
enrichment_error = Column(Text, nullable=True)
|
||||
enriched_at = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -13,10 +13,11 @@ class AIContentBlock(BaseModel):
|
||||
|
||||
class SituationalAnalysisRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
objective: str = Field(..., min_length=1, max_length=1000)
|
||||
objective: str = Field(..., min_length=1, max_length=20000)
|
||||
context: dict[str, Any] = Field(default_factory=dict)
|
||||
observations: list[str] = Field(default_factory=list)
|
||||
constraints: list[str] = Field(default_factory=list)
|
||||
system_prompt: str | None = Field(default=None, max_length=8000)
|
||||
preferred_model: str | None = Field(default=None, max_length=200)
|
||||
thinking: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.schemas.ai import AlertBriefRequest, SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
|
||||
ALERT_BRIEF_PROMPT_KEY = "alerts.brief"
|
||||
|
||||
|
||||
def _format_counter(counter: Counter[str], empty_text: str = "无") -> str:
|
||||
@@ -84,11 +87,13 @@ async def build_alert_brief_request(
|
||||
"top_datasources": dict(datasource_counts.most_common(6)),
|
||||
"top_active_datasources": dict(active_datasource_counts.most_common(5)),
|
||||
}
|
||||
prompt = await get_effective_prompt(db, ALERT_BRIEF_PROMPT_KEY)
|
||||
|
||||
return (
|
||||
SituationalAnalysisRequest(
|
||||
title="告警态势 AI 简报",
|
||||
objective="基于当前告警总量、严重度、状态、数据源分布与最近告警摘录,生成一份面向值班人员的简明告警态势简报,突出待处理风险、告警集中点和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=facts,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
@@ -11,9 +11,12 @@ from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.bgp_collectors import build_bgp_collector_coverage
|
||||
from app.services.bgp_enrichment import lookup_prefix_geography
|
||||
|
||||
BGP_BRIEF_PROMPT_KEY = "bgp.brief"
|
||||
|
||||
|
||||
def _format_counter(counter: dict[str, int], empty_text: str = "无") -> str:
|
||||
if not counter:
|
||||
@@ -243,10 +246,12 @@ async def build_bgp_brief_request(
|
||||
for prefix, item in list(prefix_geographies.items())[:8]
|
||||
},
|
||||
}
|
||||
prompt = await get_effective_prompt(db, BGP_BRIEF_PROMPT_KEY)
|
||||
|
||||
return SituationalAnalysisRequest(
|
||||
title="BGP 态势 AI 简报",
|
||||
objective="基于当前 BGP incidents、anomalies、原始观测事件、观测站覆盖与 prefix geography 证据,生成一份面向操作员的简明态势简报,突出区域热点、观测偏差、当前风险、证据和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=observations_lines,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
@@ -39,12 +39,6 @@ from app.services.collectors.news_live_streams import NewsLiveStreamsCollector
|
||||
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
|
||||
from app.services.collectors.aisstream import AISStreamCollector
|
||||
from app.services.collectors.vessel_ais import VesselAISCollector
|
||||
from app.services.collectors.earth_boundaries import (
|
||||
EarthAdmin0BoundaryCollector,
|
||||
EarthBoundaryTileCollector,
|
||||
EarthClaimLinesCollector,
|
||||
EarthCoastlineCollector,
|
||||
)
|
||||
|
||||
collector_registry.register(TOP500Collector())
|
||||
collector_registry.register(EpochAIGPUCollector())
|
||||
@@ -75,10 +69,6 @@ collector_registry.register(NewsLiveStreamsCollector())
|
||||
collector_registry.register(MediaNewsArchiveCollector())
|
||||
collector_registry.register(VesselAISCollector())
|
||||
collector_registry.register(AISStreamCollector())
|
||||
collector_registry.register(EarthAdmin0BoundaryCollector())
|
||||
collector_registry.register(EarthCoastlineCollector())
|
||||
collector_registry.register(EarthClaimLinesCollector())
|
||||
collector_registry.register(EarthBoundaryTileCollector())
|
||||
|
||||
__all__ = [
|
||||
"BaseCollector",
|
||||
@@ -115,8 +105,4 @@ __all__ = [
|
||||
"MediaNewsArchiveCollector",
|
||||
"VesselAISCollector",
|
||||
"AISStreamCollector",
|
||||
"EarthAdmin0BoundaryCollector",
|
||||
"EarthCoastlineCollector",
|
||||
"EarthClaimLinesCollector",
|
||||
"EarthBoundaryTileCollector",
|
||||
]
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
"""Earth boundary source and static tile collector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.earth_boundary_defaults import EARTH_BOUNDARY_DEFAULT_SOURCES, default_earth_boundary_config
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.services.collectors.base import BaseCollector
|
||||
from app.services.custom_datasource_runtime import build_query_params, build_request_headers
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources"
|
||||
SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json"
|
||||
BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1"
|
||||
BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json"
|
||||
PMTILES_ARTIFACT_PATH = REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
BUILD_CONFIG = {
|
||||
"builder": "scripts/build_earth_boundary_china_pov_geojson.py",
|
||||
"format": "geojson-high-precision",
|
||||
"production_target": "geojson-high-precision",
|
||||
}
|
||||
|
||||
EARTH_BOUNDARY_SOURCE_COLLECTORS = {
|
||||
"earth_admin0_boundaries": "admin0-boundaries",
|
||||
"earth_coastline": "coastline",
|
||||
"earth_claim_lines": "claim-lines",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _directory_stats(path: Path) -> dict[str, int]:
|
||||
if not path.exists():
|
||||
return {"file_count": 0, "size_bytes": 0}
|
||||
files = [item for item in path.rglob("*") if item.is_file()]
|
||||
return {
|
||||
"file_count": len(files),
|
||||
"size_bytes": sum(item.stat().st_size for item in files),
|
||||
}
|
||||
|
||||
|
||||
def _stable_json_hash(payload: Any) -> str:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _build_input_hash(source_manifest: dict[str, Any]) -> str:
|
||||
return _stable_json_hash(
|
||||
{
|
||||
"source_manifest_schema": source_manifest.get("schema"),
|
||||
"sources": [
|
||||
{
|
||||
"id": source.get("id"),
|
||||
"sha256": source.get("sha256"),
|
||||
"kind": source.get("kind"),
|
||||
"pov": source.get("pov"),
|
||||
}
|
||||
for source in source_manifest.get("sources", [])
|
||||
],
|
||||
"pov_policy": source_manifest.get("povPolicy"),
|
||||
"build_config": BUILD_CONFIG,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool:
|
||||
if not boundary_manifest:
|
||||
return False
|
||||
if boundary_manifest.get("buildInputHash") != build_input_hash:
|
||||
return False
|
||||
if not BOUNDARY_OUTPUT_DIR.exists():
|
||||
return False
|
||||
if boundary_manifest.get("tileProvider") == "pmtiles-mvt":
|
||||
return PMTILES_ARTIFACT_PATH.exists()
|
||||
return (BOUNDARY_OUTPUT_DIR / "base.geojson").exists() and (
|
||||
BOUNDARY_OUTPUT_DIR / "hover-index.geojson"
|
||||
).exists()
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _json_feature_count(payload: Any) -> int:
|
||||
if isinstance(payload, dict) and isinstance(payload.get("features"), list):
|
||||
return len(payload["features"])
|
||||
if isinstance(payload, list):
|
||||
return len(payload)
|
||||
return 1 if payload else 0
|
||||
|
||||
|
||||
def _json_sample_properties(payload: Any) -> dict[str, Any]:
|
||||
feature = None
|
||||
if isinstance(payload, dict) and isinstance(payload.get("features"), list) and payload["features"]:
|
||||
feature = payload["features"][0]
|
||||
elif isinstance(payload, list) and payload:
|
||||
feature = payload[0]
|
||||
elif isinstance(payload, dict):
|
||||
feature = payload
|
||||
if not isinstance(feature, dict):
|
||||
return {}
|
||||
props = feature.get("properties") if isinstance(feature.get("properties"), dict) else feature
|
||||
return {str(key): value for key, value in list(props.items())[:20]}
|
||||
|
||||
|
||||
def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str:
|
||||
suffix = Path(endpoint.split("?", 1)[0]).suffix.lower()
|
||||
if suffix in {".json", ".geojson", ".zip", ".pbf"}:
|
||||
return suffix
|
||||
if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]:
|
||||
return ".geojson"
|
||||
if "json" in content_type:
|
||||
return ".json"
|
||||
return ".dat"
|
||||
|
||||
|
||||
async def _load_datasource_config(db, name: str) -> DataSourceConfig | None:
|
||||
if db is None:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(DataSourceConfig)
|
||||
.where(DataSourceConfig.name == name)
|
||||
.where(DataSourceConfig.is_active.is_(True))
|
||||
.order_by(DataSourceConfig.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _latest_boundary_source_record(db, source_name: str) -> CollectedData | None:
|
||||
result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == source_name)
|
||||
.where(CollectedData.data_type == "earth_boundary_source")
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class EarthBoundarySourceCollector(BaseCollector):
|
||||
priority = "P1"
|
||||
module = "L3"
|
||||
frequency_hours = 168
|
||||
data_type = "earth_boundary_source"
|
||||
fail_on_empty = True
|
||||
source_kind = "unknown"
|
||||
|
||||
def _default_config(self):
|
||||
source = EARTH_BOUNDARY_DEFAULT_SOURCES.get(self.name)
|
||||
if not source:
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
name=self.name,
|
||||
description=f"内置默认源:{self.name}",
|
||||
endpoint=source["endpoint"],
|
||||
source_type="http",
|
||||
auth_type="none",
|
||||
auth_config={},
|
||||
headers={},
|
||||
config=default_earth_boundary_config(self.name),
|
||||
)
|
||||
|
||||
async def _download_payload(self, config: DataSourceConfig) -> tuple[bytes, str]:
|
||||
request_config = config.config or {}
|
||||
method = str(request_config.get("method") or request_config.get("request_method") or "GET").upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
raise RuntimeError("Earth boundary source collectors support GET and POST only")
|
||||
|
||||
endpoint = str(config.endpoint or "").strip()
|
||||
if not endpoint:
|
||||
raise RuntimeError(
|
||||
f"{self.name} requires an endpoint in Collector Settings before it can collect data"
|
||||
)
|
||||
|
||||
if endpoint.startswith("file://") or Path(endpoint).expanduser().exists():
|
||||
path = Path(endpoint.removeprefix("file://")).expanduser()
|
||||
return path.read_bytes(), "application/octet-stream"
|
||||
|
||||
headers = build_request_headers(config.auth_type, config.auth_config or {}, config.headers or {})
|
||||
params = build_query_params(config.auth_type, config.auth_config or {}, request_config)
|
||||
timeout = float(request_config.get("timeout", 120))
|
||||
json_body = request_config.get("json_body") or request_config.get("body")
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
endpoint,
|
||||
headers=headers,
|
||||
params=params or None,
|
||||
json=json_body if isinstance(json_body, (dict, list)) else None,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content, response.headers.get("content-type", "")
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
db = getattr(self, "_db_session", None)
|
||||
config = await _load_datasource_config(db, self.name) or self._default_config()
|
||||
if config is None:
|
||||
raise RuntimeError(
|
||||
f"{self.name} has no active Collector Settings config and no built-in default source."
|
||||
)
|
||||
|
||||
await self.set_phase("fetching_source", message=f"正在下载 {self.source_kind} 源数据")
|
||||
payload, content_type = await self._download_payload(config)
|
||||
sha256 = _sha256_bytes(payload)
|
||||
endpoint = str(config.endpoint or "")
|
||||
extension = _artifact_extension(endpoint, content_type, payload)
|
||||
source_dir = SOURCE_OUTPUT_DIR / self.name
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_path = source_dir / f"{sha256}{extension}"
|
||||
artifact_path.write_bytes(payload)
|
||||
|
||||
parsed: Any = None
|
||||
if extension in {".json", ".geojson"}:
|
||||
parsed = json.loads(payload.decode("utf-8"))
|
||||
|
||||
feature_count = _json_feature_count(parsed)
|
||||
if feature_count <= 0:
|
||||
raise RuntimeError(f"{self.name} downloaded data but found no JSON/GeoJSON features")
|
||||
|
||||
config_body = config.config or {}
|
||||
target_schema = str(config_body.get("target_schema") or "earth_boundary_source")
|
||||
if target_schema != "earth_boundary_source":
|
||||
raise RuntimeError(f"{self.name} target_schema must be earth_boundary_source")
|
||||
|
||||
await self.update_phase_progress(
|
||||
current=1,
|
||||
total=1,
|
||||
unit="artifact",
|
||||
message=f"已保存 {feature_count} 个 {self.source_kind} feature",
|
||||
progress=100,
|
||||
commit=True,
|
||||
force=True,
|
||||
)
|
||||
|
||||
relative_artifact_path = str(artifact_path.relative_to(REPO_ROOT))
|
||||
return [
|
||||
{
|
||||
"id": f"{self.source_kind}:{sha256}",
|
||||
"name": config.description or self.name,
|
||||
"description": f"Earth boundary source artifact collected from configured endpoint",
|
||||
"source_kind": self.source_kind,
|
||||
"source_id": sha256,
|
||||
"value": feature_count,
|
||||
"unit": "features",
|
||||
"metadata": {
|
||||
"target_schema": target_schema,
|
||||
"source_kind": self.source_kind,
|
||||
"endpoint": endpoint,
|
||||
"method": str(config_body.get("method") or config_body.get("request_method") or "GET").upper(),
|
||||
"artifact_path": relative_artifact_path,
|
||||
"sha256": sha256,
|
||||
"feature_count": feature_count,
|
||||
"size_bytes": len(payload),
|
||||
"content_type": content_type,
|
||||
"license": config_body.get("license"),
|
||||
"mapping_json": config_body.get("mapping_json"),
|
||||
"sample_properties": _json_sample_properties(parsed),
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class EarthAdmin0BoundaryCollector(EarthBoundarySourceCollector):
|
||||
name = "earth_admin0_boundaries"
|
||||
source_kind = "admin0-boundaries"
|
||||
|
||||
|
||||
class EarthCoastlineCollector(EarthBoundarySourceCollector):
|
||||
name = "earth_coastline"
|
||||
source_kind = "coastline"
|
||||
|
||||
|
||||
class EarthClaimLinesCollector(EarthBoundarySourceCollector):
|
||||
name = "earth_claim_lines"
|
||||
source_kind = "claim-lines"
|
||||
|
||||
|
||||
class EarthBoundaryTileCollector(BaseCollector):
|
||||
name = "earth_boundary_tiles"
|
||||
priority = "P1"
|
||||
module = "L3"
|
||||
frequency_hours = 168
|
||||
data_type = "earth_boundary_tiles"
|
||||
fail_on_empty = False
|
||||
|
||||
async def _run_step(self, args: list[str], *, allow_failure: bool = False) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
*args,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await process.communicate()
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
if process.returncode != 0 and not allow_failure:
|
||||
raise RuntimeError(stderr or stdout or f"command failed: {' '.join(args)}")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"returncode": process.returncode,
|
||||
}
|
||||
last_line = stdout.splitlines()[-1:] or []
|
||||
if last_line:
|
||||
try:
|
||||
payload["result"] = json.loads(last_line[0])
|
||||
except json.JSONDecodeError:
|
||||
payload["result"] = last_line[0]
|
||||
return payload
|
||||
|
||||
async def fetch(self) -> list[dict[str, Any]]:
|
||||
db = getattr(self, "_db_session", None)
|
||||
if db is None:
|
||||
raise RuntimeError("Earth PMTiles builder requires an active database session")
|
||||
|
||||
await self.set_phase("checking_sources", message="正在检查三类 Earth 边界源")
|
||||
await self.update_phase_progress(
|
||||
current=0,
|
||||
total=3,
|
||||
unit="steps",
|
||||
message="读取 admin0 / coastline / claim-lines 最新采集结果",
|
||||
progress=5,
|
||||
commit=True,
|
||||
force=True,
|
||||
)
|
||||
|
||||
source_records: dict[str, CollectedData] = {}
|
||||
missing_sources: list[str] = []
|
||||
for source_name in EARTH_BOUNDARY_SOURCE_COLLECTORS:
|
||||
record = await _latest_boundary_source_record(db, source_name)
|
||||
if record is None:
|
||||
missing_sources.append(source_name)
|
||||
else:
|
||||
source_records[source_name] = record
|
||||
|
||||
if missing_sources:
|
||||
missing_text = ", ".join(missing_sources)
|
||||
raise RuntimeError(f"未就绪:缺少 {missing_text}。不会更新 Earth 国界。")
|
||||
|
||||
sources = []
|
||||
for source_name, record in source_records.items():
|
||||
metadata = record.extra_data or {}
|
||||
artifact_path = metadata.get("artifact_path")
|
||||
if not artifact_path or not (REPO_ROOT / str(artifact_path)).exists():
|
||||
missing_sources.append(f"{source_name}: artifact missing")
|
||||
continue
|
||||
sources.append(
|
||||
{
|
||||
"id": source_name,
|
||||
"kind": metadata.get("source_kind") or EARTH_BOUNDARY_SOURCE_COLLECTORS[source_name],
|
||||
"path": str(artifact_path),
|
||||
"sha256": metadata.get("sha256"),
|
||||
"featureCount": metadata.get("feature_count"),
|
||||
"license": metadata.get("license"),
|
||||
}
|
||||
)
|
||||
|
||||
if missing_sources:
|
||||
missing_text = ", ".join(missing_sources)
|
||||
raise RuntimeError(f"未就绪:{missing_text}。不会更新 Earth 国界。")
|
||||
|
||||
source_manifest = {
|
||||
"schema": "planet-earth-boundary-sources/v2",
|
||||
"sources": sources,
|
||||
"povPolicy": _read_json(POV_POLICY_PATH),
|
||||
}
|
||||
SOURCE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with SOURCE_MANIFEST_PATH.open("w", encoding="utf-8") as f:
|
||||
json.dump(source_manifest, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
build_input_hash = _build_input_hash(source_manifest)
|
||||
await self.update_phase_progress(
|
||||
current=1,
|
||||
total=3,
|
||||
unit="steps",
|
||||
message="三类边界源已就绪",
|
||||
progress=35,
|
||||
commit=True,
|
||||
force=True,
|
||||
)
|
||||
|
||||
await self.set_phase("checking_readiness", message="正在检查 Earth 国界构建就绪状态")
|
||||
missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None]
|
||||
readiness_result = {
|
||||
"returncode": 0,
|
||||
"result": {
|
||||
"ready": True,
|
||||
"failures": [f"external tool not found in PATH: {tool}" for tool in missing_tools],
|
||||
"sources": sources,
|
||||
"artifact": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)),
|
||||
"fallback_builder": "geojson-high-precision" if missing_tools else None,
|
||||
},
|
||||
}
|
||||
|
||||
await self.set_phase("building_tiles", message="正在检查 Earth 国界瓦片产物")
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash)
|
||||
if build_skipped:
|
||||
build_result = {
|
||||
"result": {
|
||||
"status": "unchanged",
|
||||
"reason": "source manifest and build config hash unchanged",
|
||||
"buildInputHash": build_input_hash,
|
||||
}
|
||||
}
|
||||
elif shutil.which("tippecanoe") and shutil.which("pmtiles"):
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
build_result = await self._run_step(
|
||||
[
|
||||
"scripts/build_earth_boundary_pmtiles.py",
|
||||
"--admin0-source",
|
||||
admin0["path"],
|
||||
"--coastline-source",
|
||||
coastline["path"],
|
||||
"--claims-source",
|
||||
claim_lines["path"],
|
||||
"--output",
|
||||
str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)),
|
||||
"--manifest",
|
||||
str(BOUNDARY_MANIFEST_PATH.relative_to(REPO_ROOT)),
|
||||
"--build-input-hash",
|
||||
build_input_hash,
|
||||
"--pov-policy",
|
||||
str(POV_POLICY_PATH.relative_to(REPO_ROOT)),
|
||||
]
|
||||
)
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
else:
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
build_result = await self._run_step(
|
||||
[
|
||||
"scripts/build_earth_boundary_china_pov_geojson.py",
|
||||
"--admin0-source",
|
||||
admin0["path"],
|
||||
"--coastline-source",
|
||||
coastline["path"],
|
||||
"--claims-source",
|
||||
claim_lines["path"],
|
||||
"--output-dir",
|
||||
str(BOUNDARY_OUTPUT_DIR.relative_to(REPO_ROOT)),
|
||||
"--build-input-hash",
|
||||
build_input_hash,
|
||||
]
|
||||
)
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR)
|
||||
pmtiles_exists = PMTILES_ARTIFACT_PATH.exists()
|
||||
pmtiles_size = PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0
|
||||
await self.update_phase_progress(
|
||||
current=2,
|
||||
total=3,
|
||||
unit="steps",
|
||||
message=(
|
||||
"边界源未变化,已跳过瓦片重建"
|
||||
if build_skipped
|
||||
else f"已生成 {boundary_manifest.get('tileProvider') or 'boundary'} 国界产物"
|
||||
),
|
||||
progress=80,
|
||||
commit=True,
|
||||
force=True,
|
||||
)
|
||||
|
||||
await self.set_phase("indexing_artifacts", message="正在登记边界瓦片产物")
|
||||
tile_counts = boundary_manifest.get("tiles", {}).get("countsByZoom", {})
|
||||
records = [
|
||||
{
|
||||
"id": "source-manifest",
|
||||
"name": "Earth boundary source manifest",
|
||||
"description": "Offline source collection manifest for Earth boundary tiles",
|
||||
"value": len(source_manifest.get("sources", [])),
|
||||
"unit": "sources",
|
||||
"metadata": {
|
||||
"manifest_path": str(SOURCE_MANIFEST_PATH.relative_to(REPO_ROOT)),
|
||||
"manifest": source_manifest,
|
||||
"collector_result": {
|
||||
"status": "loaded_from_collected_data",
|
||||
"sources": [source["id"] for source in sources],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "production-readiness",
|
||||
"name": "Earth boundary production readiness",
|
||||
"description": "Checks whether all source artifacts and boundary build tooling are available",
|
||||
"value": 1 if readiness_result.get("returncode") == 0 else 0,
|
||||
"unit": "ready",
|
||||
"metadata": {
|
||||
"result": readiness_result.get("result"),
|
||||
"returncode": readiness_result.get("returncode"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "boundary-manifest",
|
||||
"name": "Earth boundary tile manifest",
|
||||
"description": "Versioned static vector tile manifest for Earth country boundaries",
|
||||
"value": boundary_stats["file_count"],
|
||||
"unit": "files",
|
||||
"metadata": {
|
||||
"manifest_path": str(BOUNDARY_MANIFEST_PATH.relative_to(REPO_ROOT)),
|
||||
"output_dir": str(BOUNDARY_OUTPUT_DIR.relative_to(REPO_ROOT)),
|
||||
"size_bytes": boundary_stats["size_bytes"],
|
||||
"manifest": boundary_manifest,
|
||||
"collector_result": build_result.get("result"),
|
||||
"build_skipped": build_skipped,
|
||||
"production_target": BUILD_CONFIG["production_target"],
|
||||
"pmtiles_artifact": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)),
|
||||
"pmtiles_exists": pmtiles_exists,
|
||||
},
|
||||
},
|
||||
]
|
||||
if pmtiles_exists:
|
||||
records.append(
|
||||
{
|
||||
"id": "pmtiles-artifact",
|
||||
"name": "Earth boundary PMTiles artifact",
|
||||
"description": "Single-file PMTiles/MVT artifact for Earth boundaries",
|
||||
"value": pmtiles_size,
|
||||
"unit": "bytes",
|
||||
"metadata": {
|
||||
"path": str(PMTILES_ARTIFACT_PATH.relative_to(REPO_ROOT)),
|
||||
"exists": pmtiles_exists,
|
||||
"provider": "pmtiles-mvt",
|
||||
},
|
||||
}
|
||||
)
|
||||
for zoom, count in sorted(tile_counts.items(), key=lambda item: int(item[0])):
|
||||
records.append(
|
||||
{
|
||||
"id": f"tile-z{zoom}",
|
||||
"name": f"Earth boundary tiles z{zoom}",
|
||||
"description": f"Generated Earth boundary tile count for zoom {zoom}",
|
||||
"value": int(count),
|
||||
"unit": "tiles",
|
||||
"metadata": {
|
||||
"zoom": int(zoom),
|
||||
"tile_count": int(count),
|
||||
"output_dir": str((BOUNDARY_OUTPUT_DIR / str(zoom)).relative_to(REPO_ROOT)),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await self.update_phase_progress(
|
||||
current=3,
|
||||
total=3,
|
||||
unit="steps",
|
||||
message="边界瓦片产物已登记",
|
||||
progress=100,
|
||||
commit=True,
|
||||
force=True,
|
||||
)
|
||||
return records
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
@@ -15,6 +16,7 @@ from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||||
|
||||
|
||||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||||
CREDENTIAL_GUIDE_PROMPT_KEY = "credential.guide"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -240,14 +242,12 @@ async def generate_credential_guide(
|
||||
guide["sources"] = []
|
||||
return guide
|
||||
|
||||
prompt = await get_effective_prompt(db, CREDENTIAL_GUIDE_PROMPT_KEY)
|
||||
response = await ai_client.analyze(
|
||||
SituationalAnalysisRequest(
|
||||
title=f"Generate credential guide for {provider}",
|
||||
objective=(
|
||||
default.prompt
|
||||
+ "\n只能根据 context.search_evidence 中的来源生成教程;"
|
||||
+ "如果证据不足,明确说明需要以官方页面为准。"
|
||||
),
|
||||
objective=f"{default.prompt}\n{prompt.prompt}",
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"provider": provider,
|
||||
"current_default_guide": default.markdown,
|
||||
|
||||
@@ -301,52 +301,6 @@ async def persist_mapped_records(
|
||||
transport: str | None = None,
|
||||
) -> int:
|
||||
"""Persist validated mapped records to the destination for a target schema."""
|
||||
if target_schema == "earth_boundary_source":
|
||||
from app.models.collected_data import CollectedData
|
||||
|
||||
now = datetime.now(UTC)
|
||||
written_count = 0
|
||||
for index, record in enumerate(records):
|
||||
source_id = record.get("source_id") or record.get("sha256") or str(index)
|
||||
entity_key = f"{datasource_name}:{source_id}"
|
||||
previous_result = await db.execute(
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == datasource_name)
|
||||
.where(CollectedData.entity_key == entity_key)
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.order_by(CollectedData.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
previous = previous_result.scalar_one_or_none()
|
||||
if previous is not None:
|
||||
previous.is_current = False
|
||||
db.add(
|
||||
CollectedData(
|
||||
source=datasource_name,
|
||||
source_id=str(source_id),
|
||||
entity_key=entity_key,
|
||||
data_type=target_schema,
|
||||
name=record.get("name") or str(source_id),
|
||||
description=f"Earth boundary source artifact: {record.get('source_kind')}",
|
||||
extra_data={
|
||||
**record,
|
||||
"datasource_config_id": datasource_config_id,
|
||||
"mapping_version": mapping_version,
|
||||
"delivery_mode": delivery_mode or "polling",
|
||||
"transport": transport or "http",
|
||||
},
|
||||
collected_at=now,
|
||||
is_valid=1,
|
||||
is_current=True,
|
||||
previous_record_id=previous.id if previous else None,
|
||||
change_type="updated" if previous else "created",
|
||||
change_summary={},
|
||||
)
|
||||
)
|
||||
written_count += 1
|
||||
await db.commit()
|
||||
return written_count
|
||||
|
||||
if target_schema == "vessel_ais":
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
|
||||
671
backend/app/services/earth_boundaries.py
Normal file
671
backend/app/services/earth_boundaries.py
Normal file
@@ -0,0 +1,671 @@
|
||||
"""Earth boundary static asset service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SOURCE_OUTPUT_DIR = REPO_ROOT / "data/earth-boundary-sources"
|
||||
SOURCE_MANIFEST_PATH = SOURCE_OUTPUT_DIR / "manifest.json"
|
||||
BUILD_RESULT_PATH = SOURCE_OUTPUT_DIR / "build-result.json"
|
||||
BUILD_JOB_PATH = SOURCE_OUTPUT_DIR / "build-job.json"
|
||||
BOUNDARY_OUTPUT_DIR = REPO_ROOT / "frontend/public/earth/data/boundaries/v1"
|
||||
BOUNDARY_MANIFEST_PATH = BOUNDARY_OUTPUT_DIR / "manifest.json"
|
||||
PMTILES_ARTIFACT_PATH = (
|
||||
REPO_ROOT / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
)
|
||||
LEGACY_GEOJSON_PATH = REPO_ROOT / "frontend/public/earth/data/countries-admin0.min.geojson"
|
||||
POV_POLICY_PATH = REPO_ROOT / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
LOCAL_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.local.json"
|
||||
EXAMPLE_CONFIG_PATH = REPO_ROOT / "config/earth-boundary-sources.example.json"
|
||||
|
||||
BOUNDARY_SOURCE_KINDS = {
|
||||
"earth_admin0_boundaries": "admin0-boundaries",
|
||||
"earth_coastline": "coastline",
|
||||
"earth_claim_lines": "claim-lines",
|
||||
}
|
||||
|
||||
DEFAULT_PUBLIC_BOUNDARY_SOURCES = {
|
||||
"earth_admin0_boundaries": {
|
||||
"displayName": "Natural Earth Admin-0 Countries",
|
||||
"sourceKind": "admin0-boundaries",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_coastline": {
|
||||
"displayName": "Natural Earth Coastline",
|
||||
"sourceKind": "coastline",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
"earth_claim_lines": {
|
||||
"displayName": "Natural Earth Disputed Boundaries",
|
||||
"sourceKind": "claim-lines",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"license": "Natural Earth public domain",
|
||||
},
|
||||
}
|
||||
|
||||
BUILD_CONFIG = {
|
||||
"builder": "scripts/build_earth_boundary_pmtiles.py",
|
||||
"format": "pmtiles+mvt",
|
||||
"production_target": "pmtiles-mvt",
|
||||
}
|
||||
|
||||
|
||||
class EarthBoundaryBuildError(RuntimeError):
|
||||
def __init__(self, message: str, *, code: str = "build_failed", details: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.details = details
|
||||
|
||||
|
||||
_build_job_lock = asyncio.Lock()
|
||||
_build_task: asyncio.Task | None = None
|
||||
_build_job_state: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _utc_now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _public_job_state() -> dict[str, Any]:
|
||||
if _build_job_state:
|
||||
return dict(_build_job_state)
|
||||
return _read_json(BUILD_JOB_PATH)
|
||||
|
||||
|
||||
def get_boundary_build_status() -> dict[str, Any]:
|
||||
return {"job": _public_job_state()}
|
||||
|
||||
|
||||
def _set_job_state(**updates: Any) -> dict[str, Any]:
|
||||
global _build_job_state
|
||||
current = dict(_build_job_state)
|
||||
current.update(updates)
|
||||
current["updated_at"] = _utc_now_iso()
|
||||
_build_job_state = current
|
||||
_write_json(BUILD_JOB_PATH, current)
|
||||
return current
|
||||
|
||||
|
||||
def _append_job_log(message: str) -> None:
|
||||
logs = list(_build_job_state.get("logs") or [])
|
||||
logs.append({"time": _utc_now_iso(), "message": message})
|
||||
_set_job_state(logs=logs[-40:])
|
||||
|
||||
|
||||
def _update_job_progress(progress: float, phase: str, message: str, **extra: Any) -> None:
|
||||
bounded_progress = max(0, min(100, int(round(progress))))
|
||||
_set_job_state(
|
||||
status="running",
|
||||
progress=bounded_progress,
|
||||
phase=phase,
|
||||
message=message,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _stable_json_hash(payload: Any) -> str:
|
||||
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _artifact_extension(endpoint: str, content_type: str, payload: bytes) -> str:
|
||||
suffix = Path(endpoint.split("?", 1)[0]).suffix.lower()
|
||||
if suffix in {".json", ".geojson", ".zip", ".pbf"}:
|
||||
return suffix
|
||||
if "geo+json" in content_type or b'"FeatureCollection"' in payload[:4096]:
|
||||
return ".geojson"
|
||||
if "json" in content_type:
|
||||
return ".json"
|
||||
return ".dat"
|
||||
|
||||
|
||||
def _json_feature_count(payload: Any) -> int:
|
||||
if isinstance(payload, dict) and isinstance(payload.get("features"), list):
|
||||
return len(payload["features"])
|
||||
if isinstance(payload, list):
|
||||
return len(payload)
|
||||
return 1 if payload else 0
|
||||
|
||||
|
||||
def _directory_stats(path: Path) -> dict[str, int]:
|
||||
if not path.exists():
|
||||
return {"file_count": 0, "size_bytes": 0}
|
||||
files = [item for item in path.rglob("*") if item.is_file()]
|
||||
return {"file_count": len(files), "size_bytes": sum(item.stat().st_size for item in files)}
|
||||
|
||||
|
||||
def _load_source_feature_collection(source: dict[str, Any]) -> dict[str, Any]:
|
||||
path = REPO_ROOT / source["path"]
|
||||
payload = _read_json(path)
|
||||
features = payload.get("features") if isinstance(payload, dict) else None
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": features if isinstance(features, list) else [],
|
||||
}
|
||||
|
||||
|
||||
def _write_high_precision_geojson_manifest(
|
||||
sources: list[dict[str, Any]],
|
||||
build_input_hash: str,
|
||||
missing_tools: list[str],
|
||||
) -> dict[str, Any]:
|
||||
BOUNDARY_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
|
||||
admin0_payload = _load_source_feature_collection(admin0)
|
||||
coastline_payload = _load_source_feature_collection(coastline)
|
||||
claim_payload = _load_source_feature_collection(claim_lines)
|
||||
for feature in coastline_payload["features"]:
|
||||
props = feature.setdefault("properties", {})
|
||||
if isinstance(props, dict):
|
||||
props["PLANET_LAYER"] = "coastline"
|
||||
|
||||
base_payload = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [*admin0_payload["features"], *coastline_payload["features"]],
|
||||
}
|
||||
base_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-base.geojson"
|
||||
hover_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-hover.geojson"
|
||||
claim_path = BOUNDARY_OUTPUT_DIR / "earth-boundaries-high-precision-claims.geojson"
|
||||
_write_json(base_path, base_payload)
|
||||
_write_json(hover_path, admin0_payload)
|
||||
_write_json(claim_path, claim_payload)
|
||||
|
||||
manifest = {
|
||||
"version": "natural-earth-v1",
|
||||
"builtAt": _utc_now_iso(),
|
||||
"tileProvider": "geojson-high-precision",
|
||||
"format": "geojson-directory",
|
||||
"buildInputHash": build_input_hash,
|
||||
"base": base_path.name,
|
||||
"hoverIndex": hover_path.name,
|
||||
"claimLine": claim_path.name,
|
||||
"sourceFeatureCount": {
|
||||
"admin0": len(admin0_payload["features"]),
|
||||
"coastline": len(coastline_payload["features"]),
|
||||
"claimLines": len(claim_payload["features"]),
|
||||
},
|
||||
"pmtiles": None,
|
||||
"missingTools": missing_tools,
|
||||
}
|
||||
_write_json(BOUNDARY_MANIFEST_PATH, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def _relative(path: Path) -> str:
|
||||
return str(path.relative_to(REPO_ROOT))
|
||||
|
||||
|
||||
def load_boundary_config() -> tuple[dict[str, Any], str]:
|
||||
if LOCAL_CONFIG_PATH.exists():
|
||||
return _read_json(LOCAL_CONFIG_PATH), "local"
|
||||
return _read_json(EXAMPLE_CONFIG_PATH), "example"
|
||||
|
||||
|
||||
def save_boundary_config(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise EarthBoundaryBuildError("Earth boundary config must be a JSON object", code="invalid_config")
|
||||
_write_json(LOCAL_CONFIG_PATH, payload)
|
||||
return get_boundary_status()
|
||||
|
||||
|
||||
def _source_configs(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_sources = payload.get("collectorConfigs") or payload.get("sources") or {}
|
||||
return raw_sources if isinstance(raw_sources, dict) else {}
|
||||
|
||||
|
||||
def _is_placeholder_endpoint(endpoint: Any) -> bool:
|
||||
value = str(endpoint or "").strip()
|
||||
return not value or "example.com" in value
|
||||
|
||||
|
||||
def _source_configs_with_defaults(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
raw_sources = _source_configs(payload)
|
||||
merged: dict[str, Any] = {}
|
||||
for source_key, default_config in DEFAULT_PUBLIC_BOUNDARY_SOURCES.items():
|
||||
configured = raw_sources.get(source_key)
|
||||
if not isinstance(configured, dict) or _is_placeholder_endpoint(configured.get("endpoint")):
|
||||
merged[source_key] = dict(default_config)
|
||||
else:
|
||||
merged[source_key] = {**default_config, **configured}
|
||||
for source_key, source_config in raw_sources.items():
|
||||
if source_key not in merged:
|
||||
merged[source_key] = source_config
|
||||
return merged
|
||||
|
||||
|
||||
def _build_input_hash(source_manifest: dict[str, Any]) -> str:
|
||||
return _stable_json_hash(
|
||||
{
|
||||
"source_manifest_schema": source_manifest.get("schema"),
|
||||
"sources": [
|
||||
{
|
||||
"id": source.get("id"),
|
||||
"sha256": source.get("sha256"),
|
||||
"kind": source.get("kind"),
|
||||
}
|
||||
for source in source_manifest.get("sources", [])
|
||||
],
|
||||
"pov_policy": source_manifest.get("povPolicy"),
|
||||
"build_config": BUILD_CONFIG,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _has_current_artifacts(boundary_manifest: dict[str, Any], build_input_hash: str) -> bool:
|
||||
return (
|
||||
bool(boundary_manifest)
|
||||
and boundary_manifest.get("buildInputHash") == build_input_hash
|
||||
and boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
||||
and PMTILES_ARTIFACT_PATH.exists()
|
||||
)
|
||||
|
||||
|
||||
def get_boundary_status() -> dict[str, Any]:
|
||||
config_payload, config_source = load_boundary_config()
|
||||
effective_source_configs = _source_configs_with_defaults(config_payload)
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
pmtiles_exists = PMTILES_ARTIFACT_PATH.exists()
|
||||
manifest_exists = BOUNDARY_MANIFEST_PATH.exists()
|
||||
high_precision_ready = (
|
||||
manifest_exists
|
||||
and (
|
||||
(
|
||||
boundary_manifest.get("tileProvider") == "pmtiles-mvt"
|
||||
and pmtiles_exists
|
||||
)
|
||||
or boundary_manifest.get("tileProvider") == "geojson-high-precision"
|
||||
)
|
||||
)
|
||||
legacy_exists = LEGACY_GEOJSON_PATH.exists()
|
||||
provider = (
|
||||
boundary_manifest.get("tileProvider")
|
||||
if high_precision_ready
|
||||
else "legacy-geojson" if legacy_exists else "missing"
|
||||
)
|
||||
return {
|
||||
"provider": provider,
|
||||
"high_precision_ready": high_precision_ready,
|
||||
"fallback_available": legacy_exists,
|
||||
"config_source": config_source,
|
||||
"config_path": _relative(LOCAL_CONFIG_PATH),
|
||||
"config_exists": LOCAL_CONFIG_PATH.exists(),
|
||||
"config": config_payload,
|
||||
"effective_default_sources": [
|
||||
source_key
|
||||
for source_key, source_config in effective_source_configs.items()
|
||||
if source_key in DEFAULT_PUBLIC_BOUNDARY_SOURCES
|
||||
and source_config.get("endpoint") == DEFAULT_PUBLIC_BOUNDARY_SOURCES[source_key]["endpoint"]
|
||||
],
|
||||
"manifest": {
|
||||
"path": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"exists": manifest_exists,
|
||||
"tileProvider": boundary_manifest.get("tileProvider"),
|
||||
"buildInputHash": boundary_manifest.get("buildInputHash"),
|
||||
"builtAt": boundary_manifest.get("builtAt"),
|
||||
},
|
||||
"pmtiles": {
|
||||
"path": _relative(PMTILES_ARTIFACT_PATH),
|
||||
"exists": pmtiles_exists,
|
||||
"size_bytes": PMTILES_ARTIFACT_PATH.stat().st_size if pmtiles_exists else 0,
|
||||
},
|
||||
"legacy": {
|
||||
"path": _relative(LEGACY_GEOJSON_PATH),
|
||||
"exists": legacy_exists,
|
||||
"size_bytes": LEGACY_GEOJSON_PATH.stat().st_size if legacy_exists else 0,
|
||||
},
|
||||
"source_manifest": {
|
||||
"path": _relative(SOURCE_MANIFEST_PATH),
|
||||
"exists": SOURCE_MANIFEST_PATH.exists(),
|
||||
},
|
||||
"last_build": _read_json(BUILD_RESULT_PATH),
|
||||
"current_job": _public_job_state(),
|
||||
}
|
||||
|
||||
|
||||
async def _download_source(
|
||||
source_key: str,
|
||||
source_config: dict[str, Any],
|
||||
progress_callback: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
endpoint = str(source_config.get("endpoint") or "").strip()
|
||||
if _is_placeholder_endpoint(endpoint):
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} endpoint is not configured",
|
||||
code="source_not_configured",
|
||||
details={"source": source_key},
|
||||
)
|
||||
method = str(source_config.get("method") or "GET").upper()
|
||||
if method not in {"GET", "POST"}:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} method must be GET or POST",
|
||||
code="invalid_config",
|
||||
details={"source": source_key, "method": method},
|
||||
)
|
||||
|
||||
if endpoint.startswith("file://") or Path(endpoint).expanduser().exists():
|
||||
payload = Path(endpoint.removeprefix("file://")).expanduser().read_bytes()
|
||||
content_type = "application/octet-stream"
|
||||
if progress_callback:
|
||||
progress_callback(1, len(payload), len(payload))
|
||||
else:
|
||||
timeout = float(source_config.get("timeout") or 120)
|
||||
headers = source_config.get("headers") if isinstance(source_config.get("headers"), dict) else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
async with client.stream(method, endpoint, headers=headers) as response:
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "")
|
||||
total = int(response.headers.get("content-length") or 0)
|
||||
chunks = []
|
||||
downloaded = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
(downloaded / total) if total else None,
|
||||
downloaded,
|
||||
total,
|
||||
)
|
||||
payload = b"".join(chunks)
|
||||
|
||||
extension = _artifact_extension(endpoint, content_type, payload)
|
||||
parsed: Any = None
|
||||
if extension in {".json", ".geojson"}:
|
||||
parsed = json.loads(payload.decode("utf-8"))
|
||||
feature_count = _json_feature_count(parsed)
|
||||
if feature_count <= 0:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} downloaded payload contains no features",
|
||||
code="empty_source",
|
||||
details={"source": source_key},
|
||||
)
|
||||
|
||||
sha256 = _sha256_bytes(payload)
|
||||
source_dir = SOURCE_OUTPUT_DIR / source_key
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_path = source_dir / f"{sha256}{extension}"
|
||||
artifact_path.write_bytes(payload)
|
||||
return {
|
||||
"id": source_key,
|
||||
"kind": source_config.get("sourceKind") or BOUNDARY_SOURCE_KINDS[source_key],
|
||||
"path": _relative(artifact_path),
|
||||
"sha256": sha256,
|
||||
"featureCount": feature_count,
|
||||
"license": source_config.get("license"),
|
||||
}
|
||||
|
||||
|
||||
async def _run_step(args: list[str]) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
*args,
|
||||
cwd=REPO_ROOT,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout_bytes, stderr_bytes = await process.communicate()
|
||||
stdout = stdout_bytes.decode("utf-8", errors="replace").strip()
|
||||
stderr = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||||
payload: dict[str, Any] = {"stdout": stdout, "stderr": stderr, "returncode": process.returncode}
|
||||
last_line = stdout.splitlines()[-1:] or []
|
||||
if last_line:
|
||||
try:
|
||||
payload["result"] = json.loads(last_line[0])
|
||||
except json.JSONDecodeError:
|
||||
payload["result"] = last_line[0]
|
||||
if process.returncode != 0:
|
||||
raise EarthBoundaryBuildError(
|
||||
stderr or stdout or f"command failed: {' '.join(args)}",
|
||||
code="build_command_failed",
|
||||
details=payload,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
async def build_boundary_assets(progress_callback: Any = None) -> dict[str, Any]:
|
||||
config_payload, config_source = load_boundary_config()
|
||||
|
||||
source_configs = _source_configs_with_defaults(config_payload)
|
||||
missing = [source for source in BOUNDARY_SOURCE_KINDS if source not in source_configs]
|
||||
if missing:
|
||||
raise EarthBoundaryBuildError(
|
||||
f"Missing Earth boundary source configs: {', '.join(missing)}",
|
||||
code="missing_sources",
|
||||
details={"missing": missing},
|
||||
)
|
||||
|
||||
sources = []
|
||||
source_keys = list(BOUNDARY_SOURCE_KINDS)
|
||||
for index, source_key in enumerate(source_keys):
|
||||
source_config = source_configs[source_key]
|
||||
if not isinstance(source_config, dict):
|
||||
raise EarthBoundaryBuildError(
|
||||
f"{source_key} config must be an object",
|
||||
code="invalid_config",
|
||||
details={"source": source_key},
|
||||
)
|
||||
source_start = 8 + index * 18
|
||||
source_end = source_start + 18
|
||||
if progress_callback:
|
||||
progress_callback(source_start, "download", f"正在下载 {source_key}")
|
||||
|
||||
def report_download_progress(ratio: float | None, downloaded: int, total: int) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
if ratio is None:
|
||||
progress_callback(source_start + 8, "download", f"{source_key} 已下载 {downloaded} bytes")
|
||||
return
|
||||
progress_callback(
|
||||
source_start + (source_end - source_start) * ratio,
|
||||
"download",
|
||||
f"{source_key} 下载 {int(ratio * 100)}%",
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total,
|
||||
)
|
||||
|
||||
sources.append(await _download_source(source_key, source_config, report_download_progress))
|
||||
|
||||
source_manifest = {
|
||||
"schema": "planet-earth-boundary-sources/v2",
|
||||
"sources": sources,
|
||||
"povPolicy": _read_json(POV_POLICY_PATH),
|
||||
}
|
||||
if progress_callback:
|
||||
progress_callback(65, "manifest", "正在写入边界源 manifest")
|
||||
_write_json(SOURCE_MANIFEST_PATH, source_manifest)
|
||||
build_input_hash = _build_input_hash(source_manifest)
|
||||
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
build_skipped = _has_current_artifacts(boundary_manifest, build_input_hash)
|
||||
missing_tools = [tool for tool in ("tippecanoe", "pmtiles") if shutil.which(tool) is None]
|
||||
if missing_tools and not build_skipped:
|
||||
if progress_callback:
|
||||
progress_callback(82, "build", "缺少 PMTiles 工具,正在生成 GeoJSON 高清包")
|
||||
boundary_manifest = _write_high_precision_geojson_manifest(
|
||||
sources,
|
||||
build_input_hash,
|
||||
missing_tools,
|
||||
)
|
||||
result = {
|
||||
"status": "built_geojson_fallback",
|
||||
"code": "missing_tools",
|
||||
"missing_tools": missing_tools,
|
||||
"sources": sources,
|
||||
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"manifest": boundary_manifest,
|
||||
}
|
||||
_write_json(BUILD_RESULT_PATH, result)
|
||||
if progress_callback:
|
||||
progress_callback(96, "finalize", "GeoJSON 高清国界包已生成")
|
||||
return {**get_boundary_status(), "build": result}
|
||||
|
||||
if build_skipped:
|
||||
if progress_callback:
|
||||
progress_callback(96, "unchanged", "高精国界已是最新")
|
||||
build_result = {
|
||||
"status": "unchanged",
|
||||
"reason": "source manifest and build config hash unchanged",
|
||||
"buildInputHash": build_input_hash,
|
||||
}
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(72, "build", "正在构建 PMTiles/MVT")
|
||||
admin0 = next(source for source in sources if source["kind"] == "admin0-boundaries")
|
||||
coastline = next(source for source in sources if source["kind"] == "coastline")
|
||||
claim_lines = next(source for source in sources if source["kind"] == "claim-lines")
|
||||
build_result = await _run_step(
|
||||
[
|
||||
"scripts/build_earth_boundary_pmtiles.py",
|
||||
"--admin0-source",
|
||||
admin0["path"],
|
||||
"--coastline-source",
|
||||
coastline["path"],
|
||||
"--claims-source",
|
||||
claim_lines["path"],
|
||||
"--output",
|
||||
_relative(PMTILES_ARTIFACT_PATH),
|
||||
"--manifest",
|
||||
_relative(BOUNDARY_MANIFEST_PATH),
|
||||
"--build-input-hash",
|
||||
build_input_hash,
|
||||
"--pov-policy",
|
||||
_relative(POV_POLICY_PATH),
|
||||
]
|
||||
)
|
||||
if progress_callback:
|
||||
progress_callback(95, "finalize", "正在校验构建产物")
|
||||
|
||||
boundary_manifest = _read_json(BOUNDARY_MANIFEST_PATH)
|
||||
boundary_stats = _directory_stats(BOUNDARY_OUTPUT_DIR)
|
||||
result = {
|
||||
"status": "unchanged" if build_skipped else "built",
|
||||
"sources": sources,
|
||||
"source_manifest": _relative(SOURCE_MANIFEST_PATH),
|
||||
"boundary_manifest": _relative(BOUNDARY_MANIFEST_PATH),
|
||||
"pmtiles_artifact": _relative(PMTILES_ARTIFACT_PATH),
|
||||
"pmtiles_exists": PMTILES_ARTIFACT_PATH.exists(),
|
||||
"boundary_stats": boundary_stats,
|
||||
"manifest": boundary_manifest,
|
||||
"build_result": build_result,
|
||||
}
|
||||
_write_json(BUILD_RESULT_PATH, result)
|
||||
return {**get_boundary_status(), "build": result}
|
||||
|
||||
|
||||
async def _run_boundary_build_job(job_id: str) -> None:
|
||||
def report(progress: float, phase: str, message: str, **extra: Any) -> None:
|
||||
if _build_job_state.get("id") != job_id:
|
||||
return
|
||||
_update_job_progress(progress, phase, message, **extra)
|
||||
|
||||
try:
|
||||
report(3, "prepare", "正在准备高精国界构建")
|
||||
result = await build_boundary_assets(report)
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="succeeded",
|
||||
progress=100,
|
||||
phase="complete",
|
||||
message="高精国界构建完成",
|
||||
finished_at=_utc_now_iso(),
|
||||
result={
|
||||
"provider": result.get("provider"),
|
||||
"high_precision_ready": result.get("high_precision_ready"),
|
||||
"pmtiles": result.get("pmtiles"),
|
||||
"manifest": result.get("manifest"),
|
||||
},
|
||||
)
|
||||
_append_job_log("高精国界构建完成")
|
||||
except EarthBoundaryBuildError as exc:
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="failed",
|
||||
progress=_build_job_state.get("progress", 0),
|
||||
phase="failed",
|
||||
message=str(exc),
|
||||
code=exc.code,
|
||||
details=exc.details,
|
||||
finished_at=_utc_now_iso(),
|
||||
)
|
||||
_append_job_log(str(exc))
|
||||
except Exception as exc: # pragma: no cover - defensive guard for background task
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="failed",
|
||||
progress=_build_job_state.get("progress", 0),
|
||||
phase="failed",
|
||||
message=str(exc),
|
||||
code="build_failed",
|
||||
finished_at=_utc_now_iso(),
|
||||
)
|
||||
_append_job_log(str(exc))
|
||||
|
||||
|
||||
async def start_boundary_build_job() -> dict[str, Any]:
|
||||
global _build_task
|
||||
async with _build_job_lock:
|
||||
if _build_task and not _build_task.done():
|
||||
return {"accepted": False, "job": _public_job_state()}
|
||||
job_id = uuid4().hex
|
||||
_set_job_state(
|
||||
id=job_id,
|
||||
status="queued",
|
||||
progress=0,
|
||||
phase="queued",
|
||||
message="高精国界构建已加入队列",
|
||||
logs=[],
|
||||
started_at=_utc_now_iso(),
|
||||
finished_at=None,
|
||||
code=None,
|
||||
details=None,
|
||||
)
|
||||
_append_job_log("高精国界构建已启动")
|
||||
_build_task = asyncio.create_task(_run_boundary_build_job(job_id))
|
||||
return {"accepted": True, "job": _public_job_state()}
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
import hashlib
|
||||
@@ -18,6 +18,7 @@ from bs4 import BeautifulSoup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.countries import COUNTRY_VARIANTS_MAP, get_country_centroid, normalize_country
|
||||
from app.ai_tasks.prompts import EffectiveAIPrompt, get_effective_prompt
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.services.location.resolvers.nominatim import build_default_nominatim_geocoder
|
||||
@@ -31,6 +32,8 @@ STALE_CACHE_MAX_AGE_SECONDS = 60 * 45
|
||||
RSS_SUPPLEMENT_MAX_AGE_SECONDS = STALE_CACHE_MAX_AGE_SECONDS
|
||||
MAX_TARGET_INFERENCE_CONCURRENCY = 3
|
||||
TARGET_INFERENCE_TIMEOUT_SECONDS = 6.0
|
||||
DEFAULT_NEWS_LOCALE = "zh-CN"
|
||||
NEWS_ENRICH_PROMPT_KEY = "earth.news.enrich"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -82,6 +85,11 @@ class ParsedNewsItem:
|
||||
feed_region: str
|
||||
homepage_url: str
|
||||
published_at: datetime | None
|
||||
content_language: str = "en"
|
||||
localizations: dict[str, dict[str, str]] = field(default_factory=dict)
|
||||
enrichment_status: str = "pending"
|
||||
enrichment_error: str | None = None
|
||||
enriched_at: datetime | None = None
|
||||
target_location: NewsTargetLocation | None = None
|
||||
target_resolution_stage: str = "unresolved"
|
||||
target_ai_attempted: bool = False
|
||||
@@ -365,6 +373,65 @@ def _first_json_object(text: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_localizations(value: Any) -> dict[str, dict[str, str]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
normalized: dict[str, dict[str, str]] = {}
|
||||
for locale, payload in value.items():
|
||||
locale_key = _coerce_str(locale)
|
||||
if not locale_key or not isinstance(payload, dict):
|
||||
continue
|
||||
title = _coerce_str(payload.get("title"))
|
||||
summary = _coerce_str(payload.get("summary"))
|
||||
entry: dict[str, str] = {}
|
||||
if title:
|
||||
entry["title"] = title
|
||||
if summary:
|
||||
entry["summary"] = summary
|
||||
if entry:
|
||||
normalized[locale_key] = entry
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_locale_text(
|
||||
item: ParsedNewsItem,
|
||||
key: str,
|
||||
*,
|
||||
locale: str = DEFAULT_NEWS_LOCALE,
|
||||
) -> str:
|
||||
localized = item.localizations.get(locale)
|
||||
if isinstance(localized, dict):
|
||||
value = _coerce_str(localized.get(key))
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _has_default_localization(item: ParsedNewsItem) -> bool:
|
||||
localized = item.localizations.get(DEFAULT_NEWS_LOCALE)
|
||||
if not isinstance(localized, dict):
|
||||
return False
|
||||
return bool(_coerce_str(localized.get("title")) and _coerce_str(localized.get("summary")))
|
||||
|
||||
|
||||
def apply_enrichment_patch_to_item(
|
||||
item: ParsedNewsItem,
|
||||
patch: dict[str, Any],
|
||||
) -> ParsedNewsItem:
|
||||
item.location_patch = patch
|
||||
if "content_language" in patch:
|
||||
item.content_language = _coerce_str(patch.get("content_language")) or item.content_language
|
||||
if "localizations" in patch:
|
||||
item.localizations = _normalize_localizations(patch.get("localizations"))
|
||||
if "enrichment_status" in patch:
|
||||
item.enrichment_status = _coerce_str(patch.get("enrichment_status")) or item.enrichment_status
|
||||
if "enrichment_error" in patch:
|
||||
item.enrichment_error = _coerce_str(patch.get("enrichment_error"))
|
||||
if "enriched_at" in patch:
|
||||
item.enriched_at = _parse_datetime(_coerce_str(patch.get("enriched_at")))
|
||||
return item
|
||||
|
||||
|
||||
async def _geocode_target_location(query: str) -> dict[str, Any] | None:
|
||||
return await asyncio.to_thread(_news_target_geocode, query)
|
||||
|
||||
@@ -513,42 +580,60 @@ async def _infer_news_target_location(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> NewsTargetLocation | None:
|
||||
target, _localizations = await _infer_news_enrichment(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
async def _infer_news_enrichment(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> tuple[NewsTargetLocation | None, dict[str, dict[str, str]]]:
|
||||
text_hint = await _extract_target_location_from_text(item)
|
||||
content_error: str | None = None
|
||||
if text_hint is not None and text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "skipped_text_hint"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"text hint matched {text_hint.label}"
|
||||
return text_hint
|
||||
localizations: dict[str, dict[str, str]] = {}
|
||||
|
||||
if provider_client is None:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "unavailable"
|
||||
item.target_ai_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_attempted = False
|
||||
item.target_ai_status = "unavailable"
|
||||
item.target_ai_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
item.enrichment_status = "unavailable"
|
||||
item.enrichment_error = "AI provider is not configured or unavailable for earth-feed."
|
||||
return text_hint, localizations
|
||||
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_ai_attempted = True
|
||||
item.target_ai_status = "attempted"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
return text_hint
|
||||
|
||||
item.target_ai_attempted = True
|
||||
item.target_ai_status = "attempted"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = (
|
||||
f"text hint matched {text_hint.label}" if text_hint is not None else "no text location hint matched"
|
||||
)
|
||||
item.enrichment_status = "attempted"
|
||||
item.enrichment_error = None
|
||||
|
||||
prompt = prompt or await get_effective_prompt(None, NEWS_ENRICH_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title="Infer likely event location for Earth news cruise",
|
||||
objective=(
|
||||
"Return exactly one strict JSON object for the most likely physical "
|
||||
"location the news event is about. Prefer the host city when a state "
|
||||
"visit, summit, meeting, attack, or disaster is clearly centered in a "
|
||||
"known city. Fall back to the best-supported country only when a city "
|
||||
"cannot be inferred."
|
||||
),
|
||||
title="Enrich Earth news item with event location and zh-CN content",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"news_item": {
|
||||
"title": item.title,
|
||||
@@ -564,17 +649,28 @@ async def _infer_news_target_location(
|
||||
),
|
||||
},
|
||||
"required_json_schema": {
|
||||
"country": "string|null",
|
||||
"city": "string|null",
|
||||
"matched_location_name": "string|null",
|
||||
"latitude": "number|null",
|
||||
"longitude": "number|null",
|
||||
"confidence": "number from 0 to 1",
|
||||
"reasoning_summary": "short string",
|
||||
"location": {
|
||||
"country": "string|null",
|
||||
"city": "string|null",
|
||||
"matched_location_name": "string|null",
|
||||
"latitude": "number|null",
|
||||
"longitude": "number|null",
|
||||
"confidence": "number from 0 to 1",
|
||||
"reasoning_summary": "short string",
|
||||
},
|
||||
"localizations": {
|
||||
"zh-CN": {
|
||||
"title": "faithful Simplified Chinese title",
|
||||
"summary": "1-2 sentence faithful Simplified Chinese summary",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
constraints=[
|
||||
"Return only strict JSON. Do not wrap it in markdown.",
|
||||
"For localizations, do not add facts that are absent from the RSS headline, description, source, or date.",
|
||||
"If the RSS description is thin, write a conservative summary that says only what is supported.",
|
||||
"Keep zh-CN summary concise, factual, and non-promotional.",
|
||||
"Prefer the event location, not the newsroom or publisher headquarters.",
|
||||
"When a country visit or summit is the clear topic but the city is omitted, use the most likely host city only if it is broadly public knowledge.",
|
||||
"Use null for unknown fields instead of inventing details.",
|
||||
@@ -584,40 +680,68 @@ async def _infer_news_target_location(
|
||||
try:
|
||||
response = await provider_client.analyze(request)
|
||||
except Exception as exc:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "provider_error"
|
||||
item.target_ai_error = str(exc)
|
||||
return text_hint
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "provider_error"
|
||||
item.target_ai_error = str(exc)
|
||||
item.enrichment_status = "provider_error"
|
||||
item.enrichment_error = str(exc)
|
||||
return text_hint, localizations
|
||||
|
||||
payload = _first_json_object(response.content)
|
||||
if not isinstance(payload, dict):
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "parse_error"
|
||||
item.target_ai_error = "AI response did not contain a parseable JSON object."
|
||||
return text_hint
|
||||
if text_hint is None or not text_hint.city:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "parse_error"
|
||||
item.target_ai_error = "AI response did not contain a parseable JSON object."
|
||||
item.enrichment_status = "parse_error"
|
||||
item.enrichment_error = "AI response did not contain a parseable JSON object."
|
||||
return text_hint, localizations
|
||||
|
||||
target = await _build_target_location_from_payload(payload)
|
||||
localizations = _normalize_localizations(payload.get("localizations"))
|
||||
if not localizations:
|
||||
content_error = "AI returned no usable localizations."
|
||||
|
||||
location_payload = payload.get("location") if isinstance(payload.get("location"), dict) else payload
|
||||
if text_hint is not None and text_hint.city:
|
||||
target = text_hint
|
||||
else:
|
||||
target = await _build_target_location_from_payload(location_payload)
|
||||
if target is None:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "no_result"
|
||||
item.target_ai_error = "AI returned no usable target coordinates or geocodeable location."
|
||||
return text_hint
|
||||
if target.confidence is not None and target.confidence < 0.45:
|
||||
target = text_hint
|
||||
elif target.confidence is not None and target.confidence < 0.45:
|
||||
item.target_resolution_stage = text_hint.source if text_hint is not None else "unresolved"
|
||||
item.target_ai_status = "low_confidence"
|
||||
item.target_ai_error = f"AI target confidence too low: {target.confidence:.2f}"
|
||||
return text_hint
|
||||
item.target_resolution_stage = target.source
|
||||
item.target_ai_status = "success"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"ai inferred {target.label}"
|
||||
return target
|
||||
target = text_hint
|
||||
else:
|
||||
item.target_resolution_stage = target.source
|
||||
item.target_ai_status = "success"
|
||||
item.target_ai_error = None
|
||||
item.target_debug_note = f"ai inferred {target.label}"
|
||||
|
||||
item.localizations = localizations
|
||||
if localizations and item.target_ai_status in {"success", "skipped_text_hint"}:
|
||||
item.enrichment_status = "success"
|
||||
item.enrichment_error = None
|
||||
elif localizations:
|
||||
item.enrichment_status = "content_only"
|
||||
item.enrichment_error = item.target_ai_error
|
||||
else:
|
||||
item.enrichment_status = "location_only" if target is not None else "no_result"
|
||||
item.enrichment_error = content_error or item.target_ai_error
|
||||
item.enriched_at = datetime.now(UTC) if localizations else None
|
||||
return target, localizations
|
||||
|
||||
|
||||
async def _enrich_items_with_target_locations(
|
||||
items: list[ParsedNewsItem],
|
||||
*,
|
||||
provider_client: AIProviderClient | None,
|
||||
prompt: EffectiveAIPrompt | None = None,
|
||||
) -> list[ParsedNewsItem]:
|
||||
if not items:
|
||||
return items
|
||||
@@ -626,7 +750,11 @@ async def _enrich_items_with_target_locations(
|
||||
|
||||
async def enrich(item: ParsedNewsItem) -> ParsedNewsItem:
|
||||
async with semaphore:
|
||||
target = await _infer_news_target_location(item, provider_client=provider_client)
|
||||
target = await _infer_news_target_location(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
item.target_location = target
|
||||
return item
|
||||
|
||||
@@ -783,6 +911,20 @@ def _serialize_target(target: NewsTargetLocation | None) -> dict[str, Any] | Non
|
||||
}
|
||||
|
||||
|
||||
def _serialize_enriched_at(value: datetime | None) -> str | None:
|
||||
return value.isoformat().replace("+00:00", "Z") if value else None
|
||||
|
||||
|
||||
def _content_patch(item: ParsedNewsItem) -> dict[str, Any]:
|
||||
return {
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
}
|
||||
|
||||
|
||||
def build_anchor_location_patch(
|
||||
item: ParsedNewsItem,
|
||||
*,
|
||||
@@ -798,6 +940,9 @@ def build_anchor_location_patch(
|
||||
resolution_stage = item.target_resolution_stage
|
||||
ai_status = item.target_ai_status
|
||||
debug_note = item.target_debug_note
|
||||
content_patch = _content_patch(item)
|
||||
if queued and content_patch["enrichment_status"] == "pending":
|
||||
content_patch["enrichment_status"] = "queued"
|
||||
return {
|
||||
"latitude": anchor.latitude,
|
||||
"longitude": anchor.longitude,
|
||||
@@ -814,6 +959,7 @@ def build_anchor_location_patch(
|
||||
"target": None,
|
||||
"anchor": _serialize_anchor(anchor),
|
||||
},
|
||||
**content_patch,
|
||||
}
|
||||
|
||||
|
||||
@@ -836,6 +982,7 @@ def build_target_location_patch(item: ParsedNewsItem, target: NewsTargetLocation
|
||||
"target": _serialize_target(target),
|
||||
"anchor": _serialize_anchor(anchor),
|
||||
},
|
||||
**_content_patch(item),
|
||||
}
|
||||
|
||||
|
||||
@@ -845,6 +992,11 @@ def build_target_location_job_payload(item: ParsedNewsItem) -> dict[str, Any]:
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
@@ -859,6 +1011,11 @@ def parsed_news_item_from_job_payload(payload: dict[str, Any]) -> ParsedNewsItem
|
||||
id=str(payload.get("id") or ""),
|
||||
title=str(payload.get("title") or ""),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
content_language=str(payload.get("content_language") or "en"),
|
||||
localizations=_normalize_localizations(payload.get("localizations")),
|
||||
enrichment_status=str(payload.get("enrichment_status") or "pending"),
|
||||
enrichment_error=_coerce_str(payload.get("enrichment_error")),
|
||||
enriched_at=_parse_datetime(_coerce_str(payload.get("enriched_at"))),
|
||||
url=str(payload.get("url") or ""),
|
||||
source=str(payload.get("source") or ""),
|
||||
feed_name=str(payload.get("feed_name") or ""),
|
||||
@@ -875,10 +1032,15 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An
|
||||
"id": item.id,
|
||||
"title": item.title,
|
||||
"summary": item.summary,
|
||||
"content_language": item.content_language,
|
||||
"localizations": item.localizations,
|
||||
"display_title": _get_locale_text(item, "title"),
|
||||
"display_summary": _get_locale_text(item, "summary"),
|
||||
"url": item.url,
|
||||
"source": item.source,
|
||||
"feed_name": item.feed_name,
|
||||
"region": item.feed_region,
|
||||
"display_region": get_region_anchor(item.feed_region).label,
|
||||
"homepage_url": item.homepage_url,
|
||||
"published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None,
|
||||
"latitude": location_patch["latitude"],
|
||||
@@ -887,6 +1049,9 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An
|
||||
"location_source": location_patch["location_source"],
|
||||
"verified": location_patch["verified"],
|
||||
"location_meta": location_patch["location_meta"],
|
||||
"enrichment_status": item.enrichment_status,
|
||||
"enrichment_error": item.enrichment_error,
|
||||
"enriched_at": _serialize_enriched_at(item.enriched_at),
|
||||
"is_focus_match": item.feed_region == active_region,
|
||||
}
|
||||
|
||||
@@ -911,6 +1076,7 @@ def _build_payload(
|
||||
"lon": lon,
|
||||
"region": active_region,
|
||||
"label": profile.label,
|
||||
"display_region": get_region_anchor(active_region).label,
|
||||
"accent": profile.accent,
|
||||
},
|
||||
"sources": _serialize_sources(sources),
|
||||
@@ -966,13 +1132,27 @@ async def _apply_cached_locations_and_enqueue(items: list[ParsedNewsItem]) -> li
|
||||
get_cached_target_location_patch,
|
||||
)
|
||||
|
||||
async def enqueue_item(item: ParsedNewsItem, *, force: bool = False) -> bool:
|
||||
return await enqueue_target_location_job(build_target_location_job_payload(item), force=force)
|
||||
|
||||
async def apply_location(item: ParsedNewsItem) -> ParsedNewsItem:
|
||||
cached_patch = await get_cached_target_location_patch(item.id)
|
||||
if cached_patch:
|
||||
item.location_patch = cached_patch
|
||||
apply_enrichment_patch_to_item(item, cached_patch)
|
||||
if not _has_default_localization(item):
|
||||
queued = await enqueue_item(item, force=True)
|
||||
if queued and item.enrichment_status in {
|
||||
"pending",
|
||||
"unavailable",
|
||||
"provider_error",
|
||||
"parse_error",
|
||||
"no_result",
|
||||
"location_only",
|
||||
}:
|
||||
item.enrichment_status = "queued"
|
||||
return item
|
||||
|
||||
queued = await enqueue_target_location_job(build_target_location_job_payload(item))
|
||||
queued = await enqueue_item(item)
|
||||
item.location_patch = build_anchor_location_patch(
|
||||
item,
|
||||
queued=queued,
|
||||
@@ -991,9 +1171,16 @@ async def _enqueue_unverified_locations(items: list[ParsedNewsItem]) -> None:
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
enqueue_target_location_job(build_target_location_job_payload(item))
|
||||
enqueue_target_location_job(
|
||||
build_target_location_job_payload(item),
|
||||
force=not _has_default_localization(item),
|
||||
)
|
||||
for item in items
|
||||
if item.location_patch is None or item.location_patch.get("verified") is False
|
||||
if (
|
||||
item.location_patch is None
|
||||
or item.location_patch.get("verified") is False
|
||||
or not _has_default_localization(item)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class NewsTargetLocationMessage:
|
||||
|
||||
|
||||
class NewsTargetLocationQueue(Protocol):
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any]) -> bool:
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
...
|
||||
|
||||
async def consume_batch(
|
||||
@@ -91,9 +91,11 @@ class RedisStreamsNewsTargetLocationQueue:
|
||||
raise
|
||||
self._group_ready = True
|
||||
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any]) -> bool:
|
||||
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
|
||||
await self._ensure_group()
|
||||
if await self.client.exists(_result_key(item_id)):
|
||||
if force:
|
||||
await self.client.delete(_result_key(item_id), _queued_key(item_id))
|
||||
elif await self.client.exists(_result_key(item_id)):
|
||||
return False
|
||||
queued = await self.client.set(
|
||||
_queued_key(item_id),
|
||||
@@ -187,13 +189,13 @@ def get_news_target_location_queue() -> NewsTargetLocationQueue:
|
||||
return RedisStreamsNewsTargetLocationQueue()
|
||||
|
||||
|
||||
async def enqueue_target_location_job(payload: dict[str, Any]) -> bool:
|
||||
async def enqueue_target_location_job(payload: dict[str, Any], *, force: bool = False) -> bool:
|
||||
item_id = str(payload.get("id") or "")
|
||||
if not item_id:
|
||||
return False
|
||||
try:
|
||||
queue = get_news_target_location_queue()
|
||||
return await queue.enqueue(item_id=item_id, payload=payload)
|
||||
return await queue.enqueue(item_id=item_id, payload=payload, force=force)
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"Failed to enqueue Earth news target location job",
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.earth_news import EarthNewsItem
|
||||
from app.services.earth_news import (
|
||||
ParsedNewsItem,
|
||||
apply_enrichment_patch_to_item,
|
||||
build_anchor_location_patch,
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ def _location_patch_from_record(record: EarthNewsItem) -> dict[str, Any]:
|
||||
|
||||
|
||||
def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem:
|
||||
return ParsedNewsItem(
|
||||
item = ParsedNewsItem(
|
||||
id=record.id,
|
||||
title=record.title,
|
||||
summary=record.summary or "",
|
||||
@@ -43,8 +44,13 @@ def record_to_parsed_news_item(record: EarthNewsItem) -> ParsedNewsItem:
|
||||
feed_region=record.region or "global",
|
||||
homepage_url=record.homepage_url or "",
|
||||
published_at=_coerce_datetime(record.published_at),
|
||||
location_patch=_location_patch_from_record(record),
|
||||
content_language=record.content_language or "en",
|
||||
localizations=dict(record.localizations or {}),
|
||||
enrichment_status=record.enrichment_status or "pending",
|
||||
enrichment_error=record.enrichment_error,
|
||||
enriched_at=_coerce_datetime(record.enriched_at),
|
||||
)
|
||||
return apply_enrichment_patch_to_item(item, _location_patch_from_record(record))
|
||||
|
||||
|
||||
def _query_sort_key(active_region: str):
|
||||
@@ -108,6 +114,8 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem])
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
summary=item.summary,
|
||||
content_language=item.content_language,
|
||||
localizations=dict(item.localizations or {}),
|
||||
url=item.url,
|
||||
source=item.source,
|
||||
feed_name=item.feed_name,
|
||||
@@ -122,6 +130,9 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem])
|
||||
location_meta=patch["location_meta"],
|
||||
first_seen_at=now,
|
||||
last_seen_at=now,
|
||||
enrichment_status=item.enrichment_status,
|
||||
enrichment_error=item.enrichment_error,
|
||||
enriched_at=item.enriched_at,
|
||||
)
|
||||
db.add(record)
|
||||
changed += 1
|
||||
@@ -136,6 +147,12 @@ async def upsert_earth_news_items(db: AsyncSession, items: list[ParsedNewsItem])
|
||||
record.homepage_url = item.homepage_url
|
||||
record.published_at = item.published_at
|
||||
record.last_seen_at = now
|
||||
if item.localizations:
|
||||
record.content_language = item.content_language
|
||||
record.localizations = dict(item.localizations or {})
|
||||
record.enrichment_status = item.enrichment_status
|
||||
record.enrichment_error = item.enrichment_error
|
||||
record.enriched_at = item.enriched_at
|
||||
changed += 1
|
||||
await db.flush()
|
||||
return changed
|
||||
@@ -161,6 +178,45 @@ async def update_earth_news_item_location(
|
||||
return True
|
||||
|
||||
|
||||
async def update_earth_news_item_enrichment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
item_id: str,
|
||||
patch: dict[str, Any],
|
||||
) -> bool:
|
||||
record = await db.get(EarthNewsItem, item_id)
|
||||
if record is None:
|
||||
return False
|
||||
if "latitude" in patch:
|
||||
record.latitude = float(patch["latitude"])
|
||||
record.longitude = float(patch["longitude"])
|
||||
record.location_label = str(patch["location_label"])
|
||||
record.location_source = str(patch["location_source"])
|
||||
record.verified = bool(patch["verified"])
|
||||
record.location_meta = dict(patch.get("location_meta") or {})
|
||||
record.resolved_at = datetime.now(UTC) if record.verified else None
|
||||
if "content_language" in patch:
|
||||
record.content_language = str(patch.get("content_language") or "en")
|
||||
if "localizations" in patch:
|
||||
record.localizations = dict(patch.get("localizations") or {})
|
||||
if "enrichment_status" in patch:
|
||||
record.enrichment_status = str(patch.get("enrichment_status") or "pending")
|
||||
if "enrichment_error" in patch:
|
||||
record.enrichment_error = patch.get("enrichment_error")
|
||||
if patch.get("enriched_at"):
|
||||
try:
|
||||
parsed_enriched_at = datetime.fromisoformat(
|
||||
str(patch["enriched_at"]).replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
parsed_enriched_at = datetime.now(UTC)
|
||||
record.enriched_at = _coerce_datetime(parsed_enriched_at)
|
||||
elif patch.get("localizations"):
|
||||
record.enriched_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def list_unverified_earth_news_items(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -9,8 +9,10 @@ from app.core.logging import get_logger
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import async_session_factory
|
||||
from app.services.ai_client import AIProviderClient
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.earth_news import (
|
||||
_infer_news_target_location,
|
||||
NEWS_ENRICH_PROMPT_KEY,
|
||||
_infer_news_enrichment,
|
||||
build_target_location_patch,
|
||||
parsed_news_item_from_job_payload,
|
||||
)
|
||||
@@ -19,7 +21,7 @@ from app.services.earth_news_queue import (
|
||||
get_news_target_location_queue,
|
||||
save_target_location_patch,
|
||||
)
|
||||
from app.services.earth_news_store import update_earth_news_item_location
|
||||
from app.services.earth_news_store import update_earth_news_item_enrichment as update_earth_news_item_location
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="earth_news")
|
||||
@@ -59,8 +61,15 @@ async def process_target_location_message(
|
||||
provider_client: AIProviderClient | None,
|
||||
) -> dict[str, Any]:
|
||||
item = parsed_news_item_from_job_payload(message.payload)
|
||||
target = await _infer_news_target_location(item, provider_client=provider_client)
|
||||
async with async_session_factory() as session:
|
||||
prompt = await get_effective_prompt(session, NEWS_ENRICH_PROMPT_KEY)
|
||||
target, localizations = await _infer_news_enrichment(
|
||||
item,
|
||||
provider_client=provider_client,
|
||||
prompt=prompt,
|
||||
)
|
||||
item.target_location = target
|
||||
item.localizations = localizations or item.localizations
|
||||
patch = build_target_location_patch(item, target)
|
||||
await save_target_location_patch(item.id, patch)
|
||||
async with async_session_factory() as session:
|
||||
|
||||
@@ -7,8 +7,11 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.countries import COUNTRY_ENTRIES, normalize_country
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
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
|
||||
@@ -23,6 +26,8 @@ from app.services.location.text import (
|
||||
|
||||
VALID_LLM_PRECISIONS = {"precise", "site", "city"}
|
||||
DEFAULT_MIN_CONFIDENCE = 0.55
|
||||
LOCATION_NORMALIZE_PROMPT_KEY = "location.factcheck.normalize"
|
||||
LOCATION_RESOLVE_PROMPT_KEY = "location.factcheck.resolve"
|
||||
MODEL_CONFIDENCE_WEIGHT = 0.25
|
||||
_geocode_llm_city = build_default_nominatim_geocoder()
|
||||
_LLM_LOCATION_NAME_KEYS = (
|
||||
@@ -876,6 +881,7 @@ async def _repair_location_payload_from_text(
|
||||
raw_text: str,
|
||||
query: LocationQuery,
|
||||
entity_type: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Second-pass structure repair for models that answer in prose.
|
||||
|
||||
@@ -884,12 +890,11 @@ async def _repair_location_payload_from_text(
|
||||
"""
|
||||
if not coerce_str(raw_text):
|
||||
return None
|
||||
prompt = await get_effective_prompt(db, LOCATION_NORMALIZE_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title=f"Normalize location factcheck for {entity_type}",
|
||||
objective=(
|
||||
"Convert the supplied location factcheck text into exactly one strict "
|
||||
"JSON object. Extract only facts present in the text or original query."
|
||||
),
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"entity_type": entity_type,
|
||||
"location_query": _query_context(query),
|
||||
@@ -929,6 +934,7 @@ async def collect_llm_location_fallback_candidate(
|
||||
provider_client: AIProviderClient,
|
||||
query: LocationQuery,
|
||||
entity_type: str,
|
||||
db: AsyncSession | None = None,
|
||||
attempted_queries: Iterable[str] = (),
|
||||
search_evidence: list[dict[str, Any]] | None = None,
|
||||
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
|
||||
@@ -946,13 +952,11 @@ async def collect_llm_location_fallback_candidate(
|
||||
attempted_queries=[attempt],
|
||||
failure_reason="LLM location factcheck skipped: no WebSearch evidence.",
|
||||
)
|
||||
prompt = await get_effective_prompt(db, LOCATION_RESOLVE_PROMPT_KEY)
|
||||
request = SituationalAnalysisRequest(
|
||||
title=f"Location factcheck fallback for {entity_type}",
|
||||
objective=(
|
||||
"Return exactly one JSON object for the most likely physical location. "
|
||||
"Use only fact-checkable public knowledge; return null fields rather "
|
||||
"than guessing when evidence is weak."
|
||||
),
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
context={
|
||||
"entity_type": entity_type,
|
||||
"location_query": _query_context(query),
|
||||
@@ -1001,6 +1005,7 @@ async def collect_llm_location_fallback_candidate(
|
||||
raw_text=response.content,
|
||||
query=query,
|
||||
entity_type=entity_type,
|
||||
db=db,
|
||||
)
|
||||
if payload is None:
|
||||
payload = _payload_from_free_text(response.content, query=query)
|
||||
|
||||
@@ -10,8 +10,11 @@ from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.schemas.ai import SituationalAnalysisRequest
|
||||
from app.ai_tasks.prompts import get_effective_prompt
|
||||
from app.services.bgp_ai_brief_store import get_latest_bgp_brief_record
|
||||
|
||||
SITUATIONAL_ALERT_BRIEF_PROMPT_KEY = "alerts.situational.brief"
|
||||
|
||||
|
||||
def _format_pairs(pairs: list[tuple[str, int]], empty_text: str = "无") -> str:
|
||||
if not pairs:
|
||||
@@ -158,10 +161,12 @@ async def build_situational_alert_brief_request(
|
||||
"latest_bgp_brief_id": latest_bgp_brief.id if latest_bgp_brief else None,
|
||||
"latest_bgp_brief_generated_at": latest_bgp_brief.generated_at if latest_bgp_brief else None,
|
||||
}
|
||||
prompt = await get_effective_prompt(db, SITUATIONAL_ALERT_BRIEF_PROMPT_KEY)
|
||||
|
||||
request = SituationalAnalysisRequest(
|
||||
title="态势告警 AI 简报",
|
||||
objective="综合系统告警、BGP incidents、BGP anomalies 与近期 BGP AI 简报,生成一份面向值班人员的态势告警简报,指出当前最需要关注的风险域、跨模块联动迹象和优先动作。",
|
||||
objective=prompt.prompt,
|
||||
system_prompt=prompt.system_prompt or None,
|
||||
observations=facts,
|
||||
constraints=[
|
||||
"明确区分事实、推断与建议。",
|
||||
|
||||
177
backend/tests/test_earth_boundaries.py
Normal file
177
backend/tests/test_earth_boundaries.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
||||
from app.services.collectors.registry import collector_registry
|
||||
from app.services import earth_boundaries
|
||||
|
||||
|
||||
def write_geojson(path, name="Test"):
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {"name": name},
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[-1, -1], [1, -1], [1, 1], [-1, 1], [-1, -1]]],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def patch_paths(monkeypatch, tmp_path):
|
||||
repo = tmp_path
|
||||
source_dir = repo / "data/earth-boundary-sources"
|
||||
boundary_dir = repo / "frontend/public/earth/data/boundaries/v1"
|
||||
pmtiles = repo / "frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"
|
||||
legacy = repo / "frontend/public/earth/data/countries-admin0.min.geojson"
|
||||
config = repo / "config/earth-boundary-sources.local.json"
|
||||
example = repo / "config/earth-boundary-sources.example.json"
|
||||
policy = repo / "config/earth-boundary-pov-policy.china-v1.json"
|
||||
for path in (source_dir, boundary_dir, pmtiles.parent, legacy.parent, config.parent):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
policy.write_text('{"productionTileFormat":"pmtiles+mvt"}\n', encoding="utf-8")
|
||||
example.write_text('{"collectorConfigs":{}}\n', encoding="utf-8")
|
||||
monkeypatch.setattr(earth_boundaries, "REPO_ROOT", repo)
|
||||
monkeypatch.setattr(earth_boundaries, "SOURCE_OUTPUT_DIR", source_dir)
|
||||
monkeypatch.setattr(earth_boundaries, "SOURCE_MANIFEST_PATH", source_dir / "manifest.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BUILD_RESULT_PATH", source_dir / "build-result.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BUILD_JOB_PATH", source_dir / "build-job.json")
|
||||
monkeypatch.setattr(earth_boundaries, "BOUNDARY_OUTPUT_DIR", boundary_dir)
|
||||
monkeypatch.setattr(earth_boundaries, "BOUNDARY_MANIFEST_PATH", boundary_dir / "manifest.json")
|
||||
monkeypatch.setattr(earth_boundaries, "PMTILES_ARTIFACT_PATH", pmtiles)
|
||||
monkeypatch.setattr(earth_boundaries, "LEGACY_GEOJSON_PATH", legacy)
|
||||
monkeypatch.setattr(earth_boundaries, "LOCAL_CONFIG_PATH", config)
|
||||
monkeypatch.setattr(earth_boundaries, "EXAMPLE_CONFIG_PATH", example)
|
||||
monkeypatch.setattr(earth_boundaries, "POV_POLICY_PATH", policy)
|
||||
return {
|
||||
"repo": repo,
|
||||
"config": config,
|
||||
"legacy": legacy,
|
||||
"pmtiles": pmtiles,
|
||||
"manifest": boundary_dir / "manifest.json",
|
||||
}
|
||||
|
||||
|
||||
def test_boundary_status_uses_legacy_provider_when_pmtiles_missing(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
write_geojson(paths["legacy"])
|
||||
|
||||
status = earth_boundaries.get_boundary_status()
|
||||
|
||||
assert status["provider"] == "legacy-geojson"
|
||||
assert status["fallback_available"] is True
|
||||
assert status["high_precision_ready"] is False
|
||||
|
||||
|
||||
def test_boundary_status_prefers_high_precision_when_manifest_and_pmtiles_exist(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
write_geojson(paths["legacy"])
|
||||
paths["pmtiles"].write_bytes(b"pmtiles")
|
||||
paths["manifest"].write_text('{"tileProvider":"pmtiles-mvt"}\n', encoding="utf-8")
|
||||
|
||||
status = earth_boundaries.get_boundary_status()
|
||||
|
||||
assert status["provider"] == "pmtiles-mvt"
|
||||
assert status["high_precision_ready"] is True
|
||||
|
||||
|
||||
def test_save_boundary_config_writes_local_config(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
payload = {"collectorConfigs": {"earth_admin0_boundaries": {"endpoint": "file:///tmp/a.geojson"}}}
|
||||
|
||||
status = earth_boundaries.save_boundary_config(payload)
|
||||
|
||||
assert paths["config"].exists()
|
||||
assert status["config_source"] == "local"
|
||||
assert status["config"] == payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_reports_missing_tools_after_source_artifacts(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
source_files = {}
|
||||
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
|
||||
source_path = paths["repo"] / f"{source}.geojson"
|
||||
write_geojson(source_path, name=source)
|
||||
source_files[source] = source_path
|
||||
paths["config"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collectorConfigs": {
|
||||
source: {
|
||||
"sourceKind": kind,
|
||||
"endpoint": str(source_files[source]),
|
||||
"method": "GET",
|
||||
}
|
||||
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
|
||||
progress_events = []
|
||||
|
||||
status = await earth_boundaries.build_boundary_assets(
|
||||
lambda progress, phase, message, **_extra: progress_events.append((progress, phase, message))
|
||||
)
|
||||
|
||||
assert status["provider"] == "geojson-high-precision"
|
||||
assert status["high_precision_ready"] is True
|
||||
assert (paths["repo"] / "data/earth-boundary-sources/manifest.json").exists()
|
||||
assert paths["manifest"].exists()
|
||||
assert any(phase == "download" for _progress, phase, _message in progress_events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_boundary_build_job_records_geojson_fallback_success(monkeypatch, tmp_path):
|
||||
paths = patch_paths(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(earth_boundaries, "_build_task", None)
|
||||
monkeypatch.setattr(earth_boundaries, "_build_job_state", {})
|
||||
source_files = {}
|
||||
for source in earth_boundaries.BOUNDARY_SOURCE_KINDS:
|
||||
source_path = paths["repo"] / f"{source}.geojson"
|
||||
write_geojson(source_path, name=source)
|
||||
source_files[source] = source_path
|
||||
paths["config"].write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collectorConfigs": {
|
||||
source: {
|
||||
"sourceKind": kind,
|
||||
"endpoint": str(source_files[source]),
|
||||
"method": "GET",
|
||||
}
|
||||
for source, kind in earth_boundaries.BOUNDARY_SOURCE_KINDS.items()
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(earth_boundaries.shutil, "which", lambda _tool: None)
|
||||
|
||||
response = await earth_boundaries.start_boundary_build_job()
|
||||
await earth_boundaries._build_task
|
||||
status = earth_boundaries.get_boundary_build_status()
|
||||
|
||||
assert response["accepted"] is True
|
||||
assert status["job"]["status"] == "succeeded"
|
||||
assert status["job"]["result"]["provider"] == "geojson-high-precision"
|
||||
|
||||
|
||||
def test_earth_boundary_collectors_are_not_registered_as_datasources():
|
||||
removed = set(earth_boundaries.BOUNDARY_SOURCE_KINDS) | {"earth_boundary_tiles"}
|
||||
|
||||
assert removed.isdisjoint(DEFAULT_DATASOURCES)
|
||||
for source in removed:
|
||||
assert collector_registry.get(source) is None
|
||||
@@ -158,6 +158,56 @@ async def test_enrich_items_with_target_locations_uses_ai_and_geocode(monkeypatc
|
||||
assert enriched[0].target_ai_error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_items_with_target_locations_adds_localizations(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
id="global-scan:localized",
|
||||
title="Global leaders meet to discuss energy security",
|
||||
summary="Officials said the talks focused on supply chains and grid resilience.",
|
||||
url="https://example.com/energy-security",
|
||||
source="Example Source",
|
||||
feed_name="Global Monitor / World",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 14, 7, 13, 24, tzinfo=UTC),
|
||||
)
|
||||
|
||||
async def fake_geocode(_query: str):
|
||||
return {
|
||||
"lat": "50.1109",
|
||||
"lon": "8.6821",
|
||||
"display_name": "Frankfurt am Main, Germany",
|
||||
}
|
||||
|
||||
class FakeProviderClient:
|
||||
async def analyze(self, _request):
|
||||
class Response:
|
||||
content = (
|
||||
'{"location":{"country":"Germany","city":"Frankfurt",'
|
||||
'"matched_location_name":"Frankfurt, Germany",'
|
||||
'"latitude":null,"longitude":null,"confidence":0.77},'
|
||||
'"localizations":{"zh-CN":{"title":"全球领导人讨论能源安全",'
|
||||
'"summary":"官员表示,会谈聚焦供应链和电网韧性。"}}}'
|
||||
)
|
||||
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news._geocode_target_location", fake_geocode)
|
||||
|
||||
enriched = await _enrich_items_with_target_locations(
|
||||
[item],
|
||||
provider_client=FakeProviderClient(),
|
||||
)
|
||||
payload = _serialize_item(enriched[0], active_region="global")
|
||||
|
||||
assert payload["title"] == "Global leaders meet to discuss energy security"
|
||||
assert payload["summary"] == "Officials said the talks focused on supply chains and grid resilience."
|
||||
assert payload["localizations"]["zh-CN"]["title"] == "全球领导人讨论能源安全"
|
||||
assert payload["display_title"] == "全球领导人讨论能源安全"
|
||||
assert payload["display_summary"] == "官员表示,会谈聚焦供应链和电网韧性。"
|
||||
assert payload["enrichment_status"] == "success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_target_location_from_text_uses_country_hint(monkeypatch):
|
||||
item = ParsedNewsItem(
|
||||
@@ -241,7 +291,7 @@ async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job
|
||||
|
||||
enqueued_payloads = []
|
||||
|
||||
async def fake_enqueue_target_location_job(payload):
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued_payloads.append(payload)
|
||||
return True
|
||||
|
||||
@@ -260,6 +310,8 @@ async def test_earth_news_payload_returns_anchor_items_and_enqueues_location_job
|
||||
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["id"] == "test-feed:timeout"
|
||||
assert payload["items"][0]["display_title"] == ""
|
||||
assert payload["items"][0]["display_summary"] == ""
|
||||
assert payload["items"][0]["latitude"] == 20.0
|
||||
assert payload["items"][0]["longitude"] == 0.0
|
||||
assert payload["items"][0]["location_source"] == "region_anchor"
|
||||
@@ -359,7 +411,7 @@ async def test_earth_news_payload_initializes_empty_database_from_rss(monkeypatc
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
return [item]
|
||||
|
||||
async def fake_enqueue_target_location_job(payload):
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
@@ -415,7 +467,7 @@ async def test_earth_news_payload_supplements_stale_database_items(monkeypatch):
|
||||
async def fake_list_earth_news_items(_db, *, active_region, limit):
|
||||
return [old_item]
|
||||
|
||||
async def fake_enqueue_target_location_job(_payload):
|
||||
async def fake_enqueue_target_location_job(_payload, **_kwargs):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news_store.get_earth_news_freshness", fake_get_earth_news_freshness)
|
||||
@@ -473,8 +525,11 @@ async def test_earth_news_payload_merges_cached_location_patch(monkeypatch):
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return cached_patch
|
||||
|
||||
async def fake_enqueue_target_location_job(_payload):
|
||||
raise AssertionError("cached items should not be enqueued")
|
||||
enqueued = []
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
@@ -493,6 +548,70 @@ async def test_earth_news_payload_merges_cached_location_patch(monkeypatch):
|
||||
assert payload["items"][0]["longitude"] == 116.3912972
|
||||
assert payload["items"][0]["verified"] is True
|
||||
assert payload["items"][0]["location_source"] == "headline_location_hint"
|
||||
assert enqueued[0]["id"] == "test-feed:cached"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_news_payload_requeues_cached_failed_localization(monkeypatch):
|
||||
source = NewsFeedSource(
|
||||
id="test-feed",
|
||||
name="Test Feed",
|
||||
region="global",
|
||||
homepage_url="https://example.com",
|
||||
feed_url="https://example.com/rss.xml",
|
||||
)
|
||||
item = ParsedNewsItem(
|
||||
id="test-feed:failed-localization",
|
||||
title="Failed localization story",
|
||||
summary="English source summary.",
|
||||
url="https://example.com/failed-localization",
|
||||
source="Test Feed",
|
||||
feed_name="Test Feed",
|
||||
feed_region="global",
|
||||
homepage_url="https://example.com",
|
||||
published_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
cached_patch = {
|
||||
"latitude": 20.0,
|
||||
"longitude": 0.0,
|
||||
"location_label": "全球",
|
||||
"location_source": "region_anchor",
|
||||
"verified": False,
|
||||
"location_meta": {"target": None, "anchor": {"region": "global"}},
|
||||
"content_language": "en",
|
||||
"localizations": {},
|
||||
"enrichment_status": "parse_error",
|
||||
"enrichment_error": "AI response did not contain a parseable JSON object.",
|
||||
"enriched_at": None,
|
||||
}
|
||||
enqueued = []
|
||||
|
||||
async def fake_fetch_source(_client, feed_source):
|
||||
return feed_source, [item], None
|
||||
|
||||
async def fake_get_cached_target_location_patch(_item_id):
|
||||
return cached_patch
|
||||
|
||||
async def fake_enqueue_target_location_job(payload, **_kwargs):
|
||||
enqueued.append(payload)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.services.earth_news.get_sources_for_region", lambda _region: [source])
|
||||
monkeypatch.setattr("app.services.earth_news._fetch_source", fake_fetch_source)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.get_cached_target_location_patch",
|
||||
fake_get_cached_target_location_patch,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.earth_news_queue.enqueue_target_location_job",
|
||||
fake_enqueue_target_location_job,
|
||||
)
|
||||
|
||||
payload = await get_earth_news_payload(provider_client=None)
|
||||
|
||||
assert enqueued[0]["id"] == "test-feed:failed-localization"
|
||||
assert payload["items"][0]["display_title"] == ""
|
||||
assert payload["items"][0]["enrichment_status"] == "queued"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -598,6 +717,11 @@ async def test_media_news_archive_collector_maps_news_items(monkeypatch):
|
||||
location_source="headline_location_hint",
|
||||
verified=True,
|
||||
location_meta={"target": {"country": "中国", "city": "Beijing"}},
|
||||
content_language="en",
|
||||
localizations={"zh-CN": {"title": "归档新闻", "summary": "归档概要"}},
|
||||
enrichment_status="success",
|
||||
enrichment_error=None,
|
||||
enriched_at=datetime(2026, 5, 15, 3, 6, tzinfo=UTC),
|
||||
first_seen_at=datetime(2026, 5, 15, 2, 0, tzinfo=UTC),
|
||||
last_seen_at=datetime(2026, 5, 15, 3, 0, tzinfo=UTC),
|
||||
resolved_at=datetime(2026, 5, 15, 3, 5, tzinfo=UTC),
|
||||
@@ -619,3 +743,5 @@ async def test_media_news_archive_collector_maps_news_items(monkeypatch):
|
||||
assert items[0]["city"] == "Beijing"
|
||||
assert items[0]["latitude"] == 39.9057136
|
||||
assert items[0]["metadata"]["verified"] is True
|
||||
assert "localizations" not in items[0]["metadata"]
|
||||
assert "enrichment_status" not in items[0]["metadata"]
|
||||
|
||||
92
backend/tests/test_settings_ai_prompts.py
Normal file
92
backend/tests/test_settings_ai_prompts.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai_tasks.prompts import (
|
||||
get_effective_prompt,
|
||||
list_effective_prompts,
|
||||
reset_prompt_override,
|
||||
save_prompt_override,
|
||||
)
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def scalar_one_or_none(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class _PromptSettingsDB:
|
||||
def __init__(self, payload=None):
|
||||
self.record = SimpleNamespace(category="ai_prompts", payload=payload) if payload is not None else None
|
||||
self.added = None
|
||||
self.commits = 0
|
||||
|
||||
async def execute(self, _statement):
|
||||
return _ScalarResult(self.record)
|
||||
|
||||
def add(self, record):
|
||||
self.record = record
|
||||
self.added = record
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_defaults_are_loaded_without_override():
|
||||
db = _PromptSettingsDB()
|
||||
|
||||
prompt = await get_effective_prompt(db, "earth.news.enrich")
|
||||
|
||||
assert prompt.key == "earth.news.enrich"
|
||||
assert prompt.is_custom is False
|
||||
assert "strict JSON" in prompt.prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_override_save_and_reset():
|
||||
db = _PromptSettingsDB()
|
||||
|
||||
saved = await save_prompt_override(
|
||||
db,
|
||||
"alerts.brief",
|
||||
system_prompt="system custom",
|
||||
prompt="prompt custom",
|
||||
)
|
||||
|
||||
assert saved.is_custom is True
|
||||
assert saved.system_prompt == "system custom"
|
||||
assert saved.prompt == "prompt custom"
|
||||
assert db.commits == 1
|
||||
|
||||
effective = await get_effective_prompt(db, "alerts.brief")
|
||||
assert effective.prompt == "prompt custom"
|
||||
|
||||
reset = await reset_prompt_override(db, "alerts.brief")
|
||||
assert reset.is_custom is False
|
||||
assert reset.prompt != "prompt custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_list_marks_custom_items():
|
||||
db = _PromptSettingsDB(
|
||||
{
|
||||
"overrides": {
|
||||
"bgp.brief": {
|
||||
"system_prompt": "",
|
||||
"prompt": "custom bgp prompt",
|
||||
"updated_at": "2026-05-16T00:00:00Z",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
prompts = await list_effective_prompts(db)
|
||||
by_key = {prompt.key: prompt for prompt in prompts}
|
||||
|
||||
assert by_key["bgp.brief"].is_custom is True
|
||||
assert by_key["bgp.brief"].prompt == "custom bgp prompt"
|
||||
assert by_key["earth.news.enrich"].is_custom is False
|
||||
@@ -5,18 +5,17 @@
|
||||
"povPolicyPath": "config/earth-boundary-pov-policy.china-v1.json",
|
||||
"productionTileFormat": "pmtiles+mvt",
|
||||
"debugTileFormat": "geojson-directory",
|
||||
"description": "Example Collector Settings payloads for audited Earth boundary source ingestion. Replace every endpoint, license, and checksum note before production."
|
||||
"description": "Default Earth boundary update sources. These public Natural Earth endpoints make local high-precision boundary download work out of the box; replace with audited internal sources for production if needed."
|
||||
},
|
||||
"collectorConfigs": {
|
||||
"earth_admin0_boundaries": {
|
||||
"displayName": "Earth Admin-0 国界源",
|
||||
"sourceKind": "admin0-boundaries",
|
||||
"endpoint": "https://example.com/admin0-boundaries.geojson",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_countries.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"target_schema": "earth_boundary_source",
|
||||
"license": "REPLACE_WITH_SOURCE_LICENSE",
|
||||
"license": "Natural Earth public domain",
|
||||
"mapping_json": {
|
||||
"source": {
|
||||
"items_path": "$.features[*]"
|
||||
@@ -44,12 +43,11 @@
|
||||
"earth_coastline": {
|
||||
"displayName": "Earth 海岸线源",
|
||||
"sourceKind": "coastline",
|
||||
"endpoint": "https://example.com/coastline.geojson",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_coastline.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"target_schema": "earth_boundary_source",
|
||||
"license": "REPLACE_WITH_SOURCE_LICENSE",
|
||||
"license": "Natural Earth public domain",
|
||||
"mapping_json": {
|
||||
"source": {
|
||||
"items_path": "$.features[*]"
|
||||
@@ -77,12 +75,11 @@
|
||||
"earth_claim_lines": {
|
||||
"displayName": "Earth 主张线源",
|
||||
"sourceKind": "claim-lines",
|
||||
"endpoint": "https://example.com/claim-lines.geojson",
|
||||
"endpoint": "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_0_boundary_lines_disputed_areas.geojson",
|
||||
"method": "GET",
|
||||
"headers": {},
|
||||
"auth_type": "none",
|
||||
"target_schema": "earth_boundary_source",
|
||||
"license": "REPLACE_WITH_SOURCE_LICENSE",
|
||||
"license": "Natural Earth public domain",
|
||||
"mapping_json": {
|
||||
"source": {
|
||||
"items_path": "$.features[*]"
|
||||
@@ -109,7 +106,7 @@
|
||||
}
|
||||
},
|
||||
"notes": [
|
||||
"Use the actual Collector Settings page as the source of truth for runtime endpoints.",
|
||||
"The repository seed GeoJSON may be used only for smoke tests and cannot satisfy production high precision."
|
||||
"Earth can download these sources directly from the toolbar settings when no local source override exists.",
|
||||
"If tippecanoe/pmtiles are unavailable, the backend generates a GeoJSON high-precision package so the feature remains usable."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.59.0] — 2026-05-16
|
||||
|
||||
Released: 2026-05-16
|
||||
|
||||
### Highlights
|
||||
- 将 Earth 国界从采集器体系迁移为 Earth 静态资产,恢复低精 GeoJSON fallback,并新增 Earth 工具栏高精国界下载/构建进度与热应用。
|
||||
- 重组后台“运维与配置”:新增 Earth 内容与采集管理二级入口,电视直播、国界精度、采集器、采集调度各归其位,未接入模块以占位页呈现。
|
||||
- 新增 AI task prompt 覆盖管理,按稳定 task key 管理新闻汉化、告警研判、BGP 简报等业务提示词,避免全局 prompt 污染。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- Earth 新闻锚点链路增加队列化 enrichment 状态、Redis Streams 后台精修和 WebSocket patch 语义,前端汉化/锚点策略更稳定。
|
||||
- 国界 hover 与 interactable tooltip 解耦,鼠标位于国家 polygon 内时保持国界高亮,同时卫星/船只/BGP 等对象仍可显示自身信息。
|
||||
- 新增 `/api/v1/earth/boundaries/*` 状态、配置、构建和进度接口,并在启动初始化中清理旧 boundary datasource/task/snapshot 历史入口。
|
||||
- 补齐中英文用户手册、FAQ、quickstart、运维手册和开发者上下文文档,明确用户 UI、运维操作和开发者稳定边界。
|
||||
|
||||
---
|
||||
|
||||
## [0.58.0] — 2026-05-15
|
||||
|
||||
Released: 2026-05-15
|
||||
|
||||
52
docs/plans/ai-prompt-settings-task-registry-plan.md
Normal file
52
docs/plans/ai-prompt-settings-task-registry-plan.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# AI Prompt Settings and Task Registry Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Add an AI prompt settings tab under the operations AI settings page. Operators can select a business AI task from a dropdown, edit its prompt, save the override, and reset it back to the shipped default. Runtime LLM calls must resolve prompts through a task registry instead of embedding large prompt blocks at each call site.
|
||||
|
||||
Default prompts are shipped as versioned resource data, not scattered business-code literals. Business services reference stable task keys, and the runtime resolves the effective prompt from the database override first, then the shipped default resource.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add a backend task prompt registry with stable keys, labels, groups, versions, default system prompts, and default task prompts.
|
||||
- Store operator overrides in the existing `SystemSetting` table under an `ai_prompts` category. Store only custom overrides; defaults remain in the versioned prompt resource.
|
||||
- Add settings APIs:
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
- Migrate business LLM entrypoints to resolve prompts by task key. `aiprovider` remains a pure model adapter and does not inject business prompts.
|
||||
- Add a “提示词” tab to `/ai`. The tab shows a grouped task dropdown, current/default prompt status, editable prompt fields, save, and reset-to-default controls.
|
||||
|
||||
## Initial Tasks
|
||||
|
||||
- `earth.news.enrich` — Earth news localization and location enrichment.
|
||||
- `alerts.brief` — system alert AI brief.
|
||||
- `alerts.situational.brief` — situational alert AI brief.
|
||||
- `bgp.brief` — BGP AI brief.
|
||||
- `location.factcheck.normalize` — location factcheck normalization.
|
||||
- `location.factcheck.resolve` — location factcheck fallback resolution.
|
||||
- `datasource.mapping` — datasource mapping DSL generation.
|
||||
- `credential.guide` — credential guide generation.
|
||||
- `ai.connection_test` — AI provider connection test.
|
||||
|
||||
Playground and public free-form analyze endpoints stay caller-controlled and are not shown in the prompt settings dropdown.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Backend uses `uv`:
|
||||
- `uv run pytest tests/test_settings_ai_prompts.py`
|
||||
- `uv run pytest tests/test_earth_news.py`
|
||||
- `uv run pytest tests/test_api.py`
|
||||
- Frontend uses `bun`:
|
||||
- `cd frontend && bun run build`
|
||||
- Manual checks:
|
||||
- Prompt dropdown switches task content correctly.
|
||||
- Save persists an override and runtime calls use it.
|
||||
- Reset deletes the override and restores the shipped default.
|
||||
- Alert prompts do not leak into news, BGP, datasource, or location tasks.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- This iteration does not add prompt history, approval workflows, A/B testing, or per-user prompt variants.
|
||||
- Strict JSON tasks may fail validation if an operator edits away the output contract; existing task-specific failure and retry behavior remains responsible for recovery.
|
||||
- The UI stays Chinese-only in this iteration.
|
||||
@@ -31,7 +31,7 @@
|
||||
3. **登录与找回密码** — 登录页、忘记密码流程
|
||||
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
|
||||
5. **Console 总览** — 左侧菜单结构、各路由用途
|
||||
6. **配置数据采集器** — `/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
6. **配置数据采集器** — `/collection-management?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 权限组
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
- 打开管理员给你的 URL
|
||||
- 注册账号 + 邮箱验证
|
||||
- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 登录后第一次做什么(建议先到 `/collection-management?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 看 Earth
|
||||
|
||||
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## Status
|
||||
|
||||
Current implementation:
|
||||
Superseded status:
|
||||
|
||||
This plan originally treated boundaries as collector-managed source records. The current implementation has moved country boundaries out of the datasource / collector lifecycle. Boundaries are now Earth static rendering assets managed by `Operations and Configuration -> Earth Content -> Boundary Precision` and `/api/v1/earth/boundaries/*`. The bundled low-precision GeoJSON is the default fallback, and high precision is an opt-in local PMTiles build.
|
||||
|
||||
Historical implementation notes below are retained only as context and must not be used as the current architecture:
|
||||
|
||||
- Three standard source collectors now handle real source ingestion: `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`.
|
||||
- Each source collector reads endpoint / headers / auth / `target_schema=earth_boundary_source` from Collector Settings, downloads the configured payload, stores the full artifact under `data/earth-boundary-sources/<collector>/<sha256>.*`, and writes a hash / feature-count / artifact-path record to `CollectedData`.
|
||||
@@ -21,11 +25,11 @@ Still required before claiming true one-to-one high precision:
|
||||
|
||||
The Earth boundary layer should use one static PMTiles archive containing MVT tiles instead of thousands of loose GeoJSON files. The artifact is POV-specific: `earth-boundaries-china-pov-v1.pmtiles` has China POV baked in during offline source preparation, and the browser never patches political boundaries at runtime.
|
||||
|
||||
Production must serve a single PMTiles artifact through static hosting and HTTP range requests. Missing PMTiles is a hard boundary-layer error, not a silent low-precision fallback.
|
||||
Production should serve a single PMTiles artifact through static hosting and HTTP range requests. In development or on machines that have not opted into high precision, missing PMTiles falls back to the bundled low-precision GeoJSON so the Earth base remains usable.
|
||||
|
||||
## Key Implementation Rules
|
||||
|
||||
- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Collector Settings using the `earth_boundary_source` target schema; `config/earth-boundary-sources.example.json` remains the versioned example template.
|
||||
- Source inputs must be auditable. OSM admin boundaries, coastline packages, and claim-line endpoints are configured through Earth Content boundary precision settings; `config/earth-boundary-sources.example.json` remains the versioned example template.
|
||||
- China POV geometry is applied before tiling:
|
||||
- Zangnan and Aksai Chin are unioned into China and subtracted from India.
|
||||
- Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, and South China Sea islands are China hover/country features.
|
||||
@@ -36,9 +40,9 @@ Production must serve a single PMTiles artifact through static hosting and HTTP
|
||||
- `boundary_disputed_internal`
|
||||
- `coastline`
|
||||
- `claim_line`
|
||||
- The frontend provider is selected from the boundary manifest:
|
||||
- The frontend provider is selected from local high-precision preference plus the boundary manifest:
|
||||
- `tileProvider: "pmtiles-mvt"` reads the PMTiles artifact.
|
||||
- Any other provider, missing manifest, or missing PMTiles artifact is treated as an error.
|
||||
- Missing high-precision preference, missing manifest, or missing PMTiles artifact falls back to low-precision GeoJSON.
|
||||
- Redis is not part of v1. Static PMTiles plus browser/CDN range caching is the default performance model.
|
||||
|
||||
## Cleanup And Documentation
|
||||
@@ -46,11 +50,11 @@ Production must serve a single PMTiles artifact through static hosting and HTTP
|
||||
- Do not commit generated loose tiles under `frontend/public/earth/data/boundaries/` or source downloads under `data/earth-boundary-sources/`.
|
||||
- Remove stale generated debug data before production builds; regenerate it only when smoke testing the debug path.
|
||||
- Keep the high-level plan, backend collector docs, layer style docs, and ops runbook aligned whenever the provider contract changes.
|
||||
- After implementation changes, provide user-facing operation steps covering source configuration, source collection, artifact build/deploy, page verification, and fallback troubleshooting.
|
||||
- After implementation changes, provide user-facing operation steps covering source configuration, artifact build/deploy, page verification, and fallback troubleshooting.
|
||||
|
||||
## Verification
|
||||
|
||||
- The PMTiles builder fails as not ready when any of `earth_admin0_boundaries`, `earth_coastline`, or `earth_claim_lines` has not produced a current artifact record.
|
||||
- The Earth boundary build API reports missing source configuration or missing tools clearly, without creating datasource collection records.
|
||||
- The PMTiles builder fails as not ready when source artifacts exist but `tippecanoe` / `pmtiles` are missing.
|
||||
- Running the PMTiles builder twice returns `unchanged` on the second run when inputs are stable.
|
||||
- `git add . --dry-run` does not include generated loose boundary tiles or source downloads.
|
||||
@@ -58,10 +62,10 @@ Production must serve a single PMTiles artifact through static hosting and HTTP
|
||||
- Manual Earth checks confirm:
|
||||
- PMTiles range requests are issued only for visible tiles.
|
||||
- Boundary toggle, hover tooltip, and country highlight still work.
|
||||
- PMTiles failure reports a boundary-layer error; the frontend must not draw low-precision legacy boundaries.
|
||||
- PMTiles failure reports a high-precision boundary error; machines without high-precision enabled continue drawing low-precision fallback boundaries.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "One-to-one" means source-faithful to the selected audited vector source, not hand-tuned to a screenshot.
|
||||
- The China POV artifact is static and versioned; no runtime region-based POV switching is planned.
|
||||
- The removed repository seed file cannot be used as a runtime fallback for country boundaries.
|
||||
- The repository low-precision seed file is retained as the runtime fallback for country boundaries.
|
||||
|
||||
@@ -95,9 +95,18 @@ The AI settings page uses:
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
|
||||
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.
|
||||
|
||||
The `ai-prompts` endpoints back the Prompts tab in AI settings. Shipped defaults come from versioned backend resources, while business code references stable task keys. The API stores only operator overrides. Resetting a prompt removes the override and falls back to the current shipped default.
|
||||
|
||||
### Prompt Boundary
|
||||
|
||||
`aiprovider` is a pure model adapter and does not inject a global business system prompt. News localization, alert briefing, BGP briefing, location factcheck, datasource mapping, and credential guide generation each resolve their own effective prompt by task key. Alert-analysis system prompts are only sent by alert-related tasks and do not leak into other LLM calls.
|
||||
|
||||
### AI provider internal API
|
||||
|
||||
Internal-only endpoints:
|
||||
@@ -287,7 +296,6 @@ SERVICE_VERSION=0.1.0
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_HTTP_RETRY_ATTEMPTS=2
|
||||
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
|
||||
```
|
||||
|
||||
Optional provider-specific keys:
|
||||
|
||||
@@ -86,16 +86,10 @@ async def run(self, db):
|
||||
| TeleGeography | submarine_cable | Submarine cable information | 7 days |
|
||||
| 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 |
|
||||
| Earth Admin-0 Boundaries | earth_admin0_boundaries | Downloads the configured country-boundary source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings |
|
||||
| Earth Coastline | earth_coastline | Downloads the configured coastline source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings |
|
||||
| Earth Claim Lines | earth_claim_lines | Downloads the configured claim-line source, saves an artifact, and writes an `earth_boundary_source` manifest record | Collector settings |
|
||||
| Earth PMTiles Builder | earth_boundary_tiles | Reads the three Earth boundary source records and builds / registers the PMTiles artifact | 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.
|
||||
|
||||
Earth boundaries are now split into three real source collectors plus one downstream builder. `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines` read endpoint, headers, auth, and `config.target_schema=earth_boundary_source` from Collector Settings. Triggering them requests the configured endpoint, writes the full response to `data/earth-boundary-sources/<collector>/<sha256>.*`, and stores sha256, feature count, license, artifact path, sample properties, and mapping metadata in `CollectedData`.
|
||||
|
||||
`earth_boundary_tiles` no longer means source-data collection. It reads the latest successful records from those three source collectors; if any source is missing, the task fails as "not ready" and does not register "4 high-precision tile" records. Once all sources exist, it uses `tippecanoe` / `pmtiles` to build `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`; if those tools are missing, the task fails with the missing-tool message. There is no legacy low-precision fallback for country boundaries.
|
||||
Earth boundaries are no longer data collectors. They are Earth static rendering assets: the Earth Assets settings panel owns source configuration, and `/api/v1/earth/boundaries/*` builds `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`. When no high-precision PMTiles artifact is available locally, the frontend uses the bundled low-precision GeoJSON fallback and does not write boundary records to `CollectedData`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -306,7 +300,7 @@ State semantics:
|
||||
- `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.
|
||||
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 `Collection Management -> Collectors -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
|
||||
|
||||
The console manages AISStream from `/datasources -> Realtime Streams`, not from the normal finite collection progress bar. The realtime stream API aggregates runtime state, health, configuration preview, and raw observation counters:
|
||||
|
||||
@@ -374,9 +368,9 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l
|
||||
|
||||
`/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
|
||||
## X. Collectors 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:
|
||||
The console "Collectors" 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
|
||||
@@ -397,7 +391,7 @@ 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.
|
||||
See [Collectors and Connectivity Validation](/home/ray/dev/linkong/planet/docs/technical/en/datasource-collector-settings-connectivity.md) for the full flow.
|
||||
|
||||
## XI. Data Usage
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ The console now separates the "data source catalog" from "collector configuratio
|
||||
- Lists all data sources, including built-in and custom sources.
|
||||
- Clicking a name only opens an information drawer.
|
||||
- Focuses on status, manual collection, and running collection tasks.
|
||||
- `/settings?tab=collector_credentials`
|
||||
- Displays as "Collector Settings".
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- Displays as "Collectors".
|
||||
- Owns endpoint, headers, base parameters, and credentials.
|
||||
- Every collector exposes a connection button for health checks.
|
||||
|
||||
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to collector settings instead of being scattered across the data source list and system settings.
|
||||
This reduces first-use confusion: API endpoints, headers, credentials, and custom source configuration all belong to Collectors instead of being scattered across the data source list and system settings.
|
||||
|
||||
## User-Facing Rules
|
||||
|
||||
@@ -53,7 +53,7 @@ Current behavior:
|
||||
|
||||
`data-source-bulk-toolbar__running-pill` is the styling entry point for the "Collecting" pill. It is aligned with other status tags, while hover treatment, arrow affordance, and blue outline indicate interactivity.
|
||||
|
||||
### Collector Settings
|
||||
### Collectors
|
||||
|
||||
File:
|
||||
|
||||
@@ -61,7 +61,7 @@ File:
|
||||
|
||||
Current behavior:
|
||||
|
||||
- The `collector_credentials` tab is displayed as "Collector Settings".
|
||||
- The `collector_credentials` tab is displayed as "Collectors" under `/collection-management`.
|
||||
- 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:
|
||||
@@ -307,7 +307,7 @@ Files:
|
||||
|
||||
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.
|
||||
|
||||
Earth high-precision boundaries use the same target-schema mechanism. `earth_boundary_source` receives mapped records for `earth_admin0_boundaries`, `earth_coastline`, and `earth_claim_lines`; full GeoJSON / JSON payloads are stored as artifacts, while the database only keeps source kind, sha256, feature count, license, artifact path, and sample properties so large geometries do not land in a single row.
|
||||
Earth high-precision boundaries no longer use custom-source target schemas. Boundaries are Earth static assets: the Earth Assets settings panel saves local source configuration and triggers PMTiles builds without writing records to `CollectedData`.
|
||||
|
||||
### Configuration Semantics
|
||||
|
||||
@@ -318,7 +318,7 @@ Important fields:
|
||||
- `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` or `earth_boundary_source`.
|
||||
- `config.target_schema`: for example `vessel_ais`, `geo_points`, or `generic_records`.
|
||||
- `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`.
|
||||
|
||||
|
||||
@@ -216,6 +216,8 @@ The real satellite altitude preference is persisted by `controls.js`, while the
|
||||
|
||||
SGP4 propagation returns an inertial-frame position, so it must not be drawn directly as Earth-fixed longitude / latitude. `satellites.js` uses `gstime` to convert ECI/TEME positions to ECF, then maps that result into the same Three.js axes as `latLonToVector3()`. Satellite dots and short trails use Earth-fixed coordinates for each sample time, representing the object's current position relative to the globe surface. The locked predicted orbit uses the `gstime` from the lock moment for the whole future orbit, projecting the inertial orbit plane onto the current globe pose; that keeps the line closed and keeps the visual orbit inclination aligned with the details card. Fallback predicted orbits must also use a real RAAN + inclination orbital-plane formula, not treat inclination as a constant latitude.
|
||||
|
||||
Boundary precision is stored separately by `country-boundaries.js` under `planet.earth.boundaries.highPrecisionEnabled`. When high precision is off, Earth keeps using the bundled low-precision `countries-admin0.min.geojson` fallback even if high-precision manifest/PMTiles files exist locally. When high precision is on but the artifact is missing, the Earth toolbar settings call `/api/v1/earth/boundaries/build` and poll progress. After success, `reloadCountryBoundaries()` hot-swaps the boundary layer without refreshing the page. Boundary hover is independent from interactable hover: a country polygon remains highlighted whenever the surface coordinate is inside it, while the tooltip can still prioritize a satellite, vessel, BGP marker, or other interactable.
|
||||
|
||||
## Current Terrain Pipeline
|
||||
|
||||
1. `terrain.js` creates a sphere geometry with enough segments
|
||||
|
||||
@@ -78,9 +78,10 @@ The land/ocean base is an Earth base-map asset and preloads at startup; the "Bor
|
||||
|
||||
| Name | Variable | Current Value | Location / Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | Required production PMTiles manifest; missing manifest is an error |
|
||||
| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"pmtiles-mvt"` | Only PMTiles/MVT is accepted for country boundaries |
|
||||
| Boundary tile manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | High-precision PMTiles manifest; missing manifest uses the low-precision fallback |
|
||||
| Boundary tile provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | Prefer high-precision PMTiles/MVT, then fall back to legacy GeoJSON |
|
||||
| PMTiles artifact path | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | Production single-file PMTiles/MVT artifact |
|
||||
| Low-precision fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | Default land/ocean base and hover data when no high-precision boundary asset has been built locally |
|
||||
| MVT layer names | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | Fixed layer names decoded by the PMTiles provider |
|
||||
| Boundary tile base path | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest base path |
|
||||
| Boundary tile zoom thresholds | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | Production PMTiles zoom selection |
|
||||
|
||||
@@ -103,7 +103,7 @@ Goals:
|
||||
|
||||
## Collector Configuration
|
||||
|
||||
`news_live_streams` does not need a separate new page; it reuses Collector Settings under `/settings`:
|
||||
`news_live_streams` does not need a separate new page; it reuses Collectors under `/collection-management`:
|
||||
|
||||
- `endpoint`
|
||||
- Channel directory JSON API URL
|
||||
|
||||
@@ -294,7 +294,7 @@ If only AI Provider is unhealthy, restart just that service:
|
||||
|
||||
### 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.
|
||||
Connectivity validation can read saved console settings, environment variables, and some credentials from `~/.zshrc`. For actual collection, prefer saving credentials in Collection Management -> Collectors, 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.
|
||||
|
||||
@@ -308,7 +308,7 @@ 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.
|
||||
For stable operation, save credentials in Collectors so connectivity validation, collection jobs, and Earth realtime aggregation use the same configuration.
|
||||
|
||||
## Docs / Permissions
|
||||
|
||||
@@ -323,6 +323,17 @@ Docs visibility is controlled by Gatekeeper groups:
|
||||
|
||||
## Earth Common Tasks
|
||||
|
||||
### Why does Earth say the boundary endpoint is not configured, or only show low precision boundaries?
|
||||
|
||||
Country boundaries have moved out of the collector system. They are no longer generated by datasource collection tasks. The low-precision boundary file is bundled with the frontend and is the expected fallback when no local high-precision PMTiles artifact exists.
|
||||
|
||||
There are two high-precision entry points:
|
||||
|
||||
- Earth page settings gear -> Boundary Precision: switching to High Precision starts the first background download/build, shows percentage progress, and applies the result automatically.
|
||||
- Console `Operations and Configuration -> Earth Content -> Boundary Precision`: use this to inspect provider, manifest, PMTiles, fallback state, edit source JSON, or rebuild manually.
|
||||
|
||||
If the UI says the update source is incomplete, save the source configuration from `Earth Content -> Boundary Precision`. The private local config is written to `config/earth-boundary-sources.local.json`; do not commit it. Falling back to low precision is normal when no high-precision artifact has been built.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -33,6 +33,8 @@ Current admin-related routes:
|
||||
- `/alerts/situational`
|
||||
- `/bgp`
|
||||
- `/ai`
|
||||
- `/earth-content`
|
||||
- `/collection-management`
|
||||
- `/settings`
|
||||
|
||||
`/earth` is a standalone display page and is not part of the console shell.
|
||||
@@ -277,6 +279,42 @@ Constraints:
|
||||
- Do not let tables blow out the full page
|
||||
- New table areas should reuse `TableScrollRegion` / `ScrollbarOverlay`
|
||||
|
||||
### Datasource Directory Page
|
||||
|
||||
[DataSources.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/DataSources/DataSources.tsx) is the datasource directory and collection operation page. It should not grow back into a configuration editor.
|
||||
|
||||
Current page boundary:
|
||||
|
||||
- Built-in and custom sources are merged as `UnifiedDataSource`.
|
||||
- The list shows type, state, last run, collection progress, and actions.
|
||||
- Clicking a name opens a read-only drawer.
|
||||
- Endpoint, headers, and config are displayed here, not edited.
|
||||
- Credential-bearing collectors point users to `Collection Management -> Collectors`.
|
||||
|
||||
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?tab=collector_credentials`.
|
||||
|
||||
### Collectors Page
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` tab is shown as `Collectors` under `/collection-management`.
|
||||
|
||||
Current boundary:
|
||||
|
||||
- The dropdown selects built-in collectors.
|
||||
- The plug icon beside the dropdown runs the health check.
|
||||
- Credential-bearing collectors place credential forms above base config.
|
||||
- Free collectors show endpoint, default endpoint, headers, timeout, and retry.
|
||||
- BarentsWatch AIS keeps its dedicated credential form.
|
||||
|
||||
### Earth Content Page
|
||||
|
||||
`/earth-content` reuses the same single-screen tab container from [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx), but its ownership is separate from System Settings:
|
||||
|
||||
- `TV Livestream` owns the Earth media-panel source configuration.
|
||||
- `Boundary Precision` owns the Earth static boundary asset state: provider, low-precision fallback, high-precision manifest/PMTiles, source JSON, and build action.
|
||||
- `Base Map`, `Layer Resources`, `3D Assets`, and `News Anchor Strategy` are placeholders only. They show module status and do not invent fake APIs or fake data.
|
||||
|
||||
Do not add Earth experience resources or collection-lifecycle tabs back into `/settings`; collection belongs to `/collection-management`, and Earth display resources belong to `/earth-content`.
|
||||
|
||||
### 3. Complex Workspace Pages
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -76,15 +76,17 @@ The console at `http://localhost:3000/admin` is built with React + Ant Design. T
|
||||
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
|
||||
| Situational Alerts | `/alerts/situational` | Situational analysis alerts |
|
||||
| AI | `/ai` | Model providers, tools, testbench |
|
||||
| Earth Content | `/earth-content` | TV livestreams, boundary precision, base-map and layer-resource entry points |
|
||||
| Collection Management | `/collection-management` | Collectors, scheduling, collection history entry points |
|
||||
| Logs | `/logs` | Usually visible only to super admin |
|
||||
| Users | `/users` | Create/delete users, change roles/groups |
|
||||
| Settings | `/settings` | System, SMTP, TV, collectors |
|
||||
| System Settings | `/settings` | Display, notification, security, SMTP |
|
||||
|
||||
Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups.
|
||||
|
||||
## Configure Data Collectors
|
||||
|
||||
`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials.
|
||||
`/collection-management?tab=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials. Legacy `/settings?tab=collector_credentials` redirects here; the datasource directory remains at `/datasources`.
|
||||
|
||||
Steps:
|
||||
|
||||
@@ -125,7 +127,7 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
|
||||
|
||||
Steps:
|
||||
|
||||
1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
1. Open `/collection-management?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`
|
||||
@@ -139,10 +141,11 @@ Steps:
|
||||
|
||||
## Configure AI Credentials
|
||||
|
||||
`/ai?tab=providers` is the AI management entry. Two key sub-tabs:
|
||||
`/ai?tab=providers` is the AI management entry. Three key sub-tabs:
|
||||
|
||||
- `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
|
||||
- `Prompts`: a task dropdown for news localization, alert analysis, BGP briefs, and other LLM tasks. Operators can edit the prompt or reset it to the default
|
||||
|
||||
### Model Providers
|
||||
|
||||
@@ -163,6 +166,10 @@ The plug icon at the end of the Base URL input runs a connection test. A passing
|
||||
- **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
|
||||
|
||||
### Prompts
|
||||
|
||||
After selecting a task, the page shows the effective prompt, whether it is customized, the shipped default version, and a reset button. Saving affects only that task. Reset restores the default prompt from the current release package. Business facts, context, and output schemas are still assembled by the backend for each task.
|
||||
|
||||
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
|
||||
## System Settings
|
||||
@@ -173,8 +180,26 @@ The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
- **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
|
||||
|
||||
TV livestreams and boundary precision moved to `/earth-content`; collectors and scheduling moved to `/collection-management`; AI Provider / WebSearch / OCR live at `/ai`.
|
||||
|
||||
### Earth Content
|
||||
|
||||
`/earth-content` is under the console's Operations and Configuration group and owns resources used by the Earth frontend:
|
||||
|
||||
- **TV Livestream**: manages sources shown in the Earth media panel.
|
||||
- **Boundary Precision**: shows the current provider, low-precision fallback, high-precision PMTiles/manifest status, local source JSON, and manual build action.
|
||||
- **Base Map**, **Layer Resources**, **3D Assets**, and **News Anchor Strategy**: placeholder tabs for future configuration. They do not display fake data.
|
||||
|
||||
The Earth page settings gear also includes Boundary Precision. Switching to High Precision starts a local background download/build, like a game update package, when no high-precision asset exists yet. Progress is shown as a percentage, and the result applies automatically after success without a page reload. Switching back to Low Precision only changes the local display preference.
|
||||
|
||||
### Collection Management
|
||||
|
||||
`/collection-management` is also under Operations and Configuration and owns the collection lifecycle:
|
||||
|
||||
- **Collectors**: endpoint, headers, credentials, timeout, retry, and connection checks.
|
||||
- **Collection Scheduling**: the existing scheduling configuration.
|
||||
- **Collection History / Snapshots**: a placeholder for future collection task, snapshot, and collected-data browsing.
|
||||
|
||||
### SMTP Email Settings
|
||||
|
||||
@@ -204,7 +229,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## Data Exploration
|
||||
|
||||
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; 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. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. 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
|
||||
- `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; 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. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/collection-management -> Collectors`. 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
|
||||
|
||||
@@ -243,13 +243,12 @@ uv run pytest backend/tests/test_otp_service.py
|
||||
|
||||
## Earth Boundary PMTiles Operations
|
||||
|
||||
1. In Collector Settings, configure endpoints, headers/auth, `config.target_schema=earth_boundary_source`, license, and mapping for `Earth Admin-0 Boundaries`, `Earth Coastline`, and `Earth Claim Lines`.
|
||||
2. In the data-source console, collect those three sources first. Each successful source writes the full artifact to `data/earth-boundary-sources/<collector>/<sha256>.*` and stores sha256, feature count, artifact path, and sample properties.
|
||||
3. After all three sources succeed, collect `Earth PMTiles Builder`. If any source is missing, it fails as "not ready" and does not update Earth boundaries.
|
||||
4. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools fail the task with a clear message.
|
||||
5. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest.
|
||||
6. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy.
|
||||
7. If PMTiles loading fails, Earth reports a boundary-layer error and does not draw legacy low-precision borders. Troubleshoot in this order: browser Network range requests for PMTiles, manifest `tileProvider: "pmtiles-mvt"`, Nginx static serving for `.pmtiles`, and artifact path / sha256 consistency with the manifest.
|
||||
1. In the console, open `Operations and Configuration -> Earth Content -> Boundary Precision` to save boundary source configuration. The local config is written to `config/earth-boundary-sources.local.json`; do not commit it.
|
||||
2. Click "Build high precision boundaries", or switch the Earth toolbar settings gear to High Precision for the first build. The backend downloads the three source packages to `data/earth-boundary-sources/`, writes the source manifest, and invokes the PMTiles build script.
|
||||
3. The builder requires `tippecanoe` and `pmtiles` on PATH. Missing tools return a clear API error and do not write data-source collection records.
|
||||
4. A successful production build outputs `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` and its manifest.
|
||||
5. After deployment, open Earth, enable "Border Lines", and inspect China's southeast coast, Taiwan, Hainan, the South China Sea, Zangnan, Kosovo, and Gaza for hover behavior and boundary policy.
|
||||
6. If no high-precision manifest/PMTiles exists locally, Earth uses the bundled `frontend/public/earth/data/countries-admin0.min.geojson` fallback. If high-precision assets exist but tile requests fail, troubleshoot PMTiles range requests, manifest provider, Nginx `.pmtiles` static serving, and sha256 consistency.
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Open the URL your administrator gave you, e.g. `http://planet.example.com`. A lo
|
||||
Entry points are split in two:
|
||||
|
||||
- Public: `/earth` (3D situational view), `/docs` (public documentation)
|
||||
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system configuration)
|
||||
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system settings), `/earth-content` (Earth content), `/collection-management` (collection management)
|
||||
|
||||
## 2. Register
|
||||
|
||||
@@ -32,7 +32,7 @@ The default role is `viewer`: you can sign in but only see public pages. For col
|
||||
|
||||
After landing on the `/admin` dashboard, here's a recommended walk-through:
|
||||
|
||||
1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters
|
||||
4. `/alerts/system`: verify system alerts look right
|
||||
|
||||
@@ -95,9 +95,18 @@ AI 配置页使用的接口:
|
||||
- `POST /api/v1/settings/integrations/ai-provider/connect`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/secrets`
|
||||
- `GET /api/v1/settings/integrations/ai-provider/presets`
|
||||
- `GET /api/v1/settings/ai-prompts`
|
||||
- `PUT /api/v1/settings/ai-prompts/{task_key}`
|
||||
- `POST /api/v1/settings/ai-prompts/{task_key}/reset`
|
||||
|
||||
这些接口都需要用户登录。`secrets` 接口只用于配置页点击显示 key/token 时取回明文,隐藏时前端恢复为脱敏预览。
|
||||
|
||||
`ai-prompts` 接口用于运维配置页的“提示词”Tab。默认提示词来自后端随发布包携带的版本化资源,业务代码只引用稳定 task key;接口只保存运维覆盖值。重置时删除覆盖值并恢复当前发布包中的缺省提示词。
|
||||
|
||||
### 提示词边界
|
||||
|
||||
`aiprovider` 是纯模型适配器,不注入通用业务 system prompt。新闻汉化、告警研判、BGP 简报、位置 factcheck、数据源映射和凭据教程等入口各自通过 task key 解析有效提示词。告警研判 prompt 只会在告警相关 task 中作为 system prompt 传入,不会污染其它 LLM 调用。
|
||||
|
||||
### AI Provider 内部 API
|
||||
|
||||
仅供内部调用的接口:
|
||||
@@ -287,7 +296,6 @@ SERVICE_VERSION=0.1.0
|
||||
AI_PROVIDER_SERVICE_TOKEN=change_me
|
||||
AI_TIMEOUT_SECONDS=60
|
||||
AI_HTTP_RETRY_ATTEMPTS=2
|
||||
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
|
||||
```
|
||||
|
||||
可选 provider 专属 key:
|
||||
|
||||
@@ -87,16 +87,10 @@ async def run(self, db):
|
||||
| Space-Track TLE | satellite_tle | 卫星轨道 TLE 数据 | 依采集器配置 |
|
||||
| BarentsWatch AIS | vessel | 船只位置、航速、航向、MMSI 等 AIS 数据 | 依采集器配置 |
|
||||
| AISStream Vessels | vessel_ais | AIS WebSocket 实时流,写入原始观测层并由聚合接口展示 | 依采集器配置 |
|
||||
| Earth Admin-0 Boundaries | earth_admin0_boundaries | 从配置 endpoint 下载国家级边界源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 |
|
||||
| Earth Coastline | earth_coastline | 从配置 endpoint 下载海岸线源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 |
|
||||
| Earth Claim Lines | earth_claim_lines | 从配置 endpoint 下载主张线源,保存 artifact 并写入 `earth_boundary_source` manifest 记录 | 依采集器配置 |
|
||||
| Earth PMTiles Builder | earth_boundary_tiles | 读取三类 Earth 边界源采集结果并构建 / 登记 PMTiles 产物 | 依采集器配置 |
|
||||
|
||||
AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。
|
||||
|
||||
Earth 边界现在拆成三个真实源采集器和一个下游构建器。`earth_admin0_boundaries`、`earth_coastline`、`earth_claim_lines` 都读取后台 Collector Settings 里的 endpoint、headers、auth 和 `config.target_schema=earth_boundary_source`,点击采集时会真实请求 endpoint,保存完整响应到 `data/earth-boundary-sources/<collector>/<sha256>.*`,并在 `CollectedData` 中写入 sha256、feature count、license、artifact path、sample properties 和 mapping 信息。
|
||||
|
||||
`earth_boundary_tiles` 不再代表源数据采集。它只读取上述三类源的最新成功记录;缺任一源时任务失败并显示“未就绪”,不会登记“4 条高精度瓦片”。三类源齐全后,它会调用 `tippecanoe` / `pmtiles` 生成 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`;本机缺少这些工具时任务失败并说明缺失工具。国界不再有旧低精度兜底。
|
||||
Earth 国界不再属于采集器体系。它是 Earth 静态渲染资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback,不会向 `CollectedData` 写入国界记录。
|
||||
|
||||
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
|
||||
|
||||
@@ -232,7 +226,7 @@ if datasource.last_status == "success":
|
||||
)
|
||||
```
|
||||
|
||||
这个记录用于控制台“采集器设置”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时,才需要重新验证。
|
||||
这个记录用于控制台“采集管理 -> 采集器”中的连接状态判断:如果当前配置和成功采集时的 checksum 一致,就视为已连接,不要求用户再手动点击连接按钮。只有 endpoint、请求头、基础配置或凭证指纹变化时,才需要重新验证。
|
||||
|
||||
相关实现见:
|
||||
|
||||
@@ -273,8 +267,8 @@ backend/app/models/
|
||||
|
||||
| 采集器 | credential provider | 凭证来源 |
|
||||
| --- | --- | --- |
|
||||
| `barentswatch_vessels` | `barentswatch` | 控制台采集器设置、环境变量、`~/.zshrc` |
|
||||
| `aisstream_vessels` | `aisstream` | 控制台采集器设置、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到采集器设置或注入后端环境) |
|
||||
| `barentswatch_vessels` | `barentswatch` | 控制台“采集管理 -> 采集器”、环境变量、`~/.zshrc` |
|
||||
| `aisstream_vessels` | `aisstream` | 控制台“采集管理 -> 采集器”、环境变量、`~/.zshrc`(连接验证可读;正式采集建议保存到“采集管理 -> 采集器”或注入后端环境) |
|
||||
| `spacetrack_tle` | `spacetrack` | 环境变量、`~/.zshrc` |
|
||||
|
||||
### BarentsWatch AIS
|
||||
@@ -333,7 +327,7 @@ AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默
|
||||
- `reconnecting`:上游断开或网络异常,采集器记录 `AISSourceHealth` 后等待重连。
|
||||
- `stopped` / `cancelled`:任务被测试上限或用户停止。
|
||||
|
||||
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
|
||||
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“采集管理 -> 采集器 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
|
||||
|
||||
控制台通过 `/datasources -> 实时流` 管理 AISStream,而不是把它放进普通有限采集任务的进度条。实时流 API 会聚合运行态、健康状态、配置摘要和 raw observation 计数:
|
||||
|
||||
@@ -403,7 +397,7 @@ GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&l
|
||||
|
||||
## 十、采集器设置与连接验证
|
||||
|
||||
控制台的“采集器设置”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum:
|
||||
控制台的“采集管理 -> 采集器”页提供所有内置采集器的 endpoint、请求头、超时、重试和凭证配置。连接验证不是只看前端按钮状态,而是由后端计算 checksum:
|
||||
|
||||
- endpoint
|
||||
- auth type
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
- 展示所有数据源,包括内置和自定义。
|
||||
- 点击名称只打开信息抽屉。
|
||||
- 负责查看状态、触发采集和查看采集中任务。
|
||||
- `/settings?tab=collector_credentials`
|
||||
- 显示为“采集器设置”。
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- 显示为“采集器”。
|
||||
- 负责 endpoint、请求头、基础参数和凭证配置。
|
||||
- 所有采集器都提供连接按钮,用于健康检查。
|
||||
|
||||
这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器设置”,而不是散落在数据源列表和系统设置多个入口里。
|
||||
这样做是为了减少首次使用时的认知分裂:接口地址、请求头、凭证和自定义源配置都属于“采集器”,而不是散落在数据源列表和系统设置多个入口里。
|
||||
|
||||
## 用户侧规则
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
`data-source-bulk-toolbar__running-pill` 是“采集中”标签的样式入口。它和其他状态标签同排,但通过 hover、箭头和蓝色描边表达可交互性。
|
||||
|
||||
### 采集器设置
|
||||
### 采集器
|
||||
|
||||
文件:
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
当前行为:
|
||||
|
||||
- `collector_credentials` tab 展示为“采集器设置”。
|
||||
- `collector_credentials` tab 在 `/collection-management` 下展示为“采集器”。
|
||||
- 下拉框列出内置采集器,并支持维护合并到内置数据的自定义补充源。
|
||||
- 下拉框右侧只有一个插头图标按钮,用于健康检查。
|
||||
- 下拉框下方用状态标签展示:
|
||||
@@ -309,7 +309,7 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=
|
||||
|
||||
自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。
|
||||
|
||||
Earth 高精度边界使用同一套目标 schema 机制。`earth_boundary_source` 承接 `earth_admin0_boundaries`、`earth_coastline`、`earth_claim_lines` 三类源的映射结果;完整 GeoJSON / JSON 原文保存为 artifact,数据库只保存 source kind、sha256、feature count、license、artifact path 和 sample properties,避免把大型几何塞进单行记录。
|
||||
Earth 高精度边界不再使用自定义源目标 schema。国界是 Earth 静态资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存本机源配置并触发 PMTiles 构建,不写入 `CollectedData`。
|
||||
|
||||
### 配置语义
|
||||
|
||||
@@ -320,7 +320,7 @@ Earth 高精度边界使用同一套目标 schema 机制。`earth_boundary_sourc
|
||||
- `auth_type`:`none`、`bearer`、`api_key`、`basic`。
|
||||
- `headers`:静态请求头。
|
||||
- `auth_config`:token、API key、basic 用户名密码,API key 支持 header 或 query。
|
||||
- `config.target_schema`:例如 `vessel_ais` 或 `earth_boundary_source`。
|
||||
- `config.target_schema`:例如 `vessel_ais`、`geo_points` 或 `generic_records`。
|
||||
- `config.delivery_mode`:REST 默认 `polling`,WebSocket 默认 `realtime_stream`。
|
||||
- `config.merge_target_source`:记录该自定义源补充哪个内置数据,例如 `barentswatch_vessels`。
|
||||
|
||||
|
||||
@@ -426,6 +426,7 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
- 图层控制开关:`地形 / 卫星 / 海缆 / BGP`
|
||||
- 卫星显示偏好:`卫星显示风格 / 卫星呼吸闪烁 / 真实卫星高度 / 轨迹显示`
|
||||
- 地表 hover 提示偏好:`国家 / 位置 / 完整`
|
||||
- 国界精度偏好:低精 fallback / 高精 PMTiles
|
||||
- 地形透明度
|
||||
|
||||
也就是说,Earth 设置不是一次性 UI 状态了,而是本地设备级偏好。后续如果再加入新的设置项,应优先接入同一条持久化链,而不是各自散着写 `localStorage`。
|
||||
@@ -436,6 +437,8 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
|
||||
SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一圈惯性轨道投到当前地球姿态上显示,因此会闭合,并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。
|
||||
|
||||
国界精度偏好独立存储在 `country-boundaries.js` 的 `planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles,也继续加载低精 `countries-admin0.min.geojson` fallback;开启高精但高精产物缺失时,Earth 工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮;如果鼠标同时命中卫星、船只、BGP 等 interactable,tooltip 显示 interactable 信息,但国界高亮不应闪烁。
|
||||
|
||||
## 当前地形链路
|
||||
|
||||
真实地形首次启用会慢,原因不只是一个:
|
||||
|
||||
@@ -84,9 +84,10 @@
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 必需的生产 PMTiles manifest;缺失即报错 |
|
||||
| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"pmtiles-mvt"` | 国界只接受 PMTiles/MVT |
|
||||
| 国界瓦片 manifest | `COUNTRY_BOUNDARY_CONFIG.tileManifestPath` | `"/earth/data/boundaries/v1/manifest.json"` | 高精 PMTiles manifest;缺失时使用低精度 fallback |
|
||||
| 国界瓦片 provider | `COUNTRY_BOUNDARY_CONFIG.tileProvider` | `"auto"` | 优先高精 PMTiles/MVT,缺失时降级到 legacy GeoJSON |
|
||||
| PMTiles 产物路径 | `COUNTRY_BOUNDARY_CONFIG.pmtilesPath` | `"/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles"` | 生产单文件 PMTiles/MVT artifact |
|
||||
| 低精度 fallback | `COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath` | `"/earth/data/countries-admin0.min.geojson"` | 本地未构建高精国界时的缺省海陆基座和 hover 数据 |
|
||||
| MVT 图层名 | `COUNTRY_BOUNDARY_CONFIG.mvtLayerNames` | `boundary_admin0 / boundary_disputed_internal / coastline / claim_line` | PMTiles provider 解码时读取的固定 layer 名 |
|
||||
| 国界瓦片基础路径 | `COUNTRY_BOUNDARY_CONFIG.tileBasePath` | `"/earth/data/boundaries/v1/"` | PMTiles manifest 基础路径 |
|
||||
| 国界瓦片缩放阈值 | `COUNTRY_BOUNDARY_CONFIG.tileZoomThresholds` | `1.6 -> z5`, `2.8 -> z6`, `3.4 -> z7`, `4.0 -> z8`, `4.6 -> z9`, `5.2 -> z10` | 生产 PMTiles zoom 选择 |
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
|
||||
## 采集器配置方式
|
||||
|
||||
`news_live_streams` 不需要单独新页面,直接复用控制台 `/settings` 的“采集器设置”:
|
||||
`news_live_streams` 不需要单独新页面,直接复用控制台 `/collection-management` 的“采集器”配置:
|
||||
|
||||
- `endpoint`
|
||||
- 频道目录 JSON API 地址
|
||||
|
||||
@@ -296,7 +296,7 @@ USB 摄像头透传到 WSL 属于高级路径;脚本不会默认把无摄像
|
||||
|
||||
### 采集器连接验证通过,但正式采集拿不到凭证怎么办?
|
||||
|
||||
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“设置 -> 采集器设置”,尤其是 AISStream 这类长连接 collector。
|
||||
连接验证会读取控制台保存配置、环境变量和部分 `~/.zshrc` 凭证。正式采集更推荐把凭证保存到“采集管理 -> 采集器”,尤其是 AISStream 这类长连接 collector。
|
||||
|
||||
如果只把 `AISSTREAM_API_KEY` 放在 `~/.zshrc`,需要确认后端进程实际继承了该变量。否则可能出现连接验证可用,但 collector 运行时没有 key 的情况。
|
||||
|
||||
@@ -310,7 +310,7 @@ export BARENTSWATCH_CLIENT_ID="..."
|
||||
export BARENTSWATCH_CLIENT_SECRET="..."
|
||||
```
|
||||
|
||||
稳定运行时,优先在控制台采集器设置中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
|
||||
稳定运行时,优先在控制台“采集管理 -> 采集器”中保存凭证,保证连接验证、采集任务和 Earth 实时聚合使用同一份配置。
|
||||
|
||||
## Docs / 权限
|
||||
|
||||
@@ -325,6 +325,17 @@ Docs 按 Gatekeeper 权限组控制可见性:
|
||||
|
||||
## Earth 常见操作
|
||||
|
||||
### 为什么国界提示“未配置 endpoint”或只能看到低精度?
|
||||
|
||||
国界已经从采集器体系移出,不再通过数据源采集任务生成。低精度国界是随前端打包的兜底资产,本地未构建高精 PMTiles 时会自动使用它。
|
||||
|
||||
要启用高精国界,有两条入口:
|
||||
|
||||
- Earth 页面齿轮设置里的“国界精度”:切到“高精”会启动首次后台下载/构建,并显示百分比,完成后自动应用。
|
||||
- 控制台 `运维与配置 -> Earth 内容 -> 国界精度`:适合查看 provider、manifest、PMTiles、fallback 状态,编辑源配置 JSON,或手动重建。
|
||||
|
||||
如果看到“更新源未配置完整”,先到 `Earth 内容 -> 国界精度` 保存源配置;本机私有配置写入 `config/earth-boundary-sources.local.json`,不要提交到仓库。没有高精产物时,使用低精 fallback 是正常行为。
|
||||
|
||||
### Earth 位置候选采集后没有写入怎么办?
|
||||
|
||||
“采集候选”和“保存候选”是两步。候选可以先在 Earth 上预览,只有点击保存或使用待定位列表中的“一键采用”后,才会写入维表并刷新图层。
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
- `/alerts/situational`
|
||||
- `/bgp`
|
||||
- `/ai`
|
||||
- `/earth-content`
|
||||
- `/collection-management`
|
||||
- `/settings`
|
||||
|
||||
`/earth` 是独立展示页,不属于控制台骨架。
|
||||
@@ -288,9 +290,9 @@
|
||||
- 点击名称打开只读抽屉。
|
||||
- 抽屉中明确显示“内置数据源”或“自定义数据源”。
|
||||
- endpoint、headers、config 只展示,不在这里编辑。
|
||||
- 需要凭证的采集器提示用户到“设置 -> 采集器设置”维护。
|
||||
- 需要凭证的采集器提示用户到“采集管理 -> 采集器”维护。
|
||||
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/settings?tab=collector_credentials`。
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?tab=collector_credentials`。
|
||||
|
||||
页面顶部的总进度区域新增 `采集中 N` 标签:
|
||||
|
||||
@@ -303,7 +305,7 @@
|
||||
|
||||
### 采集器设置页
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 中的 `collector_credentials` tab 当前显示为“采集器设置”。
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是 Earth 内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。
|
||||
|
||||
当前页面边界:
|
||||
|
||||
@@ -334,6 +336,16 @@
|
||||
|
||||
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
|
||||
|
||||
### Earth 内容页
|
||||
|
||||
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Settings/Settings.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
|
||||
|
||||
- `电视直播` 迁移原直播源配置,继续管理 Earth 媒体面板内容源。
|
||||
- `国界精度` 管理 Earth 静态国界资产:provider 状态、低精 fallback、高精 manifest/PMTiles、源配置 JSON 和构建动作。
|
||||
- `地球底图`、`图层资源`、`三维素材`、`新闻锚点策略` 是占位页,只显示模块待接入,不造假接口或假数据。
|
||||
|
||||
系统级 `/settings` 不应再新增 Earth 体验资源或采集生命周期 tab;采集相关入口属于 `/collection-management`,Earth 展示资源属于 `/earth-content`。
|
||||
|
||||
### 3. 复杂工作区页面
|
||||
|
||||
例如:
|
||||
|
||||
@@ -76,15 +76,17 @@
|
||||
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
|
||||
| 态势告警 | `/alerts/situational` | 态势研判告警 |
|
||||
| AI | `/ai` | 模型供应商、工具、测试台 |
|
||||
| Earth 内容 | `/earth-content` | 电视直播、国界精度、底图和图层资源入口 |
|
||||
| 采集管理 | `/collection-management` | 采集器、采集调度、采集历史入口 |
|
||||
| 系统日志 | `/logs` | 通常仅 super admin 可见 |
|
||||
| 用户管理 | `/users` | 创建/删除/改角色/调权限组 |
|
||||
| 系统配置 | `/settings` | 系统、SMTP、TV、采集器设置 |
|
||||
| 系统设置 | `/settings` | 系统显示、通知、安全、SMTP |
|
||||
|
||||
权限不足时菜单项会自动隐藏。如果发现某个菜单看不到,先确认自己的角色和 Gatekeeper 权限组。
|
||||
|
||||
## 配置数据采集器
|
||||
|
||||
`/settings?tab=collector_credentials` 是"采集器设置"页。这里统一维护所有采集器的连接配置,不仅是凭证。
|
||||
`/collection-management?tab=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证。旧链接 `/settings?tab=collector_credentials` 会自动跳转到这个入口;数据源目录仍保留在 `/datasources`。
|
||||
|
||||
操作步骤:
|
||||
|
||||
@@ -128,7 +130,7 @@
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. `/settings?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
1. `/collection-management?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
2. 在 `AISStream 凭证` 填入 API Key
|
||||
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
|
||||
4. 点击插头图标进行连接测试,确认显示 `可用`
|
||||
@@ -142,10 +144,11 @@
|
||||
|
||||
## 配置 AI 凭证
|
||||
|
||||
`/ai?tab=providers` 是 AI 模型管理入口。包含两个核心子 tab:
|
||||
`/ai?tab=providers` 是 AI 模型管理入口。包含三个核心子 tab:
|
||||
|
||||
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
|
||||
- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
|
||||
- `提示词`:通过功能入口下拉菜单选择新闻汉化、告警研判、BGP 简报等 LLM 任务,手动调整提示词或重置为缺省
|
||||
|
||||
### 模型供应商
|
||||
|
||||
@@ -166,6 +169,10 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
- **WebSearch**:provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰
|
||||
- **OCR**:provider、Base URL、API Key、模型/engine、识别语言、超时、最大文件大小、输出格式
|
||||
|
||||
### 提示词
|
||||
|
||||
选择功能入口后,页面会显示当前提示词、是否已自定义、缺省版本和重置按钮。保存只影响该功能入口;重置会恢复当前发布包中的缺省提示词。业务事实、上下文和输出 schema 仍由后端按功能入口自动传入。
|
||||
|
||||
旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`。
|
||||
|
||||
## 系统设置
|
||||
@@ -176,8 +183,26 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
- **通知策略**:告警邮件开关、收件邮箱、严重/警告/每日摘要通知
|
||||
- **安全策略**:会话超时、最大登录尝试、密码策略
|
||||
- **SMTP 邮件**:注册和找回密码所需的发件配置(仅 `admin` / `super_admin` 可见)
|
||||
- **电视直播**:电视直播源管理
|
||||
- **AI / WebSearch / OCR**:见上节
|
||||
|
||||
电视直播和国界精度已经移到 `/earth-content`,采集器和采集调度已经移到 `/collection-management`,AI Provider / WebSearch / OCR 在 `/ai`。
|
||||
|
||||
### Earth 内容
|
||||
|
||||
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
|
||||
|
||||
- **电视直播**:维护 Earth 媒体面板里的直播源。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
|
||||
|
||||
### 采集管理
|
||||
|
||||
`/collection-management` 位于控制台“运维与配置”下,面向采集生命周期:
|
||||
|
||||
- **采集器**:维护 endpoint、请求头、凭证、timeout、retry,并运行连接检查。
|
||||
- **采集调度**:维护原有调度相关设置。
|
||||
- **采集历史 / 快照**:当前是待接入占位页,后续承载 collection task、snapshot、collected data 浏览能力。
|
||||
|
||||
### SMTP 邮件设置
|
||||
|
||||
@@ -207,7 +232,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
## 数据探索
|
||||
|
||||
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
|
||||
- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警
|
||||
|
||||
@@ -243,13 +243,12 @@ uv run pytest backend/tests/test_otp_service.py
|
||||
|
||||
## Earth 国界 PMTiles 操作步骤
|
||||
|
||||
1. 在后台 Collector Settings 中分别配置 `Earth Admin-0 国界源`、`Earth 海岸线源`、`Earth 主张线源` 的 endpoint、headers/auth、`config.target_schema=earth_boundary_source`、license 和 mapping。
|
||||
2. 在数据源页依次采集这三个源。每个源成功后会保存完整 artifact 到 `data/earth-boundary-sources/<collector>/<sha256>.*`,并写入 sha256、feature count、artifact path、sample properties。
|
||||
3. 三个源都成功后,再采集 `Earth PMTiles 构建器`。缺任一源时它会失败为“未就绪”,不会更新 Earth 国界。
|
||||
4. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时任务失败并显示缺失工具。
|
||||
5. 生产构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。
|
||||
6. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。
|
||||
7. 如果 PMTiles 加载失败,Earth 会报国界图层错误并且不绘制旧低精度国界。排查顺序是:浏览器 Network 是否有 PMTiles range 请求、manifest 的 `tileProvider` 是否为 `pmtiles-mvt`、Nginx 是否能静态返回 `.pmtiles`、artifact 路径和 sha256 是否与 manifest 一致。
|
||||
1. 在控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存国界源配置;本机配置写入 `config/earth-boundary-sources.local.json`,不要提交。
|
||||
2. 点击“构建高精国界”,或在 Earth 页面工具栏齿轮中切到“高精”触发首次构建。后端会下载三类源到 `data/earth-boundary-sources/`,生成 source manifest,并调用 PMTiles 构建脚本。
|
||||
3. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时接口返回明确错误,不会写入数据源采集记录。
|
||||
4. 构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。
|
||||
5. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。
|
||||
6. 如果本地没有高精 manifest/PMTiles,Earth 会使用 `frontend/public/earth/data/countries-admin0.min.geojson` 低精度 fallback;如果高精产物存在但瓦片请求失败,按 PMTiles range 请求、manifest provider、Nginx `.pmtiles` 静态返回和 sha256 一致性排查。
|
||||
|
||||
## 相关文档
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
入口分两类:
|
||||
|
||||
- 公开页:`/earth`(3D 态势)、`/docs`(公共文档)
|
||||
- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统配置)
|
||||
- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统设置)、`/earth-content`(Earth 内容)、`/collection-management`(采集管理)
|
||||
|
||||
## 2. 注册账号
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
|
||||
|
||||
1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.58.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.59.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.59.0` | feature | `dev` | `pending` | Earth 国界迁移为静态资产并恢复低精 fallback,新增工具栏高精构建进度、后台 Earth 内容/采集管理拆分、AI task prompt 管理和新闻锚点队列补丁链路 |
|
||||
| `0.58.0` | feature | `dev` | `pending` | Earth 高精度国界切换到 PMTiles/MVT 和标准源采集器,移除旧低精度兜底,修复远距地表 z-fighting 雪花/黑块,并补齐新闻目标地点队列与文档 |
|
||||
| `0.57.0` | feature | `dev` | `pending` | WSL `--allow-lan` 新增临时 Windows relay,保持 localhost 与局域网同用 3000/8000,并自动处理旧 portproxy、防火墙授权和 Vite ESM 配置 |
|
||||
| `0.56.0` | feature | `dev` | `pending` | 修复 Earth 卫星 ECI/TEME 到 ECF 坐标转换和闭合预测轨道,调校真实高度压缩上限,统一 BGP 光晕色调,并更新超算图标与 Earth HUD/新闻体验 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.58.0",
|
||||
"version": "0.59.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1309,23 +1309,44 @@
|
||||
}
|
||||
|
||||
.earth-mobile-settings-segmented {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
--item-count: 2;
|
||||
--active-index: 0;
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(var(--item-count), minmax(0, 1fr));
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-segmented::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: calc((100% - 8px) / var(--item-count));
|
||||
border-radius: 999px;
|
||||
background: rgba(122, 180, 255, 0.16);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
transform: translateX(calc(var(--active-index) * 100%));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-pill {
|
||||
border: 1px solid rgba(212, 227, 244, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.earth-mobile-settings-pill.is-active {
|
||||
color: var(--hud-title);
|
||||
border-color: rgba(122, 180, 255, 0.24);
|
||||
background: rgba(122, 180, 255, 0.14);
|
||||
}
|
||||
|
||||
.earth-mobile-settings-chip-group {
|
||||
@@ -2256,7 +2277,11 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
}
|
||||
|
||||
.earth-settings-segmented {
|
||||
display: inline-flex;
|
||||
--item-count: 2;
|
||||
--active-index: 0;
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(var(--item-count), minmax(0, 1fr));
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
border-radius: 999px;
|
||||
@@ -2265,10 +2290,30 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(212, 227, 244, 0.08);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.earth-settings-segmented::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
width: calc((100% - 8px) / var(--item-count));
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
transform: translateX(calc(var(--active-index) * 100%));
|
||||
transition: transform 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--hud-text-soft);
|
||||
@@ -2280,9 +2325,7 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -2293,12 +2336,73 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
|
||||
.earth-settings-segmented-btn.is-active {
|
||||
color: var(--hud-title);
|
||||
}
|
||||
|
||||
.earth-settings-segmented-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.54;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-boundary-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-settings-segmented--boundary {
|
||||
flex: 0 1 auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(30px * var(--hud-scale));
|
||||
height: calc(30px * var(--hud-scale));
|
||||
border: 1px solid rgba(122, 180, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 8px 18px rgba(0, 0, 0, 0.2);
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.18), rgba(72, 101, 139, 0.24));
|
||||
color: var(--hud-title);
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action--text {
|
||||
width: auto;
|
||||
min-width: calc(46px * var(--hud-scale));
|
||||
padding: 0 calc(12px * var(--hud-scale));
|
||||
font: inherit;
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(125, 197, 255, 0.46);
|
||||
}
|
||||
|
||||
.earth-settings-reload-action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.52;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action .material-symbols-rounded {
|
||||
font-size: calc(1rem * var(--hud-scale));
|
||||
}
|
||||
|
||||
.earth-settings-chip-group {
|
||||
@@ -2432,6 +2536,41 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-boundary-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.earth-boundary-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.earth-boundary-progress__track {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(135, 162, 190, 0.26);
|
||||
}
|
||||
|
||||
.earth-boundary-progress__bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #55d6be, #7cb7ff);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.earth-boundary-progress__value {
|
||||
min-width: 40px;
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.earth-settings-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
1
frontend/public/earth/data/countries-admin0.min.geojson
Normal file
File diff suppressed because one or more lines are too long
@@ -1420,6 +1420,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">国界精度</div>
|
||||
<div class="earth-settings-list">
|
||||
<div class="earth-settings-item earth-settings-item--stacked">
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title" id="boundary-precision-status">国界精度</span>
|
||||
<span class="earth-settings-item-subtitle" id="boundary-precision-detail">正在读取高清国界状态...</span>
|
||||
</div>
|
||||
<div id="boundary-precision-progress-wrap" class="earth-boundary-progress" hidden>
|
||||
<div class="earth-boundary-progress__track">
|
||||
<div id="boundary-precision-progress-bar" class="earth-boundary-progress__bar"></div>
|
||||
</div>
|
||||
<span id="boundary-precision-progress-value" class="earth-boundary-progress__value">0%</span>
|
||||
</div>
|
||||
<div class="earth-settings-boundary-action-row">
|
||||
<div class="earth-settings-segmented earth-settings-segmented--boundary" role="group" aria-label="选择国界精度">
|
||||
<button id="boundary-precision-disable" type="button" class="earth-settings-segmented-btn is-active" aria-pressed="true">低精</button>
|
||||
<button id="boundary-precision-build" type="button" class="earth-settings-segmented-btn" aria-pressed="false">高精</button>
|
||||
</div>
|
||||
<button id="boundary-precision-rebuild" type="button" class="earth-settings-reload-action" aria-label="重新获取并构建高清国界" title="重新获取并构建" hidden disabled>
|
||||
<span class="material-symbols-rounded">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="earth-settings-section">
|
||||
<div class="earth-settings-section-title">系统</div>
|
||||
<div class="earth-settings-list">
|
||||
|
||||
@@ -190,6 +190,7 @@ export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
tileBasePath: new URL("../data/boundaries/v1/", import.meta.url).href,
|
||||
tileProvider: "auto",
|
||||
pmtilesPath: new URL("../data/boundaries/earth-boundaries-china-pov-v1.pmtiles", import.meta.url).href,
|
||||
legacyFallbackPath: new URL("../data/countries-admin0.min.geojson", import.meta.url).href,
|
||||
mvtLayerNames: {
|
||||
boundary: ["boundary_admin0", "boundary_disputed_internal", "coastline"],
|
||||
claim: ["claim_line"],
|
||||
|
||||
223
frontend/public/earth/js/controls.js
vendored
223
frontend/public/earth/js/controls.js
vendored
@@ -34,6 +34,7 @@ import {
|
||||
} from "./terrain.js";
|
||||
import {
|
||||
reloadData,
|
||||
reloadCountryBoundaries,
|
||||
clearLockedObject,
|
||||
clearLockedObjectAndInfo,
|
||||
setCablesEnabled,
|
||||
@@ -64,7 +65,12 @@ import {
|
||||
} from "./interactable.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
import { getShowCountryBoundaries, toggleCountryBoundaries } from "./country-boundaries.js";
|
||||
import {
|
||||
getHighPrecisionBoundariesEnabled,
|
||||
getShowCountryBoundaries,
|
||||
setHighPrecisionBoundariesEnabled,
|
||||
toggleCountryBoundaries,
|
||||
} from "./country-boundaries.js";
|
||||
import {
|
||||
toggleComputeCenters,
|
||||
getShowComputeCenters,
|
||||
@@ -115,6 +121,8 @@ let motionProvider = DEFAULT_MOTION_PROVIDER;
|
||||
let motionDebugSkeletonOnly = false;
|
||||
let activeCamera = null;
|
||||
let settingsApplyPromise = Promise.resolve();
|
||||
let boundaryBuildPollTimer = null;
|
||||
let boundaryBuildAttemptedThisSession = false;
|
||||
|
||||
let earthObj = null;
|
||||
let listeners = [];
|
||||
@@ -1076,6 +1084,7 @@ function syncMotionProviderControls(nextProvider = motionProvider) {
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
syncSegmentedControlSliders();
|
||||
}
|
||||
|
||||
function syncMotionDebugSkeletonOnlyToggle(nextEnabled = motionDebugSkeletonOnly) {
|
||||
@@ -1206,6 +1215,16 @@ function syncCruiseModuleControls() {
|
||||
});
|
||||
}
|
||||
|
||||
function syncSegmentedControlSliders() {
|
||||
document.querySelectorAll(".earth-settings-segmented, .earth-mobile-settings-segmented").forEach((segmented) => {
|
||||
if (!(segmented instanceof HTMLElement)) return;
|
||||
const buttons = Array.from(segmented.querySelectorAll(".earth-settings-segmented-btn, .earth-mobile-settings-pill"));
|
||||
const activeIndex = Math.max(0, buttons.findIndex((button) => button.classList.contains("is-active")));
|
||||
segmented.style.setProperty("--item-count", String(Math.max(1, buttons.length)));
|
||||
segmented.style.setProperty("--active-index", String(activeIndex));
|
||||
});
|
||||
}
|
||||
|
||||
function syncSatelliteDisplayStyleControls() {
|
||||
const activeStyle = getSatelliteDisplayStyle();
|
||||
document.querySelectorAll("[data-satellite-display-style]").forEach((button) => {
|
||||
@@ -1215,6 +1234,7 @@ function syncSatelliteDisplayStyleControls() {
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
syncSegmentedControlSliders();
|
||||
}
|
||||
|
||||
function syncSatelliteIdleBreathingToggle() {
|
||||
@@ -1253,6 +1273,7 @@ function syncSurfaceHoverInfoModeControls() {
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
syncSegmentedControlSliders();
|
||||
}
|
||||
|
||||
export function getCruiseModules() {
|
||||
@@ -2694,6 +2715,204 @@ export function setDayNightInteractable(enabled) {
|
||||
});
|
||||
}
|
||||
|
||||
function getBoundaryPrecisionEls() {
|
||||
return {
|
||||
status: document.getElementById("boundary-precision-status"),
|
||||
detail: document.getElementById("boundary-precision-detail"),
|
||||
progressWrap: document.getElementById("boundary-precision-progress-wrap"),
|
||||
progressBar: document.getElementById("boundary-precision-progress-bar"),
|
||||
progressValue: document.getElementById("boundary-precision-progress-value"),
|
||||
buildButton: document.getElementById("boundary-precision-build"),
|
||||
rebuildButton: document.getElementById("boundary-precision-rebuild"),
|
||||
disableButton: document.getElementById("boundary-precision-disable"),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBoundaryPrecisionJson(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
cache: "no-store",
|
||||
...options,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!response.ok) {
|
||||
let detail = `HTTP ${response.status}`;
|
||||
if (contentType.includes("application/json")) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
detail = payload?.detail?.message || payload?.detail || detail;
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (!contentType.includes("application/json")) {
|
||||
throw new Error("后端没有返回 JSON 状态");
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function renderBoundaryPrecisionStatus(payload = {}) {
|
||||
const els = getBoundaryPrecisionEls();
|
||||
const job = payload.job || payload.current_job || {};
|
||||
const highReady = Boolean(payload.high_precision_ready || job?.result?.high_precision_ready);
|
||||
const enabled = getHighPrecisionBoundariesEnabled();
|
||||
const running = job.status === "queued" || job.status === "running";
|
||||
const failed = job.status === "failed" && boundaryBuildAttemptedThisSession;
|
||||
const progress = Math.max(0, Math.min(100, Number(job.progress || 0)));
|
||||
const failureMessage = job.code === "source_not_configured" || job.code === "missing_sources"
|
||||
? `高清国界更新源未配置完整:${job.message || job.code}`
|
||||
: `高清国界下载失败:${job.message || job.code || "请检查更新源"}`;
|
||||
|
||||
if (els.status) {
|
||||
els.status.textContent = "国界精度";
|
||||
}
|
||||
if (els.detail) {
|
||||
els.detail.textContent = running
|
||||
? (job.message || "正在准备高清国界")
|
||||
: failed
|
||||
? failureMessage
|
||||
: highReady
|
||||
? (enabled ? "当前使用高精国界;可重新获取并构建。" : "高精国界已就绪,切到高精会立即应用。")
|
||||
: "当前使用低精国界;切到高精会下载并构建。";
|
||||
}
|
||||
if (els.progressWrap) {
|
||||
els.progressWrap.hidden = !running;
|
||||
}
|
||||
if (els.progressBar) {
|
||||
els.progressBar.style.width = `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
|
||||
}
|
||||
if (els.progressValue) {
|
||||
els.progressValue.textContent = job.status === "failed"
|
||||
? `失败:${job.message || job.code || "构建失败"}`
|
||||
: `${running || job.status === "succeeded" ? progress || 100 : progress}%`;
|
||||
}
|
||||
if (els.buildButton instanceof HTMLButtonElement) {
|
||||
els.buildButton.disabled = running;
|
||||
els.buildButton.textContent = "高精";
|
||||
els.buildButton.classList.toggle("is-active", enabled || running);
|
||||
els.buildButton.setAttribute("aria-pressed", enabled || running ? "true" : "false");
|
||||
}
|
||||
if (els.rebuildButton instanceof HTMLButtonElement) {
|
||||
const shouldShowRebuild = highReady && enabled && !running;
|
||||
els.rebuildButton.hidden = !shouldShowRebuild;
|
||||
els.rebuildButton.disabled = !shouldShowRebuild;
|
||||
}
|
||||
if (els.disableButton instanceof HTMLButtonElement) {
|
||||
els.disableButton.disabled = running;
|
||||
els.disableButton.classList.toggle("is-active", !enabled && !running);
|
||||
els.disableButton.setAttribute("aria-pressed", !enabled && !running ? "true" : "false");
|
||||
}
|
||||
syncSegmentedControlSliders();
|
||||
}
|
||||
|
||||
async function refreshBoundaryPrecisionStatus() {
|
||||
const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/status");
|
||||
renderBoundaryPrecisionStatus(payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function stopBoundaryBuildPolling() {
|
||||
if (boundaryBuildPollTimer) {
|
||||
window.clearInterval(boundaryBuildPollTimer);
|
||||
boundaryBuildPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startBoundaryBuildPolling() {
|
||||
stopBoundaryBuildPolling();
|
||||
boundaryBuildPollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const payload = await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build/status");
|
||||
renderBoundaryPrecisionStatus(payload);
|
||||
const status = payload.job?.status;
|
||||
if (status === "succeeded" || status === "failed") {
|
||||
stopBoundaryBuildPolling();
|
||||
await refreshBoundaryPrecisionStatus();
|
||||
if (status === "succeeded" && boundaryBuildAttemptedThisSession) {
|
||||
setHighPrecisionBoundariesEnabled(true);
|
||||
await reloadCountryBoundaries({ suppressStatus: true });
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
showStatusMessage("高精国界已下载并应用", "info");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
stopBoundaryBuildPolling();
|
||||
showStatusMessage(`高清国界进度读取失败:${error.message || error}`, "warning");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function startBoundaryPrecisionBuild() {
|
||||
renderBoundaryPrecisionStatus({
|
||||
job: { status: "queued", progress: 0, message: "正在启动高精国界构建" },
|
||||
});
|
||||
boundaryBuildAttemptedThisSession = true;
|
||||
await fetchBoundaryPrecisionJson("/api/v1/earth/boundaries/build", { method: "POST", body: "{}" });
|
||||
showStatusMessage("高精国界构建已启动", "info");
|
||||
startBoundaryBuildPolling();
|
||||
}
|
||||
|
||||
async function setupBoundaryPrecisionControls() {
|
||||
const els = getBoundaryPrecisionEls();
|
||||
if (!els.buildButton && !els.disableButton) return;
|
||||
try {
|
||||
const payload = await refreshBoundaryPrecisionStatus();
|
||||
const jobStatus = payload.current_job?.status;
|
||||
if (jobStatus === "queued" || jobStatus === "running") {
|
||||
startBoundaryBuildPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
renderBoundaryPrecisionStatus({});
|
||||
showStatusMessage(`高清国界状态读取失败:${error.message || error}`, "warning");
|
||||
}
|
||||
|
||||
if (els.buildButton instanceof HTMLButtonElement) {
|
||||
bindListener(els.buildButton, "click", async () => {
|
||||
try {
|
||||
const statusPayload = await refreshBoundaryPrecisionStatus();
|
||||
const job = statusPayload.job || statusPayload.current_job || {};
|
||||
const highReady = Boolean(statusPayload.high_precision_ready || job?.result?.high_precision_ready);
|
||||
if (highReady) {
|
||||
if (getHighPrecisionBoundariesEnabled()) return;
|
||||
setHighPrecisionBoundariesEnabled(true);
|
||||
await reloadCountryBoundaries({ suppressStatus: true });
|
||||
showStatusMessage("已切换到高精国界", "info");
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
return;
|
||||
}
|
||||
await startBoundaryPrecisionBuild();
|
||||
} catch (error) {
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
showStatusMessage(`高精国界切换失败:${error.message || error}`, "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (els.rebuildButton instanceof HTMLButtonElement) {
|
||||
bindListener(els.rebuildButton, "click", async () => {
|
||||
try {
|
||||
await startBoundaryPrecisionBuild();
|
||||
} catch (error) {
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
showStatusMessage(`高精国界重建启动失败:${error.message || error}`, "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (els.disableButton instanceof HTMLButtonElement) {
|
||||
bindListener(els.disableButton, "click", async () => {
|
||||
try {
|
||||
if (!getHighPrecisionBoundariesEnabled()) return;
|
||||
setHighPrecisionBoundariesEnabled(false);
|
||||
await reloadCountryBoundaries({ suppressStatus: true });
|
||||
showStatusMessage("已切换到低精国界", "info");
|
||||
await refreshBoundaryPrecisionStatus().catch(() => {});
|
||||
} catch (error) {
|
||||
showStatusMessage(`低精国界切换失败:${error.message || error}`, "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupSettingsControls() {
|
||||
const settingsTrigger = document.getElementById("settings-trigger");
|
||||
const settingsClose = document.getElementById("settings-close");
|
||||
@@ -2907,6 +3126,7 @@ function setupSettingsControls() {
|
||||
syncMotionDebugToggle(motionDebugEnabled);
|
||||
syncMotionProviderControls(motionProvider);
|
||||
syncMotionDebugSkeletonOnlyToggle(motionDebugSkeletonOnly);
|
||||
void setupBoundaryPrecisionControls();
|
||||
}
|
||||
|
||||
function setupHudPanelControls() {
|
||||
@@ -4212,6 +4432,7 @@ function syncRotationModeButtons() {
|
||||
button.classList.toggle("is-active", isActive);
|
||||
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
});
|
||||
syncSegmentedControlSliders();
|
||||
}
|
||||
|
||||
function updateRotateUI() {
|
||||
|
||||
@@ -23,6 +23,7 @@ let _lastHoverInfo = null;
|
||||
let _hoverGeometryCache = new Map();
|
||||
let _tileManifest = null;
|
||||
let _tileProvider = "pmtiles-mvt";
|
||||
let _boundaryProviderState = "unloaded";
|
||||
let _pmtilesArchive = null;
|
||||
let _tileCache = new Map();
|
||||
let _tileLru = [];
|
||||
@@ -39,6 +40,32 @@ let _loaded = false;
|
||||
let _loadPromise = null;
|
||||
let _tileAssetVersion = "";
|
||||
|
||||
const HIGH_PRECISION_BOUNDARIES_STORAGE_KEY = "planet.earth.boundaries.highPrecisionEnabled";
|
||||
|
||||
function canUseLocalStorage() {
|
||||
try {
|
||||
return typeof window !== "undefined" && !!window.localStorage;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getHighPrecisionBoundariesEnabled() {
|
||||
if (!canUseLocalStorage()) return false;
|
||||
return window.localStorage.getItem(HIGH_PRECISION_BOUNDARIES_STORAGE_KEY) === "true";
|
||||
}
|
||||
|
||||
export function setHighPrecisionBoundariesEnabled(enabled) {
|
||||
const nextEnabled = Boolean(enabled);
|
||||
if (canUseLocalStorage()) {
|
||||
window.localStorage.setItem(
|
||||
HIGH_PRECISION_BOUNDARIES_STORAGE_KEY,
|
||||
nextEnabled ? "true" : "false",
|
||||
);
|
||||
}
|
||||
return nextEnabled;
|
||||
}
|
||||
|
||||
const OCEAN_HEX = 0x010609;
|
||||
// ─── Equirectangular land/ocean fill texture ──────────────────────────────────
|
||||
|
||||
@@ -346,6 +373,22 @@ function manifestProvider(manifest) {
|
||||
return manifest?.tileProvider || manifest?.format || "pmtiles-mvt";
|
||||
}
|
||||
|
||||
async function fetchJsonAsset(url, { required = false } = {}) {
|
||||
const resp = await fetch(url, { cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
if (required) throw new Error(`${url} HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
if (required) throw err;
|
||||
console.warn("[country-boundaries] JSON asset unavailable", url, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function manifestPmtilesUrl(manifest) {
|
||||
const fromManifest =
|
||||
manifest?.pmtiles?.url ||
|
||||
@@ -545,7 +588,7 @@ function tileKeysForBbox(bbox, zoom, { claim = false } = {}) {
|
||||
|
||||
async function refreshBoundaryTiles({ camera, renderer, earth, viewZoom }) {
|
||||
if (!_loaded || !_visible || !_tileManifest) return;
|
||||
if (_tileProvider === "geojson-high-precision") return;
|
||||
if (_tileProvider !== "pmtiles-mvt") return;
|
||||
const tileZoom = tileZoomForViewZoom(viewZoom);
|
||||
if (!tileZoom) {
|
||||
_lastTileSignature = "";
|
||||
@@ -787,39 +830,54 @@ export async function loadCountryBoundaries() {
|
||||
_loadPromise = (async () => {
|
||||
let geojson = { type: "FeatureCollection", features: [] };
|
||||
let baseGeojson = null;
|
||||
const manifestResp = await fetch(COUNTRY_BOUNDARY_CONFIG.tileManifestPath, { cache: "no-store" });
|
||||
if (!manifestResp.ok) {
|
||||
throw new Error(`高精度国界 PMTiles manifest 未生成 HTTP ${manifestResp.status}`);
|
||||
}
|
||||
_tileManifest = await manifestResp.json();
|
||||
_tileProvider = manifestProvider(_tileManifest);
|
||||
if (!["pmtiles-mvt", "geojson-high-precision"].includes(_tileProvider)) {
|
||||
throw new Error(`高精度国界 provider 不可用: ${_tileProvider}`);
|
||||
}
|
||||
_tileAssetVersion = [
|
||||
_tileManifest.version,
|
||||
_tileManifest.builtAt,
|
||||
_tileManifest.sourceFeatureCount,
|
||||
_tileManifest.pmtiles?.sha256,
|
||||
].filter(Boolean).join("-");
|
||||
if (_tileProvider === "pmtiles-mvt") {
|
||||
const pmtilesUrl = manifestPmtilesUrl(_tileManifest);
|
||||
const pmtilesResp = await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" });
|
||||
if (!pmtilesResp.ok) {
|
||||
throw new Error(`高精度国界 PMTiles 不存在 HTTP ${pmtilesResp.status}: ${pmtilesUrl}`);
|
||||
let claimGeojson = null;
|
||||
const highPrecisionEnabled = getHighPrecisionBoundariesEnabled();
|
||||
const manifest = highPrecisionEnabled
|
||||
? await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.tileManifestPath)
|
||||
: null;
|
||||
if (manifest) {
|
||||
const provider = manifestProvider(manifest);
|
||||
const pmtilesUrl = manifestPmtilesUrl(manifest);
|
||||
const pmtilesResp = provider === "pmtiles-mvt"
|
||||
? await fetch(pmtilesUrl, { method: "HEAD", cache: "no-store" })
|
||||
: null;
|
||||
const highPrecisionReady = (
|
||||
["pmtiles-mvt", "geojson-high-precision"].includes(provider) &&
|
||||
(provider !== "pmtiles-mvt" || pmtilesResp?.ok)
|
||||
);
|
||||
if (highPrecisionReady) {
|
||||
_tileManifest = manifest;
|
||||
_tileProvider = provider;
|
||||
_boundaryProviderState = provider;
|
||||
_tileAssetVersion = [
|
||||
_tileManifest.version,
|
||||
_tileManifest.builtAt,
|
||||
_tileManifest.sourceFeatureCount,
|
||||
_tileManifest.pmtiles?.sha256,
|
||||
].filter(Boolean).join("-");
|
||||
const basePath = _tileManifest.base || _tileManifest.baseGeojson;
|
||||
const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson;
|
||||
if (basePath) baseGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(basePath));
|
||||
if (hoverPath) {
|
||||
geojson = await fetchJsonAsset(versionedBoundaryAssetUrl(hoverPath)) || geojson;
|
||||
}
|
||||
const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path;
|
||||
if (claimPath) claimGeojson = await fetchJsonAsset(versionedBoundaryAssetUrl(claimPath));
|
||||
}
|
||||
}
|
||||
const basePath = _tileManifest.base || _tileManifest.baseGeojson;
|
||||
const hoverPath = _tileManifest.hoverIndex || _tileManifest.hoverIndexGeojson;
|
||||
const baseResp = basePath ? await fetch(versionedBoundaryAssetUrl(basePath)) : null;
|
||||
const hoverResp = hoverPath ? await fetch(versionedBoundaryAssetUrl(hoverPath)) : null;
|
||||
if (hoverResp?.ok) {
|
||||
if (baseResp?.ok) baseGeojson = await baseResp.json();
|
||||
geojson = await hoverResp.json();
|
||||
if (_tileManifest && !(geojson.features || []).length) {
|
||||
console.warn("[country-boundaries] high precision hover index unavailable; using legacy fallback");
|
||||
_tileManifest = null;
|
||||
_pmtilesArchive = null;
|
||||
claimGeojson = null;
|
||||
}
|
||||
if (!_tileManifest) {
|
||||
geojson = await fetchJsonAsset(COUNTRY_BOUNDARY_CONFIG.legacyFallbackPath, { required: true });
|
||||
baseGeojson = geojson;
|
||||
_tileProvider = "legacy-geojson";
|
||||
_boundaryProviderState = "legacy-geojson";
|
||||
_tileAssetVersion = "legacy";
|
||||
}
|
||||
const claimPath = _tileManifest.claimLine || _tileManifest.chinaClaims?.path;
|
||||
const claimResp = claimPath ? await fetch(versionedBoundaryAssetUrl(claimPath)) : null;
|
||||
const claimGeojson = claimResp?.ok ? await claimResp.json() : null;
|
||||
_features = (geojson.features || []).filter(f => f.geometry);
|
||||
const baseFeatures = (baseGeojson?.features || _features).filter(f => f.geometry);
|
||||
const boundaryBaseFeatures = baseFeatures.filter(
|
||||
@@ -940,6 +998,10 @@ export function getShowCountryBoundaries() {
|
||||
return _visible;
|
||||
}
|
||||
|
||||
export function getCountryBoundaryProviderState() {
|
||||
return _boundaryProviderState;
|
||||
}
|
||||
|
||||
/** Clear the hover highlight without hiding the full layer. */
|
||||
export function clearCountryBoundaryHover({ cancelSticky = true } = {}) {
|
||||
if (cancelSticky) cancelPendingHoverClear();
|
||||
@@ -1048,6 +1110,7 @@ export function clearCountryBoundaryData() {
|
||||
_landTexture = null;
|
||||
_tileManifest = null;
|
||||
_tileProvider = "pmtiles-mvt";
|
||||
_boundaryProviderState = "unloaded";
|
||||
_pmtilesArchive = null;
|
||||
_features = [];
|
||||
disposeHoverGeometryCache();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// info-card.js - Unified info card module
|
||||
import { showStatusMessage } from './ui.js';
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
} from './news-locale.js';
|
||||
|
||||
let currentType = null;
|
||||
let cardMounted = false;
|
||||
@@ -124,7 +128,7 @@ function escapeCssIdentifier(value) {
|
||||
}
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
return getNewsDisplaySummary(data);
|
||||
}
|
||||
|
||||
function getNewsSummaryPreview(data, maxLength = 34) {
|
||||
@@ -180,12 +184,13 @@ function startTypewriterAnimation(target, text, options = {}) {
|
||||
function renderNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
const title = getNewsDisplayTitle(data);
|
||||
content.innerHTML = `
|
||||
<div class="info-card-news-layout">
|
||||
<div class="info-card-news-kicker">NEWS SIGNAL</div>
|
||||
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="info-card-news-kicker">新闻信号</div>
|
||||
<div class="info-card-news-title">${escapeInfoCardHtml(title)}</div>
|
||||
<div class="info-card-news-summary-shell">
|
||||
<div class="info-card-news-summary-label">SUMMARY</div>
|
||||
<div class="info-card-news-summary-label">概要</div>
|
||||
<div class="info-card-news-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,12 +202,13 @@ function renderNewsCardContent(content, data) {
|
||||
function renderMobileNewsCardContent(content, data) {
|
||||
if (!(content instanceof HTMLElement)) return;
|
||||
const summary = getNewsSummaryText(data);
|
||||
const title = getNewsDisplayTitle(data);
|
||||
content.innerHTML = `
|
||||
<div class="earth-mobile-news-detail">
|
||||
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
|
||||
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
|
||||
<div class="earth-mobile-news-detail-kicker">新闻信号</div>
|
||||
<div class="earth-mobile-news-detail-title">${escapeInfoCardHtml(title)}</div>
|
||||
<div class="earth-mobile-news-detail-summary-shell">
|
||||
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
|
||||
<div class="earth-mobile-news-detail-summary-label">概要</div>
|
||||
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1026,7 +1032,7 @@ function getMobilePopupTitle(type, data) {
|
||||
case 'landing_point': return data.name || '登陆点';
|
||||
case 'satellite': return data.name || '卫星';
|
||||
case 'bgp': return data.anomaly_type || 'BGP事件';
|
||||
case 'news': return data.title || '新闻事件';
|
||||
case 'news': return getNewsDisplayTitle(data);
|
||||
case 'bgp_collector': return data.collector || 'BGP观测站';
|
||||
case 'compute_center_unresolved': return '待定位算力中心';
|
||||
case 'supercomputer': return data.name || '超算';
|
||||
@@ -1709,12 +1715,12 @@ export function showInfoCard(type, data, options = {}) {
|
||||
if (icon) icon.textContent = config.icon;
|
||||
if (title) {
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
? getNewsDisplayTitle(data)
|
||||
: config.title;
|
||||
}
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent = type === 'news'
|
||||
? 'news signal'
|
||||
? '新闻信号'
|
||||
: type.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
@@ -1750,7 +1756,7 @@ export function showInfoCard(type, data, options = {}) {
|
||||
card.className = 'info-card ' + config.className;
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = type === 'news'
|
||||
? (data?.title || '新闻事件')
|
||||
? getNewsDisplayTitle(data)
|
||||
: config.title;
|
||||
|
||||
if (type === 'news') {
|
||||
|
||||
@@ -630,7 +630,6 @@ function clearTransientHoverState() {
|
||||
resetTransientBGPStates();
|
||||
resetTransientComputeCenterStates();
|
||||
resetTransientVesselStates();
|
||||
clearCountryBoundaryHover();
|
||||
hoveredBGP = null;
|
||||
hoveredComputeCenter = null;
|
||||
hoveredVessel = null;
|
||||
@@ -4119,6 +4118,26 @@ export async function setCountryBoundariesEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadCountryBoundaries({ suppressStatus = false } = {}) {
|
||||
const wasVisible = getShowCountryBoundaries();
|
||||
clearCountryBoundaryHover();
|
||||
clearCountryBoundaryData();
|
||||
if (wasVisible) {
|
||||
return await setCountryBoundariesEnabled(true, { suppressStatus });
|
||||
}
|
||||
|
||||
const countryCount = await ensureCountryBoundariesReady();
|
||||
const textureOn = getEarthTextureVisible();
|
||||
toggleCountryBoundaries(false, {
|
||||
showTint: !textureOn,
|
||||
showLandFill: true,
|
||||
suppressLandFill: false,
|
||||
});
|
||||
setLegendItems("countryBoundaries", getCountryBoundaryLegendItems());
|
||||
refreshLegend();
|
||||
return countryCount;
|
||||
}
|
||||
|
||||
let _dayNightBeforeTextureOff = null;
|
||||
let _terrainBeforeTextureOff = null;
|
||||
|
||||
@@ -4571,6 +4590,34 @@ function onMouseMove(event) {
|
||||
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
|
||||
const hoveredVesselMarker =
|
||||
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
getEarthSurfacePickTarget() || earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
);
|
||||
const surfaceHover = earthPoint
|
||||
? { coords: vector3ToLatLon(earthPoint), hoveredCountry: null }
|
||||
: null;
|
||||
|
||||
if (surfaceHover) {
|
||||
updateCoordinatesDisplay(
|
||||
surfaceHover.coords.lat,
|
||||
surfaceHover.coords.lon,
|
||||
surfaceHover.coords.alt,
|
||||
);
|
||||
surfaceHover.hoveredCountry = getShowCountryBoundaries()
|
||||
? updateCountryBoundaryHover(surfaceHover.coords)
|
||||
: null;
|
||||
if (!getShowCountryBoundaries()) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
|
||||
if (
|
||||
hoveredComputeCenter &&
|
||||
@@ -4688,18 +4735,8 @@ function onMouseMove(event) {
|
||||
}
|
||||
|
||||
if (!objectTooltipShown) {
|
||||
const earthPoint = screenToEarthCoords(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
camera,
|
||||
getEarthSurfacePickTarget() || earth,
|
||||
document.body,
|
||||
interactionRaycaster,
|
||||
interactionMouse,
|
||||
);
|
||||
if (earthPoint) {
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||||
if (surfaceHover) {
|
||||
const { coords, hoveredCountry } = surfaceHover;
|
||||
const hoverInfoMode = getSurfaceHoverInfoMode();
|
||||
const shouldShowCountry =
|
||||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.COUNTRY ||
|
||||
@@ -4707,18 +4744,11 @@ function onMouseMove(event) {
|
||||
const shouldShowPosition =
|
||||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.POSITION ||
|
||||
hoverInfoMode === SURFACE_HOVER_INFO_MODES.FULL;
|
||||
const hoveredCountry = shouldShowCountry && getShowCountryBoundaries()
|
||||
? updateCountryBoundaryHover(coords)
|
||||
: null;
|
||||
const positionHtml = shouldShowPosition
|
||||
? getSurfacePositionBriefHtml(coords)
|
||||
: "";
|
||||
|
||||
if (!shouldShowCountry) {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
|
||||
if (hoveredCountry && shouldShowPosition) {
|
||||
if (hoveredCountry && shouldShowCountry && shouldShowPosition) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
@@ -4727,7 +4757,7 @@ function onMouseMove(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hoveredCountry) {
|
||||
if (hoveredCountry && shouldShowCountry) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
@@ -4736,7 +4766,6 @@ function onMouseMove(event) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCountryBoundaryHover();
|
||||
if (shouldShowPosition) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_COORDS_OFFSET,
|
||||
@@ -4750,8 +4779,6 @@ function onMouseMove(event) {
|
||||
clearCountryBoundaryHover();
|
||||
hideTooltip();
|
||||
}
|
||||
} else {
|
||||
clearCountryBoundaryHover();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
selectNewsItem,
|
||||
clearSelectedNewsItem,
|
||||
} from "./news.js";
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
getNewsFeedLabel,
|
||||
getNewsLocationSourceLabel,
|
||||
getNewsRegionLabel,
|
||||
} from "./news-locale.js";
|
||||
|
||||
const CRUISE_PRESENTATION_HIDE_MS = 220;
|
||||
const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200;
|
||||
@@ -22,14 +29,6 @@ const MOBILE_CARD_TOP_RATIO = 0.16;
|
||||
const MOBILE_CARD_WIDTH_PX = 220;
|
||||
const scratchNewsWorldPosition = new THREE.Vector3();
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
function getItemTimestamp(item) {
|
||||
const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
@@ -94,21 +93,22 @@ function mapNewsItemToCruiseEvent(item) {
|
||||
id: `news:${item.id}`,
|
||||
sourceId: item.id,
|
||||
type: "news",
|
||||
title: item.title || "新闻事件",
|
||||
summary: item.summary || "",
|
||||
title: getNewsDisplayTitle(item),
|
||||
summary: getNewsDisplaySummary(item),
|
||||
source: item.source || "",
|
||||
feedName: item.feed_name || "",
|
||||
feedName: getNewsFeedLabel(item.feed_name),
|
||||
region: item.region || "global",
|
||||
regionLabel: REGION_LABELS[item.region] || item.region || "全球",
|
||||
regionLabel: item.display_region || getNewsRegionLabel(item.region),
|
||||
url: item.url || "",
|
||||
publishedAt: item.published_at || null,
|
||||
publishedAtDisplay: formatPublishedAt(item.published_at),
|
||||
latitude,
|
||||
longitude,
|
||||
locationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
sourceLocationLabel: item.location_label || REGION_LABELS[item.region] || "全球",
|
||||
locationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
|
||||
sourceLocationLabel: item.location_label || item.display_region || getNewsRegionLabel(item.region),
|
||||
targetLocationConfidence: item.location_meta?.target?.confidence ?? null,
|
||||
targetLocationSource: item.location_source || "",
|
||||
targetLocationSourceLabel: getNewsLocationSourceLabel(item.location_source),
|
||||
verified: item.verified === true,
|
||||
locationMeta: item.location_meta || null,
|
||||
sortTimestamp: getItemTimestamp(item),
|
||||
|
||||
107
frontend/public/earth/js/news-locale.js
Normal file
107
frontend/public/earth/js/news-locale.js
Normal file
@@ -0,0 +1,107 @@
|
||||
const DEFAULT_LOCALE = "zh-CN";
|
||||
|
||||
const REGION_LABELS = {
|
||||
americas: "美洲",
|
||||
europe: "欧洲",
|
||||
"middle-east-africa": "中东与非洲",
|
||||
"asia-pacific": "亚太",
|
||||
global: "全球",
|
||||
};
|
||||
|
||||
const FEED_LABELS = {
|
||||
"Global Monitor / World": "全球监测",
|
||||
"Global Monitor / Americas": "美洲监测",
|
||||
"Global Monitor / Europe": "欧洲监测",
|
||||
"Global Monitor / MEA": "中东与非洲监测",
|
||||
"Global Monitor / APAC": "亚太监测",
|
||||
};
|
||||
|
||||
const LOCATION_SOURCE_LABELS = {
|
||||
region_anchor: "区域锚点",
|
||||
ai_inferred_target: "AI 推断位置",
|
||||
headline_location_hint: "标题位置线索",
|
||||
headline_country_hint: "标题国家线索",
|
||||
};
|
||||
|
||||
const ENRICHMENT_STATUS_LABELS = {
|
||||
pending: "待增强",
|
||||
queued: "增强排队中",
|
||||
attempted: "增强中",
|
||||
success: "已汉化",
|
||||
content_only: "已汉化",
|
||||
location_only: "位置已增强",
|
||||
unavailable: "AI 未配置",
|
||||
provider_error: "增强失败",
|
||||
parse_error: "增强解析失败",
|
||||
no_result: "暂无增强结果",
|
||||
};
|
||||
|
||||
const TITLE_PLACEHOLDERS = {
|
||||
queued: "新闻汉化排队中",
|
||||
attempted: "新闻汉化中",
|
||||
provider_error: "新闻汉化失败,正在重试",
|
||||
parse_error: "新闻解析失败,正在重试",
|
||||
unavailable: "等待 AI 配置",
|
||||
no_result: "新闻汉化待重试",
|
||||
location_only: "新闻汉化待重试",
|
||||
};
|
||||
|
||||
const SUMMARY_PLACEHOLDERS = {
|
||||
queued: "中文概要正在生成,请稍后刷新。",
|
||||
attempted: "中文概要正在生成,请稍后刷新。",
|
||||
provider_error: "中文概要生成失败,系统会重新提交增强任务。",
|
||||
parse_error: "中文概要解析失败,系统会重新提交增强任务。",
|
||||
unavailable: "AI 服务配置完成后将生成中文概要。",
|
||||
no_result: "中文概要暂未生成,系统会继续重试。",
|
||||
location_only: "已完成位置增强,中文概要将继续重试。",
|
||||
};
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function getLocalization(item, locale = DEFAULT_LOCALE) {
|
||||
const localizations = item?.localizations;
|
||||
const localized = localizations && typeof localizations === "object"
|
||||
? localizations[locale]
|
||||
: null;
|
||||
return localized && typeof localized === "object" ? localized : {};
|
||||
}
|
||||
|
||||
export function getNewsDisplayTitle(item, locale = DEFAULT_LOCALE) {
|
||||
const localized = getLocalization(item, locale).title;
|
||||
if (localized || item?.display_title) {
|
||||
return normalizeText(item?.display_title || localized);
|
||||
}
|
||||
return normalizeText(
|
||||
TITLE_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "新闻汉化中",
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewsDisplaySummary(item, locale = DEFAULT_LOCALE) {
|
||||
const localized = getLocalization(item, locale).summary;
|
||||
if (localized || item?.display_summary) {
|
||||
return normalizeText(item?.display_summary || localized);
|
||||
}
|
||||
return normalizeText(
|
||||
SUMMARY_PLACEHOLDERS[item?.enrichment_status]
|
||||
|| "中文概要生成中,请稍后刷新。",
|
||||
);
|
||||
}
|
||||
|
||||
export function getNewsRegionLabel(region, fallback = "") {
|
||||
return REGION_LABELS[region] || fallback || region || REGION_LABELS.global;
|
||||
}
|
||||
|
||||
export function getNewsFeedLabel(feedName) {
|
||||
return FEED_LABELS[feedName] || feedName || "聚合源";
|
||||
}
|
||||
|
||||
export function getNewsLocationSourceLabel(source) {
|
||||
return LOCATION_SOURCE_LABELS[source] || source || "位置来源";
|
||||
}
|
||||
|
||||
export function getNewsEnrichmentStatusLabel(status) {
|
||||
return ENRICHMENT_STATUS_LABELS[status] || status || "增强状态";
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { showStatusMessage } from "./ui.js";
|
||||
import {
|
||||
getNewsDisplaySummary,
|
||||
getNewsDisplayTitle,
|
||||
getNewsEnrichmentStatusLabel,
|
||||
getNewsFeedLabel,
|
||||
getNewsRegionLabel,
|
||||
} from "./news-locale.js";
|
||||
|
||||
// Desktop news has two surfaces:
|
||||
// - a persistent top ticker
|
||||
@@ -285,6 +292,14 @@ function escapeTickerText(value) {
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeNewsHtml(value) {
|
||||
return escapeTickerText(value);
|
||||
}
|
||||
|
||||
function hasLocalizedNewsContent(item) {
|
||||
return Boolean(String(item?.display_title || "").trim() && String(item?.display_summary || "").trim());
|
||||
}
|
||||
|
||||
function renderTicker(nextPayload) {
|
||||
const { ticker, tickerRegion, tickerTrack } = getElements();
|
||||
if (!(ticker instanceof HTMLElement) || !(tickerTrack instanceof HTMLElement)) return;
|
||||
@@ -292,7 +307,7 @@ function renderTicker(nextPayload) {
|
||||
const focus = nextPayload?.focus || {};
|
||||
|
||||
if (tickerRegion instanceof HTMLElement) {
|
||||
tickerRegion.textContent = (focus.region || "global").toUpperCase();
|
||||
tickerRegion.textContent = focus.display_region || getNewsRegionLabel(focus.region);
|
||||
tickerRegion.style.color = focus.accent || "";
|
||||
}
|
||||
|
||||
@@ -302,13 +317,18 @@ function renderTicker(nextPayload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleItems = items.slice(0, 6);
|
||||
const visibleItems = items.filter(hasLocalizedNewsContent).slice(0, 6);
|
||||
if (visibleItems.length === 0) {
|
||||
tickerTrack.textContent = "正在等待中文新闻...";
|
||||
tickerTrack.style.removeProperty("--news-ticker-duration");
|
||||
return;
|
||||
}
|
||||
const tickerItems = [...visibleItems, ...visibleItems];
|
||||
tickerTrack.innerHTML = tickerItems
|
||||
.map((item) => `
|
||||
<span class="earth-news-ticker__item" data-news-id="${escapeTickerText(item.id || "")}">
|
||||
<span class="earth-news-ticker__source">${escapeTickerText(item.source || item.feed_name || "NEWS")}</span>
|
||||
<span>${escapeTickerText(item.title || "未命名新闻")}</span>
|
||||
<span>${escapeTickerText(getNewsDisplayTitle(item))}</span>
|
||||
</span>
|
||||
`)
|
||||
.join("");
|
||||
@@ -360,7 +380,7 @@ function renderPayload(nextPayload) {
|
||||
}
|
||||
|
||||
if (regionChip) {
|
||||
regionChip.textContent = focus.region || "global";
|
||||
regionChip.textContent = focus.display_region || getNewsRegionLabel(focus.region);
|
||||
regionChip.style.setProperty("--news-accent", focus.accent || "#d6e6ff");
|
||||
}
|
||||
|
||||
@@ -412,20 +432,26 @@ function renderPayload(nextPayload) {
|
||||
const cardClass = item.is_focus_match
|
||||
? "news-story-card news-story-card--focus"
|
||||
: "news-story-card";
|
||||
const summary = item.summary
|
||||
? `<div class="news-story-summary">${item.summary}</div>`
|
||||
const title = getNewsDisplayTitle(item);
|
||||
const summaryText = getNewsDisplaySummary(item);
|
||||
const regionLabel = item.display_region || getNewsRegionLabel(item.region);
|
||||
const feedLabel = getNewsFeedLabel(item.feed_name);
|
||||
const statusLabel = getNewsEnrichmentStatusLabel(item.enrichment_status);
|
||||
const summary = summaryText
|
||||
? `<div class="news-story-summary">${escapeNewsHtml(summaryText)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
|
||||
<div class="news-story-meta">
|
||||
<span class="news-story-source">${item.source}</span>
|
||||
<span class="news-story-source">${escapeNewsHtml(item.source || "NEWS")}</span>
|
||||
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
|
||||
</div>
|
||||
<div class="news-story-title">${item.title}</div>
|
||||
<div class="news-story-title">${escapeNewsHtml(title)}</div>
|
||||
${summary}
|
||||
<div class="news-story-tags">
|
||||
<span class="news-story-tag">${item.region}</span>
|
||||
<span class="news-story-tag">${item.feed_name}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(regionLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(feedLabel)}</span>
|
||||
<span class="news-story-tag">${escapeNewsHtml(statusLabel)}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
|
||||
@@ -714,6 +714,7 @@ function createSatelliteTwinkleState(index) {
|
||||
function createSatellitePositionState(index = 0) {
|
||||
return {
|
||||
current: new THREE.Vector3(),
|
||||
currentTime: null,
|
||||
trail: [],
|
||||
trailIndex: 0,
|
||||
trailCount: 0,
|
||||
@@ -1309,6 +1310,7 @@ export function updateSatellitePositions(deltaTime = 0, force = false, options =
|
||||
}
|
||||
|
||||
satellitePositions[i].current.copy(pos);
|
||||
satellitePositions[i].currentTime = adjustedTime;
|
||||
|
||||
if (shouldUpdateTrails) {
|
||||
const satPos = satellitePositions[i];
|
||||
@@ -2647,11 +2649,11 @@ function calculatePredictedOrbit(
|
||||
) {
|
||||
const points = [];
|
||||
const samples = Math.ceil(periodSeconds / sampleInterval);
|
||||
const now = new Date();
|
||||
const fixedSiderealTime = gstime(now);
|
||||
const startTime = getSelectedSatellitePositionState(satellite)?.currentTime || new Date();
|
||||
const fixedSiderealTime = gstime(startTime);
|
||||
|
||||
for (let i = 0; i <= samples; i++) {
|
||||
const time = new Date(now.getTime() + i * sampleInterval * 1000);
|
||||
const time = new Date(startTime.getTime() + i * sampleInterval * 1000);
|
||||
const pos = computeSatelliteInertialOrbitPosition(
|
||||
satellite,
|
||||
time,
|
||||
@@ -2710,6 +2712,21 @@ function calculateFallbackPredictedOrbit(satellite, samples) {
|
||||
return points;
|
||||
}
|
||||
|
||||
function getSelectedSatellitePositionState(satellite) {
|
||||
if (selectedSatellite === null || !satellitePositions?.[selectedSatellite]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedData = satelliteData?.[selectedSatellite];
|
||||
const selectedNoradId = selectedData?.properties?.norad_cat_id;
|
||||
const targetNoradId = satellite?.properties?.norad_cat_id;
|
||||
if (selectedData !== satellite && selectedNoradId !== targetNoradId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return satellitePositions[selectedSatellite];
|
||||
}
|
||||
|
||||
export function showPredictedOrbit(satellite) {
|
||||
hidePredictedOrbit();
|
||||
if (!earthObjRef) return;
|
||||
|
||||
@@ -64,6 +64,8 @@ function App() {
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/datasources" element={<DataSources />} />
|
||||
<Route path="/earth-content" element={<Settings />} />
|
||||
<Route path="/collection-management" element={<Settings />} />
|
||||
<Route path="/data" element={<DataList />} />
|
||||
<Route path="/alerts" element={<Navigate to="/alerts/system" replace />} />
|
||||
<Route path="/alerts/system" element={<SystemAlerts />} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ToolOutlined,
|
||||
InboxOutlined,
|
||||
FileTextOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
|
||||
@@ -85,9 +86,11 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
label: '运维与配置',
|
||||
children: [
|
||||
{ key: '/ai', icon: <ApiOutlined />, label: 'AI' },
|
||||
{ key: '/earth-content', icon: <VideoCameraOutlined />, label: 'Earth 内容' },
|
||||
{ key: '/collection-management', icon: <DatabaseOutlined />, label: '采集管理' },
|
||||
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
ApiOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
SaveOutlined,
|
||||
SyncOutlined,
|
||||
ToolOutlined,
|
||||
UndoOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
@@ -167,6 +169,19 @@ interface WebSearchPreset {
|
||||
scrape_formats?: string[]
|
||||
}
|
||||
|
||||
interface AIPromptConfig {
|
||||
key: string
|
||||
label: string
|
||||
group: string
|
||||
version: string
|
||||
default_system_prompt: string
|
||||
default_prompt: string
|
||||
system_prompt: string
|
||||
prompt: string
|
||||
is_custom: boolean
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
function AISettingsPanel({ loading, children }: { loading: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
@@ -180,11 +195,16 @@ function AISettingsPanel({ loading, children }: { loading: boolean; children: Re
|
||||
export default function AISettings() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [form] = Form.useForm()
|
||||
const [promptForm] = Form.useForm()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savingPrompt, setSavingPrompt] = useState(false)
|
||||
const [resettingPrompt, setResettingPrompt] = useState(false)
|
||||
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
|
||||
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
|
||||
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
|
||||
const [aiPrompts, setAiPrompts] = useState<AIPromptConfig[]>([])
|
||||
const [selectedPromptKey, setSelectedPromptKey] = useState<string>('')
|
||||
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
|
||||
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
|
||||
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
|
||||
@@ -210,20 +230,28 @@ export default function AISettings() {
|
||||
: integrations?.web_search.api_key
|
||||
const activeTab = useMemo(() => {
|
||||
const tab = searchParams.get('tab') || 'providers'
|
||||
return new Set(['providers', 'tools', 'playground']).has(tab) ? tab : 'providers'
|
||||
return new Set(['providers', 'tools', 'prompts', 'playground']).has(tab) ? tab : 'providers'
|
||||
}, [searchParams])
|
||||
const selectedPrompt = useMemo(
|
||||
() => aiPrompts.find((item) => item.key === selectedPromptKey) || aiPrompts[0],
|
||||
[aiPrompts, selectedPromptKey],
|
||||
)
|
||||
|
||||
const fetchAISettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [settingsResponse, aiPresetsResponse, webPresetsResponse] = await Promise.all([
|
||||
const [settingsResponse, aiPresetsResponse, webPresetsResponse, promptsResponse] = await Promise.all([
|
||||
axios.get('/api/v1/settings'),
|
||||
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
||||
axios.get('/api/v1/settings/integrations/web-search/presets'),
|
||||
axios.get('/api/v1/settings/ai-prompts'),
|
||||
])
|
||||
setIntegrations(settingsResponse.data.integrations || null)
|
||||
setAiProviderPresets(aiPresetsResponse.data.data || [])
|
||||
setWebSearchPresets(webPresetsResponse.data.data || [])
|
||||
const prompts = promptsResponse.data.data || []
|
||||
setAiPrompts(prompts)
|
||||
setSelectedPromptKey((current) => current || prompts[0]?.key || '')
|
||||
} catch {
|
||||
message.error('获取 AI 配置失败')
|
||||
} finally {
|
||||
@@ -235,6 +263,14 @@ export default function AISettings() {
|
||||
void fetchAISettings()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPrompt) return
|
||||
promptForm.setFieldsValue({
|
||||
system_prompt: selectedPrompt.system_prompt,
|
||||
prompt: selectedPrompt.prompt,
|
||||
})
|
||||
}, [promptForm, selectedPrompt])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !integrations) return
|
||||
form.setFieldsValue({
|
||||
@@ -391,6 +427,52 @@ export default function AISettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveSelectedPrompt = async (values: { system_prompt?: string; prompt?: string }) => {
|
||||
if (!selectedPrompt) return
|
||||
try {
|
||||
setSavingPrompt(true)
|
||||
const response = await axios.put(`/api/v1/settings/ai-prompts/${selectedPrompt.key}`, {
|
||||
system_prompt: values.system_prompt || '',
|
||||
prompt: values.prompt || '',
|
||||
})
|
||||
const nextPrompt = response.data.data as AIPromptConfig
|
||||
setAiPrompts((items) => items.map((item) => (item.key === nextPrompt.key ? nextPrompt : item)))
|
||||
setFeedback({ type: 'success', message: '提示词已保存' })
|
||||
message.success('提示词已保存')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || '提示词保存失败'
|
||||
setFeedback({ type: 'error', message: errorMessage })
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setSavingPrompt(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetSelectedPrompt = async () => {
|
||||
if (!selectedPrompt) return
|
||||
if (!window.confirm(`重置「${selectedPrompt.label}」为缺省提示词?`)) return
|
||||
try {
|
||||
setResettingPrompt(true)
|
||||
const response = await axios.post(`/api/v1/settings/ai-prompts/${selectedPrompt.key}/reset`)
|
||||
const nextPrompt = response.data.data as AIPromptConfig
|
||||
setAiPrompts((items) => items.map((item) => (item.key === nextPrompt.key ? nextPrompt : item)))
|
||||
promptForm.setFieldsValue({
|
||||
system_prompt: nextPrompt.system_prompt,
|
||||
prompt: nextPrompt.prompt,
|
||||
})
|
||||
setFeedback({ type: 'success', message: '提示词已重置为缺省' })
|
||||
message.success('提示词已重置为缺省')
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || '提示词重置失败'
|
||||
setFeedback({ type: 'error', message: errorMessage })
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setResettingPrompt(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revealAiProviderSecrets = async (provider: string) => {
|
||||
const cached = revealedAiProviderSecrets[provider]
|
||||
if (cached) return cached
|
||||
@@ -988,6 +1070,95 @@ export default function AISettings() {
|
||||
</AISettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
label: '提示词',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<AISettingsPanel loading={loading}>
|
||||
<Form form={promptForm} layout="vertical" onFinish={saveSelectedPrompt} onValuesChange={() => setFeedback(null)}>
|
||||
<Card size="small" title={<Space><ToolOutlined />功能提示词</Space>}>
|
||||
<Form.Item label="功能入口">
|
||||
<Select
|
||||
showSearch
|
||||
value={selectedPrompt?.key}
|
||||
optionFilterProp="label"
|
||||
onChange={setSelectedPromptKey}
|
||||
options={Object.entries(
|
||||
aiPrompts.reduce<Record<string, AIPromptConfig[]>>((groups, prompt) => {
|
||||
groups[prompt.group] = [...(groups[prompt.group] || []), prompt]
|
||||
return groups
|
||||
}, {}),
|
||||
).map(([group, prompts]) => ({
|
||||
label: group,
|
||||
options: prompts.map((prompt) => ({
|
||||
value: prompt.key,
|
||||
label: `${prompt.label} · ${prompt.key}`,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{selectedPrompt ? (
|
||||
<>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Tag color={selectedPrompt.is_custom ? 'blue' : 'default'}>
|
||||
{selectedPrompt.is_custom ? '已自定义' : '缺省'}
|
||||
</Tag>
|
||||
<Tag>版本 {selectedPrompt.version}</Tag>
|
||||
{selectedPrompt.updated_at ? <Text type="secondary">更新于 {selectedPrompt.updated_at}</Text> : null}
|
||||
</Space>
|
||||
|
||||
<Form.Item name="system_prompt" label="System Prompt">
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
placeholder="可留空。只在这个功能入口调用 LLM 时作为 system prompt 传入。"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="prompt"
|
||||
label="任务提示词"
|
||||
rules={[{ required: true, message: '请输入任务提示词' }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
placeholder="描述这个功能入口希望模型完成的任务。"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="重置会恢复到当前发布包中的缺省提示词;业务事实、schema 和上下文仍由后端按功能入口自动传入。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={savingPrompt}>
|
||||
保存提示词
|
||||
</Button>
|
||||
<Button
|
||||
icon={<UndoOutlined />}
|
||||
loading={resettingPrompt}
|
||||
disabled={!selectedPrompt.is_custom}
|
||||
onClick={() => { void resetSelectedPrompt() }}
|
||||
>
|
||||
重置为缺省
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : (
|
||||
<Alert showIcon type="warning" message="暂无可配置提示词。" />
|
||||
)}
|
||||
</Card>
|
||||
{feedback ? <Alert showIcon type={feedback.type} message={feedback.message} style={{ marginTop: 12 }} /> : null}
|
||||
</Form>
|
||||
</AISettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'playground',
|
||||
label: '测试台',
|
||||
|
||||
@@ -290,7 +290,7 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
function DataSources() {
|
||||
function DataSources({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
@@ -860,8 +860,8 @@ function DataSources() {
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
const content = (
|
||||
<>
|
||||
{contextHolder}
|
||||
{modalContextHolder}
|
||||
<div className="page-shell">
|
||||
@@ -1197,7 +1197,7 @@ function DataSources() {
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => navigate(`/settings?tab=collector_credentials&collector=${encodeURIComponent(viewingSource.source)}`)}
|
||||
onClick={() => navigate(`/collection-management?tab=collector_credentials&collector=${encodeURIComponent(viewingSource.source)}`)}
|
||||
>
|
||||
去配置
|
||||
</Button>
|
||||
@@ -1228,8 +1228,14 @@ function DataSources() {
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
</AppLayout>
|
||||
</>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return content
|
||||
}
|
||||
|
||||
return <AppLayout>{content}</AppLayout>
|
||||
}
|
||||
|
||||
export default DataSources
|
||||
|
||||
@@ -43,8 +43,9 @@ import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import SmtpPanel from './SmtpPanel'
|
||||
import DataSources from '../DataSources/DataSources'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
|
||||
@@ -131,6 +132,34 @@ interface TVSettings {
|
||||
sources: TVStreamSource[]
|
||||
}
|
||||
|
||||
interface EarthBoundaryStatus {
|
||||
provider: string
|
||||
high_precision_ready: boolean
|
||||
fallback_available: boolean
|
||||
config_source: string
|
||||
config_path: string
|
||||
config_exists: boolean
|
||||
config: Record<string, unknown>
|
||||
manifest: {
|
||||
path: string
|
||||
exists: boolean
|
||||
tileProvider?: string
|
||||
buildInputHash?: string
|
||||
builtAt?: string
|
||||
}
|
||||
pmtiles: {
|
||||
path: string
|
||||
exists: boolean
|
||||
size_bytes: number
|
||||
}
|
||||
legacy: {
|
||||
path: string
|
||||
exists: boolean
|
||||
size_bytes: number
|
||||
}
|
||||
last_build?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface SecretStatus {
|
||||
configured: boolean
|
||||
preview: string
|
||||
@@ -321,6 +350,13 @@ const formatLagSeconds = (value: number | null | undefined) => {
|
||||
return `${Math.round(value / 3600)} 小时`
|
||||
}
|
||||
|
||||
const formatBytes = (value: number | null | undefined) => {
|
||||
if (!value) return '0 B'
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function SettingsPanel({
|
||||
loading,
|
||||
children,
|
||||
@@ -339,9 +375,15 @@ function SettingsPanel({
|
||||
|
||||
function Settings() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const requestedTab = searchParams.get('tab') || 'display'
|
||||
const requestedCollector = searchParams.get('collector') || ''
|
||||
const pageMode = location.pathname === '/earth-content'
|
||||
? 'earth'
|
||||
: location.pathname === '/collection-management'
|
||||
? 'collection'
|
||||
: 'settings'
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
|
||||
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
|
||||
@@ -350,6 +392,10 @@ function Settings() {
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
|
||||
const [earthBoundaryStatus, setEarthBoundaryStatus] = useState<EarthBoundaryStatus | null>(null)
|
||||
const [earthBoundaryConfigText, setEarthBoundaryConfigText] = useState('')
|
||||
const [savingEarthBoundaryConfig, setSavingEarthBoundaryConfig] = useState(false)
|
||||
const [buildingEarthBoundary, setBuildingEarthBoundary] = useState(false)
|
||||
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
|
||||
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
|
||||
const [webSearchPresets, setWebSearchPresets] = useState<WebSearchPreset[]>([])
|
||||
@@ -417,30 +463,27 @@ function Settings() {
|
||||
const collectorOptions = useMemo(() => [...collectors, ...customCollectors], [collectors, customCollectors])
|
||||
const selectedCollector = collectorOptions.find((collector) => collector.source === selectedCollectorSource)
|
||||
const selectedCollectorConfig = [...collectorConfigs, ...customSourceConfigs].find((config) => config.name === selectedCollectorSource)
|
||||
const isEarthBoundarySourceCollector = Boolean(
|
||||
selectedCollector?.source &&
|
||||
['earth_admin0_boundaries', 'earth_coastline', 'earth_claim_lines'].includes(selectedCollector.source),
|
||||
)
|
||||
const isEarthPmtilesBuilder = selectedCollector?.source === 'earth_boundary_tiles'
|
||||
const [customStreamStatus, setCustomStreamStatus] = useState<{ running: boolean; done: boolean } | null>(null)
|
||||
const [customStreamBusy, setCustomStreamBusy] = useState(false)
|
||||
const selectedCollectorHealth = selectedCollector
|
||||
? collectorHealthStatus[selectedCollector.source]
|
||||
: undefined
|
||||
const selectedAisRuntimeHealth = selectedCollector?.ais_health || null
|
||||
const settingsTabKeys = new Set([
|
||||
'display',
|
||||
'notifications',
|
||||
'security',
|
||||
'tv',
|
||||
'collector_credentials',
|
||||
'collectors',
|
||||
])
|
||||
const settingsTabKeysByMode: Record<string, Set<string>> = {
|
||||
settings: new Set(['display', 'notifications', 'security', 'smtp']),
|
||||
earth: new Set(['tv', 'earth_assets', 'basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy']),
|
||||
collection: new Set(['collector_credentials', 'collectors', 'collection_history']),
|
||||
}
|
||||
const defaultTabByMode: Record<string, string> = {
|
||||
settings: 'display',
|
||||
earth: 'tv',
|
||||
collection: 'collector_credentials',
|
||||
}
|
||||
const activeSettingsTab = requestedTab === 'system'
|
||||
? 'display'
|
||||
: settingsTabKeys.has(requestedTab)
|
||||
? defaultTabByMode[pageMode]
|
||||
: settingsTabKeysByMode[pageMode].has(requestedTab)
|
||||
? requestedTab
|
||||
: 'display'
|
||||
: defaultTabByMode[pageMode]
|
||||
|
||||
const updateSettingsTab = (tabKey: string) => {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
@@ -455,18 +498,28 @@ function Settings() {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [response, presetsResponse, webSearchPresetsResponse, collectorConfigsResponse, customConfigsResponse] = await Promise.all([
|
||||
const [
|
||||
response,
|
||||
presetsResponse,
|
||||
webSearchPresetsResponse,
|
||||
collectorConfigsResponse,
|
||||
customConfigsResponse,
|
||||
earthBoundaryResponse,
|
||||
] = await Promise.all([
|
||||
axios.get('/api/v1/settings'),
|
||||
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
||||
axios.get('/api/v1/settings/integrations/web-search/presets'),
|
||||
axios.get('/api/v1/datasources/configs/all'),
|
||||
axios.get('/api/v1/datasources/configs'),
|
||||
axios.get('/api/v1/earth/boundaries/status'),
|
||||
])
|
||||
setSystemSettings(response.data.system)
|
||||
setNotificationSettings(response.data.notifications)
|
||||
setSecuritySettings(response.data.security)
|
||||
setTvSettings(response.data.tv || null)
|
||||
setIntegrations(response.data.integrations || null)
|
||||
setEarthBoundaryStatus(earthBoundaryResponse.data)
|
||||
setEarthBoundaryConfigText(JSON.stringify(earthBoundaryResponse.data.config || {}, null, 2))
|
||||
setCollectors(response.data.collectors || [])
|
||||
setAiProviderPresets(presetsResponse.data.data || [])
|
||||
setWebSearchPresets(webSearchPresetsResponse.data.data || [])
|
||||
@@ -491,8 +544,14 @@ function Settings() {
|
||||
useEffect(() => {
|
||||
if (requestedTab === 'ai') {
|
||||
navigate('/ai', { replace: true })
|
||||
} else if (pageMode === 'settings' && ['tv', 'earth_assets'].includes(requestedTab)) {
|
||||
navigate(`/earth-content?tab=${requestedTab}`, { replace: true })
|
||||
} else if (pageMode === 'settings' && ['collector_credentials', 'collectors'].includes(requestedTab)) {
|
||||
navigate(`/collection-management?tab=${requestedTab}`, { replace: true })
|
||||
} else if (pageMode === 'settings' && requestedTab === 'datasources') {
|
||||
navigate('/datasources', { replace: true })
|
||||
}
|
||||
}, [navigate, requestedTab])
|
||||
}, [navigate, pageMode, requestedTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && systemSettings) {
|
||||
@@ -601,7 +660,7 @@ function Settings() {
|
||||
timeout: config.timeout ?? 30,
|
||||
retry: config.retry ?? 3,
|
||||
method: config.method || 'GET',
|
||||
target_schema: config.target_schema || (isEarthBoundarySourceCollector ? 'earth_boundary_source' : undefined),
|
||||
target_schema: config.target_schema,
|
||||
license: config.license || '',
|
||||
mapping_json_text: JSON.stringify(config.mapping_json || {}, null, 2),
|
||||
max_messages: config.max_messages ?? 500,
|
||||
@@ -611,7 +670,7 @@ function Settings() {
|
||||
bounding_boxes_json: stringifyBoundingBoxes(boundingBoxes),
|
||||
},
|
||||
})
|
||||
}, [collectorConfigForm, isEarthBoundarySourceCollector, loading, selectedCollector, selectedCollectorConfig])
|
||||
}, [collectorConfigForm, loading, selectedCollector, selectedCollectorConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedCollector || !collectorOptions.some((collector) => collector.source === requestedCollector)) return
|
||||
@@ -988,22 +1047,10 @@ function Settings() {
|
||||
delete configValues.bounding_boxes_json
|
||||
delete configValues.bounding_box_preset
|
||||
}
|
||||
if (['earth_admin0_boundaries', 'earth_coastline', 'earth_claim_lines'].includes(selectedCollector.source)) {
|
||||
try {
|
||||
configValues.mapping_json = JSON.parse(configValues.mapping_json_text || '{}')
|
||||
} catch {
|
||||
message.error('Earth 边界 mapping_json 必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
configValues.target_schema = configValues.target_schema || 'earth_boundary_source'
|
||||
configValues.method = configValues.method || 'GET'
|
||||
delete configValues.mapping_json_text
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: selectedCollector.source,
|
||||
description: `内置采集器覆盖配置:${selectedCollector.name}`,
|
||||
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : isEarthPmtilesBuilder ? 'internal' : 'http',
|
||||
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
|
||||
endpoint: baseValues.endpoint || '',
|
||||
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
|
||||
headers,
|
||||
@@ -1554,6 +1601,44 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveEarthBoundaryConfig = async () => {
|
||||
try {
|
||||
setSavingEarthBoundaryConfig(true)
|
||||
const config = JSON.parse(earthBoundaryConfigText || '{}')
|
||||
const response = await axios.put('/api/v1/earth/boundaries/config', { config })
|
||||
setEarthBoundaryStatus(response.data)
|
||||
setEarthBoundaryConfigText(JSON.stringify(response.data.config || {}, null, 2))
|
||||
message.success('Earth 国界源配置已保存')
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail
|
||||
message.error(detail?.message || error.message || 'Earth 国界源配置保存失败')
|
||||
} finally {
|
||||
setSavingEarthBoundaryConfig(false)
|
||||
}
|
||||
}
|
||||
|
||||
const buildEarthBoundaryAssets = async () => {
|
||||
try {
|
||||
setBuildingEarthBoundary(true)
|
||||
const response = await axios.post('/api/v1/earth/boundaries/build')
|
||||
const statusResponse = await axios.get('/api/v1/earth/boundaries/status')
|
||||
setEarthBoundaryStatus(statusResponse.data)
|
||||
setEarthBoundaryConfigText(JSON.stringify(statusResponse.data.config || {}, null, 2))
|
||||
message.success(response.data.accepted === false ? 'Earth 国界构建已在运行' : 'Earth 国界构建已启动')
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail
|
||||
message.error(detail?.message || error.message || '国界精度构建失败')
|
||||
try {
|
||||
const response = await axios.get('/api/v1/earth/boundaries/status')
|
||||
setEarthBoundaryStatus(response.data)
|
||||
} catch {
|
||||
// Keep the previous status visible when refresh also fails.
|
||||
}
|
||||
} finally {
|
||||
setBuildingEarthBoundary(false)
|
||||
}
|
||||
}
|
||||
|
||||
const collectorColumns = [
|
||||
{
|
||||
title: '数据源',
|
||||
@@ -1776,7 +1861,22 @@ function Settings() {
|
||||
},
|
||||
]
|
||||
|
||||
const renderPlaceholderTab = (title: string, description: string) => (
|
||||
<SettingsPanel loading={false}>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Title level={4} style={{ margin: 0 }}>{title}</Title>
|
||||
<Text type="secondary">{description}</Text>
|
||||
</Space>
|
||||
</SettingsPanel>
|
||||
)
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'datasources',
|
||||
label: '数据源',
|
||||
forceRender: true,
|
||||
children: <DataSources embedded />,
|
||||
},
|
||||
{
|
||||
key: 'display',
|
||||
label: '系统显示',
|
||||
@@ -1975,6 +2075,88 @@ function Settings() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'earth_assets',
|
||||
label: '国界精度',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<SettingsPanel loading={loading}>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card size="small" title="国界精度">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color={earthBoundaryStatus?.high_precision_ready ? 'success' : 'default'}>
|
||||
{earthBoundaryStatus?.provider || '未知'}
|
||||
</Tag>
|
||||
<Tag color={earthBoundaryStatus?.fallback_available ? 'green' : 'error'}>
|
||||
fallback {earthBoundaryStatus?.fallback_available ? '可用' : '缺失'}
|
||||
</Tag>
|
||||
<Tag color={earthBoundaryStatus?.config_exists ? 'blue' : 'default'}>
|
||||
配置 {earthBoundaryStatus?.config_source || 'unknown'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(160px, 1fr))', gap: 12, marginTop: 16 }}>
|
||||
<div>
|
||||
<Text type="secondary">Manifest</Text>
|
||||
<div>{earthBoundaryStatus?.manifest.exists ? '已生成' : '未生成'}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{earthBoundaryStatus?.manifest.path}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">PMTiles</Text>
|
||||
<div>{earthBoundaryStatus?.pmtiles.exists ? formatBytes(earthBoundaryStatus.pmtiles.size_bytes) : '未生成'}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{earthBoundaryStatus?.pmtiles.path}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">低精度 fallback</Text>
|
||||
<div>{earthBoundaryStatus?.legacy.exists ? formatBytes(earthBoundaryStatus.legacy.size_bytes) : '缺失'}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{earthBoundaryStatus?.legacy.path}</Text>
|
||||
</div>
|
||||
</div>
|
||||
{earthBoundaryStatus?.last_build && Object.keys(earthBoundaryStatus.last_build).length > 0 ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type={earthBoundaryStatus.last_build.status === 'not_ready' ? 'warning' : 'info'}
|
||||
message={`最近构建: ${String(earthBoundaryStatus.last_build.status || 'unknown')}`}
|
||||
description={JSON.stringify(earthBoundaryStatus.last_build, null, 2).slice(0, 600)}
|
||||
style={{ marginTop: 16, whiteSpace: 'pre-wrap' }}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="国界源配置">
|
||||
<Input.TextArea
|
||||
rows={16}
|
||||
value={earthBoundaryConfigText}
|
||||
onChange={(event) => setEarthBoundaryConfigText(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={savingEarthBoundaryConfig}
|
||||
onClick={() => { void saveEarthBoundaryConfig() }}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={buildingEarthBoundary}
|
||||
disabled={!earthBoundaryStatus?.config_exists}
|
||||
onClick={() => { void buildEarthBoundaryAssets() }}
|
||||
>
|
||||
构建高精国界
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { void fetchSettings() }}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</SettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: 'AI',
|
||||
@@ -2266,7 +2448,7 @@ function Settings() {
|
||||
},
|
||||
{
|
||||
key: 'collector_credentials',
|
||||
label: '采集器设置',
|
||||
label: '采集器',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<SettingsPanel loading={loading}>
|
||||
@@ -2493,11 +2675,10 @@ function Settings() {
|
||||
<Form.Item
|
||||
name="endpoint"
|
||||
label="Endpoint"
|
||||
rules={isEarthPmtilesBuilder ? [] : [{ required: true, message: '请输入 Endpoint' }]}
|
||||
rules={[{ required: true, message: '请输入 Endpoint' }]}
|
||||
>
|
||||
<Input
|
||||
disabled={isEarthPmtilesBuilder}
|
||||
placeholder={isEarthPmtilesBuilder ? '内部构建器不需要 Endpoint' : selectedCollectorConfig?.default_url || 'https://api.example.com'}
|
||||
placeholder={selectedCollectorConfig?.default_url || 'https://api.example.com'}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!selectedCollector?.is_custom ? (
|
||||
@@ -2505,24 +2686,6 @@ function Settings() {
|
||||
<Input value={selectedCollectorConfig?.default_url || '-'} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isEarthBoundarySourceCollector ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name={['config', 'method']} label="请求方法">
|
||||
<Select options={[{ value: 'GET', label: 'GET' }, { value: 'POST', label: 'POST' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'target_schema']} label="目标 Schema">
|
||||
<Select options={[{ value: 'earth_boundary_source', label: 'earth_boundary_source' }]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name={['config', 'license']} label="License">
|
||||
<Input placeholder="例如 ODbL / source license / internal" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'mapping_json_text']} label="Mapping JSON">
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
<Form.List name="headers">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label="请求头">
|
||||
@@ -2656,15 +2819,57 @@ function Settings() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'collection_history',
|
||||
label: '采集历史 / 快照',
|
||||
children: renderPlaceholderTab('采集历史 / 快照', '后续会接入 collection task、snapshot 和 collected data 浏览能力。'),
|
||||
},
|
||||
{
|
||||
key: 'basemap',
|
||||
label: '地球底图',
|
||||
children: renderPlaceholderTab('地球底图', '地球底图配置待接入。'),
|
||||
},
|
||||
{
|
||||
key: 'layer_resources',
|
||||
label: '图层资源',
|
||||
children: renderPlaceholderTab('图层资源', '图层资源配置待接入。'),
|
||||
},
|
||||
{
|
||||
key: 'models_3d',
|
||||
label: '三维素材',
|
||||
children: renderPlaceholderTab('三维素材', '三维素材管理待接入。'),
|
||||
},
|
||||
{
|
||||
key: 'news_anchor_strategy',
|
||||
label: '新闻锚点策略',
|
||||
children: renderPlaceholderTab('新闻锚点策略', '新闻区域锚点与精修策略配置待接入。'),
|
||||
},
|
||||
]
|
||||
|
||||
const pageCopy = {
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
subtitle: '管理全局平台配置、安全策略、通知和基础集成。',
|
||||
},
|
||||
earth: {
|
||||
title: 'Earth 内容',
|
||||
subtitle: '管理 Earth 前端体验依赖的内容源和可视化资源。',
|
||||
},
|
||||
collection: {
|
||||
title: '采集管理',
|
||||
subtitle: '管理采集器配置、调度和采集历史;数据源状态入口保留在采集与数据下。',
|
||||
},
|
||||
}[pageMode]
|
||||
|
||||
const visibleTabItems = tabItems.filter((item) => settingsTabKeysByMode[pageMode].has(String(item.key)))
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="page-shell settings-shell">
|
||||
<div className="page-shell__header">
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>系统配置中心</Title>
|
||||
<Text type="secondary">这一页现在已经直接连接数据库配置和采集调度,不再只是演示表单。</Text>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>{pageCopy.title}</Title>
|
||||
<Text type="secondary">{pageCopy.subtitle}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2673,7 +2878,7 @@ function Settings() {
|
||||
className="settings-tabs"
|
||||
activeKey={activeSettingsTab}
|
||||
onChange={updateSettingsTab}
|
||||
items={tabItems.filter((item) => item.key !== 'ai')}
|
||||
items={visibleTabItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
133
planet.sh
133
planet.sh
@@ -78,6 +78,7 @@ WAIT_SPINNER_TICKS_PER_SECOND=8
|
||||
WAIT_SPINNER_DETAIL=""
|
||||
WAIT_SPINNER_MESSAGE=""
|
||||
WAIT_SESSION_ACTIVE=0
|
||||
PORT_CHECK_STATUS_ACTIVE=0
|
||||
VERBOSE=0
|
||||
WAIT_VERBOSE_LOG_FILE=""
|
||||
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
|
||||
@@ -897,9 +898,17 @@ request_windows_port_cleanup() {
|
||||
mkdir -p "$PLANET_STATE_DIR"
|
||||
write_windows_port_cleanup_script "$script_path" "$@"
|
||||
|
||||
log_warn "即将请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}"
|
||||
else
|
||||
log_warn "即将请求管理员 PowerShell 清理 Windows 侧端口 ${ports_label}"
|
||||
fi
|
||||
run_windows_admin_powershell_script "$script_path" "清理 Windows 侧端口 ${ports_label}" || {
|
||||
log_warn "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定"
|
||||
else
|
||||
log_warn "管理员 PowerShell 未完成端口清理;端口 ${ports_label} 可能仍无法绑定"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -1655,13 +1664,21 @@ retry_wait_for_port_release() {
|
||||
pids="$(collect_port_pids "$port" || true)"
|
||||
elapsed_seconds="$(format_wait_elapsed_seconds "$((attempt - 1))" "$PORT_PRESTART_RETRY_INTERVAL")"
|
||||
if [ -n "$pids" ]; then
|
||||
log_warn "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理"
|
||||
else
|
||||
log_warn "${service_name}端口 ${port} 仍有监听进程,已等待 ${elapsed_seconds} 秒,继续清理"
|
||||
fi
|
||||
for pid in $pids; do
|
||||
terminate_process_tree KILL "$pid"
|
||||
done
|
||||
else
|
||||
log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放"
|
||||
print_port_listener_details "$port"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放"
|
||||
else
|
||||
log_warn "${service_name}端口 ${port} 暂未发现监听进程但仍不可绑定,已等待 ${elapsed_seconds} 秒,继续等待释放"
|
||||
print_port_listener_details "$port"
|
||||
fi
|
||||
fi
|
||||
|
||||
sleep "$PORT_PRESTART_RETRY_INTERVAL"
|
||||
@@ -1721,20 +1738,36 @@ force_cleanup_external_port_listener() {
|
||||
is_wsl_environment || return 1
|
||||
command -v powershell.exe >/dev/null 2>&1 || return 1
|
||||
|
||||
log_warn "检查 Windows 侧端口占用: ${port}"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "检查 Windows 侧端口 ${port}"
|
||||
else
|
||||
log_warn "检查 Windows 侧端口占用: ${port}"
|
||||
fi
|
||||
if ! print_windows_port_listener_details "$port"; then
|
||||
log_note "Windows 侧未发现端口 ${port} 监听,继续启动"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "Windows 侧未发现端口 ${port} 监听,继续检查"
|
||||
else
|
||||
log_note "Windows 侧未发现端口 ${port} 监听,继续启动"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理"
|
||||
else
|
||||
log_warn "Windows 侧端口 ${port} 存在监听者,准备请求管理员权限清理"
|
||||
fi
|
||||
request_windows_port_cleanup "$port" || return 1
|
||||
|
||||
if wait_for_port_release "$port" 10 0.3; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_warn "Windows 侧端口 ${port} 清理后仍不可绑定"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "Windows 侧端口 ${port} 清理后仍不可绑定"
|
||||
else
|
||||
log_warn "Windows 侧端口 ${port} 清理后仍不可绑定"
|
||||
fi
|
||||
print_windows_cleanup_recovery_steps "$port" "$service_name"
|
||||
return 1
|
||||
}
|
||||
@@ -2134,61 +2167,92 @@ wait_for_port_release() {
|
||||
return 1
|
||||
}
|
||||
|
||||
format_port_check_detail() {
|
||||
local service_name="$1"
|
||||
local port="$2"
|
||||
|
||||
printf "检查 %s 端口 %s" "$service_name" "$port" | sed -E 's/检查 (前端|后端) /检查\1/'
|
||||
}
|
||||
|
||||
kill_port_if_requested() {
|
||||
local port="$1"
|
||||
local service_name="$2"
|
||||
local pids=""
|
||||
local pid=""
|
||||
|
||||
log_warn "检测 ${service_name} 端口 ${port} 占用"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "$(format_port_check_detail "$service_name" "$port")"
|
||||
else
|
||||
log_warn "检测 ${service_name} 端口 ${port} 占用"
|
||||
fi
|
||||
|
||||
pids="$(collect_port_pids "$port" || true)"
|
||||
|
||||
if [ -z "$pids" ] && can_bind_port "$port"; then
|
||||
log_success "端口 ${port} 未被占用"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then
|
||||
log_success "端口 ${port} 未被占用"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
if force_cleanup_external_port_listener "$port" "$service_name"; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fail_unreleased_port "$port"
|
||||
fi
|
||||
|
||||
log_step "发现端口 ${port} 占用,正在终止"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "${service_name}端口 ${port} 被占用,正在释放"
|
||||
else
|
||||
log_step "发现端口 ${port} 占用,正在终止"
|
||||
fi
|
||||
for pid in $pids; do
|
||||
terminate_process_tree TERM "$pid"
|
||||
done
|
||||
|
||||
if retry_wait_for_port_release "$port" "$service_name"; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$(collect_port_pids "$port" || true)" ]; then
|
||||
if force_cleanup_external_port_listener "$port" "$service_name"; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fail_unreleased_port "$port"
|
||||
fi
|
||||
|
||||
log_warn "端口 ${port} 仍被占用,正在强制终止"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "端口 ${port} 仍被占用,正在强制释放"
|
||||
else
|
||||
log_warn "端口 ${port} 仍被占用,正在强制终止"
|
||||
fi
|
||||
pids="$(collect_port_pids "$port" || true)"
|
||||
for pid in $pids; do
|
||||
terminate_process_tree KILL "$pid"
|
||||
done
|
||||
|
||||
if retry_wait_for_port_release "$port" "$service_name"; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$(collect_port_pids "$port" || true)" ]; then
|
||||
if force_cleanup_external_port_listener "$port" "$service_name"; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 0 ]; then
|
||||
log_success "端口 ${port} 已释放"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
fail_unreleased_port "$port"
|
||||
@@ -2197,6 +2261,27 @@ kill_port_if_requested() {
|
||||
fail_unreleased_port "$port"
|
||||
}
|
||||
|
||||
release_requested_ports() {
|
||||
local service_name=""
|
||||
local port=""
|
||||
|
||||
[ "$#" -gt 0 ] || return 0
|
||||
|
||||
start_wait_session "检测端口占用情况"
|
||||
PORT_CHECK_STATUS_ACTIVE=1
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
service_name="$1"
|
||||
port="$2"
|
||||
kill_port_if_requested "$port" "$service_name"
|
||||
shift 2
|
||||
done
|
||||
|
||||
PORT_CHECK_STATUS_ACTIVE=0
|
||||
stop_wait_session
|
||||
log_success "端口已全部释放"
|
||||
}
|
||||
|
||||
frontend_log_indicates_port_conflict() {
|
||||
local log_file="$1"
|
||||
|
||||
@@ -2232,6 +2317,10 @@ foreach (\$connection in \$connections) {
|
||||
)"
|
||||
|
||||
[ -n "$output" ] || return 1
|
||||
if [ "$PORT_CHECK_STATUS_ACTIVE" -eq 1 ]; then
|
||||
set_wait_detail "$(printf "%s\n" "$output" | head -n 1)"
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
printf "${DIM} %s${NC}\n" "$line"
|
||||
@@ -2860,10 +2949,10 @@ PY
|
||||
prepare_allow_lan_public_ports() {
|
||||
[ "$FRONTEND_LAN_ENABLED" -eq 1 ] || return 0
|
||||
|
||||
log_step "检查局域网端口可绑定"
|
||||
kill_port_if_requested "$FRONTEND_PORT" "前端"
|
||||
kill_port_if_requested "$BACKEND_PORT" "后端"
|
||||
kill_port_if_requested "$AI_PROVIDER_PORT" "AI Provider"
|
||||
release_requested_ports \
|
||||
"后端" "$BACKEND_PORT" \
|
||||
"前端" "$FRONTEND_PORT" \
|
||||
"AI Provider" "$AI_PROVIDER_PORT"
|
||||
request_windows_portproxy_cleanup_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true
|
||||
request_windows_firewall_rules_if_needed "$FRONTEND_PORT" "$BACKEND_PORT" "$AI_PROVIDER_PORT" || true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.58.0"
|
||||
version = "0.59.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user