feat: 模型配置支持删除,岗位行动规划对接任务系统字典与批量新增 Skill

模型管理新增 DELETE 接口与前端删除能力(支持含斜杠的模型名);行动规划改为 9 字段 JSON,prepare 传入 taskId/字典并绑定 position-roundtable-action-create Skill。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-12 14:47:14 +08:00
1 parent af509eb670
commit c9c2dc7ea8
12 files changed
+697 -95

No files matched your search

@@ -6,6 +6,7 @@ from types import SimpleNamespace
import pytest
import yaml
from starlette.routing import Match
from app.gateway.routers import model_management as router
@@ -53,6 +54,64 @@ def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
def test_model_mutation_routes_accept_names_with_slashes() -> None:
for method in ("PUT", "DELETE"):
route = next(
item
for item in router.router.routes
if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}"
)
match, child_scope = route.matches(
{
"type": "http",
"method": method,
"path": "/api/admin/models/zai-org/GLM-5-FP8",
}
)
assert match is Match.FULL
assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"}
@pytest.mark.asyncio
async def test_delete_model_configuration_removes_matching_model_and_activates_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: GLM-5-FP8
- name: keep-me
use: langchain_openai:ChatOpenAI
model: keep-me
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["raw_before"] = raw_before
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
await router.delete_model_configuration("zai-org/GLM-5-FP8", request)
assert "zai-org/GLM-5-FP8" in str(saved["raw_before"])
assert saved["models"] == [
{
"name": "keep-me",
"use": "langchain_openai:ChatOpenAI",
"model": "keep-me",
}
]
@pytest.mark.asyncio
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
tmp_path,
@@ -3,6 +3,9 @@
from __future__ import annotations
import asyncio
import importlib.util
import json
import zipfile
from types import SimpleNamespace
from typing import Any
@@ -65,6 +68,120 @@ def test_position_intent_agent_is_a_seeded_functional_agent():
assert POSITION_INTENT_AGENT_ID in set(required_agent_ids())
def test_action_plan_seed_binds_the_batch_create_skill_and_nine_field_contract():
"""New deployments must not fall back to the retired subtask JSON contract."""
action_assets = roundtable_seed._ASSETS_DIR / roundtable_seed.ACTION_PLAN_AGENT_ID
soul = (action_assets / "SOUL.md").read_text(encoding="utf-8")
config = (action_assets / "config.yaml").read_text(encoding="utf-8")
assert "position-roundtable-action-create" in config
for field in (
"actionName",
"actionDescription",
"actionType",
"publishChannel",
"infoFormat",
"responsiblePosition",
"startTime",
"endTime",
"taskId",
):
assert field in soul
assert "/task/action/add" in soul
assert "批量新增" in soul
@pytest.mark.asyncio
async def test_action_plan_prompt_receives_frontend_task_id_and_dictionary_values():
"""The action planner must see exact dictValue choices, not frontend labels only."""
dictionaries = position_roundtable_router.ActionPlanDictionaries(
publish_channels=[{"label": "微信公众号", "value": "11"}],
action_types=[{"label": "宣传", "value": "21"}],
info_formats=[{"label": "图文", "value": "31"}],
responsible_positions=[{"label": "规划岗", "value": "41"}],
)
prompt = await position_roundtable_router._build_builtin_prompt(
kind="action_plan",
session={
"task_snapshot": {"title": "任务 A", "description": "生成行动"},
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
},
nodes=[],
action_task_id=1001,
action_dictionaries=dictionaries,
)
assert "当前 taskId:1001" in prompt
assert "微信公众号 → 11" in prompt
assert "宣传 → 21" in prompt
assert "图文 → 31" in prompt
assert "规划岗 → 41" in prompt
assert "id、title、goal" in prompt
@pytest.mark.asyncio
async def test_action_plan_prompt_degrades_when_task_dictionaries_are_unavailable():
"""A 404 from the task-system dictionaries must not block local planning."""
prompt = await position_roundtable_router._build_builtin_prompt(
kind="action_plan",
session={
"task_snapshot": {"title": "本地演示任务"},
"summary_snapshot": {"status": "done", "content": "方案总结正文"},
},
nodes=[],
action_task_id="abc123",
action_dictionaries=position_roundtable_router.ActionPlanDictionaries(),
)
assert "字典未获取完整" in prompt
assert "不得调用 Skill" in prompt
assert "0 占位" in prompt
def test_action_plan_prepare_keeps_a_local_mock_task_id_without_numeric_coercion():
"""A local demo id must not stop the action-plan conversation at prepare time."""
body = position_roundtable_router.PrepareBuiltinRunRequest(task_id="abc123")
assert body.task_id == "abc123"
def test_action_batch_skill_accepts_only_the_nine_field_array(tmp_path):
"""The batch Skill must reject old task-subdivision fields before any HTTP call."""
archive_path = roundtable_seed._ASSETS_DIR.parents[3] / "skill-packages" / "position-roundtable-action-create.skill"
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(tmp_path / "skill-package")
script_path = tmp_path / "skill-package" / "position-roundtable-action-create" / "scripts" / "batch_add_actions.py"
spec = importlib.util.spec_from_file_location("position_action_batch_skill", script_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
valid = [{
"actionName": "发布内容",
"actionDescription": "向目标渠道发布审核通过的信息。",
"actionType": "1",
"publishChannel": "2",
"infoFormat": "3",
"responsiblePosition": "4",
"startTime": "2026-08-12T09:00:00+08:00",
"endTime": "2026-08-12T18:00:00+08:00",
"taskId": 1001,
}]
source = tmp_path / "action-plan-subtasks.json"
source.write_text(json.dumps(valid, ensure_ascii=False), encoding="utf-8")
assert module.read_actions(source, 1001) == [{
**valid[0],
"actionType": 1,
"publishChannel": 2,
"infoFormat": 3,
"responsiblePosition": 4,
}]
source.write_text(json.dumps([{**valid[0], "id": "legacy"}], ensure_ascii=False), encoding="utf-8")
with pytest.raises(module.ActionInputError, match="多余字段"):
module.read_actions(source, 1001)
def test_position_intent_prompt_keeps_the_original_step_one_guardrails():
"""Dedicated schema must not regress into solutioning or gatekeeping."""
prompt = (
@@ -165,6 +282,29 @@ def test_position_intent_seed_repairs_its_contract_without_touching_other_agents
assert generic_soul.read_text(encoding="utf-8") == "deployed generic tune"
def test_existing_action_planner_only_merges_the_owned_skill(tmp_path, monkeypatch):
"""A code upgrade may add the Skill without overwriting a user-tuned SOUL."""
agents_dir = tmp_path / "agents"
monkeypatch.setattr(
roundtable_seed,
"get_paths",
lambda: SimpleNamespace(agent_dir=lambda agent_id: agents_dir / agent_id),
)
action_dir = agents_dir / roundtable_seed.ACTION_PLAN_AGENT_ID
action_dir.mkdir(parents=True)
(action_dir / "SOUL.md").write_text("deployed action-plan tune", encoding="utf-8")
(action_dir / "config.yaml").write_text(
"id: position-action-planner\nskills:\n - existing-skill\n",
encoding="utf-8",
)
assert roundtable_seed._ensure_one(roundtable_seed.ACTION_PLAN_AGENT_ID) is True
config = (action_dir / "config.yaml").read_text(encoding="utf-8")
assert "existing-skill" in config
assert "position-roundtable-action-create" in config
assert (action_dir / "SOUL.md").read_text(encoding="utf-8") == "deployed action-plan tune"
@pytest.mark.asyncio
async def test_intent_init_creates_thread_directly_without_loopback(monkeypatch):
"""Step 1 initialization must not depend on the deployed Gateway port."""