mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 22:53:27 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
83f46f8aa2
commit
b0e0c05d8a
60 changed files with 1143 additions and 46 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -67,5 +67,6 @@ oclaw/desktop/node_modules/
|
||||||
oclaw/desktop/dist/
|
oclaw/desktop/dist/
|
||||||
oclaw/desktop/runtime-data/
|
oclaw/desktop/runtime-data/
|
||||||
oclaw/desktop/assets/oclaw.ico
|
oclaw/desktop/assets/oclaw.ico
|
||||||
|
vendor/
|
||||||
vendor/oclaw-main.zip
|
vendor/oclaw-main.zip
|
||||||
vendor/oclaw/
|
vendor/oclaw/
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,8 @@ OCLAW_WS_RATE_LIMIT_USER_PER_WINDOW=360
|
||||||
OCLAW_WS_SEND_QUEUE_MAX_MESSAGES=256
|
OCLAW_WS_SEND_QUEUE_MAX_MESSAGES=256
|
||||||
OCLAW_WS_SEND_QUEUE_MAX_BYTES=
|
OCLAW_WS_SEND_QUEUE_MAX_BYTES=
|
||||||
OCLAW_WS_EVENT_REPLAY_MAX=256
|
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 消息回放
|
# 十五、模型请求:工具 JSON、Replay 策略、Agent 消息回放
|
||||||
|
|
@ -351,3 +353,17 @@ TAVILY_API_KEY=
|
||||||
# AIA_OCLAW_RETRYABLE_ERROR_CODES / AIA_OCLAW_RETRY_CODES_STRICT_MODE
|
# AIA_OCLAW_RETRYABLE_ERROR_CODES / AIA_OCLAW_RETRY_CODES_STRICT_MODE
|
||||||
# rag_mode、embedding 相关部分亦在后台 memory/RAG 页
|
# rag_mode、embedding 相关部分亦在后台 memory/RAG 页
|
||||||
# 【前端】多数上述项在 Admin 设置表单中有对应勾选或输入框。
|
# 【前端】多数上述项在 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
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
- `runtime/`:运行时主域(agent、gateway 执行流、skills/hooks/extensions、operations)。
|
- `runtime/`:运行时主域(agent、gateway 执行流、skills/hooks/extensions、operations)。
|
||||||
- `interfaces/`:对外接口层(HTTP、WS、Admin、Gateway method bridge)。
|
- `interfaces/`:对外接口层(HTTP、WS、Admin、Gateway method bridge)。
|
||||||
- `platform/`:通用平台能力(配置、存储、LLM transport、文件层)。
|
- `platform/`:通用平台能力(配置、存储、LLM transport、文件层)。
|
||||||
- `prompts/`:统一提示词体系(含 runtime 相关提示模板)。
|
- `runtime/workspaces/_system/`:内置系统提示词 Markdown 树(原顶层 `prompts/`,与按角色分区的 `workspaces/<role>/` 并列);`runtime/prompt_templates/` 为加载与 frontmatter 解析。
|
||||||
- `tests/`:测试代码(按你的要求保持顶层)。
|
- `tests/`:测试代码(按你的要求保持顶层)。
|
||||||
- `docs/`:设计文档、运维说明、迁移记录。
|
- `docs/`:设计文档、运维说明、迁移记录。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@
|
||||||
|
|
||||||
- `AIA_PROMPT_FRONTMATTER_STRICT`
|
- `AIA_PROMPT_FRONTMATTER_STRICT`
|
||||||
- 默认:`0`
|
- 默认:`0`
|
||||||
- 作用:`1` 时 `SKILL.md` / `oclaw/prompts/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析)
|
- 作用:`1` 时 `SKILL.md` / `runtime/workspaces/_system/**/*.md` 的 frontmatter 必须为可解析 YAML;解析失败直接报错(不回落旧版行解析)
|
||||||
- 生效:`oclaw/prompts/frontmatter.py`, `oclaw/prompts/loader.py`, `oclaw/oclaw_runtime/skills.py`
|
- 生效:`oclaw/runtime/prompt_templates/frontmatter.py`, `oclaw/runtime/prompt_templates/loader.py`, `oclaw/runtime/skills.py`
|
||||||
|
|
||||||
- `AIA_SKILLS_PROMPT_IN_SYSTEM`
|
- `AIA_SKILLS_PROMPT_IN_SYSTEM`
|
||||||
- 默认:`1`(开启;仅当技能运行时启用)
|
- 默认:`1`(开启;仅当技能运行时启用)
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
- `AIA_OCLAW_ROUTER_MODE`
|
- `AIA_OCLAW_ROUTER_MODE`
|
||||||
- 默认:`rule`
|
- 默认:`rule`
|
||||||
- 取值:`rule`(启发式)或 `llm_json`(由当前 executor 的 `model.chat` 产出 `{mode,reason}` JSON;解析失败则回落 `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/oclaw_runtime/router.py`, `oclaw/oclaw_runtime/gateway.py`
|
||||||
|
|
||||||
- oclaw trace 字段与 `event_type` ↔ `oc_stage` 对照见 `oclaw/docs/oclaw-trace-taxonomy.md`
|
- oclaw trace 字段与 `event_type` ↔ `oc_stage` 对照见 `oclaw/docs/oclaw-trace-taxonomy.md`
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
- 依赖:`PyYAML`(`requirements.txt`)。
|
- 依赖:`PyYAML`(`requirements.txt`)。
|
||||||
|
|
||||||
### Changed
|
### 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/`。)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
105
docs/NETX_MCP_INTEGRATION.md
Normal file
105
docs/NETX_MCP_INTEGRATION.md
Normal file
|
|
@ -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=<your_token>`
|
||||||
|
2. Set same token in `netx`:
|
||||||
|
- `NETX_OCLAW_ANALYZE_TOKEN=<your_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).
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Prompt Style Guide
|
# Prompt Style Guide
|
||||||
|
|
||||||
## Goal
|
## 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/<role>/` (e.g. `ROLE_SYSTEM.md`). Load via `oclaw.runtime.prompt_templates`.
|
||||||
- Business code must inject variables only; no long inline prompt strings.
|
- Business code must inject variables only; no long inline prompt strings.
|
||||||
|
|
||||||
## Template Contract
|
## Template Contract
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ from oclaw.platform.persistence.sqlite_store import (
|
||||||
|
|
||||||
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
|
_LLM_MODE_OPTIONS = frozenset({"openai", "openai_responses", "anthropic", "google", "ollama", "rule"})
|
||||||
_LLM_USER_CREATE_MODES = frozenset({"openai", "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:
|
def _require_permission(ctx: dict[str, Any], permission: str) -> None:
|
||||||
|
|
@ -136,6 +138,16 @@ def _normalize_active(
|
||||||
return active_id
|
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(
|
def include_model_mgmt_routes(
|
||||||
router: APIRouter,
|
router: APIRouter,
|
||||||
*,
|
*,
|
||||||
|
|
@ -179,6 +191,20 @@ def include_model_mgmt_routes(
|
||||||
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
# 便于核对「浏览器连的是哪台网关、网关读的是哪个库文件」
|
||||||
"db_path": db_path(),
|
"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
|
return out
|
||||||
|
|
||||||
@mg.post("/active")
|
@mg.post("/active")
|
||||||
|
|
@ -225,6 +251,49 @@ def include_model_mgmt_routes(
|
||||||
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
|
store.set_setting(_bindings_key(ctx), dump_agent_profile_bindings(cur))
|
||||||
return {"ok": True, "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")
|
@mg.post("/profiles")
|
||||||
def api_models_create_profile(
|
def api_models_create_profile(
|
||||||
payload: dict[str, Any] | None = Body(default=None),
|
payload: dict[str, Any] | None = Body(default=None),
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ from fastapi import HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
from oclaw.runtime.operations.mcp_env import apply_gateway_mcp_env_to_os
|
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.providers.registry import build_channel_registry
|
||||||
from oclaw.runtime.operations.runtime import (
|
from oclaw.runtime.operations.runtime import (
|
||||||
cleanup_service_processes_by_pid,
|
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.paths import PROJECT_ROOT, db_path
|
||||||
from oclaw.platform.config.passwords import load_expected_password
|
from oclaw.platform.config.passwords import load_expected_password
|
||||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
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 (
|
from oclaw.runtime.tools.mcp.installer import (
|
||||||
_safe_server_id,
|
_safe_server_id,
|
||||||
detect_local_dependencies,
|
detect_local_dependencies,
|
||||||
|
|
@ -381,6 +384,19 @@ def build_admin_router() -> APIRouter:
|
||||||
return
|
return
|
||||||
raise HTTPException(status_code=403, detail="cross_tenant_forbidden")
|
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]:
|
def _ordered_specialists() -> list[str]:
|
||||||
base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()]
|
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)]
|
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))
|
store.revoke_auth_session(session_token_hash=_sha256_hex(token))
|
||||||
return {"ok": True}
|
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")
|
@router.get("/admin/api/admin-audit")
|
||||||
def api_admin_audit(
|
def api_admin_audit(
|
||||||
limit: int = Query(default=200),
|
limit: int = Query(default=200),
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const I18N = {
|
||||||
"nav.skills": "技能",
|
"nav.skills": "技能",
|
||||||
"nav.attachments": "附件",
|
"nav.attachments": "附件",
|
||||||
"nav.profile": "用户信息",
|
"nav.profile": "用户信息",
|
||||||
|
"nav.netx": "打开 netx 运维工具",
|
||||||
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
|
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
|
||||||
"action.refresh": "刷新",
|
"action.refresh": "刷新",
|
||||||
"title.stack": "运行时",
|
"title.stack": "运行时",
|
||||||
|
|
@ -447,6 +448,7 @@ const I18N = {
|
||||||
"nav.skills": "Skills",
|
"nav.skills": "Skills",
|
||||||
"nav.attachments": "Attachments",
|
"nav.attachments": "Attachments",
|
||||||
"nav.profile": "User Info",
|
"nav.profile": "User Info",
|
||||||
|
"nav.netx": "Open netx ops tool",
|
||||||
"notice.noLogin": "v2 login enabled: internal network only",
|
"notice.noLogin": "v2 login enabled: internal network only",
|
||||||
"action.refresh": "Refresh",
|
"action.refresh": "Refresh",
|
||||||
"title.stack": "Runtime",
|
"title.stack": "Runtime",
|
||||||
|
|
@ -3446,6 +3448,8 @@ async function renderModels() {
|
||||||
});
|
});
|
||||||
const activeSelect = el("select", { class: "input", disabled: !canPickActive });
|
const activeSelect = el("select", { class: "input", disabled: !canPickActive });
|
||||||
const bindingWrap = el("div", {});
|
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 modelsGrantsLinkRow = el("div", { class: "muted", style: "display:none" });
|
||||||
const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") });
|
const newName = el("input", { class: "input", placeholder: t("models.createNamePlaceholder") });
|
||||||
const newMode = el("select", { class: "input", disabled: !canMutateProfiles }, [
|
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() {
|
function paintEval() {
|
||||||
evalMetrics.innerHTML = "";
|
evalMetrics.innerHTML = "";
|
||||||
evalTableWrap.innerHTML = "";
|
evalTableWrap.innerHTML = "";
|
||||||
|
|
@ -3833,8 +3860,11 @@ async function renderModels() {
|
||||||
const aid = String(state.active_llm_profile_id || "");
|
const aid = String(state.active_llm_profile_id || "");
|
||||||
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
|
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
|
||||||
activeSelect.disabled = !canPickActive;
|
activeSelect.disabled = !canPickActive;
|
||||||
|
opsAiSpecialistSelect.disabled = !canConfigureBindings;
|
||||||
|
opsAiProfileSelect.disabled = !canConfigureBindings;
|
||||||
|
|
||||||
paintBindings();
|
paintBindings();
|
||||||
|
paintOpsAiBindings();
|
||||||
|
|
||||||
const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {};
|
const selProf = profiles.find((p) => String(p.id) === aid) || profiles[0] || {};
|
||||||
const pid = String(selProf.id || "");
|
const pid = String(selProf.id || "");
|
||||||
|
|
@ -3911,6 +3941,21 @@ async function renderModels() {
|
||||||
status.textContent = String(e.message || e);
|
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 () => {
|
const btnCreate = el("button", { class: "btn btn--primary", text: t("models.createBtn"), onclick: async () => {
|
||||||
if (!canMutateProfiles) return;
|
if (!canMutateProfiles) return;
|
||||||
|
|
@ -4129,6 +4174,7 @@ async function renderModels() {
|
||||||
sections: [
|
sections: [
|
||||||
{ id: "models-overview", label: "概览" },
|
{ id: "models-overview", label: "概览" },
|
||||||
{ id: "models-bindings", label: "绑定" },
|
{ id: "models-bindings", label: "绑定" },
|
||||||
|
{ id: "models-ops-ai", label: "内部API" },
|
||||||
{ id: "models-api", label: "API配置" },
|
{ id: "models-api", label: "API配置" },
|
||||||
{ id: "models-experts", label: "Experts" },
|
{ id: "models-experts", label: "Experts" },
|
||||||
{ id: "models-eval", label: "评估" },
|
{ id: "models-eval", label: "评估" },
|
||||||
|
|
@ -4150,6 +4196,10 @@ async function renderModels() {
|
||||||
el("div", { class: "muted", text: t("models.bindingsScopeHint") }),
|
el("div", { class: "muted", text: t("models.bindingsScopeHint") }),
|
||||||
bindingWrap,
|
bindingWrap,
|
||||||
], { id: "models-bindings" }),
|
], { 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"), "", [
|
renderSectionCard(t("models.sectionApi"), "", [
|
||||||
builtinCap,
|
builtinCap,
|
||||||
readonlyProfileHint,
|
readonlyProfileHint,
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,17 @@
|
||||||
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
|
<a class="nav__item" data-page="users" href="#/users" data-i18n="nav.users">用户管理</a>
|
||||||
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
|
<a class="nav__item" data-page="profile" href="#/profile" data-i18n="nav.profile">设置</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav__group">
|
||||||
|
<div class="nav__groupTitle">运维工具</div>
|
||||||
|
<a
|
||||||
|
class="nav__item"
|
||||||
|
href="http://127.0.0.1:5173/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-i18n="nav.netx"
|
||||||
|
>打开 netx 运维工具</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar__footer">
|
<div class="sidebar__footer">
|
||||||
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
|
<div class="muted" data-i18n="notice.noLogin">v1 无登录:请仅在内网访问</div>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from oclaw.prompts import render_prompt
|
from oclaw.runtime.prompt_templates import render_prompt
|
||||||
|
|
||||||
|
|
||||||
def _summarize_data_url(url: str) -> dict[str, Any]:
|
def _summarize_data_url(url: str) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from .loader import render_prompt, render_prompt_for_lang, render_runtime_prompt
|
|
||||||
|
|
||||||
__all__ = ["render_prompt", "render_prompt_for_lang", "render_runtime_prompt"]
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
title: network_ops_system_zh
|
|
||||||
summary: Network ops agent system prompt wrapper.
|
|
||||||
read_when: network ops agent init
|
|
||||||
---
|
|
||||||
|
|
||||||
{{ROLE_SYSTEM_CONTEXT}}
|
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ from oclaw.platform.persistence.sqlite_store import (
|
||||||
agent_profile_bindings_setting_key,
|
agent_profile_bindings_setting_key,
|
||||||
is_administrator_model_pool,
|
is_administrator_model_pool,
|
||||||
)
|
)
|
||||||
from oclaw.prompts import render_prompt
|
from oclaw.runtime.prompt_templates import render_prompt
|
||||||
from oclaw.runtime.tools.catalog import default_registry
|
from oclaw.runtime.tools.catalog import default_registry
|
||||||
from oclaw.runtime.tools.plugin_loader import sync_plugin_metadata
|
from oclaw.runtime.tools.plugin_loader import sync_plugin_metadata
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,10 @@ from typing import Any
|
||||||
from oclaw.runtime.chat.agent import Agent
|
from oclaw.runtime.chat.agent import Agent
|
||||||
from oclaw.runtime.agent_context import build_role_system_context
|
from oclaw.runtime.agent_context import build_role_system_context
|
||||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||||
from oclaw.prompts.loader import render_prompt
|
|
||||||
from oclaw.runtime.tools import default_registry
|
from oclaw.runtime.tools import default_registry
|
||||||
|
|
||||||
NETWORK_SYSTEM_PROMPT_ZH = render_prompt(
|
# Ops 专家走与 specialists 相同的工作区框架:runtime/workspaces/ops/{SOUL,ROLE_SYSTEM}.md
|
||||||
"agents/network_ops_system.zh.md",
|
NETWORK_SYSTEM_PROMPT_ZH = build_role_system_context("ops")
|
||||||
variables={"ROLE_SYSTEM_CONTEXT": build_role_system_context("ops")},
|
|
||||||
strict=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkOpsAgent(Agent):
|
class NetworkOpsAgent(Agent):
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from oclaw.platform.llm.chat_models import (
|
||||||
build_default_model,
|
build_default_model,
|
||||||
gemini_openai_compat_client,
|
gemini_openai_compat_client,
|
||||||
)
|
)
|
||||||
from oclaw.prompts.loader import render_prompt_for_lang
|
from oclaw.runtime.prompt_templates import render_prompt_for_lang
|
||||||
from oclaw.runtime.tools.tool_validation import validate_tool_arguments
|
from oclaw.runtime.tools.tool_validation import validate_tool_arguments
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from oclaw.prompts import render_prompt
|
from oclaw.runtime.prompt_templates import render_prompt
|
||||||
|
|
||||||
|
|
||||||
def format_ollama_failure_banner(*, lang: str, exc: BaseException) -> str:
|
def format_ollama_failure_banner(*, lang: str, exc: BaseException) -> str:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from typing import Any
|
||||||
from oclaw.platform.llm.chat_models import _normalize_image_b64_payload, gemini_openai_compat_client, ChatModel
|
from oclaw.platform.llm.chat_models import _normalize_image_b64_payload, gemini_openai_compat_client, ChatModel
|
||||||
from oclaw.runtime.chat.media_redact import redact_embedded_image_blobs
|
from oclaw.runtime.chat.media_redact import redact_embedded_image_blobs
|
||||||
from oclaw.runtime.chat.tool_runtime import tool_llm_message_max_chars, truncate_tool_result_for_llm_messages
|
from oclaw.runtime.chat.tool_runtime import tool_llm_message_max_chars, truncate_tool_result_for_llm_messages
|
||||||
from oclaw.prompts import render_prompt
|
from oclaw.runtime.prompt_templates import render_prompt
|
||||||
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
|
from oclaw.platform.files.attachment_assets import attachment_id_to_data_url
|
||||||
from oclaw.runtime.relay_pointer import parse_pointer_uri
|
from oclaw.runtime.relay_pointer import parse_pointer_uri
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from oclaw.runtime.types import OclawMemoryContext
|
||||||
from oclaw.runtime.orchestration.trace import new_span_id
|
from oclaw.runtime.orchestration.trace import new_span_id
|
||||||
from oclaw.runtime.tools.base import ToolRegistry
|
from oclaw.runtime.tools.base import ToolRegistry
|
||||||
from oclaw.runtime.hooks_runtime import trigger_hook_event
|
from oclaw.runtime.hooks_runtime import trigger_hook_event
|
||||||
|
from oclaw.runtime.tools.experts.network_ops.netx_tools import ops_netx_system_context_extension
|
||||||
|
|
||||||
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
|
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
|
||||||
_OCLAW_ATTACHMENT_TEXT_REPLAY_CAP_CHARS = 4_000
|
_OCLAW_ATTACHMENT_TEXT_REPLAY_CAP_CHARS = 4_000
|
||||||
|
|
@ -631,6 +632,13 @@ def _build_model_context(
|
||||||
final_system = f"{prepend}\n\n{final_system}".strip()
|
final_system = f"{prepend}\n\n{final_system}".strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
if str(skill_binding_role or "").strip().lower() == "ops":
|
||||||
|
ext = ops_netx_system_context_extension(lang=lang or "zh")
|
||||||
|
if str(ext or "").strip():
|
||||||
|
final_system = f"{final_system}\n\n{ext.strip()}".strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
trunc_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
|
trunc_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
|
||||||
tool_context_truncate_enabled = trunc_raw not in ("0", "false", "no", "off")
|
tool_context_truncate_enabled = trunc_raw not in ("0", "false", "no", "off")
|
||||||
llm_messages = build_llm_messages(
|
llm_messages = build_llm_messages(
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from oclaw.runtime.types import (
|
||||||
normalize_requested_specialist,
|
normalize_requested_specialist,
|
||||||
)
|
)
|
||||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||||
from oclaw.prompts import render_prompt
|
from oclaw.runtime.prompt_templates import render_prompt
|
||||||
|
|
||||||
from oclaw.runtime.command_parser import parse_internal_command
|
from oclaw.runtime.command_parser import parse_internal_command
|
||||||
from oclaw.runtime.core.agent_execution import AgentCoreRunInput, build_memory_context, run_agent_core
|
from oclaw.runtime.core.agent_execution import AgentCoreRunInput, build_memory_context, run_agent_core
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import Any
|
||||||
|
|
||||||
from oclaw.runtime.types import OclawMemoryContext
|
from oclaw.runtime.types import OclawMemoryContext
|
||||||
from oclaw.runtime.orchestration.memory import semantic_retrieve, session_memory_digest
|
from oclaw.runtime.orchestration.memory import semantic_retrieve, session_memory_digest
|
||||||
from oclaw.prompts.loader import render_runtime_prompt
|
from oclaw.runtime.prompt_templates import render_runtime_prompt
|
||||||
|
|
||||||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Any
|
||||||
from oclaw.runtime.agents.agent_scope import resolve_agent_id_by_workspace_path
|
from oclaw.runtime.agents.agent_scope import resolve_agent_id_by_workspace_path
|
||||||
from oclaw.runtime.hooks_runtime import get_active_hooks_config, trigger_hook_event
|
from oclaw.runtime.hooks_runtime import get_active_hooks_config, trigger_hook_event
|
||||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||||
from oclaw.prompts.loader import render_runtime_prompt
|
from oclaw.runtime.prompt_templates import render_runtime_prompt
|
||||||
|
|
||||||
_PROJECT_CONTEXT_FILES: tuple[str, ...] = (
|
_PROJECT_CONTEXT_FILES: tuple[str, ...] = (
|
||||||
"TOOLS.md",
|
"TOOLS.md",
|
||||||
|
|
|
||||||
17
runtime/prompt_templates/__init__.py
Normal file
17
runtime/prompt_templates/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from .loader import (
|
||||||
|
PromptDoc,
|
||||||
|
load_prompt_doc,
|
||||||
|
load_runtime_prompt_doc,
|
||||||
|
render_prompt,
|
||||||
|
render_prompt_for_lang,
|
||||||
|
render_runtime_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PromptDoc",
|
||||||
|
"load_prompt_doc",
|
||||||
|
"load_runtime_prompt_doc",
|
||||||
|
"render_prompt",
|
||||||
|
"render_prompt_for_lang",
|
||||||
|
"render_runtime_prompt",
|
||||||
|
]
|
||||||
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||||
from oclaw.prompts.frontmatter import parse_markdown_document
|
from oclaw.runtime.prompt_templates.frontmatter import parse_markdown_document
|
||||||
|
|
||||||
_VAR_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
_VAR_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}")
|
||||||
|
|
||||||
|
|
@ -18,13 +18,17 @@ class PromptDoc:
|
||||||
body: str
|
body: str
|
||||||
|
|
||||||
|
|
||||||
|
def _system_prompt_root() -> Path:
|
||||||
|
"""Builtin Markdown templates: ``runtime/workspaces/_system`` (replaces former ``prompts/``)."""
|
||||||
|
return (PROJECT_ROOT / "runtime" / "workspaces" / "_system").resolve()
|
||||||
|
|
||||||
|
|
||||||
def _prompts_root() -> Path:
|
def _prompts_root() -> Path:
|
||||||
return (PROJECT_ROOT / "prompts").resolve()
|
return _system_prompt_root()
|
||||||
|
|
||||||
|
|
||||||
def _runtime_prompts_root() -> Path:
|
def _runtime_prompts_root() -> Path:
|
||||||
# Runtime prompts are unified into the single prompts tree.
|
return _system_prompt_root()
|
||||||
return _prompts_root()
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=512)
|
@lru_cache(maxsize=512)
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Any
|
||||||
|
|
||||||
from oclaw.runtime.types import StandardMessage
|
from oclaw.runtime.types import StandardMessage
|
||||||
from oclaw.runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
from oclaw.runtime.types import normalize_interaction_mode, normalize_requested_specialist
|
||||||
from oclaw.prompts.loader import render_runtime_prompt
|
from oclaw.runtime.prompt_templates import render_runtime_prompt
|
||||||
from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload
|
from oclaw.runtime.chat.model_path_audit import ensure_no_tool_or_embedded_image_payload
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||||
from oclaw.platform.config.runtime_paths import runtime_skills_root
|
from oclaw.platform.config.runtime_paths import runtime_skills_root
|
||||||
from oclaw.prompts.frontmatter import parse_frontmatter_dict, split_markdown_frontmatter
|
from oclaw.runtime.prompt_templates.frontmatter import parse_frontmatter_dict, split_markdown_frontmatter
|
||||||
from oclaw.runtime.skill_manifest_core import (
|
from oclaw.runtime.skill_manifest_core import (
|
||||||
SkillInstallSpec,
|
SkillInstallSpec,
|
||||||
normalize_frontmatter,
|
normalize_frontmatter,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from oclaw.runtime.skills_workspace_lane import (
|
||||||
)
|
)
|
||||||
from oclaw.runtime.types import OclawMemoryContext
|
from oclaw.runtime.types import OclawMemoryContext
|
||||||
from oclaw.runtime.workspaces.experts import expert_workspace_signature_token
|
from oclaw.runtime.workspaces.experts import expert_workspace_signature_token
|
||||||
from oclaw.prompts.loader import render_runtime_prompt
|
from oclaw.runtime.prompt_templates import render_runtime_prompt
|
||||||
from oclaw.runtime.tools.base import ToolRegistry
|
from oclaw.runtime.tools.base import ToolRegistry
|
||||||
|
|
||||||
_EXECUTOR_STATIC_PROMPT_CACHE_LOCK = threading.Lock()
|
_EXECUTOR_STATIC_PROMPT_CACHE_LOCK = threading.Lock()
|
||||||
|
|
|
||||||
466
runtime/tools/experts/network_ops/netx_tools.py
Normal file
466
runtime/tools/experts/network_ops/netx_tools.py
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
"""Internal read-only tools: query netx REST API (PostgreSQL backend on netx side).
|
||||||
|
|
||||||
|
Configure via environment:
|
||||||
|
- ``OCLAW_NETX_BASE_URL`` (default ``http://127.0.0.1:8890``)
|
||||||
|
- ``OCLAW_NETX_API_TOKEN`` (optional) → sent as ``Authorization: Bearer …`` if set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from oclaw.runtime.tools.base import ToolSpec
|
||||||
|
|
||||||
|
|
||||||
|
def _netx_base_url() -> str:
|
||||||
|
return (os.getenv("OCLAW_NETX_BASE_URL") or "http://127.0.0.1:8890").strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _netx_headers() -> dict[str, str]:
|
||||||
|
h = {"accept": "application/json"}
|
||||||
|
tok = (os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
|
||||||
|
if tok:
|
||||||
|
h["authorization"] = f"Bearer {tok}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def _http_json(method: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
|
base = _netx_base_url()
|
||||||
|
url = f"{base}{path}"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=45.0) as client:
|
||||||
|
resp = client.request(method, url, params=params or None, headers=_netx_headers())
|
||||||
|
text = resp.text
|
||||||
|
if not resp.is_success:
|
||||||
|
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
|
||||||
|
data = resp.json() if text else {}
|
||||||
|
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_latest_import_batch_id() -> dict[str, Any]:
|
||||||
|
"""``GET /v1/batches`` 按 created_at 降序;取第一条为当前最新导入批次。"""
|
||||||
|
r = _http_json("GET", "/v1/batches", params={"limit": 1})
|
||||||
|
if not r.get("ok"):
|
||||||
|
return {"ok": False, "error": "netx_list_batches_failed", "detail": r.get("detail"), "upstream": r}
|
||||||
|
data = r.get("data") or {}
|
||||||
|
items = data.get("items")
|
||||||
|
if not isinstance(items, list) or not items:
|
||||||
|
return {"ok": False, "error": "no_import_batches", "detail": "netx 中尚无导入批次,请先导入或显式提供 batch_id"}
|
||||||
|
first = items[0]
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
return {"ok": False, "error": "no_import_batches", "detail": "batch 列表格式异常"}
|
||||||
|
bid = str(first.get("batch_id") or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return {"ok": False, "error": "no_import_batches", "detail": "batch 列表中无 batch_id"}
|
||||||
|
return {"ok": True, "batch_id": bid, "batch_row": first}
|
||||||
|
|
||||||
|
|
||||||
|
_OPS_NETX_SYS_CTX_LOCK = threading.Lock()
|
||||||
|
# Lang code -> (monotonic_ts, formatted extension text); short TTL to avoid hammering netx each tool round.
|
||||||
|
_OPS_NETX_SYS_CTX_CACHE: dict[str, tuple[float, str]] = {}
|
||||||
|
_OPS_NETX_SYS_CTX_TTL_SEC = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ops_netx_system_extension(r: dict[str, Any], *, lang_en: bool) -> str:
|
||||||
|
if r.get("ok"):
|
||||||
|
bid = str(r.get("batch_id") or "")
|
||||||
|
row = r.get("batch_row") if isinstance(r.get("batch_row"), dict) else {}
|
||||||
|
created = str(row.get("created_at") or "")
|
||||||
|
src = str(row.get("source_file") or "")
|
||||||
|
status = str(row.get("status") or "")
|
||||||
|
total = row.get("total_rows")
|
||||||
|
lines_en = [
|
||||||
|
"[Netx alarm import anchor]",
|
||||||
|
f"- batch_id: {bid}",
|
||||||
|
f"- source_file: {src}",
|
||||||
|
f"- created_at: {created}",
|
||||||
|
]
|
||||||
|
lines_zh = [
|
||||||
|
"[当前 netx 告警导入锚点]",
|
||||||
|
f"- batch_id: {bid}",
|
||||||
|
f"- 源文件: {src}",
|
||||||
|
f"- 导入时间: {created}",
|
||||||
|
]
|
||||||
|
if status:
|
||||||
|
(lines_en if lang_en else lines_zh).append(f"- status: {status}" if lang_en else f"- 状态: {status}")
|
||||||
|
if total is not None:
|
||||||
|
(lines_en if lang_en else lines_zh).append(f"- rows: {total}" if lang_en else f"- 行数: {total}")
|
||||||
|
tail_en = (
|
||||||
|
"- tools: netx_query_alarms, netx_aggregate_alarms, netx_run_diagnostics (pass batch_id above)\n"
|
||||||
|
"- note: numbers below are not alarm facts—call tools for evidence."
|
||||||
|
)
|
||||||
|
tail_zh = (
|
||||||
|
"- 工具: netx_query_alarms、netx_aggregate_alarms、netx_run_diagnostics(使用上述 batch_id)\n"
|
||||||
|
"- 说明: 此处仅为批次锚点;具体告警必须以工具返回为准,勿臆测。"
|
||||||
|
)
|
||||||
|
return "\n".join(lines_en + [tail_en]) if lang_en else "\n".join(lines_zh + [tail_zh])
|
||||||
|
err = str(r.get("error") or "")
|
||||||
|
detail = str(r.get("detail") or "")[:240]
|
||||||
|
if err == "no_import_batches":
|
||||||
|
if lang_en:
|
||||||
|
return (
|
||||||
|
"[Netx alarm import anchor]\n"
|
||||||
|
"- batch_id: (none)\n"
|
||||||
|
"- note: no import batches in netx yet—import alarms or pass batch_id in chat."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"[当前 netx 告警导入锚点]\n"
|
||||||
|
"- batch_id: (暂无)\n"
|
||||||
|
"- 说明: netx 中尚无导入批次;请先导入告警或在对话中提供 batch_id。"
|
||||||
|
)
|
||||||
|
if lang_en:
|
||||||
|
return (
|
||||||
|
"[Netx alarm import anchor]\n"
|
||||||
|
f"- error: {err}\n"
|
||||||
|
f"- detail: {detail}\n"
|
||||||
|
"- fix: check OCLAW_NETX_BASE_URL and that netx API is reachable."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"[当前 netx 告警导入锚点]\n"
|
||||||
|
f"- 错误: {err}\n"
|
||||||
|
f"- 详情: {detail}\n"
|
||||||
|
"- 处理: 检查 OCLAW_NETX_BASE_URL 与 netx 服务是否可达。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ops_netx_system_context_extension(*, lang: str = "zh") -> str:
|
||||||
|
"""Append to ops specialist system prompt: latest batch_id anchor (direct_loop injection).
|
||||||
|
|
||||||
|
Cached briefly to reduce duplicate HTTP calls across tool rounds.
|
||||||
|
"""
|
||||||
|
if str(os.getenv("OCLAW_OPS_NETX_CONTEXT_INJECT") or "1").strip().lower() in {"0", "false", "no", "off"}:
|
||||||
|
return ""
|
||||||
|
lang_en = str(lang or "").strip().lower().startswith("en")
|
||||||
|
lk = "en" if lang_en else "zh"
|
||||||
|
now = time.monotonic()
|
||||||
|
with _OPS_NETX_SYS_CTX_LOCK:
|
||||||
|
hit = _OPS_NETX_SYS_CTX_CACHE.get(lk)
|
||||||
|
if hit and (now - hit[0]) < _OPS_NETX_SYS_CTX_TTL_SEC:
|
||||||
|
return hit[1]
|
||||||
|
r = _resolve_latest_import_batch_id()
|
||||||
|
text = _format_ops_netx_system_extension(r, lang_en=lang_en)
|
||||||
|
store_ts = time.monotonic()
|
||||||
|
with _OPS_NETX_SYS_CTX_LOCK:
|
||||||
|
_OPS_NETX_SYS_CTX_CACHE[lk] = (store_ts, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def netx_query_alarms_tool() -> ToolSpec:
|
||||||
|
"""Paginated alarm rows from netx (same filters as netx UI REST)."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
explicit = str(args.get("batch_id") or "").strip()
|
||||||
|
resolution = "explicit"
|
||||||
|
batch_id = explicit
|
||||||
|
if not batch_id:
|
||||||
|
res = _resolve_latest_import_batch_id()
|
||||||
|
if not res.get("ok"):
|
||||||
|
return res
|
||||||
|
batch_id = str(res.get("batch_id") or "")
|
||||||
|
resolution = "latest_import"
|
||||||
|
page = max(1, int(args.get("page") or 1))
|
||||||
|
page_size = min(200, max(1, int(args.get("page_size") or 50)))
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
if str(args.get("alarm_code") or "").strip():
|
||||||
|
params["alarm_code"] = str(args.get("alarm_code")).strip()
|
||||||
|
if str(args.get("ne_name") or "").strip():
|
||||||
|
params["ne_name"] = str(args.get("ne_name")).strip()
|
||||||
|
if str(args.get("severity") or "").strip():
|
||||||
|
params["severity"] = str(args.get("severity")).strip()
|
||||||
|
out = _http_json("GET", "/v1/alarms", params=params)
|
||||||
|
if out.get("ok") and resolution == "latest_import":
|
||||||
|
out = {**out, "batch_id_used": batch_id, "batch_resolution": resolution}
|
||||||
|
return out
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_query_alarms",
|
||||||
|
description=(
|
||||||
|
"从独立运维工具 netx 读取告警明细(PostgreSQL 侧由 netx 托管)。"
|
||||||
|
"batch_id 可选:不传表示使用 netx 当前「最新」导入批次(/v1/batches 第一条)。"
|
||||||
|
"可选 alarm_code / ne_name / severity(与 netx 告警列表过滤语义一致);支持分页。"
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"batch_id": {"type": "string", "description": "导入批次 ID;省略则用最新导入批次"},
|
||||||
|
"alarm_code": {"type": "string", "description": "告警码包含匹配(可选)"},
|
||||||
|
"ne_name": {"type": "string", "description": "网元名包含匹配(可选)"},
|
||||||
|
"severity": {"type": "string", "description": "规范化级别 critical/major/minor/warning/..."},
|
||||||
|
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||||
|
"page_size": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "alarms", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def netx_aggregate_alarms_tool() -> ToolSpec:
|
||||||
|
"""Aggregate buckets from netx."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
explicit = str(args.get("batch_id") or "").strip()
|
||||||
|
resolution = "explicit"
|
||||||
|
batch_id = explicit
|
||||||
|
if not batch_id:
|
||||||
|
res = _resolve_latest_import_batch_id()
|
||||||
|
if not res.get("ok"):
|
||||||
|
return res
|
||||||
|
batch_id = str(res.get("batch_id") or "")
|
||||||
|
resolution = "latest_import"
|
||||||
|
group_by = str(args.get("group_by") or "severity_norm").strip()
|
||||||
|
if group_by not in {"severity_norm", "alarm_code", "ne_name"}:
|
||||||
|
return {"ok": False, "error": "invalid_group_by"}
|
||||||
|
params: dict[str, Any] = {"group_by": group_by, "batch_id": batch_id}
|
||||||
|
out = _http_json("GET", "/v1/alarms/aggregate", params=params)
|
||||||
|
if out.get("ok") and resolution == "latest_import":
|
||||||
|
out = {**out, "batch_id_used": batch_id, "batch_resolution": resolution}
|
||||||
|
return out
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_aggregate_alarms",
|
||||||
|
description=(
|
||||||
|
"按 severity_norm / alarm_code / ne_name 对 netx 告警做聚合统计(读 netx API,不落直连 PG)。"
|
||||||
|
"batch_id 可选:不传则限定为 netx 当前最新导入批次(与告警查询默认语义一致)。"
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"batch_id": {"type": "string"},
|
||||||
|
"group_by": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["severity_norm", "alarm_code", "ne_name"],
|
||||||
|
"default": "severity_norm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "alarms", "aggregate", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def netx_run_diagnostics_tool() -> ToolSpec:
|
||||||
|
"""Diagnostics summary (same stats netx uses for dashboard slices)."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
explicit = str(args.get("batch_id") or "").strip()
|
||||||
|
resolution = "explicit"
|
||||||
|
batch_id = explicit
|
||||||
|
if not batch_id:
|
||||||
|
res = _resolve_latest_import_batch_id()
|
||||||
|
if not res.get("ok"):
|
||||||
|
return res
|
||||||
|
batch_id = str(res.get("batch_id") or "")
|
||||||
|
resolution = "latest_import"
|
||||||
|
out = _http_json("GET", "/v1/diagnostics", params={"batch_id": batch_id})
|
||||||
|
if out.get("ok") and resolution == "latest_import":
|
||||||
|
out = {**out, "batch_id_used": batch_id, "batch_resolution": resolution}
|
||||||
|
return out
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_run_diagnostics",
|
||||||
|
description=(
|
||||||
|
"读取 netx /v1/diagnostics 统计摘要(批次维度:级别分布、Top 告警码/网元、协议归类等)。"
|
||||||
|
"batch_id 可选:不传则使用 netx 当前最新导入批次。"
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"batch_id": {"type": "string", "description": "批次 ID;省略则用最新导入批次"}},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "diagnostics", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def netx_list_import_batches_tool() -> ToolSpec:
|
||||||
|
"""List recent import batches (newest first)."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
limit = max(1, min(100, int(args.get("limit") or 20)))
|
||||||
|
return _http_json("GET", "/v1/batches", params={"limit": limit})
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_list_import_batches",
|
||||||
|
description="列出 netx 最近导入批次(与 UI 一致,按创建时间降序)。用于核对 batch_id 或确认「最新」批次。",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "batches", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def netx_list_alarm_fields_tool() -> ToolSpec:
|
||||||
|
"""List alarms_norm columns from netx."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
_ = args
|
||||||
|
return _http_json("GET", "/v1/alarms/fields", params=None)
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_list_alarm_fields",
|
||||||
|
description="列出 netx alarms_norm 表所有字段名(供自由查询时选择字段/确认可用字段)。",
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "alarms", "schema", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def netx_query_alarms_raw_tool() -> ToolSpec:
|
||||||
|
"""Power query alarms_norm with all fields."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
explicit = str(args.get("batch_id") or "").strip()
|
||||||
|
batch_id = explicit
|
||||||
|
if not batch_id:
|
||||||
|
res = _resolve_latest_import_batch_id()
|
||||||
|
if not res.get("ok"):
|
||||||
|
return res
|
||||||
|
batch_id = str(res.get("batch_id") or "")
|
||||||
|
page = max(1, int(args.get("page") or 1))
|
||||||
|
page_size = min(200, max(1, int(args.get("page_size") or 50)))
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
}
|
||||||
|
if str(args.get("alarm_code") or "").strip():
|
||||||
|
params["alarm_code"] = str(args.get("alarm_code")).strip()
|
||||||
|
if str(args.get("ne_name") or "").strip():
|
||||||
|
params["ne_name"] = str(args.get("ne_name")).strip()
|
||||||
|
if str(args.get("severity") or "").strip():
|
||||||
|
params["severity"] = str(args.get("severity")).strip()
|
||||||
|
if str(args.get("q") or "").strip():
|
||||||
|
params["q"] = str(args.get("q")).strip()
|
||||||
|
if str(args.get("order_by") or "").strip():
|
||||||
|
params["order_by"] = str(args.get("order_by")).strip()
|
||||||
|
if str(args.get("order") or "").strip():
|
||||||
|
params["order"] = str(args.get("order")).strip()
|
||||||
|
return _http_json("GET", "/v1/alarms/raw", params=params)
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_query_alarms_raw",
|
||||||
|
description=(
|
||||||
|
"自由查询 netx alarms_norm:返回所有字段。"
|
||||||
|
"batch_id 可选(省略则使用最新导入批次);支持 severity/alarm_code/ne_name/q 过滤与分页;"
|
||||||
|
"order_by 仅允许 id/alarm_time/severity_norm/ne_name/alarm_code。"
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"batch_id": {"type": "string", "description": "批次 ID;省略则用最新导入批次"},
|
||||||
|
"severity": {"type": "string"},
|
||||||
|
"alarm_code": {"type": "string"},
|
||||||
|
"ne_name": {"type": "string"},
|
||||||
|
"q": {"type": "string", "description": "自由文本 contains(alarm_code/ne_name/description/service)"},
|
||||||
|
"order_by": {"type": "string", "enum": ["id", "alarm_time", "severity_norm", "ne_name", "alarm_code"]},
|
||||||
|
"order": {"type": "string", "enum": ["asc", "desc"]},
|
||||||
|
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||||
|
"page_size": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "alarms", "power_query", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def netx_sql_query_tool() -> ToolSpec:
|
||||||
|
"""Execute read-only SQL on netx (server enforced SELECT-only)."""
|
||||||
|
|
||||||
|
def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
batch_id = str(args.get("batch_id") or "").strip()
|
||||||
|
if not batch_id:
|
||||||
|
res = _resolve_latest_import_batch_id()
|
||||||
|
if not res.get("ok"):
|
||||||
|
return res
|
||||||
|
batch_id = str(res.get("batch_id") or "")
|
||||||
|
sql = str(args.get("sql") or "").strip()
|
||||||
|
limit = max(1, min(2000, int(args.get("limit") or 200)))
|
||||||
|
if not sql:
|
||||||
|
return {"ok": False, "error": "sql_required"}
|
||||||
|
base = _netx_base_url()
|
||||||
|
url = f"{base}/v1/sql/query"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=60.0) as client:
|
||||||
|
resp = client.post(url, json={"sql": sql, "batch_id": batch_id, "limit": limit}, headers=_netx_headers())
|
||||||
|
text = resp.text
|
||||||
|
if not resp.is_success:
|
||||||
|
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
|
||||||
|
data = resp.json() if text else {}
|
||||||
|
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
|
||||||
|
|
||||||
|
return ToolSpec(
|
||||||
|
name="netx_sql_query",
|
||||||
|
description=(
|
||||||
|
"在 netx 上执行只读 SQL(服务端强制 SELECT-only、单语句、必须包含 :batch_id 参数,并强制 limit)。"
|
||||||
|
"用于自由组合查询;建议先用 netx_list_alarm_fields 确认可用字段。"
|
||||||
|
),
|
||||||
|
parameters={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"batch_id": {"type": "string", "description": "批次 ID;省略则用最新导入批次"},
|
||||||
|
"sql": {"type": "string", "description": "必须是 SELECT,且包含 :batch_id 占位符"},
|
||||||
|
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
|
||||||
|
},
|
||||||
|
"required": ["sql"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
handler=handler,
|
||||||
|
tags=frozenset({"netx", "ops", "sql", "power_query", "read_only"}),
|
||||||
|
risk_level="low",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"netx_query_alarms_tool",
|
||||||
|
"netx_aggregate_alarms_tool",
|
||||||
|
"netx_run_diagnostics_tool",
|
||||||
|
"netx_list_import_batches_tool",
|
||||||
|
"netx_list_alarm_fields_tool",
|
||||||
|
"netx_query_alarms_raw_tool",
|
||||||
|
"netx_sql_query_tool",
|
||||||
|
]
|
||||||
|
|
@ -90,6 +90,10 @@ def _workspace_signature() -> tuple[Any, ...]:
|
||||||
for item in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
for item in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
||||||
if not item.is_dir():
|
if not item.is_dir():
|
||||||
continue
|
continue
|
||||||
|
# Internal workspace directories (single underscore prefix) are not experts.
|
||||||
|
# Example: `_system` is used for builtin prompt templates.
|
||||||
|
if item.name.startswith("_"):
|
||||||
|
continue
|
||||||
if item.name.startswith("__"):
|
if item.name.startswith("__"):
|
||||||
continue
|
continue
|
||||||
eid = normalize_expert_id(item.name)
|
eid = normalize_expert_id(item.name)
|
||||||
|
|
@ -159,6 +163,10 @@ def list_experts() -> list[dict[str, Any]]:
|
||||||
for item in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
for item in sorted(root.iterdir(), key=lambda p: p.name.lower()):
|
||||||
if not item.is_dir():
|
if not item.is_dir():
|
||||||
continue
|
continue
|
||||||
|
# Internal workspace directories (single underscore prefix) are not experts.
|
||||||
|
# Example: `_system` is used for builtin prompt templates.
|
||||||
|
if item.name.startswith("_"):
|
||||||
|
continue
|
||||||
eid = normalize_expert_id(item.name)
|
eid = normalize_expert_id(item.name)
|
||||||
if not eid:
|
if not eid:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,13 @@
|
||||||
|
|
||||||
## 输出格式:
|
## 输出格式:
|
||||||
- 先结论,再给证据与最小修复步骤。
|
- 先结论,再给证据与最小修复步骤。
|
||||||
|
|
||||||
|
## netx 明细与统计(内部工具)
|
||||||
|
|
||||||
|
每轮对话 **system 末尾会自动附带当前最新导入的 batch_id 锚点**(类似附件里的 id),无需你先「查列表再找 batch」。涉及告警/统计时仍应用工具拉明细。
|
||||||
|
|
||||||
|
- **省略 batch_id**:`netx_query_alarms`、`netx_aggregate_alarms`、`netx_run_diagnostics` 也可不传 batch_id,此时与锚点一致(最新导入批次)。
|
||||||
|
- **netx_list_import_batches**:仅在需要多看几个历史批次时使用。
|
||||||
|
- **netx_query_alarms** / **netx_aggregate_alarms** / **netx_run_diagnostics**:用锚点中的 batch_id(或省略 batch_id)获取明细与诊断。
|
||||||
|
|
||||||
|
工具走 netx(`OCLAW_NETX_BASE_URL` / `OCLAW_NETX_API_TOKEN`)。关闭自动锚点:环境变量 `OCLAW_OPS_NETX_CONTEXT_INJECT=0`。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from oclaw.prompts.frontmatter import parse_frontmatter_dict, parse_markdown_document
|
from oclaw.runtime.prompt_templates.frontmatter import parse_frontmatter_dict, parse_markdown_document
|
||||||
|
|
||||||
|
|
||||||
def test_parse_yaml_frontmatter_nested_metadata() -> None:
|
def test_parse_yaml_frontmatter_nested_metadata() -> None:
|
||||||
|
|
|
||||||
85
tests/test_ops_netx_system_inject.py
Normal file
85
tests/test_ops_netx_system_inject.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from oclaw.platform.llm.chat_models import RuleBasedChatModel
|
||||||
|
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||||
|
from oclaw.runtime.direct_loop import _build_model_context
|
||||||
|
|
||||||
|
|
||||||
|
def test_ops_role_injects_netx_batch_anchor_into_system(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
import oclaw.runtime.tools.experts.network_ops.netx_tools as nt
|
||||||
|
|
||||||
|
def _fake_latest() -> dict:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"batch_id": "batch-inject-test",
|
||||||
|
"batch_row": {
|
||||||
|
"created_at": "2026-05-01T00:00:00",
|
||||||
|
"source_file": "alarms.xlsx",
|
||||||
|
"status": "ok",
|
||||||
|
"total_rows": 42,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(nt, "_resolve_latest_import_batch_id", _fake_latest)
|
||||||
|
nt._OPS_NETX_SYS_CTX_CACHE.clear() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
store = SqliteStore(str(tmp_path / "n.sqlite"))
|
||||||
|
sess = store.create_session("s")
|
||||||
|
store.add_message(session_id=sess.id, role="user", content="hello")
|
||||||
|
|
||||||
|
msgs = _build_model_context(
|
||||||
|
store=store,
|
||||||
|
session_id=sess.id,
|
||||||
|
max_messages=20,
|
||||||
|
system_prompt="BASE",
|
||||||
|
model=RuleBasedChatModel(),
|
||||||
|
lang="zh",
|
||||||
|
memory_context=None,
|
||||||
|
trace_id=None,
|
||||||
|
parent_span_id=None,
|
||||||
|
tools=None,
|
||||||
|
base_url="",
|
||||||
|
skill_binding_role="ops",
|
||||||
|
user_text="hello",
|
||||||
|
)
|
||||||
|
assert msgs and msgs[0].get("role") == "system"
|
||||||
|
sys_text = str(msgs[0].get("content") or "")
|
||||||
|
assert "batch-inject-test" in sys_text
|
||||||
|
assert "alarms.xlsx" in sys_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ops_role_skips_netx_inject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
calls: dict[str, int] = {"n": 0}
|
||||||
|
|
||||||
|
def _spy_latest() -> dict:
|
||||||
|
calls["n"] += 1
|
||||||
|
return {"ok": True, "batch_id": "x", "batch_row": {}}
|
||||||
|
|
||||||
|
import oclaw.runtime.tools.experts.network_ops.netx_tools as nt
|
||||||
|
|
||||||
|
monkeypatch.setattr(nt, "_resolve_latest_import_batch_id", _spy_latest)
|
||||||
|
|
||||||
|
store = SqliteStore(str(tmp_path / "g.sqlite"))
|
||||||
|
sess = store.create_session("s")
|
||||||
|
store.add_message(session_id=sess.id, role="user", content="hi")
|
||||||
|
|
||||||
|
_build_model_context(
|
||||||
|
store=store,
|
||||||
|
session_id=sess.id,
|
||||||
|
max_messages=20,
|
||||||
|
system_prompt="BASE_ONLY",
|
||||||
|
model=RuleBasedChatModel(),
|
||||||
|
lang="zh",
|
||||||
|
memory_context=None,
|
||||||
|
trace_id=None,
|
||||||
|
parent_span_id=None,
|
||||||
|
tools=None,
|
||||||
|
base_url="",
|
||||||
|
skill_binding_role="generalist",
|
||||||
|
user_text="hi",
|
||||||
|
)
|
||||||
|
assert calls["n"] == 0
|
||||||
|
|
@ -7,7 +7,6 @@ from oclaw.platform.config.paths import PROJECT_ROOT
|
||||||
|
|
||||||
_PROMPT_CRITICAL_FILES = (
|
_PROMPT_CRITICAL_FILES = (
|
||||||
"runtime/chat/agent.py",
|
"runtime/chat/agent.py",
|
||||||
"runtime/agents/network_ops_agent.py",
|
|
||||||
"runtime/agents/factory.py",
|
"runtime/agents/factory.py",
|
||||||
"runtime/chat/agent_messages.py",
|
"runtime/chat/agent_messages.py",
|
||||||
"runtime/system_prompt.py",
|
"runtime/system_prompt.py",
|
||||||
|
|
@ -27,16 +26,17 @@ def test_prompt_critical_paths_use_prompt_templates() -> None:
|
||||||
or "render_prompt_for_lang(" in text
|
or "render_prompt_for_lang(" in text
|
||||||
or "render_runtime_prompt(" in text
|
or "render_runtime_prompt(" in text
|
||||||
), (
|
), (
|
||||||
f"{rel} must render prompts from oclaw/prompts*.md"
|
f"{rel} must use oclaw.runtime.prompt_templates (Markdown under runtime/workspaces/_system)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_markdown_frontmatter_keys_present() -> None:
|
def test_prompt_markdown_frontmatter_keys_present() -> None:
|
||||||
roots = [
|
roots = [
|
||||||
PROJECT_ROOT / "prompts",
|
PROJECT_ROOT / "runtime" / "workspaces" / "_system",
|
||||||
PROJECT_ROOT / "prompts_runtime",
|
|
||||||
]
|
]
|
||||||
for prompts_root in roots:
|
for prompts_root in roots:
|
||||||
|
if not prompts_root.exists():
|
||||||
|
continue
|
||||||
for p in prompts_root.rglob("*.md"):
|
for p in prompts_root.rglob("*.md"):
|
||||||
if p.name == "MAPPING.md":
|
if p.name == "MAPPING.md":
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from oclaw.prompts.loader import load_runtime_prompt_doc, load_prompt_doc, render_runtime_prompt, render_prompt
|
from oclaw.runtime.prompt_templates import (
|
||||||
|
load_prompt_doc,
|
||||||
|
load_runtime_prompt_doc,
|
||||||
|
render_prompt,
|
||||||
|
render_runtime_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_load_prompt_doc_with_frontmatter() -> None:
|
def test_load_prompt_doc_with_frontmatter() -> None:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from oclaw.runtime.memory_stage import render_memory_context_block
|
from oclaw.runtime.memory_stage import render_memory_context_block
|
||||||
from oclaw.runtime.types import OclawMemoryContext
|
from oclaw.runtime.types import OclawMemoryContext
|
||||||
from oclaw.prompts.loader import render_runtime_prompt
|
from oclaw.runtime.prompt_templates import render_runtime_prompt
|
||||||
|
|
||||||
|
|
||||||
def test_router_prompt_renders() -> None:
|
def test_router_prompt_renders() -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue