315 lines
13 KiB
Python
315 lines
13 KiB
Python
"""Credential setup guides for collector integrations."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from copy import deepcopy
|
||
from typing import Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
|
||
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
|
||
from app.services.ai_tools.evidence_store import normalize_search_evidence
|
||
from app.services.ai_tools.web_search import WebSearchClient, WebSearchError
|
||
|
||
|
||
CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides"
|
||
CREDENTIAL_GUIDE_PROMPT_KEY = "credential.guide"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CredentialGuideDefault:
|
||
provider: str
|
||
title: str
|
||
prompt: str
|
||
markdown: str
|
||
|
||
|
||
BARENTSWATCH_DEFAULT_GUIDE = CredentialGuideDefault(
|
||
provider="barentswatch",
|
||
title="BarentsWatch AIS 凭证获取教程",
|
||
prompt=(
|
||
"请生成一份中文教程,指导开发者获取 BarentsWatch Live AIS API 的 "
|
||
"OAuth client credentials。教程要面向已经有本地开发环境的人,包含注册/登录、"
|
||
"创建 client、申请或确认 ais scope、复制 client id 和 client secret、"
|
||
"在系统设置中填写并验证连接、常见失败排查。不要编造具体页面按钮文案,"
|
||
"必须参考官方 tutorial:https://developer.barentswatch.no/docs/tutorial 。"
|
||
"必须强调 Live AIS 要选择 AIS-client / AIS - API,而不是普通 API-client。"
|
||
"如果步骤可能变化,要提醒以 BarentsWatch developer portal 当前页面为准。"
|
||
),
|
||
markdown="""## BarentsWatch AIS 凭证获取
|
||
|
||
官方教程:https://developer.barentswatch.no/docs/tutorial
|
||
|
||
1. 先打开上面的 BarentsWatch 官方 tutorial,按官方流程登录或注册开发者账号。
|
||
2. 在 Developer access 页面选择 `AIS - API`,不要选择普通的 `BarentsWatch - API`。
|
||
3. 在 `AIS - API` 下创建用于 Planet 的 AIS client。
|
||
4. 创建时记下你设置的 password / client secret。
|
||
5. 回到 My Page 复制完整 `Client ID`。它通常长得像 `your.email@example.com:client-name`。
|
||
6. 回到 Planet 的 `设置 -> 采集器设置 -> BarentsWatch AIS`,填入 `Client ID` 和 `Client Secret`。
|
||
7. 点击 `连接` 验证 token 和 AIS endpoint 是否可访问。
|
||
8. 连接成功后保存凭证。
|
||
|
||
### 请求规则
|
||
|
||
- Token 地址:`https://id.barentswatch.no/connect/token`
|
||
- 请求方式:`POST`
|
||
- Content-Type:`application/x-www-form-urlencoded`
|
||
- Body 必须包含:`grant_type=client_credentials`、`client_id`、`client_secret`、`scope=ais`
|
||
- `client_id`、`client_secret`、`scope`、`grant_type` 都要放在 body,不要放在 header。
|
||
- AIS 数据请求使用 header:`Authorization: Bearer <access_token>`
|
||
|
||
### 常见排查
|
||
|
||
- `未找到凭证`:确认 `Client ID` 和 `Client Secret` 已填写,或已经写入 `~/.zshrc`。
|
||
- `HTTP 401/403`:通常是选成了普通 `BarentsWatch - API` client、client secret 错误,或 token 请求没有使用 `scope=ais`。
|
||
- `network` 错误:检查本机是否能访问 `id.barentswatch.no` 和 `live.ais.barentswatch.no`。
|
||
- Endpoint 建议保持默认:`https://live.ais.barentswatch.no/v1/latest/combined`。
|
||
""",
|
||
)
|
||
|
||
AISSTREAM_DEFAULT_GUIDE = CredentialGuideDefault(
|
||
provider="aisstream",
|
||
title="AISStream API Key 获取教程",
|
||
prompt=(
|
||
"请生成一份中文教程,指导开发者获取 AISStream 的 API Key 并配置到 Planet。"
|
||
"教程要面向已经有本地开发环境的人,包含注册/登录 AISStream、获取 API Key、"
|
||
"理解免费额度和订阅范围、在 Planet 设置中心填写 API Key、配置 bounding boxes "
|
||
"和 message types、验证连接、常见失败排查。必须提醒用户以 AISStream 当前官网和"
|
||
"服务条款为准,不要编造具体页面按钮文案。"
|
||
),
|
||
markdown="""## AISStream API Key 获取
|
||
|
||
官方入口:https://aisstream.io/
|
||
|
||
1. 打开 AISStream 官网,按当前页面指引注册或登录账号。
|
||
2. 在账号/API 管理页面创建或复制你的 API Key。
|
||
3. 先确认当前账号额度、使用条款和可订阅区域。实时 AIS 流量可能很大,不建议一开始订阅全球范围。
|
||
4. 回到 Planet 的 `设置 -> 采集器设置 -> AISStream 实时船舶`。
|
||
5. 在 `AISStream 凭证` 中填入 API Key。
|
||
6. Endpoint 通常保持默认:`wss://stream.aisstream.io/v0/stream`。
|
||
7. 按需配置 `Bounding Boxes JSON` 和 `消息类型`。
|
||
8. 点击连接测试,确认系统能读取凭证且 WebSocket endpoint 格式有效。
|
||
9. 保存采集器设置后再运行 `aisstream_vessels` collector。
|
||
|
||
### 推荐配置
|
||
|
||
默认消息类型:
|
||
|
||
```json
|
||
["PositionReport", "ShipStaticData"]
|
||
```
|
||
|
||
默认 Bounding Boxes 示例:
|
||
|
||
```json
|
||
[[[-90, -180], [90, 180]]]
|
||
```
|
||
|
||
这个示例表示全球范围。实际使用时建议先改成较小区域,降低消息量和处理压力。
|
||
|
||
### 请求规则
|
||
|
||
- Endpoint:`wss://stream.aisstream.io/v0/stream`
|
||
- 传输方式:WebSocket
|
||
- API Key 放在订阅 payload 中,不放在 HTTP header。
|
||
- Planet 会把 AISStream 标记为 `delivery_mode = realtime_stream`、`transport = websocket`。
|
||
- AISStream collector 只写入 AIS raw observations,不直接覆盖最终船只展示表。
|
||
|
||
### 常见排查
|
||
|
||
- `未找到凭证`:确认 API Key 已保存到采集器设置,或设置了 `AISSTREAM_API_KEY` 环境变量 / `~/.zshrc`。
|
||
- `endpoint 必须是 ws:// 或 wss://`:AISStream 是 WebSocket 流接口,不要填普通 `https://` API 地址。
|
||
- 采集量过大:缩小 `Bounding Boxes JSON`,减少 `message_types`,或降低单次最大消息数。
|
||
- 没有船只数据:确认订阅区域内确实有 AIS 活动,并检查 API Key 当前额度和权限。
|
||
- 连接中断:实时流可能受网络和上游限流影响,collector 会记录源健康状态供聚合服务回退。
|
||
""",
|
||
)
|
||
|
||
|
||
DEFAULT_CREDENTIAL_GUIDES = {
|
||
BARENTSWATCH_DEFAULT_GUIDE.provider: BARENTSWATCH_DEFAULT_GUIDE,
|
||
AISSTREAM_DEFAULT_GUIDE.provider: AISSTREAM_DEFAULT_GUIDE,
|
||
}
|
||
|
||
|
||
def _normalize_provider(provider: str) -> str:
|
||
return provider.strip().lower().replace(" ", "_")
|
||
|
||
|
||
def _credential_guide_default(provider: str) -> CredentialGuideDefault:
|
||
normalized = _normalize_provider(provider)
|
||
known = DEFAULT_CREDENTIAL_GUIDES.get(normalized)
|
||
if known is not None:
|
||
return known
|
||
title = f"{normalized or 'collector'} 凭证配置教程"
|
||
return CredentialGuideDefault(
|
||
provider=normalized,
|
||
title=title,
|
||
prompt=(
|
||
f"请生成一份中文教程,指导开发者为 Planet 采集器配置 {normalized} 凭证。"
|
||
"教程要面向已经有本地开发环境的人,包含官方入口或文档查找方式、"
|
||
"获取 API Key / Token / Client credentials 的通用步骤、在 Planet 采集器配置中"
|
||
"填写凭证字段、连接测试、保存、常见失败排查。不要编造具体页面按钮文案;"
|
||
"如果公开资料不足,必须明确提醒以 provider 官方文档和当前控制台页面为准。"
|
||
),
|
||
markdown="",
|
||
)
|
||
|
||
|
||
async def _get_guide_store(db) -> tuple[SystemSetting | None, dict[str, Any]]:
|
||
result = await db.execute(
|
||
select(SystemSetting).where(SystemSetting.category == CREDENTIAL_GUIDES_CATEGORY)
|
||
)
|
||
record = result.scalar_one_or_none()
|
||
payload = deepcopy(record.payload) if record and isinstance(record.payload, dict) else {}
|
||
return record, payload
|
||
|
||
|
||
async def get_credential_guide(db, provider: str) -> dict[str, Any]:
|
||
provider = _normalize_provider(provider)
|
||
default = _credential_guide_default(provider)
|
||
|
||
_record, store = await _get_guide_store(db)
|
||
custom = store.get(provider) if isinstance(store.get(provider), dict) else None
|
||
has_default_markdown = bool(default.markdown.strip())
|
||
return {
|
||
"provider": provider,
|
||
"title": custom.get("title") if custom else default.title,
|
||
"markdown": custom.get("markdown") if custom else default.markdown,
|
||
"prompt": default.prompt,
|
||
"source": "ai" if custom else "default" if has_default_markdown else "missing",
|
||
"sources": custom.get("sources", []) if custom else [],
|
||
"verification_status": (
|
||
custom.get("verification_status", "verified_with_search_evidence")
|
||
if custom
|
||
else "default_unverified" if has_default_markdown else "missing"
|
||
),
|
||
"verification_error": custom.get("verification_error") if custom else None,
|
||
}
|
||
|
||
|
||
async def save_credential_guide(
|
||
db,
|
||
provider: str,
|
||
title: str,
|
||
markdown: str,
|
||
*,
|
||
sources: list[dict[str, Any]] | None = None,
|
||
verification_status: str = "verified_with_search_evidence",
|
||
verification_error: str | None = None,
|
||
) -> dict[str, Any]:
|
||
provider = _normalize_provider(provider)
|
||
default = _credential_guide_default(provider)
|
||
|
||
record, store = await _get_guide_store(db)
|
||
store[provider] = {
|
||
"title": title or default.title,
|
||
"markdown": markdown,
|
||
"sources": sources or [],
|
||
"verification_status": verification_status,
|
||
"verification_error": verification_error,
|
||
}
|
||
if record is None:
|
||
db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store))
|
||
else:
|
||
record.payload = deepcopy(store)
|
||
flag_modified(record, "payload")
|
||
await db.commit()
|
||
return await get_credential_guide(db, provider)
|
||
|
||
|
||
async def reset_credential_guide(db, provider: str) -> dict[str, Any]:
|
||
provider = _normalize_provider(provider)
|
||
|
||
record, store = await _get_guide_store(db)
|
||
if provider in store:
|
||
store.pop(provider, None)
|
||
if record is not None:
|
||
record.payload = deepcopy(store)
|
||
flag_modified(record, "payload")
|
||
await db.commit()
|
||
return await get_credential_guide(db, provider)
|
||
|
||
|
||
async def generate_credential_guide(
|
||
db,
|
||
provider: str,
|
||
ai_client: AIProviderClient,
|
||
web_search_client: WebSearchClient | None = None,
|
||
) -> dict[str, Any]:
|
||
provider = _normalize_provider(provider)
|
||
default = _credential_guide_default(provider)
|
||
|
||
search_evidence: list[dict[str, Any]] = []
|
||
search_error: str | None = None
|
||
if web_search_client is not None:
|
||
try:
|
||
evidence = await web_search_client.search(
|
||
_credential_guide_search_query(default),
|
||
max_results=5,
|
||
)
|
||
search_evidence = normalize_search_evidence(evidence, limit=5)
|
||
except WebSearchError as exc:
|
||
search_error = str(exc)
|
||
except Exception as exc:
|
||
search_error = f"WebSearch unavailable: {exc}"
|
||
|
||
if not search_evidence:
|
||
guide = await get_credential_guide(db, provider)
|
||
guide["verification_status"] = "unverified_no_search_evidence"
|
||
guide["verification_error"] = search_error
|
||
guide["sources"] = []
|
||
return guide
|
||
|
||
prompt = await get_effective_prompt(db, CREDENTIAL_GUIDE_PROMPT_KEY)
|
||
response = await ai_client.analyze(
|
||
SituationalAnalysisRequest(
|
||
title=f"Generate credential guide for {provider}",
|
||
objective=f"{default.prompt}\n{prompt.prompt}",
|
||
system_prompt=prompt.system_prompt or None,
|
||
context={
|
||
"provider": provider,
|
||
"current_default_guide": default.markdown,
|
||
"product_context": "Planet collector credential settings",
|
||
"search_evidence": search_evidence,
|
||
},
|
||
observations=[
|
||
"Use concise Chinese markdown.",
|
||
"Prefer stable concepts over brittle UI labels.",
|
||
"Include verification and troubleshooting steps.",
|
||
"Include a short sources section with the provided URLs.",
|
||
],
|
||
constraints=[
|
||
"Do not ask the user for secrets.",
|
||
"Do not include fabricated screenshots.",
|
||
"Do not invent source URLs or product UI labels.",
|
||
"Use only the provided search_evidence as factual support.",
|
||
"Return markdown only.",
|
||
],
|
||
)
|
||
)
|
||
markdown = response.content.strip()
|
||
if not markdown:
|
||
markdown = default.markdown
|
||
return await save_credential_guide(
|
||
db,
|
||
provider,
|
||
default.title,
|
||
markdown,
|
||
sources=search_evidence,
|
||
verification_status="verified_with_search_evidence",
|
||
)
|
||
|
||
|
||
def _credential_guide_search_query(default: CredentialGuideDefault) -> str:
|
||
if default.provider == "barentswatch":
|
||
return "BarentsWatch developer tutorial AIS API OAuth client credentials"
|
||
if default.provider == "aisstream":
|
||
return "AISStream API key documentation websocket stream"
|
||
return f"{default.provider} API credentials documentation"
|