From b0e0c05d8a51514f7f53b86a1461bfb0bf6adba9 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 3 May 2026 23:54:38 +0800 Subject: [PATCH] feat: integrate netx ops data access and unify prompt/workspace runtime Migrate prompt templates into runtime workspaces, add network_ops netx toolchain (latest batch injection, raw fields, SQL query), and align docs/tests/admin/runtime wiring so ops analysis can query netx detail data through stable role-scoped paths. Co-authored-by: Cursor --- .gitignore | 1 + _local/system.env.example | 16 + docs/ARCHITECTURE_OVERVIEW.md | 2 +- docs/ENVIRONMENT_VARIABLES.md | 6 +- docs/ENVIRONMENT_VARIABLES_CHANGELOG.md | 2 +- docs/NETX_MCP_INTEGRATION.md | 105 ++++ docs/PROMPT_STYLE_GUIDE.md | 2 +- interfaces/admin/models_api.py | 69 +++ interfaces/admin/routes.py | 259 +++++++++- interfaces/admin/static/app.js | 50 ++ interfaces/admin/static/index.html | 11 + platform/llm/image_message_client.py | 2 +- prompts/__init__.py | 3 - prompts/agents/network_ops_system.zh.md | 8 - runtime/agents/factory.py | 2 +- runtime/agents/network_ops_agent.py | 8 +- runtime/chat/agent.py | 2 +- runtime/chat/agent_errors.py | 2 +- runtime/chat/agent_messages.py | 2 +- runtime/direct_loop.py | 8 + runtime/gateway.py | 2 +- runtime/memory_stage.py | 2 +- runtime/project_context_prompt.py | 2 +- runtime/prompt_templates/__init__.py | 17 + .../prompt_templates}/frontmatter.py | 0 .../prompt_templates}/loader.py | 12 +- runtime/router.py | 2 +- runtime/skills.py | 2 +- runtime/system_prompt.py | 2 +- .../tools/experts/network_ops/netx_tools.py | 466 ++++++++++++++++++ .../_system}/fallback/ollama_failure.en.md | 0 .../_system}/fallback/ollama_failure.zh.md | 0 .../fallback/openai_missing_key_user.en.md | 0 .../fallback/openai_missing_key_user.zh.md | 0 .../fallback/openai_transport_error.en.md | 0 .../fallback/openai_transport_error.zh.md | 0 .../fallback/openai_transport_oversized.en.md | 0 .../fallback/openai_transport_oversized.zh.md | 0 .../_system}/fallback/runtime_error.en.md | 0 .../_system}/fallback/runtime_error.zh.md | 0 .../_system}/fallback/task_queued.en.md | 0 .../_system}/fallback/task_queued.zh.md | 0 .../_system}/image/default_edit_prompt.zh.md | 0 .../_system}/router/decide_route.md | 0 .../_system}/runtime/default_system.en.md | 0 .../_system}/runtime/default_system.zh.md | 0 .../_system}/runtime/memory_context_block.md | 0 .../_system}/runtime/project_context_block.md | 0 .../_system}/runtime/system_with_memory.md | 0 .../_system}/runtime/system_with_skills.md | 0 .../_system}/tools/image_attachment_meta.md | 0 .../_system}/tools/text_attachment_wrap.md | 0 .../_system}/tools/tool_result_unpaired.md | 0 runtime/workspaces/experts.py | 8 + runtime/workspaces/ops/ROLE_SYSTEM.md | 10 + tests/test_frontmatter_yaml.py | 2 +- tests/test_ops_netx_system_inject.py | 85 ++++ tests/test_prompt_ci_guard.py | 8 +- tests/test_prompt_loader.py | 7 +- tests/test_prompt_runtime_paths.py | 2 +- 60 files changed, 1143 insertions(+), 46 deletions(-) create mode 100644 docs/NETX_MCP_INTEGRATION.md delete mode 100644 prompts/__init__.py delete mode 100644 prompts/agents/network_ops_system.zh.md create mode 100644 runtime/prompt_templates/__init__.py rename {prompts => runtime/prompt_templates}/frontmatter.py (100%) rename {prompts => runtime/prompt_templates}/loader.py (89%) create mode 100644 runtime/tools/experts/network_ops/netx_tools.py rename {prompts => runtime/workspaces/_system}/fallback/ollama_failure.en.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/ollama_failure.zh.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/openai_missing_key_user.en.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/openai_missing_key_user.zh.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/openai_transport_error.en.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/openai_transport_error.zh.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/openai_transport_oversized.en.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/openai_transport_oversized.zh.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/runtime_error.en.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/runtime_error.zh.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/task_queued.en.md (100%) rename {prompts => runtime/workspaces/_system}/fallback/task_queued.zh.md (100%) rename {prompts => runtime/workspaces/_system}/image/default_edit_prompt.zh.md (100%) rename {prompts => runtime/workspaces/_system}/router/decide_route.md (100%) rename {prompts => runtime/workspaces/_system}/runtime/default_system.en.md (100%) rename {prompts => runtime/workspaces/_system}/runtime/default_system.zh.md (100%) rename {prompts => runtime/workspaces/_system}/runtime/memory_context_block.md (100%) rename {prompts => runtime/workspaces/_system}/runtime/project_context_block.md (100%) rename {prompts => runtime/workspaces/_system}/runtime/system_with_memory.md (100%) rename {prompts => runtime/workspaces/_system}/runtime/system_with_skills.md (100%) rename {prompts => runtime/workspaces/_system}/tools/image_attachment_meta.md (100%) rename {prompts => runtime/workspaces/_system}/tools/text_attachment_wrap.md (100%) rename {prompts => runtime/workspaces/_system}/tools/tool_result_unpaired.md (100%) create mode 100644 tests/test_ops_netx_system_inject.py diff --git a/.gitignore b/.gitignore index ccafbccf..1b4af78b 100644 --- a/.gitignore +++ b/.gitignore @@ -67,5 +67,6 @@ oclaw/desktop/node_modules/ oclaw/desktop/dist/ oclaw/desktop/runtime-data/ oclaw/desktop/assets/oclaw.ico +vendor/ vendor/oclaw-main.zip vendor/oclaw/ diff --git a/_local/system.env.example b/_local/system.env.example index aa12991f..0a63dd12 100644 --- a/_local/system.env.example +++ b/_local/system.env.example @@ -252,6 +252,8 @@ OCLAW_WS_RATE_LIMIT_USER_PER_WINDOW=360 OCLAW_WS_SEND_QUEUE_MAX_MESSAGES=256 OCLAW_WS_SEND_QUEUE_MAX_BYTES= OCLAW_WS_EVENT_REPLAY_MAX=256 +# netx -> oclaw AP 分析接口共享 token(可选;设置后可用 Bearer 直接访问 /admin/api/ops-ai/analyze-sync) +OCLAW_OPS_AI_SHARED_TOKEN= # ----------------------------------------------------------------------------- # 十五、模型请求:工具 JSON、Replay 策略、Agent 消息回放 @@ -351,3 +353,17 @@ TAVILY_API_KEY= # AIA_OCLAW_RETRYABLE_ERROR_CODES / AIA_OCLAW_RETRY_CODES_STRICT_MODE # rag_mode、embedding 相关部分亦在后台 memory/RAG 页 # 【前端】多数上述项在 Admin 设置表单中有对应勾选或输入框。 + +# ----------------------------------------------------------------------------- +# 二十二、netx(network_ops 内部工具:告警明细 / 聚合 / 诊断) +# ----------------------------------------------------------------------------- +# OCLAW_NETX_BASE_URL netx HTTP 根地址(无尾部斜杠);runtime/tools/experts/network_ops/netx_tools.py +# 调用 /v1/alarms、/v1/alarms/aggregate、/v1/diagnostics。 +# 默认 http://127.0.0.1:8890(代码内兜底;与本文件一致时可省略)。 +# OCLAW_NETX_API_TOKEN 可选;netx 若启用 Bearer 鉴权,填与 netx 侧一致的 token。 +# OCLAW_OPS_NETX_CONTEXT_INJECT 默认 1;ops 专家每轮把 netx 最新导入 batch_id 注入 system(类似附件锚点)。 +# 设为 0/false/off 可关闭(压测或 netx 不可达时减少噪音)。 +# 【前端】无;仅进程环境变量。 +OCLAW_NETX_BASE_URL=http://127.0.0.1:8890 +OCLAW_NETX_API_TOKEN= +OCLAW_OPS_NETX_CONTEXT_INJECT=1 diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index b65ea399..03bfacf0 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -7,7 +7,7 @@ - `runtime/`:运行时主域(agent、gateway 执行流、skills/hooks/extensions、operations)。 - `interfaces/`:对外接口层(HTTP、WS、Admin、Gateway method bridge)。 - `platform/`:通用平台能力(配置、存储、LLM transport、文件层)。 -- `prompts/`:统一提示词体系(含 runtime 相关提示模板)。 +- `runtime/workspaces/_system/`:内置系统提示词 Markdown 树(原顶层 `prompts/`,与按角色分区的 `workspaces//` 并列);`runtime/prompt_templates/` 为加载与 frontmatter 解析。 - `tests/`:测试代码(按你的要求保持顶层)。 - `docs/`:设计文档、运维说明、迁移记录。 diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 921745c6..3e2974ad 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -47,8 +47,8 @@ - `AIA_PROMPT_FRONTMATTER_STRICT` - 默认:`0` - - 作用:`1` 时 `SKILL.md` / `oclaw/prompts/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析) - - 生效:`oclaw/prompts/frontmatter.py`, `oclaw/prompts/loader.py`, `oclaw/oclaw_runtime/skills.py` + - 作用:`1` 时 `SKILL.md` / `runtime/workspaces/_system/**/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析) + - 生效:`oclaw/runtime/prompt_templates/frontmatter.py`, `oclaw/runtime/prompt_templates/loader.py`, `oclaw/runtime/skills.py` - `AIA_SKILLS_PROMPT_IN_SYSTEM` - 默认:`1`(开启;仅当技能运行时启用) @@ -83,7 +83,7 @@ - `AIA_OCLAW_ROUTER_MODE` - 默认:`rule` - 取值:`rule`(启发式)或 `llm_json`(由当前 executor 的 `model.chat` 产出 `{mode,reason}` JSON;解析失败则回落 `rule`) - - 说明:亦可通过同名环境变量覆盖;提示词见 `oclaw/prompts_runtime/router/decide_route.md` + - 说明:亦可通过同名环境变量覆盖;提示词见 `runtime/workspaces/_system/router/decide_route.md` - 生效:`oclaw/oclaw_runtime/router.py`, `oclaw/oclaw_runtime/gateway.py` - oclaw trace 字段与 `event_type` ↔ `oc_stage` 对照见 `oclaw/docs/oclaw-trace-taxonomy.md` diff --git a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md index 4335fd64..1784e8b5 100644 --- a/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md +++ b/docs/ENVIRONMENT_VARIABLES_CHANGELOG.md @@ -108,7 +108,7 @@ - 依赖:`PyYAML`(`requirements.txt`)。 ### Changed -- `oclaw/prompts/loader.py` 与 `oclaw/oclaw_runtime/skills.py` 统一使用 YAML 解析 frontmatter(失败时默认回落旧行解析,除非开启 STRICT)。 +- `runtime/prompt_templates/loader.py` 与 `runtime/skills.py` 统一使用 YAML 解析 frontmatter(失败时默认回落旧行解析,除非开启 STRICT)。(历史:`oclaw/prompts/` 已迁至 `runtime/workspaces/_system/` + `runtime/prompt_templates/`。) --- diff --git a/docs/NETX_MCP_INTEGRATION.md b/docs/NETX_MCP_INTEGRATION.md new file mode 100644 index 00000000..6d3cd0cc --- /dev/null +++ b/docs/NETX_MCP_INTEGRATION.md @@ -0,0 +1,105 @@ +# netx MCP Integration (same-host) + +This guide wires `oclaw` to the independent ops tool in: + +- `D:/project/chatgpt/netx` + +Assumption: `oclaw` and `netx` run on the same host. + +## 1) Start netx API + +In `D:/project/chatgpt/netx`: + +```powershell +python -m pip install -r requirements.txt +$env:NETX_DATABASE_URL = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx" +$env:NETX_HOST = "127.0.0.1" +$env:NETX_PORT = "8890" +python -m netx_api.main +``` + +Health check: + +```powershell +curl http://127.0.0.1:8890/health +``` + +## 2) Register netx MCP in oclaw Admin + +Use MCP install payload from: + +- `D:/project/chatgpt/netx/mcp_install_payload.json` + +Equivalent manual values: + +- `source_type`: `local` +- `source_ref`: `netx-local-mcp` +- `server_id`: `netx-local` +- `entry_command`: `python` +- `entry_args`: `["D:/project/chatgpt/netx/netx_api/mcp_server.py"]` +- `timeout_s`: `30` + +Then run: + +1. `Health` +2. `Sync Tools` + +Expected tools: + +- `queryAlarms` +- `aggregateAlarms` +- `getImportBatch` +- `runDiagnostics` + +## 3) Bind to ops specialist + +In MCP specialist binding, include `netx-local` for your ops specialist/workspace. + +## 4) Use from chat + +After binding, model can call namespaced tools like: + +- `mcp__netx-local__queryAlarms` +- `mcp__netx-local__aggregateAlarms` +- `mcp__netx-local__getImportBatch` +- `mcp__netx-local__runDiagnostics` + +## 5) External link in Admin + +`oclaw` admin sidebar includes an external link: + +- `Open netx ops tool` -> `http://127.0.0.1:5173/` + +If your netx host/port differs, update the link in: + +- `interfaces/admin/static/index.html` + +## 6) netx -> oclaw AP analyze auth + +`netx` can call: + +- `POST /admin/api/ops-ai/analyze-sync` +- `GET /admin/api/ops-ai/health` + +Recommended auth: + +1. Set shared token in `oclaw` runtime env: + - `OCLAW_OPS_AI_SHARED_TOKEN=` +2. Set same token in `netx`: + - `NETX_OCLAW_ANALYZE_TOKEN=` + +Then `netx /v1/ap/analyze` can invoke `oclaw` synchronously. + +**Timeouts:** `netx` → `oclaw` uses HTTP; `analyze-sync` often exceeds old ~35s limits. In netx set `NETX_OCLAW_ANALYZE_READ_TIMEOUT_SEC` (default `180` in `netx_api/config.py`) if you still see read timeouts on slow models or multi-tool turns. + +Integration health in netx: + +- `GET http://127.0.0.1:8890/v1/integrations/status` + +## 7) Observe AP calls in oclaw + +Recent ops-ai analyze calls can be fetched from: + +- `GET /admin/api/ops-ai/logs?limit=50&offset=0` + +Permission: `admin:user:write` (same as admin audit access). diff --git a/docs/PROMPT_STYLE_GUIDE.md b/docs/PROMPT_STYLE_GUIDE.md index 6644464a..8a733045 100644 --- a/docs/PROMPT_STYLE_GUIDE.md +++ b/docs/PROMPT_STYLE_GUIDE.md @@ -1,7 +1,7 @@ # Prompt Style Guide ## Goal -- All model-facing prompts must be Markdown templates under `oclaw/prompts/`. +- System/builtin model-facing prompt templates live under `runtime/workspaces/_system/`; role-specific copy lives under `runtime/workspaces//` (e.g. `ROLE_SYSTEM.md`). Load via `oclaw.runtime.prompt_templates`. - Business code must inject variables only; no long inline prompt strings. ## Template Contract diff --git a/interfaces/admin/models_api.py b/interfaces/admin/models_api.py index 0d4b4f4f..11b31e0b 100644 --- a/interfaces/admin/models_api.py +++ b/interfaces/admin/models_api.py @@ -31,6 +31,8 @@ from oclaw.platform.persistence.sqlite_store import ( _LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"}) _LLM_USER_CREATE_MODES = frozenset({"openai", "anthropic", "google", "ollama", "rule"}) +_OPS_AI_ACTIVE_KEY = "ops_ai_active_llm_profile_id" +_OPS_AI_BINDINGS_KEY = "ops_ai_agent_profile_bindings" def _require_permission(ctx: dict[str, Any], permission: str) -> None: @@ -136,6 +138,16 @@ def _normalize_active( return active_id +def _normalize_active_by_key(store: SqliteStore, *, key: str, profile_ids: list[str]) -> str: + if not profile_ids: + return "" + active_id = str(store.get_setting(key) or "").strip() + if active_id not in profile_ids: + active_id = LLM_BUILTIN_OLLAMA_PROFILE_ID if LLM_BUILTIN_OLLAMA_PROFILE_ID in profile_ids else profile_ids[0] + store.set_setting(key, active_id) + return active_id + + def include_model_mgmt_routes( router: APIRouter, *, @@ -179,6 +191,20 @@ def include_model_mgmt_routes( # 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」 "db_path": db_path(), } + ops_ai_active_id = _normalize_active_by_key(store, key=_OPS_AI_ACTIVE_KEY, profile_ids=profile_ids) + raw_ops_bindings = parse_agent_profile_bindings(store.get_setting(_OPS_AI_BINDINGS_KEY)) + ops_ai_bindings: dict[str, str] = {} + changed = False + for rid in agent_role_ids(): + pid = str(raw_ops_bindings.get(rid) or "").strip() + if pid and pid not in profile_ids: + pid = "" + changed = True + ops_ai_bindings[rid] = pid + if changed: + store.set_setting(_OPS_AI_BINDINGS_KEY, dump_agent_profile_bindings(ops_ai_bindings)) + out["ops_ai_active_llm_profile_id"] = ops_ai_active_id + out["ops_ai_bindings"] = ops_ai_bindings return out @mg.post("/active") @@ -225,6 +251,49 @@ def include_model_mgmt_routes( store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur)) return {"ok": True, "bindings": cur} + @mg.post("/ops-ai/active") + def api_models_set_ops_ai_active( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx)) + profile_ids = [str(p["id"]) for p in profiles] + pid = str(payload.get("profile_id") or "").strip() + if pid not in profile_ids: + raise HTTPException(status_code=400, detail="invalid_profile_id") + store.set_setting(_OPS_AI_ACTIVE_KEY, pid) + return {"ok": True, "ops_ai_active_llm_profile_id": pid} + + @mg.post("/ops-ai/bindings") + def api_models_set_ops_ai_bindings( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = resolve_auth(store, authorization) + _require_models_mutate(ctx) + profiles = store.list_llm_profiles(visible_only=True, **_models_list_kwargs(ctx)) + profile_ids = set(str(p["id"]) for p in profiles) + raw = payload.get("bindings") + if not isinstance(raw, dict): + raise HTTPException(status_code=400, detail="bindings_object_required") + cur = parse_agent_profile_bindings(store.get_setting(_OPS_AI_BINDINGS_KEY)) + for rid in agent_role_ids(): + v = raw.get(rid) + if v is None: + continue + s = str(v).strip() + if s and s not in profile_ids: + raise HTTPException(status_code=400, detail=f"invalid_binding:{rid}") + cur[rid] = s + store.set_setting(_OPS_AI_BINDINGS_KEY, dump_agent_profile_bindings(cur)) + return {"ok": True, "ops_ai_bindings": cur} + @mg.post("/profiles") def api_models_create_profile( payload: dict[str, Any] | None = Body(default=None), diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py index 44b12cb7..7ca23c33 100644 --- a/interfaces/admin/routes.py +++ b/interfaces/admin/routes.py @@ -17,6 +17,9 @@ from fastapi import HTTPException from fastapi.responses import HTMLResponse from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os +from oclaw.runtime.agents.factory import build_gateway_executor +from oclaw.runtime.gateway import OclawGateway +from oclaw.runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist from oclaw.runtime.operations.providers.registry import build_channel_registry from oclaw.runtime.operations.runtime import ( cleanup_service_processes_by_pid, @@ -31,7 +34,7 @@ from oclaw.runtime.orchestration.vector_store import read_vector_memory_runtime from oclaw.platform.config.paths import PROJECT_ROOT, db_path from oclaw.platform.config.passwords import load_expected_password from oclaw.platform.persistence.sqlite_store import SqliteStore -from oclaw.runtime.agents.specialists import discover_specialist_ids +from oclaw.runtime.agents.specialists import discover_specialist_ids, parse_agent_profile_bindings from oclaw.runtime.tools.mcp.installer import ( _safe_server_id, detect_local_dependencies, @@ -381,6 +384,19 @@ def build_admin_router() -> APIRouter: return raise HTTPException(status_code=403, detail="cross_tenant_forbidden") + def _resolve_ops_ai_auth(store: SqliteStore, authorization: str | None) -> dict[str, Any]: + shared = str(os.getenv("OCLAW_OPS_AI_SHARED_TOKEN") or "").strip() + token = _extract_bearer(authorization) + if shared and token and hmac.compare_digest(shared, token): + return { + "tenant_id": "", + "user_id": "ops-ai-service", + "username": "ops-ai-service", + "role": "service", + "permissions": ["admin:read"], + } + return _resolve_auth(store, authorization) + def _ordered_specialists() -> list[str]: base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()] preferred = [x for x in ("generalist", "ops", "image") if x in set(base)] @@ -3810,6 +3826,247 @@ def build_admin_router() -> APIRouter: store.revoke_auth_session(session_token_hash=_sha256_hex(token)) return {"ok": True} + @router.post("/admin/api/ops-ai/analyze-sync") + def api_ops_ai_analyze_sync( + payload: dict[str, Any] | None = Body(default=None), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + payload = payload or {} + store = SqliteStore(db_path()) + ctx = _resolve_ops_ai_auth(store, authorization) + _require_permission(ctx, "admin:read") + + question = str(payload.get("question") or "").strip() + dataset_ref = payload.get("dataset_ref") if isinstance(payload.get("dataset_ref"), dict) else {} + context = payload.get("context") if isinstance(payload.get("context"), dict) else {} + constraints = payload.get("constraints") if isinstance(payload.get("constraints"), dict) else {} + analysis_request_id = str(payload.get("analysis_request_id") or "").strip() + if not question: + raise HTTPException(status_code=400, detail="question_required") + + severity_summary = context.get("severity_summary") if isinstance(context.get("severity_summary"), list) else [] + top_alarm_codes = context.get("top_alarm_codes") if isinstance(context.get("top_alarm_codes"), list) else [] + top_ne = context.get("top_ne") if isinstance(context.get("top_ne"), list) else [] + findings = context.get("findings") if isinstance(context.get("findings"), list) else [] + protocol_summary = context.get("protocol_summary") if isinstance(context.get("protocol_summary"), list) else [] + lang = str(constraints.get("language") or "zh-CN") + interaction_mode = normalize_interaction_mode(payload.get("interaction_mode") or "expert") + selected_specialist = normalize_requested_specialist(payload.get("specialist") or "ops") + visible_profiles = store.list_llm_profiles(visible_only=True) + visible_profile_ids = {str(p.get("id") or "").strip() for p in visible_profiles if str(p.get("id") or "").strip()} + ops_ai_active_profile_id = str(store.get_setting("ops_ai_active_llm_profile_id") or "").strip() + if ops_ai_active_profile_id not in visible_profile_ids: + ops_ai_active_profile_id = "" + raw_ops_ai_bindings = parse_agent_profile_bindings(store.get_setting("ops_ai_agent_profile_bindings")) + + def _ops_ai_profile_for_role(role_id: str) -> str | None: + bound = str(raw_ops_ai_bindings.get(role_id) or "").strip() + if bound and bound in visible_profile_ids: + return bound + return None + selected_profile_id = _ops_ai_profile_for_role(selected_specialist) + if not selected_profile_id: + raise HTTPException(status_code=400, detail=f"ops_ai_profile_unbound:{selected_specialist}") + + ops_prompt = ( + f"问题:{question}\n" + f"数据范围:{json.dumps(dataset_ref, ensure_ascii=False)}\n" + f"级别分布:{json.dumps(severity_summary, ensure_ascii=False)}\n" + f"高频告警码:{json.dumps(top_alarm_codes, ensure_ascii=False)}\n" + f"高频网元:{json.dumps(top_ne, ensure_ascii=False)}\n" + f"协议/领域分布:{json.dumps(protocol_summary, ensure_ascii=False)}\n" + f"附加发现:{json.dumps(findings, ensure_ascii=False)}\n" + "请以网络运维专家视角给出:" + "1) 根因假设;2) 证据与缺口;3) 处置优先级;4) 需要进一步采集的数据。" + ) + + tenant_id = str(ctx.get("tenant_id") or "") + user_id = str(ctx.get("user_id") or "ops-ai-service") + username = str(ctx.get("username") or "ops-ai-service") + role = str(ctx.get("role") or "service") + gw_result = None + answer = "" + gateway_error = "" + try: + apply_gateway_mcp_env_to_os() + # Service-token calls may not map to a real UI user row. + # Use internal session to avoid ui_session_owner foreign-key constraints. + ops_session = store.create_session( + title=f"ops-ai-sync:{str(dataset_ref.get('batch_id') or 'adhoc')}" + ) + manager_agent = build_gateway_executor( + store, + lang=lang, + specialist="generalist", + profile_id=selected_profile_id, + openai_api_key=None, + llm_mode=None, + model=None, + base_url=None, + # Internal ops-ai should use the global model pool bindings + # instead of service-user personal visibility filtering. + viewer_user_id=None, + viewer_username="administrator", + viewer_tenant_id=None, + policy_session_id=str(ops_session.id), + path_policy_tenant_id=tenant_id or None, + path_policy_user_id=user_id or None, + ) + specialist_factory = lambda sid: build_gateway_executor( + store, + lang=lang, + specialist=sid, + profile_id=_ops_ai_profile_for_role(str(sid or "").strip().lower()) or selected_profile_id, + openai_api_key=None, + llm_mode=None, + model=None, + base_url=None, + viewer_user_id=None, + viewer_username="administrator", + viewer_tenant_id=None, + policy_session_id=str(ops_session.id), + path_policy_tenant_id=tenant_id or None, + path_policy_user_id=user_id or None, + ) + gw = OclawGateway(store=store) + gw_result = gw.handle_turn( + msg=StandardMessage( + session_id=str(ops_session.id), + tenant_id=tenant_id, + user_id=user_id, + role=role, + channel="ops_ai_api", + text=ops_prompt, + attachments=[], + metadata={ + "tenant_id": tenant_id, + "user_id": user_id, + "role": role, + "channel": "ops_ai_api", + "interaction_mode": interaction_mode, + "selected_specialist": selected_specialist, + "memory_mode": "default", + "execution_mode": "normal", + }, + ), + lang=lang, + executor=manager_agent, + run_id=(analysis_request_id or None), + specialist_executor_factory=specialist_factory, + ) + answer = str(getattr(gw_result, "reply_text", "") or "") + except Exception as exc: + gateway_error = str(exc) + sev_text = ";".join( + f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" + for x in severity_summary[:5] + if isinstance(x, dict) + ) + code_text = ";".join( + f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" + for x in top_alarm_codes[:5] + if isinstance(x, dict) + ) + ne_text = ";".join( + f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" + for x in top_ne[:5] + if isinstance(x, dict) + ) + proto_text = ";".join( + f"{str(x.get('key') or '')}:{int(x.get('count') or 0)}" + for x in protocol_summary[:5] + if isinstance(x, dict) + ) + answer = ( + "网关专家暂时不可用,先给出统计结论:\n" + f"- 级别分布:{sev_text or '无'}\n" + f"- 高频告警码:{code_text or '无'}\n" + f"- 高频网元:{ne_text or '无'}\n" + f"- 协议/领域:{proto_text or '无'}\n" + "- 建议优先处理 critical/major 的热点网元,并结合 top 告警码排查是否告警风暴。" + ) + + try: + store.add_admin_audit_log( + actor_tenant_id=str(ctx.get("tenant_id") or ""), + actor_user_id=str(ctx.get("user_id") or ""), + action="ops_ai_analyze_sync", + target_type="ops_ai", + target_id=analysis_request_id or str(dataset_ref.get("batch_id") or "unknown"), + status="ok", + detail={ + "question": question[:200], + "dataset_ref": dataset_ref, + "risk_hint": findings[:3] if findings else [], + "mode": interaction_mode, + "specialist": selected_specialist, + "gateway_error": gateway_error, + }, + ) + except Exception: + pass + + return { + "ok": True, + "analysis_request_id": analysis_request_id, + "answer": answer, + "evidence": { + "severity_summary": severity_summary[:10], + "top_alarm_codes": top_alarm_codes[:10], + "top_ne": top_ne[:10], + "protocol_summary": protocol_summary[:10], + "findings": findings[:10], + }, + "followups": [ + "请给出过去2小时 critical 告警的 top5 网元与同比变化。", + "请按告警码聚类,定位是否为单一根因引发的告警风暴。", + ], + "metadata": { + "engine": "oclaw_ops_ai_sync_v1", + "source": "gateway_expert" if not gateway_error else "fallback_context", + "interaction_mode": str(getattr(gw_result, "interaction_mode", interaction_mode) or interaction_mode), + "selected_specialist": str(getattr(gw_result, "selected_specialist", selected_specialist) or selected_specialist), + "ops_ai_active_profile_id": ops_ai_active_profile_id, + "ops_ai_role_profile_id": _ops_ai_profile_for_role(selected_specialist) or "", + "gateway_error": gateway_error, + "caller": str(ctx.get("username") or ""), + }, + } + + @router.get("/admin/api/ops-ai/logs") + def api_ops_ai_logs( + limit: int = Query(default=50), + offset: int = Query(default=0), + authorization: str | None = Header(default=None), + ) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_ops_ai_auth(store, authorization) + _require_permission(ctx, "admin:read") + lim = max(1, min(int(limit), 200)) + off = max(0, int(offset)) + rows = store.list_admin_audit_logs( + tenant_id=str(ctx.get("tenant_id") or ""), + action="ops_ai_analyze_sync", + actor_user_id=None, + status=None, + limit=lim, + offset=off, + ) + total = store.count_admin_audit_logs( + tenant_id=str(ctx.get("tenant_id") or ""), + action="ops_ai_analyze_sync", + actor_user_id=None, + status=None, + ) + return {"ok": True, "items": rows, "total": int(total), "limit": lim, "offset": off} + + @router.get("/admin/api/ops-ai/health") + def api_ops_ai_health(authorization: str | None = Header(default=None)) -> dict[str, Any]: + store = SqliteStore(db_path()) + ctx = _resolve_ops_ai_auth(store, authorization) + _require_permission(ctx, "admin:read") + return {"ok": True, "service": "oclaw", "component": "ops-ai", "status": "ok", "caller": str(ctx.get("username") or "")} + @router.get("/admin/api/admin-audit") def api_admin_audit( limit: int = Query(default=200), diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js index 175654fd..9b0f4a7a 100644 --- a/interfaces/admin/static/app.js +++ b/interfaces/admin/static/app.js @@ -15,6 +15,7 @@ const I18N = { "nav.skills": "技能", "nav.attachments": "附件", "nav.profile": "用户信息", + "nav.netx": "打开 netx 运维工具", "notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问", "action.refresh": "刷新", "title.stack": "运行时", @@ -447,6 +448,7 @@ const I18N = { "nav.skills": "Skills", "nav.attachments": "Attachments", "nav.profile": "User Info", + "nav.netx": "Open netx ops tool", "notice.noLogin": "v2 login enabled: internal network only", "action.refresh": "Refresh", "title.stack": "Runtime", @@ -3446,6 +3448,8 @@ async function renderModels() { }); const activeSelect = el("select", { class: "input", disabled: !canPickActive }); const bindingWrap = el("div", {}); + const opsAiSpecialistSelect = el("select", { class: "input", disabled: !canConfigureBindings }); + const opsAiProfileSelect = el("select", { class: "input", disabled: !canConfigureBindings }); const modelsGrantsLinkRow = el("div", { class: "muted", style: "display:none" }); const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") }); const newMode = el("select", { class: "input", disabled: !canMutateProfiles }, [ @@ -3772,6 +3776,29 @@ async function renderModels() { }); } + function paintOpsAiBindings() { + if (!state || !state.ops_ai_bindings) return; + const profiles = state.profiles || []; + const profileIds = profiles.map((p) => String(p.id)); + const roleIds = (Array.isArray(state.role_ids) ? state.role_ids : []).filter((rid) => String(rid || "") !== "manager"); + + const curSpecialist = String(opsAiSpecialistSelect.value || "").trim(); + opsAiSpecialistSelect.innerHTML = ""; + roleIds.forEach((rid) => { + opsAiSpecialistSelect.appendChild(el("option", { value: String(rid), text: t("models.role." + rid) })); + }); + if (roleIds.includes(curSpecialist)) opsAiSpecialistSelect.value = curSpecialist; + else if (roleIds.length) opsAiSpecialistSelect.value = roleIds[0]; + + const rid = String(opsAiSpecialistSelect.value || "").trim(); + opsAiProfileSelect.innerHTML = ""; + profiles.forEach((p) => { + opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) })); + }); + const v = String((state.ops_ai_bindings && state.ops_ai_bindings[rid]) || "").trim(); + opsAiProfileSelect.value = profileIds.includes(v) ? v : ""; + } + function paintEval() { evalMetrics.innerHTML = ""; evalTableWrap.innerHTML = ""; @@ -3833,8 +3860,11 @@ async function renderModels() { const aid = String(state.active_llm_profile_id || ""); if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid; activeSelect.disabled = !canPickActive; + opsAiSpecialistSelect.disabled = !canConfigureBindings; + opsAiProfileSelect.disabled = !canConfigureBindings; paintBindings(); + paintOpsAiBindings(); const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {}; const pid = String(selProf.id || ""); @@ -3911,6 +3941,21 @@ async function renderModels() { status.textContent = String(e.message || e); } }); + opsAiSpecialistSelect.addEventListener("change", async () => { + paintOpsAiBindings(); + }); + opsAiProfileSelect.addEventListener("change", async () => { + if (!canConfigureBindings) return; + try { + const rid = String(opsAiSpecialistSelect.value || "").trim(); + const next = Object.assign({}, (state && state.ops_ai_bindings) || {}); + next[rid] = String(opsAiProfileSelect.value || ""); + await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next }); + await refresh(); + } catch (e) { + status.textContent = String(e.message || e); + } + }); const btnCreate = el("button", { class: "btn btn--primary", text: t("models.createBtn"), onclick: async () => { if (!canMutateProfiles) return; @@ -4129,6 +4174,7 @@ async function renderModels() { sections: [ { id: "models-overview", label: "概览" }, { id: "models-bindings", label: "绑定" }, + { id: "models-ops-ai", label: "内部API" }, { id: "models-api", label: "API配置" }, { id: "models-experts", label: "Experts" }, { id: "models-eval", label: "评估" }, @@ -4150,6 +4196,10 @@ async function renderModels() { el("div", { class: "muted", text: t("models.bindingsScopeHint") }), bindingWrap, ], { id: "models-bindings" }), + renderSectionCard("内部 API模型配置", "仅用于 v1 / 专家模式:选择专家并绑定 API 配置。", [ + el("div", { class: "row" }, [el("label", { text: "专家" }), opsAiSpecialistSelect]), + el("div", { class: "row" }, [el("label", { text: "API配置" }), opsAiProfileSelect]), + ], { id: "models-ops-ai" }), renderSectionCard(t("models.sectionApi"), "", [ builtinCap, readonlyProfileHint, diff --git a/interfaces/admin/static/index.html b/interfaces/admin/static/index.html index 45192d88..814c0c29 100644 --- a/interfaces/admin/static/index.html +++ b/interfaces/admin/static/index.html @@ -80,6 +80,17 @@ 用户管理 设置 +