"""Credential setup guides for collector integrations.""" from __future__ import annotations from dataclasses import dataclass from typing import Any from sqlalchemy import select from app.models.system_setting import SystemSetting from app.schemas.ai import SituationalAnalysisRequest from app.services.ai_client import AIProviderClient CREDENTIAL_GUIDES_CATEGORY = "collector_credential_guides" @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 ` ### 常见排查 - `未找到凭证`:确认 `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, } 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 = dict(record.payload or {}) if record and isinstance(record.payload, dict) else {} return record, payload async def get_credential_guide(db, provider: str) -> dict[str, Any]: default = DEFAULT_CREDENTIAL_GUIDES.get(provider) if default is None: raise ValueError(f"Unsupported credential guide provider: {provider}") _record, store = await _get_guide_store(db) custom = store.get(provider) if isinstance(store.get(provider), dict) else None 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", } async def save_credential_guide(db, provider: str, title: str, markdown: str) -> dict[str, Any]: default = DEFAULT_CREDENTIAL_GUIDES.get(provider) if default is None: raise ValueError(f"Unsupported credential guide provider: {provider}") record, store = await _get_guide_store(db) store[provider] = { "title": title or default.title, "markdown": markdown, } if record is None: db.add(SystemSetting(category=CREDENTIAL_GUIDES_CATEGORY, payload=store)) else: record.payload = store await db.commit() return await get_credential_guide(db, provider) async def reset_credential_guide(db, provider: str) -> dict[str, Any]: default = DEFAULT_CREDENTIAL_GUIDES.get(provider) if default is None: raise ValueError(f"Unsupported credential guide provider: {provider}") record, store = await _get_guide_store(db) if provider in store: store.pop(provider, None) if record is not None: record.payload = store await db.commit() return await get_credential_guide(db, provider) async def generate_credential_guide( db, provider: str, ai_client: AIProviderClient, ) -> dict[str, Any]: default = DEFAULT_CREDENTIAL_GUIDES.get(provider) if default is None: raise ValueError(f"Unsupported credential guide provider: {provider}") response = await ai_client.analyze( SituationalAnalysisRequest( title=f"Generate credential guide for {provider}", objective=default.prompt, context={ "provider": provider, "current_default_guide": default.markdown, "product_context": "Planet collector credential settings", }, observations=[ "Use concise Chinese markdown.", "Prefer stable concepts over brittle UI labels.", "Include verification and troubleshooting steps.", ], constraints=[ "Do not ask the user for secrets.", "Do not include fabricated screenshots.", "Return markdown only.", ], ) ) markdown = response.content.strip() if not markdown: markdown = default.markdown return await save_credential_guide(db, provider, default.title, markdown)