Merge branch 'codex/aiprovider-foundation' into dev

This commit is contained in:
linkong
2026-04-07 17:33:37 +08:00
42 changed files with 1766 additions and 166 deletions

View File

@@ -184,6 +184,11 @@
## 快速启动
```bash
# 新机器首次初始化
./scripts/bootstrap-dev.sh
# 会自动安装/检查 uv、bun并同步 Python/前端依赖
# 会在缺少时生成 backend/.env、aiprovider/.env、frontend/.env.local
# 启动前后端服务
./planet.sh start
@@ -204,6 +209,84 @@
启动服务后访问: `http://localhost:8000/docs`
## AI 接口预留
项目现在采用“两层”设计:
- 主后端暴露稳定业务接口: `GET /api/v1/ai/provider/status``POST /api/v1/ai/situational-awareness/analyze`
- 独立 `aiprovider` 服务负责适配具体模型供应商
这样前端和业务代码不直接依赖 OpenAI、本地模型网关或其他订阅服务后续切换部署方式只需要调整环境变量。
主后端建议配置:
```env
AI_PROVIDER_SERVICE_URL=http://localhost:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
```
`aiprovider` 服务建议配置:
```env
AI_PROVIDER=openai_compatible
AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_api_key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me
```
OpenAI 兼容场景推荐使用:
- `AI_PROVIDER=openai_compatible`
Claude 兼容场景推荐使用:
- `AI_PROVIDER=anthropic`
- `AI_PROVIDER=anthropic_compatible`
- `AI_PROVIDER=claude_compatible`
Ollama 原生场景推荐使用:
- `AI_PROVIDER=ollama`
比如 MiniMax 或其他 Claude 兼容网关,可以这样配置:
```env
AI_PROVIDER=claude_compatible
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
AI_API_KEY=your_api_key
AI_MODEL=your-claude-compatible-model
AI_TIMEOUT_SECONDS=60
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
AI_PROVIDER_SERVICE_TOKEN=change_me
```
如果你要本地直接起模型适配层,项目里已经补了模板:
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
推荐映射关系:
- `vLLM` / `LM Studio` / `One API`: `AI_PROVIDER=openai_compatible`
- `MiniMax` / Claude 兼容网关: `AI_PROVIDER=claude_compatible`
- `Ollama`: `AI_PROVIDER=ollama`
运行与调用补充:
- `./planet.sh start` 默认会启动 `aiprovider`
- 其他服务优先调用主后端 `POST /api/v1/ai/situational-awareness/analyze`
- `backend -> aiprovider` 会透传 `X-Request-ID`
- `backend -> aiprovider``aiprovider -> 模型供应商` 都带轻量重试
详细文档:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
- [aiprovider/README.md](/home/ray/dev/linkong/planet/aiprovider/README.md)
## License
待定

View File

@@ -1 +1 @@
0.22.14
0.23.0

34
aiprovider/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Shared service settings
SERVICE_NAME=planet-ai-provider
SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
# Select one provider mode:
# - openai_compatible
# - claude_compatible
# - ollama
AI_PROVIDER=ollama
# Common model selection
AI_MODEL=qwen2.5:7b
# OpenAI-compatible example (vLLM / LM Studio / One API / local gateway)
# AI_PROVIDER=openai_compatible
# AI_BASE_URL=http://127.0.0.1:8001/v1
# AI_API_KEY=local-key
# Claude-compatible example (Anthropic / MiniMax / Claude-compatible gateway)
# AI_PROVIDER=claude_compatible
# AI_BASE_URL=http://127.0.0.1:8002
# AI_API_KEY=local-key
# AI_MAX_TOKENS=1200
# AI_ANTHROPIC_VERSION=2023-06-01
# Ollama native example
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01

23
aiprovider/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-dev
COPY . /app
EXPOSE 8010
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010", "--reload"]

81
aiprovider/README.md Normal file
View File

@@ -0,0 +1,81 @@
# AI Provider Service
`aiprovider` 是独立的模型适配服务,负责把项目内部的分析请求转发到具体的大模型供应商。
完整使用说明见:
- [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md)
当前支持:
- `AI_PROVIDER=openai`
- `AI_PROVIDER=openai_compatible`
- `AI_PROVIDER=anthropic`
- `AI_PROVIDER=anthropic_compatible`
- `AI_PROVIDER=claude_compatible`
- `AI_PROVIDER=ollama`
典型配置:
```env
AI_PROVIDER=openai_compatible
AI_BASE_URL=https://api.openai.com/v1
AI_API_KEY=your_api_key
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me
```
Claude 兼容供应商示例:
```env
AI_PROVIDER=claude_compatible
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
AI_API_KEY=your_api_key
AI_MODEL=your-claude-compatible-model
AI_TIMEOUT_SECONDS=60
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
AI_PROVIDER_SERVICE_TOKEN=change_me
```
适用场景:
- Anthropic 官方 Claude API
- Claude 兼容网关
- MiniMax 等提供 Claude/Anthropic 风格消息接口的服务
Ollama 原生示例:
```env
AI_PROVIDER=ollama
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MODEL=qwen2.5:7b
AI_TIMEOUT_SECONDS=60
AI_PROVIDER_SERVICE_TOKEN=change_me
```
本地模型接入建议:
- `vLLM``LM Studio``One API`:优先使用 `openai_compatible`
- `MiniMax`、Claude 兼容网关:使用 `claude_compatible`
- `Ollama`:可直接使用 `ollama`
启动模板:
- `aiprovider/.env.example`
- `docker-compose.local-model.yml`
跨服务调用补充:
- 业务服务优先调用主后端 `/api/v1/ai/...`
- 直接调用 `aiprovider` 时使用 `X-Provider-Token`
- 支持 `X-Request-ID` 透传
- 内置轻量重试,适合跨机器 HTTP RPC 场景
接口:
- `GET /health`
- `GET /v1/provider/status`
- `POST /v1/analyze`

1
aiprovider/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""AI provider adapter service package."""

35
aiprovider/config.py Normal file
View File

@@ -0,0 +1,35 @@
from functools import lru_cache
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SERVICE_NAME: str = "planet-ai-provider"
SERVICE_VERSION: str = "0.1.0"
AI_PROVIDER: str = "disabled"
AI_BASE_URL: str = "https://api.openai.com/v1"
AI_API_KEY: str = ""
AI_MODEL: str = ""
AI_TIMEOUT_SECONDS: int = 60
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 = ""
class Config:
env_file = Path(__file__).parent / ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
return Settings()
settings = get_settings()

79
aiprovider/main.py Normal file
View File

@@ -0,0 +1,79 @@
from uuid import uuid4
from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status
from aiprovider.config import settings
from aiprovider.provider_service import ProviderService
from aiprovider.schemas import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
app = FastAPI(
title=settings.SERVICE_NAME,
version=settings.SERVICE_VERSION,
description="AI provider adapter service for Planet",
)
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
def verify_service_token(x_provider_token: str | None = Header(default=None)) -> None:
expected = settings.AI_PROVIDER_SERVICE_TOKEN
if not expected:
return
if x_provider_token != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provider service token",
)
def get_provider_service() -> ProviderService:
return ProviderService()
@app.get("/health")
async def health_check():
return {
"status": "healthy",
"service": settings.SERVICE_NAME,
"version": settings.SERVICE_VERSION,
}
@app.get(
"/v1/provider/status",
response_model=AIProviderStatusResponse,
dependencies=[Depends(verify_service_token)],
)
async def get_provider_status(
response: Response,
request: Request,
provider_service: ProviderService = Depends(get_provider_service),
):
response.headers["X-Request-ID"] = request.state.request_id
return provider_service.get_status()
@app.post(
"/v1/analyze",
response_model=SituationalAnalysisResponse,
dependencies=[Depends(verify_service_token)],
)
async def analyze(
payload: SituationalAnalysisRequest,
response: Response,
request: Request,
provider_service: ProviderService = Depends(get_provider_service),
):
response.headers["X-Request-ID"] = request.state.request_id
return await provider_service.analyze(payload)

View File

@@ -0,0 +1,240 @@
from __future__ import annotations
import asyncio
from typing import Any
import httpx
from fastapi import HTTPException, status
from aiprovider.config import settings
from aiprovider.schemas import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
def _normalize_provider(value: str) -> str:
return (value or "disabled").strip().lower()
class ProviderService:
def __init__(self) -> None:
self.provider = _normalize_provider(settings.AI_PROVIDER)
self.base_url = settings.AI_BASE_URL.rstrip("/")
self.api_key = settings.AI_API_KEY
self.default_model = settings.AI_MODEL
self.timeout = settings.AI_TIMEOUT_SECONDS
self.http_retry_attempts = max(settings.AI_HTTP_RETRY_ATTEMPTS, 1)
self.max_tokens = settings.AI_MAX_TOKENS
self.anthropic_version = settings.AI_ANTHROPIC_VERSION
self.system_prompt = settings.AI_ANALYSIS_SYSTEM_PROMPT
def get_status(self) -> AIProviderStatusResponse:
enabled = self.provider != "disabled"
configured = enabled and bool(self.base_url and self.api_key and self.default_model)
return AIProviderStatusResponse(
provider=self.provider,
enabled=enabled,
configured=configured,
model=self.default_model or None,
base_url=self.base_url if enabled else None,
)
async def analyze(self, payload: SituationalAnalysisRequest) -> SituationalAnalysisResponse:
if self.provider == "disabled":
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI provider is disabled. Configure AI_PROVIDER in .env to enable analysis.",
)
model = payload.preferred_model or self.default_model
if not self.base_url or not self.api_key or not model:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI provider is not fully configured. Check AI_BASE_URL, AI_API_KEY, and AI_MODEL.",
)
prompt = self._build_prompt(payload)
if self.provider in {"openai", "openai_compatible"}:
data = await self._request_openai_compatible(model, prompt)
content = self._extract_openai_content(data)
elif self.provider in {"anthropic", "anthropic_compatible", "claude_compatible"}:
data = await self._request_anthropic_compatible(model, prompt)
content = self._extract_anthropic_content(data)
elif self.provider == "ollama":
data = await self._request_ollama(model, prompt)
content = self._extract_ollama_content(data)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported AI provider: {self.provider}",
)
return SituationalAnalysisResponse(
provider=self.provider,
model=model,
content=content,
raw_response=data,
)
def _build_prompt(self, payload: SituationalAnalysisRequest) -> str:
sections = [
f"任务标题:\n{payload.title}",
f"分析目标:\n{payload.objective}",
]
if payload.observations:
sections.append("观测事实:\n" + "\n".join(f"- {item}" for item in payload.observations))
if payload.constraints:
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]:
request_body = {
"model": model,
"messages": [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
}
return await self._post(
path="/chat/completions",
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
request_body=request_body,
)
async def _request_anthropic_compatible(self, model: str, prompt: str) -> dict[str, Any]:
request_body = {
"model": model,
"system": self.system_prompt,
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": prompt,
}
],
}
],
"max_tokens": self.max_tokens,
"temperature": 0.2,
}
return await self._post(
path="/messages",
headers={
"x-api-key": self.api_key,
"anthropic-version": self.anthropic_version,
"Content-Type": "application/json",
},
request_body=request_body,
)
async def _request_ollama(self, model: str, prompt: str) -> dict[str, Any]:
request_body = {
"model": model,
"stream": False,
"system": self.system_prompt,
"prompt": prompt,
"options": {
"temperature": 0.2,
},
}
return await self._post(
path="/api/generate",
headers={
"Content-Type": "application/json",
},
request_body=request_body,
)
async def _post(
self,
path: str,
headers: dict[str, str],
request_body: dict[str, Any],
) -> dict[str, Any]:
last_error: Exception | None = None
for attempt in range(1, self.http_retry_attempts + 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}{path}",
headers=headers,
json=request_body,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
last_error = exc
if attempt < self.http_retry_attempts and exc.response.status_code >= 500:
await asyncio.sleep(0.3 * attempt)
continue
detail = exc.response.text or "AI provider returned an error"
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider request failed: {detail}",
) from exc
except httpx.HTTPError as exc:
last_error = exc
if attempt < self.http_retry_attempts:
await asyncio.sleep(0.3 * attempt)
continue
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to reach AI provider: {exc}",
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider request failed: {last_error}",
)
def _extract_openai_content(self, payload: dict[str, Any]) -> str:
choices = payload.get("choices") or []
if not choices:
return ""
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(
item.get("text", "")
for item in content
if isinstance(item, dict)
)
return ""
def _extract_anthropic_content(self, payload: dict[str, Any]) -> str:
content = payload.get("content")
if isinstance(content, str):
return content
if not isinstance(content, list):
return ""
fragments: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text" and isinstance(item.get("text"), str):
fragments.append(item["text"])
return "".join(fragments)
def _extract_ollama_content(self, payload: dict[str, Any]) -> str:
response = payload.get("response")
if isinstance(response, str):
return response
return ""

27
aiprovider/schemas.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from pydantic import BaseModel, Field
class SituationalAnalysisRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
context: dict[str, Any] = Field(default_factory=dict)
observations: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
preferred_model: str | None = Field(default=None, max_length=200)
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
content: str
raw_response: dict[str, Any] = Field(default_factory=dict)
class AIProviderStatusResponse(BaseModel):
provider: str
enabled: bool
configured: bool
model: str | None = None
base_url: str | None = None

View File

@@ -1,23 +1,26 @@
# Database
PROJECT_NAME=Intelligent Planet Plan
APP_VERSION=0.23.0
SECRET_KEY=change_me_to_a_random_secret
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=0
REFRESH_TOKEN_EXPIRE_DAYS=0
POSTGRES_SERVER=localhost
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=planet_db
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db
# Redis
REDIS_SERVER=localhost
REDIS_PORT=6379
REDIS_DB=0
REDIS_URL=redis://localhost:6379/0
# Security
SECRET_KEY=your-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
AI_PROVIDER_SERVICE_URL=http://localhost:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
# API
API_V1_STR=/api/v1
PROJECT_NAME="Intelligent Planet Plan"
VERSION=1.0.0
# CORS
CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"]
SPACETRACK_USERNAME=
SPACETRACK_PASSWORD=

View File

@@ -1,19 +1,24 @@
FROM python:3.11-slim
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-dev
COPY . .
COPY backend /app/backend
COPY VERSION /app/VERSION
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import (
ai,
auth,
users,
datasource_config,
@@ -18,6 +19,7 @@ from app.api.v1 import (
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(ai.router, prefix="/ai", tags=["ai"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(
datasource_config.router, prefix="/datasources", tags=["datasource-config"]

39
backend/app/api/v1/ai.py Normal file
View File

@@ -0,0 +1,39 @@
from uuid import uuid4
from fastapi import APIRouter, Depends, Request, Response
from app.core.security import get_current_user
from app.models.user import User
from app.schemas.ai import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
router = APIRouter()
@router.get("/provider/status", response_model=AIProviderStatusResponse)
async def get_ai_provider_status(
request: Request,
response: Response,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
return await provider_client.get_status(request_id=request_id)
@router.post("/situational-awareness/analyze", response_model=SituationalAnalysisResponse)
async def analyze_situational_awareness(
payload: SituationalAnalysisRequest,
request: Request,
response: Response,
current_user: User = Depends(get_current_user),
provider_client: AIProviderClient = Depends(get_ai_provider_client),
):
request_id = request.headers.get("X-Request-ID") or str(uuid4())
response.headers["X-Request-ID"] = request_id
return await provider_client.analyze(payload, request_id=request_id)

View File

@@ -37,6 +37,11 @@ class Settings(BaseSettings):
SPACETRACK_USERNAME: str = ""
SPACETRACK_PASSWORD: str = ""
AI_PROVIDER_SERVICE_URL: str = "http://localhost:8010"
AI_PROVIDER_SERVICE_TOKEN: str = ""
AI_PROVIDER_TIMEOUT_SECONDS: int = 60
AI_PROVIDER_RETRY_ATTEMPTS: int = 2
@property
def REDIS_URL(self) -> str:
return os.getenv(
@@ -46,6 +51,7 @@ class Settings(BaseSettings):
class Config:
env_file = Path(__file__).parent.parent.parent / ".env"
case_sensitive = True
extra = "ignore"
@lru_cache()

27
backend/app/schemas/ai.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from pydantic import BaseModel, Field
class SituationalAnalysisRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
objective: str = Field(..., min_length=1, max_length=1000)
context: dict[str, Any] = Field(default_factory=dict)
observations: list[str] = Field(default_factory=list)
constraints: list[str] = Field(default_factory=list)
preferred_model: str | None = Field(default=None, max_length=200)
class SituationalAnalysisResponse(BaseModel):
provider: str
model: str
content: str
raw_response: dict[str, Any] = Field(default_factory=dict)
class AIProviderStatusResponse(BaseModel):
provider: str
enabled: bool
configured: bool
model: str | None = None
base_url: str | None = None

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import httpx
from fastapi import HTTPException, status
from app.core.config import settings
from app.schemas.ai import (
AIProviderStatusResponse,
SituationalAnalysisRequest,
SituationalAnalysisResponse,
)
class AIProviderClient:
def __init__(self) -> None:
self.service_url = settings.AI_PROVIDER_SERVICE_URL.rstrip("/")
self.service_token = settings.AI_PROVIDER_SERVICE_TOKEN
self.timeout = settings.AI_PROVIDER_TIMEOUT_SECONDS
self.retry_attempts = max(settings.AI_PROVIDER_RETRY_ATTEMPTS, 1)
def _headers(self, request_id: str | None = None) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.service_token:
headers["X-Provider-Token"] = self.service_token
if request_id:
headers["X-Request-ID"] = request_id
return headers
async def get_status(self, request_id: str | None = None) -> AIProviderStatusResponse:
if not self.service_url:
return AIProviderStatusResponse(
provider="unconfigured",
enabled=False,
configured=False,
model=None,
base_url=None,
)
data = await self._request("GET", "/v1/provider/status", request_id=request_id)
return AIProviderStatusResponse.model_validate(data)
async def analyze(
self,
payload: SituationalAnalysisRequest,
request_id: str | None = None,
) -> SituationalAnalysisResponse:
if not self.service_url:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI provider service URL is not configured.",
)
data = await self._request(
"POST",
"/v1/analyze",
json=payload.model_dump(),
request_id=request_id,
)
return SituationalAnalysisResponse.model_validate(data)
async def _request(
self,
method: str,
path: str,
json: dict | None = None,
request_id: str | None = None,
) -> dict:
last_error: Exception | None = None
for attempt in range(1, self.retry_attempts + 1):
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method,
f"{self.service_url}{path}",
headers=self._headers(request_id),
json=json,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
last_error = exc
if attempt < self.retry_attempts and exc.response.status_code >= 500:
await asyncio.sleep(0.3 * attempt)
continue
detail = exc.response.text or "AI provider service returned an error"
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider service request failed: {detail}",
) from exc
except httpx.HTTPError as exc:
last_error = exc
if attempt < self.retry_attempts:
await asyncio.sleep(0.3 * attempt)
continue
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to reach AI provider service: {exc}",
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"AI provider service request failed: {last_error}",
)
def get_ai_provider_client() -> AIProviderClient:
return AIProviderClient()

View File

@@ -19,6 +19,10 @@ ALLOWED_ACTIONS: dict[str, dict[str, Any]] = {
"command": ["./planet.sh", "restart", "-b"],
"recovery_mode": "backend",
},
"restart-ai-provider": {
"command": ["./planet.sh", "restart", "-a"],
"recovery_mode": "ai-provider",
},
"restart-database": {
"command": ["./planet.sh", "restart", "-d"],
"recovery_mode": "database",

View File

@@ -1,19 +0,0 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
sqlalchemy[asyncio]>=2.0.25
asyncpg>=0.29.0
redis>=5.0.1
pydantic>=2.5.0
pydantic-settings>=2.1.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
httpx>=0.26.0
beautifulsoup4>=4.12.0
aiofiles>=23.2.1
python-dotenv>=1.0.0
email-validator
apscheduler>=3.10.4
pytest>=7.4.0
pytest-asyncio>=0.23.0
networkx>=3.0

View File

@@ -59,6 +59,8 @@ def wait_for_recovery(action: str) -> tuple[bool, str]:
recovery_mode = get_action_recovery_mode(action)
if recovery_mode == "backend":
return wait_for_http("http://localhost:8000/health"), "backend health recovery"
if recovery_mode == "ai-provider":
return wait_for_http("http://localhost:8010/health"), "ai provider health recovery"
if recovery_mode == "database":
return True, "database container restart completion"
if recovery_mode == "system":

View File

@@ -10,6 +10,7 @@ from app.core.config import settings
from app.core.security import create_access_token
from app.db.session import get_db
from app.models.user import User
from app.schemas.ai import AIProviderStatusResponse, SituationalAnalysisResponse
@pytest.fixture
@@ -156,3 +157,89 @@ async def test_invalid_token():
headers={"Authorization": "Bearer invalid_token"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_ai_provider_status_with_auth(auth_headers):
"""Test AI provider status endpoint"""
class _FakeAIProviderClient:
async def get_status(self, request_id=None):
return AIProviderStatusResponse(
provider="openai_compatible",
enabled=True,
configured=True,
model="test-model",
base_url="http://aiprovider:8010",
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
}
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/ai/provider/status", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "provider" in data
assert "configured" in data
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers):
"""Test AI analysis endpoint proxies provider service response"""
class _FakeAIProviderClient:
async def analyze(self, _payload, request_id=None):
return SituationalAnalysisResponse(
provider="openai_compatible",
model="test-model",
content="1) 态势摘要: 测试返回",
raw_response={"id": "mock-response"},
)
def override_get_current_user():
return User(
id=1,
username="testuser",
email="test@example.com",
password_hash="hashed",
role="admin",
is_active=True,
)
app.dependency_overrides = {
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
__import__("app.services.ai_client", fromlist=["get_ai_provider_client"]).get_ai_provider_client: lambda: _FakeAIProviderClient(),
}
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/api/v1/ai/situational-awareness/analyze",
headers=auth_headers,
json={
"title": "BGP 异常研判",
"objective": "给出当前异常的风险摘要和建议动作",
"observations": ["collector A 在 5 分钟内出现多个 origin 变更"],
"constraints": ["不要假设缺失数据"],
},
)
assert response.status_code == 200
data = response.json()
assert data["provider"] == "openai_compatible"
assert data["content"]
finally:
app.dependency_overrides.clear()

View File

@@ -0,0 +1,42 @@
version: '3.8'
services:
ollama:
image: ollama/ollama:latest
container_name: planet_ollama
ports:
- "11434:11434"
volumes:
- ollama_data:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 20s
timeout: 10s
retries: 10
aiprovider:
build:
context: .
dockerfile: aiprovider/Dockerfile
container_name: planet_aiprovider
ports:
- "8010:8010"
environment:
AI_PROVIDER: ollama
AI_BASE_URL: http://ollama:11434
AI_API_KEY: ""
AI_MODEL: qwen2.5:7b
AI_TIMEOUT_SECONDS: 60
AI_MAX_TOKENS: 1200
AI_PROVIDER_SERVICE_TOKEN: change_me
depends_on:
ollama:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8010/health"]
interval: 10s
timeout: 5s
retries: 5
volumes:
ollama_data:

View File

@@ -1,6 +1,14 @@
version: '3.8'
services:
aiprovider:
build:
context: .
dockerfile: aiprovider/Dockerfile
container_name: planet_aiprovider
ports:
- "8010:8010"
postgres:
image: postgres:15
container_name: planet_postgres

View File

@@ -1,6 +1,19 @@
version: '3.8'
services:
aiprovider:
build:
context: .
dockerfile: aiprovider/Dockerfile
container_name: planet_aiprovider
ports:
- "8010:8010"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8010/health"]
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres:15
container_name: planet_postgres

View File

@@ -7,6 +7,37 @@ This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
## 0.23.0
Released: 2026-04-07
### Highlights
- Introduced a dedicated `aiprovider` service so the main backend now exposes stable AI business APIs while model-vendor integration lives behind an internal adapter boundary.
- Added multi-protocol model access for `openai-compatible`, `claude-compatible`, and native `ollama` local-model flows, including local startup templates and service-aware restart controls.
- Standardized Python runtime management on `uv` across backend and `aiprovider`, removing the old container-side `pip/requirements.txt` installation path.
### Added
- Added [backend/app/api/v1/ai.py](/home/ray/dev/linkong/planet/backend/app/api/v1/ai.py), exposing stable AI business endpoints for provider status and situational-awareness analysis.
- Added [backend/app/services/ai_client.py](/home/ray/dev/linkong/planet/backend/app/services/ai_client.py), introducing an internal HTTP client for `backend -> aiprovider` calls with request-id propagation and lightweight retry.
- Added [aiprovider/main.py](/home/ray/dev/linkong/planet/aiprovider/main.py), [aiprovider/provider_service.py](/home/ray/dev/linkong/planet/aiprovider/provider_service.py), and related config/schema files to stand up the dedicated adapter service.
- Added [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example) and [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml) as ready-to-edit local-model templates.
- Added [docs/aiprovider.md](/home/ray/dev/linkong/planet/docs/aiprovider.md), documenting architecture, configuration, single-machine and multi-machine deployment, and cross-service calling patterns.
- Added a dedicated `重启 AI Provider` control path in [Dashboard.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Dashboard/Dashboard.tsx), [system_control.py](/home/ray/dev/linkong/planet/backend/app/services/system_control.py), and [system_restart_runner.py](/home/ray/dev/linkong/planet/backend/scripts/system_restart_runner.py).
### Improved
- Improved backend-to-provider tracing by propagating `X-Request-ID` through the AI call chain and returning the same header from both backend and `aiprovider`.
- Improved resilience by adding lightweight retry handling to both `backend -> aiprovider` and `aiprovider -> model provider` HTTP calls.
- Improved operator workflow by folding `aiprovider` startup, health checks, restart support, and log viewing into [planet.sh](/home/ray/dev/linkong/planet/planet.sh).
- Improved container consistency by switching [backend/Dockerfile](/home/ray/dev/linkong/planet/backend/Dockerfile) and [aiprovider/Dockerfile](/home/ray/dev/linkong/planet/aiprovider/Dockerfile) to `uv sync` / `uv run`.
### Changed
- Changed the repository Python dependency source of truth to `pyproject.toml + uv.lock`, and removed the old `backend/requirements.txt` path.
- Changed local restart wording in the dashboard from the vague `重启服务器` label to the more specific `重启后端`, reducing ambiguity once `aiprovider` became independently restartable.
## 0.22.14
Released: 2026-04-07

290
docs/aiprovider.md Normal file
View File

@@ -0,0 +1,290 @@
# AI Provider Guide
## Overview
`aiprovider` is the model-adapter service for Planet.
It isolates model-vendor details from the main backend so the rest of the system can call a stable business API:
- Caller service -> `planet backend`
- `planet backend` -> `aiprovider`
- `aiprovider` -> concrete model provider
The recommended default is:
- External and cross-service callers use `planet backend`
- Only infrastructure-grade internal jobs call `aiprovider` directly
## Responsibilities
`backend` is responsible for:
- authentication and authorization
- business-level request shaping
- stable `/api/v1/ai/...` endpoints
- internal service-to-service authentication toward `aiprovider`
`aiprovider` is responsible for:
- model protocol adaptation
- provider selection by `.env`
- timeout and lightweight retry
- request tracing via `X-Request-ID`
## Supported Providers
`aiprovider` currently supports:
- `openai`
- `openai_compatible`
- `anthropic`
- `anthropic_compatible`
- `claude_compatible`
- `ollama`
Provider mapping:
- `vLLM`, `LM Studio`, `One API`: `openai_compatible`
- `MiniMax`, Claude-compatible gateways: `claude_compatible`
- `Ollama`: `ollama`
## API Surfaces
### Main backend API
Preferred stable entrypoints:
- `GET /api/v1/ai/provider/status`
- `POST /api/v1/ai/situational-awareness/analyze`
Authentication:
- `Authorization: Bearer <jwt>`
Optional tracing header:
- `X-Request-ID: <caller-generated-id>`
The backend will propagate `X-Request-ID` to `aiprovider` and return the same header in the response.
### AI provider internal API
Internal-only endpoints:
- `GET /v1/provider/status`
- `POST /v1/analyze`
Authentication:
- `X-Provider-Token: <shared-secret>`
Optional tracing header:
- `X-Request-ID: <caller-generated-id>`
## Request Example
### Call through backend
```bash
curl -X POST http://localhost:8000/api/v1/ai/situational-awareness/analyze \
-H "Authorization: Bearer <access_token>" \
-H "X-Request-ID: bgp-incident-20260407-001" \
-H "Content-Type: application/json" \
-d '{
"title": "BGP异常研判",
"objective": "总结当前风险并给出处置建议",
"observations": [
"collector A 在 5 分钟内出现多次 origin 变更",
"异常集中在同一地区前缀"
],
"constraints": [
"不要编造不存在的数据",
"区分事实和推断"
],
"context": {
"source": "bgp-monitor",
"severity": "high"
}
}'
```
### Call `aiprovider` directly
```bash
curl -X POST http://localhost:8010/v1/analyze \
-H "X-Provider-Token: change_me" \
-H "X-Request-ID: ai-batch-job-001" \
-H "Content-Type: application/json" \
-d '{
"title": "链路波动分析",
"objective": "给出简要态势摘要和下一步建议",
"observations": [
"多个节点出现延迟上升"
],
"constraints": [
"不要假设根因已经确认"
],
"context": {
"region": "APAC"
}
}'
```
## Response Shape
Both backend and `aiprovider` return the same payload shape:
```json
{
"provider": "openai_compatible",
"model": "gpt-4o-mini",
"content": "1) 态势摘要 ...",
"raw_response": {}
}
```
Both services also return:
- `X-Request-ID: <id>`
## Configuration
### Backend
Recommended backend `.env`:
```env
AI_PROVIDER_SERVICE_URL=http://localhost:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
```
Reference file:
- [backend/.env.example](/home/ray/dev/linkong/planet/backend/.env.example)
### AI Provider
Reference file:
- [aiprovider/.env.example](/home/ray/dev/linkong/planet/aiprovider/.env.example)
Frontend local reference:
- [frontend/.env.example](/home/ray/dev/linkong/planet/frontend/.env.example)
Common settings:
```env
SERVICE_NAME=planet-ai-provider
SERVICE_VERSION=0.1.0
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_TIMEOUT_SECONDS=60
AI_HTTP_RETRY_ATTEMPTS=2
AI_ANALYSIS_SYSTEM_PROMPT=你是态势感知分析助手。请基于输入的上下文、观测与约束,输出结构化、克制、可执行的分析。
```
### OpenAI-compatible example
```env
AI_PROVIDER=openai_compatible
AI_BASE_URL=http://127.0.0.1:8001/v1
AI_API_KEY=local-key
AI_MODEL=your-local-model
```
### Claude-compatible example
```env
AI_PROVIDER=claude_compatible
AI_BASE_URL=https://your-claude-compatible-endpoint.example.com
AI_API_KEY=your_api_key
AI_MODEL=your-model
AI_MAX_TOKENS=1200
AI_ANTHROPIC_VERSION=2023-06-01
```
### Ollama example
```env
AI_PROVIDER=ollama
AI_BASE_URL=http://127.0.0.1:11434
AI_API_KEY=
AI_MODEL=qwen2.5:7b
```
## Deployment Modes
### Single machine
Recommended local flow:
- `backend` on `localhost:8000`
- `aiprovider` on `localhost:8010`
- local model gateway on `localhost:11434` or another local port
Helpers already included:
- [planet.sh](/home/ray/dev/linkong/planet/planet.sh)
- [docker-compose.local-model.yml](/home/ray/dev/linkong/planet/docker-compose.local-model.yml)
### Multi-machine
Example topology:
- app machine: `backend`
- AI gateway machine: `aiprovider`
- model machine: local model service or cloud proxy
In that case, this becomes service-to-service HTTP RPC:
- caller -> backend
- backend -> `http://10.0.0.12:8010`
- `aiprovider` -> model endpoint
Recommended cross-machine backend config:
```env
AI_PROVIDER_SERVICE_URL=http://10.0.0.12:8010
AI_PROVIDER_SERVICE_TOKEN=change_me
AI_PROVIDER_TIMEOUT_SECONDS=60
AI_PROVIDER_RETRY_ATTEMPTS=2
```
Recommended operating rules:
- keep `aiprovider` on a private network
- protect it with `X-Provider-Token` at minimum
- always send `X-Request-ID`
- keep callers on the backend API unless they are infrastructure jobs
## Retry And Failure Behavior
`backend -> aiprovider`:
- retries lightweight network / 5xx failures
- returns `502` when the provider service is unavailable
`aiprovider -> model provider`:
- retries lightweight network / 5xx failures
- returns `502` when the model provider is unavailable
This is intentionally conservative. It avoids masking persistent errors while still absorbing short hiccups.
## Operational Notes
- `./planet.sh start` now starts `aiprovider` automatically
- `./planet.sh restart -a` restarts only `aiprovider`
- `./planet.sh log -a` tails `aiprovider` logs
- `./planet.sh health` reports `aiprovider` health
## Recommended Calling Policy
- Frontend and application services: call `backend`
- Scheduled infra jobs and diagnostics: optionally call `aiprovider`
- Do not let multiple business services integrate model vendors independently
That keeps provider switching centralized and avoids model-specific drift across the system.

View File

@@ -16,7 +16,7 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.22.9`
- `dev` 当前开发分支历史推导到:`0.23.0`
## Timeline
@@ -70,6 +70,7 @@
| `0.21.5` | bugfix | `dev` | `a761dfc5` | refine Earth legend item presentation |
| `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish |
| `0.22.9` | bugfix | `dev` | `6bfcd053` | simplify `planet.sh` readiness messaging and only show retry counts on actual restart |
| `0.23.0` | feature | `dev` | `pending` | add dedicated `aiprovider` service, multi-protocol AI adapters, uv-only Python runtime, and AI Provider restart controls |
## Maintenance Commits Not Counted as Version Bumps

View File

@@ -1,2 +1,3 @@
VITE_API_URL=/api/v1
VITE_WS_URL=ws://localhost:8000/ws
VITE_WS_URL=
VITE_SA_GATEWAY=http

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.22.14",
"version": "0.23.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -1,100 +1,20 @@
import { useEffect, useState } from 'react'
import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
import {
getSituationalAwarenessGateway,
type BGPAnomaly,
type BGPCollectorCoverage,
type BGPEvent,
type BGPIncident,
type CollectorSummary,
type EventSummary,
type Summary,
} from '../../services/situational-awareness'
const { Title, Text } = Typography
interface BGPAnomaly {
id: number
source: string
anomaly_type: string
severity: string
status: string
prefix: string | null
origin_asn: number | null
new_origin_asn: number | null
confidence: number
summary: string
created_at: string | null
}
interface BGPEvent {
id: number
collector: string | null
event_type: string
prefix: string | null
origin_asn: number | null
peer_asn: number | null
observed_at: string | null
}
interface BGPCollectorCoverage {
collector: string
city?: string | null
country?: string | null
observation_count: number
recent_24h_observation_count: number
recent_7d_observation_count: number
prefix_count: number
recent_24h_prefix_count: number
recent_7d_prefix_count: number
origin_asn_count: number
peer_asn_count: number
latest_observed_at: string | null
latest_event_type: string | null
baseline_scope: {
countries: string[]
cities: string[]
}
}
interface BGPIncident {
id: number
incident_type: string
title: string
summary: string
severity: string
status: string
confidence: number
affected_prefixes: string[]
affected_asns: number[]
affected_collectors: string[]
affected_regions: Array<{ country?: string; city?: string }>
related_cables: Array<{
landing_point?: string
city?: string
country?: string
distance_km?: number
cable_names?: string[]
}>
created_at: string | null
started_at: string | null
}
interface Summary {
total: number
by_type: Record<string, number>
by_severity: Record<string, number>
by_status: Record<string, number>
}
interface EventSummary {
total: number
collector_count: number
prefix_count: number
by_type: Record<string, number>
}
interface CollectorSummary {
total: number
active_collectors: number
observed_prefixes: number
observed_origins: number
recent_24h_events: number
recent_7d_events: number
}
const situationalAwarenessGateway = getSituationalAwarenessGateway()
function severityColor(severity: string) {
if (severity === 'critical') return 'red'
@@ -117,22 +37,20 @@ function BGP() {
const load = async () => {
setLoading(true)
try {
const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([
axios.get('/api/v1/bgp/incidents', { params: { page_size: 50 } }),
axios.get('/api/v1/bgp/incidents/summary'),
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
axios.get('/api/v1/bgp/events', { params: { page_size: 20 } }),
axios.get('/api/v1/bgp/events/summary'),
axios.get('/api/v1/bgp/collectors'),
axios.get('/api/v1/bgp/collectors/summary'),
])
setIncidents(incidentsRes.data.data || [])
setIncidentSummary(incidentSummaryRes.data)
setAnomalies(anomaliesRes.data.data || [])
setEvents(eventsRes.data.data || [])
setEventSummary(eventSummaryRes.data)
setCollectors(collectorsRes.data.data || [])
setCollectorSummary(collectorSummaryRes.data)
const snapshot = await situationalAwarenessGateway.getBGPOverview({
incidentPageSize: 50,
anomalyPageSize: 100,
eventPageSize: 20,
})
setIncidents(snapshot.incidents)
setIncidentSummary(snapshot.incidentSummary)
setAnomalies(snapshot.anomalies)
setEvents(snapshot.events)
setEventSummary(snapshot.eventSummary)
setCollectors(snapshot.collectors)
setCollectorSummary(snapshot.collectorSummary)
} catch (error) {
console.error('Failed to load BGP overview:', error)
} finally {
setLoading(false)
}

View File

@@ -47,16 +47,22 @@ interface RestartTaskLogs {
lines: string[]
}
type RestartAction = 'restart-backend' | 'restart-database' | 'restart-system'
type RestartAction = 'restart-backend' | 'restart-ai-provider' | 'restart-database' | 'restart-system'
type RestartStage = 'confirming' | 'waiting_for_shutdown' | 'waiting_for_recovery' | 'recovered' | 'failed' | 'timeout'
const RESTART_ACTION_OPTIONS: Array<{ value: RestartAction; label: string; description: string; command: string }> = [
{
value: 'restart-backend',
label: '重启服务器',
label: '重启后端',
description: '只重启后端服务,页面通常会短暂失联后自动恢复。',
command: './planet.sh restart -b',
},
{
value: 'restart-ai-provider',
label: '重启 AI Provider',
description: '只重启 AI Provider 适配服务,前端页面通常保持在线。',
command: './planet.sh restart -a',
},
{
value: 'restart-database',
label: '重启数据库',
@@ -77,6 +83,11 @@ const RESTART_GUIDE_LINES: Record<RestartAction, string[]> = {
'[ctl] handing restart to detached runner',
'[ctl] waiting for backend health recovery',
],
'restart-ai-provider': [
'[ctl] preparing ai provider restart task',
'[ctl] handing restart to detached runner',
'[ctl] waiting for ai provider health recovery',
],
'restart-database': [
'[ctl] preparing database restart task',
'[ctl] restarting PostgreSQL and Redis containers',
@@ -94,6 +105,9 @@ const RESTART_GUIDE_LINES: Record<RestartAction, string[]> = {
let cachedDashboardStats: Stats | null = null
function getRestartConfirmMessage(action: RestartAction): string {
if (action === 'restart-ai-provider') {
return '将重启 AI Provider 适配服务,页面通常保持在线,但 AI 分析请求会短暂不可用。'
}
if (action === 'restart-database') {
return '将重启 PostgreSQL 和 Redis页面通常保持在线但相关请求可能短暂波动。'
}
@@ -200,6 +214,8 @@ function Dashboard() {
setRestartMessage(
restartAction === 'restart-system'
? '已发送完全重启指令,页面可能暂时失联,恢复后会自动刷新。'
: restartAction === 'restart-ai-provider'
? '已发送 AI Provider 重启指令,正在等待 AI 服务恢复。'
: '已发送重启指令,正在等待服务进入重启流程。'
)
setRestartLogs((current) => [...current, `任务已创建: ${res.data.task_id}`])

View File

@@ -0,0 +1,46 @@
import axios from 'axios'
import type { SituationalAwarenessGateway } from './port'
import type {
BGPAnomaly,
BGPCollectorCoverage,
BGPEvent,
BGPIncident,
BGPOverviewOptions,
BGPOverviewSnapshot,
CollectorSummary,
EventSummary,
ListResponse,
Summary,
} from './types'
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway {
async getBGPOverview(options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
const {
incidentPageSize = 50,
anomalyPageSize = 100,
eventPageSize = 20,
} = options
const [incidentsRes, incidentSummaryRes, anomaliesRes, eventsRes, eventSummaryRes, collectorsRes, collectorSummaryRes] = await Promise.all([
axios.get<ListResponse<BGPIncident>>(`${API_BASE_URL}/bgp/incidents`, { params: { page_size: incidentPageSize } }),
axios.get<Summary>(`${API_BASE_URL}/bgp/incidents/summary`),
axios.get<ListResponse<BGPAnomaly>>(`${API_BASE_URL}/bgp/anomalies`, { params: { page_size: anomalyPageSize } }),
axios.get<ListResponse<BGPEvent>>(`${API_BASE_URL}/bgp/events`, { params: { page_size: eventPageSize } }),
axios.get<EventSummary>(`${API_BASE_URL}/bgp/events/summary`),
axios.get<ListResponse<BGPCollectorCoverage>>(`${API_BASE_URL}/bgp/collectors`),
axios.get<CollectorSummary>(`${API_BASE_URL}/bgp/collectors/summary`),
])
return {
incidents: incidentsRes.data.data || [],
incidentSummary: incidentSummaryRes.data,
anomalies: anomaliesRes.data.data || [],
events: eventsRes.data.data || [],
eventSummary: eventSummaryRes.data,
collectors: collectorsRes.data.data || [],
collectorSummary: collectorSummaryRes.data,
}
}
}

View File

@@ -0,0 +1,23 @@
import type { SituationalAwarenessGateway } from './port'
import { HttpSituationalAwarenessGateway } from './http-gateway'
import { MockSituationalAwarenessGateway } from './mock-gateway'
export * from './types'
export type { SituationalAwarenessGateway } from './port'
let singleton: SituationalAwarenessGateway | null = null
export function createSituationalAwarenessGateway(): SituationalAwarenessGateway {
const provider = (import.meta as any).env?.VITE_SA_GATEWAY || 'http'
if (provider === 'mock') {
return new MockSituationalAwarenessGateway()
}
return new HttpSituationalAwarenessGateway()
}
export function getSituationalAwarenessGateway(): SituationalAwarenessGateway {
if (!singleton) {
singleton = createSituationalAwarenessGateway()
}
return singleton
}

View File

@@ -0,0 +1,35 @@
import type { SituationalAwarenessGateway } from './port'
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
const EMPTY_SNAPSHOT: BGPOverviewSnapshot = {
incidents: [],
incidentSummary: {
total: 0,
by_type: {},
by_severity: {},
by_status: {},
},
anomalies: [],
events: [],
eventSummary: {
total: 0,
collector_count: 0,
prefix_count: 0,
by_type: {},
},
collectors: [],
collectorSummary: {
total: 0,
active_collectors: 0,
observed_prefixes: 0,
observed_origins: 0,
recent_24h_events: 0,
recent_7d_events: 0,
},
}
export class MockSituationalAwarenessGateway implements SituationalAwarenessGateway {
async getBGPOverview(_options: BGPOverviewOptions = {}): Promise<BGPOverviewSnapshot> {
return EMPTY_SNAPSHOT
}
}

View File

@@ -0,0 +1,5 @@
import type { BGPOverviewOptions, BGPOverviewSnapshot } from './types'
export interface SituationalAwarenessGateway {
getBGPOverview(options?: BGPOverviewOptions): Promise<BGPOverviewSnapshot>
}

View File

@@ -0,0 +1,112 @@
export interface BGPAnomaly {
id: number
source: string
anomaly_type: string
severity: string
status: string
prefix: string | null
origin_asn: number | null
new_origin_asn: number | null
confidence: number
summary: string
created_at: string | null
}
export interface BGPEvent {
id: number
collector: string | null
event_type: string
prefix: string | null
origin_asn: number | null
peer_asn: number | null
observed_at: string | null
}
export interface BGPCollectorCoverage {
collector: string
city?: string | null
country?: string | null
observation_count: number
recent_24h_observation_count: number
recent_7d_observation_count: number
prefix_count: number
recent_24h_prefix_count: number
recent_7d_prefix_count: number
origin_asn_count: number
peer_asn_count: number
latest_observed_at: string | null
latest_event_type: string | null
baseline_scope: {
countries: string[]
cities: string[]
}
}
export interface BGPIncident {
id: number
incident_type: string
title: string
summary: string
severity: string
status: string
confidence: number
affected_prefixes: string[]
affected_asns: number[]
affected_collectors: string[]
affected_regions: Array<{ country?: string; city?: string }>
related_cables: Array<{
landing_point?: string
city?: string
country?: string
distance_km?: number
cable_names?: string[]
}>
created_at: string | null
started_at: string | null
}
export interface Summary {
total: number
by_type: Record<string, number>
by_severity: Record<string, number>
by_status: Record<string, number>
}
export interface EventSummary {
total: number
collector_count: number
prefix_count: number
by_type: Record<string, number>
}
export interface CollectorSummary {
total: number
active_collectors: number
observed_prefixes: number
observed_origins: number
recent_24h_events: number
recent_7d_events: number
}
export interface ListResponse<T> {
total: number
page?: number
page_size?: number
data: T[]
}
export interface BGPOverviewSnapshot {
incidents: BGPIncident[]
incidentSummary: Summary | null
anomalies: BGPAnomaly[]
events: BGPEvent[]
eventSummary: EventSummary | null
collectors: BGPCollectorCoverage[]
collectorSummary: CollectorSummary | null
}
export interface BGPOverviewOptions {
incidentPageSize?: number
anomalyPageSize?: number
eventPageSize?: number
}

View File

@@ -14,11 +14,22 @@ NC='\033[0m'
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
AI_PROVIDER_HEALTH_CHECK_ATTEMPTS="${AI_PROVIDER_HEALTH_CHECK_ATTEMPTS:-10}"
AI_PROVIDER_HEALTH_CHECK_INTERVAL="${AI_PROVIDER_HEALTH_CHECK_INTERVAL:-2}"
FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}"
DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}"
DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
compose_up() {
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
else
docker-compose "$@"
fi
}
ensure_uv_backend_deps() {
echo -e "${BLUE}📦 检查后端 uv 环境...${NC}"
@@ -104,6 +115,18 @@ start_backend_with_retry() {
return 1
}
start_ai_provider_service() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
echo -e "${BLUE}🧠 启动 AI Provider...${NC}"
docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider
if ! wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
echo -e "${RED}❌ AI Provider 启动失败${NC}"
exit 1
fi
}
start_frontend_with_retry() {
local frontend_port="$1"
local retry=1
@@ -166,8 +189,10 @@ kill_port_if_requested() {
parse_service_args() {
BACKEND_PORT="$DEFAULT_BACKEND_PORT"
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
AI_PROVIDER_PORT="$DEFAULT_AI_PROVIDER_PORT"
BACKEND_PORT_REQUESTED=0
FRONTEND_PORT_REQUESTED=0
AI_PROVIDER_REQUESTED=0
DATABASE_REQUESTED=0
while [ "$#" -gt 0 ]; do
@@ -190,6 +215,15 @@ parse_service_args() {
shift 1
fi
;;
-a|--ai-provider-port)
AI_PROVIDER_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
AI_PROVIDER_PORT="$2"
shift 2
else
shift 1
fi
;;
-d|--database)
DATABASE_REQUESTED=1
shift 1
@@ -203,6 +237,7 @@ parse_service_args() {
validate_port "$BACKEND_PORT"
validate_port "$FRONTEND_PORT"
validate_port "$AI_PROVIDER_PORT"
}
cleanup_exit_containers() {
@@ -219,6 +254,10 @@ stop_backend_service() {
pkill -f "uvicorn" 2>/dev/null || true
}
stop_ai_provider_service() {
docker stop planet_aiprovider 2>/dev/null || true
}
stop_frontend_service() {
pkill -f "vite" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
@@ -226,18 +265,21 @@ stop_frontend_service() {
restart_database_service() {
echo -e "${BLUE}🗄️ 重启数据库...${NC}"
docker restart planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis
docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
sleep 3
}
start_backend_service() {
local backend_port="$1"
local backend_port_requested="$2"
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
docker start planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis
docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
sleep 3
start_ai_provider_service "$ai_provider_port"
if [ "$backend_port_requested" -eq 1 ]; then
kill_port_if_requested "$backend_port" "后端"
fi
@@ -280,7 +322,7 @@ create_user() {
ensure_uv_backend_deps
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
docker start planet_postgres 2>/dev/null || docker-compose up -d postgres
docker start planet_postgres 2>/dev/null || compose_up up -d postgres
sleep 2
echo -e "${BLUE}👤 创建用户${NC}"
@@ -379,18 +421,20 @@ start() {
echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED"
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
echo ""
echo -e "${GREEN}✅ 启动完成!${NC}"
echo " 前端: http://localhost:${FRONTEND_PORT}"
echo " 后端: http://localhost:${BACKEND_PORT}"
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
}
stop() {
echo -e "${YELLOW}🛑 停止服务...${NC}"
stop_backend_service
stop_ai_provider_service
stop_frontend_service
docker stop planet_postgres planet_redis 2>/dev/null || true
echo -e "${GREEN}✅ 已停止${NC}"
@@ -400,7 +444,7 @@ restart() {
parse_service_args "$@"
cleanup_exit_containers
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop
sleep 1
start
@@ -413,10 +457,16 @@ restart() {
restart_database_service
fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
stop_ai_provider_service
sleep 1
start_ai_provider_service "$AI_PROVIDER_PORT"
fi
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
stop_backend_service
sleep 1
start_backend_service "$BACKEND_PORT" 1
start_backend_service "$BACKEND_PORT" 1 "$AI_PROVIDER_PORT"
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
@@ -430,6 +480,9 @@ restart() {
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
echo " 数据库: planet_postgres, planet_redis"
fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
fi
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
echo " 后端: http://localhost:${BACKEND_PORT}"
fi
@@ -450,6 +503,12 @@ health() {
echo -e " 后端: ${RED}❌ 未运行${NC}"
fi
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
echo -e " AI Provider: ${GREEN}✅ 运行中${NC}"
else
echo -e " AI Provider: ${RED}❌ 未运行${NC}"
fi
if curl -s http://localhost:3000 > /dev/null 2>&1; then
echo -e " 前端: ${GREEN}✅ 运行中${NC}"
else
@@ -467,10 +526,16 @@ log() {
echo "📝 后端日志 (Ctrl+C 退出):"
tail -f /tmp/planet_backend.log
;;
-a|--ai-provider)
echo "📝 AI Provider 日志 (Ctrl+C 退出):"
docker logs -f planet_aiprovider
;;
*)
echo "📝 最近日志:"
echo "--- 后端 ---"
tail -20 /tmp/planet_backend.log 2>/dev/null || echo "无日志"
echo "--- AI Provider ---"
docker logs --tail 20 planet_aiprovider 2>/dev/null || echo "无日志"
echo "--- 前端 ---"
tail -20 /tmp/planet_frontend.log 2>/dev/null || echo "无日志"
;;
@@ -502,13 +567,14 @@ case "$1" in
echo "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
echo ""
echo "命令:"
echo " start 启动服务,可选: -b <后端端口> -f <前端端口>"
echo " start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口>"
echo " stop 停止服务"
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -d"
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -d"
echo " createuser 交互创建用户"
echo " health 检查健康状态"
echo " log 查看日志"
echo " log -f 查看前端日志"
echo " log -b 查看后端日志"
echo " log -a 查看 AI Provider 日志"
;;
esac

View File

@@ -99,7 +99,8 @@
│ │ ├── api/
│ │ ├── unit/
│ │ └── conftest.py
│ ├── requirements.txt
│ ├── pyproject.toml
│ ├── uv.lock
│ └── alembic/
├── frontend/ # React Admin
@@ -192,10 +193,10 @@
### Backend
```bash
uv sync --group dev
cd backend
pip install -r requirements.txt
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
docker-compose up -d backend
uv run --project .. python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
docker compose up -d backend
ruff check . && black --check .
pytest -v && pytest tests/api/test_auth.py::test_login -v
```

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.22.14"
version = "0.23.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

View File

@@ -155,7 +155,7 @@ git fetch origin && git rebase origin/main
**Rules:**
- Verify package legitimacy before adding
- Prefer well-maintained, widely-used libraries
- Pin dependency versions in `requirements.txt` and `package.json`
- Pin dependency versions in `pyproject.toml`, `uv.lock`, and `package.json`
- Review security advisories with `pip-audit` and `bun audit`
- **NEVER** add unknown packages

124
scripts/bootstrap-dev.sh Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
BLUE='\033[0;34m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
have_cmd() {
command -v "$1" >/dev/null 2>&1
}
ensure_local_bin_on_path() {
export PATH="$HOME/.local/bin:$PATH"
export PATH="$HOME/.bun/bin:$PATH"
}
install_uv_if_needed() {
if have_cmd uv; then
echo -e "${GREEN}uv 已存在: $(command -v uv)${NC}"
return
fi
echo -e "${BLUE}安装 uv...${NC}"
curl -LsSf https://astral.sh/uv/install.sh | sh
ensure_local_bin_on_path
if ! have_cmd uv; then
echo -e "${RED}uv 安装失败,请手动检查 ~/.local/bin 是否加入 PATH${NC}"
exit 1
fi
}
install_bun_if_needed() {
if have_cmd bun; then
echo -e "${GREEN}bun 已存在: $(command -v bun)${NC}"
return
fi
echo -e "${BLUE}安装 bun...${NC}"
curl -fsSL https://bun.sh/install | bash
ensure_local_bin_on_path
if ! have_cmd bun; then
echo -e "${RED}bun 安装失败,请手动检查 ~/.bun/bin 是否加入 PATH${NC}"
exit 1
fi
}
ensure_python_alias() {
mkdir -p "$HOME/.local/bin"
if [ ! -e "$HOME/.local/bin/python" ]; then
ln -sf /usr/bin/python3 "$HOME/.local/bin/python"
fi
ensure_local_bin_on_path
}
sync_python_env() {
echo -e "${BLUE}同步 Python 依赖...${NC}"
cd "$ROOT_DIR"
uv python install 3.14
uv sync --group dev
}
sync_frontend_env() {
echo -e "${BLUE}同步前端依赖...${NC}"
cd "$ROOT_DIR/frontend"
bun install
}
ensure_env_files() {
echo -e "${BLUE}检查环境变量模板...${NC}"
if [ ! -f "$ROOT_DIR/backend/.env" ] && [ -f "$ROOT_DIR/backend/.env.example" ]; then
cp "$ROOT_DIR/backend/.env.example" "$ROOT_DIR/backend/.env"
echo -e "${YELLOW}已创建 backend/.env请按需修改 SECRET_KEY / 数据库 / AI 服务配置${NC}"
fi
if [ ! -f "$ROOT_DIR/aiprovider/.env" ] && [ -f "$ROOT_DIR/aiprovider/.env.example" ]; then
cp "$ROOT_DIR/aiprovider/.env.example" "$ROOT_DIR/aiprovider/.env"
echo -e "${YELLOW}已创建 aiprovider/.env请按需修改 provider/model/api key${NC}"
fi
if [ ! -f "$ROOT_DIR/frontend/.env.local" ] && [ -f "$ROOT_DIR/frontend/.env.example" ]; then
cp "$ROOT_DIR/frontend/.env.example" "$ROOT_DIR/frontend/.env.local"
echo -e "${YELLOW}已创建 frontend/.env.local可按需覆盖 VITE_API_URL / VITE_WS_URL${NC}"
fi
}
report_optional_tools() {
if have_cmd docker; then
echo -e "${GREEN}docker 已存在: $(command -v docker)${NC}"
else
echo -e "${YELLOW}未检测到 docker。如需容器方式启动 backend/aiprovider请安装 Docker。${NC}"
fi
}
print_next_steps() {
echo ""
echo -e "${GREEN}开发环境引导完成${NC}"
echo "下一步建议:"
echo " 1. cd \"$ROOT_DIR\""
echo " 2. uv run pytest backend/tests/test_api.py -q -s"
echo " 3. ./planet.sh start"
}
main() {
ensure_local_bin_on_path
install_uv_if_needed
install_bun_if_needed
ensure_python_alias
sync_python_env
sync_frontend_env
ensure_env_files
report_optional_tools
print_next_steps
}
main "$@"

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.22.14"
version = "0.23.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },