feat(skills): 多租户阶段 3 — Curator + 配置 per-user
curator_log.py
- ring buffer 从单一 deque 改为 dict[user_id -> deque],每个用户
自己的运行日志互不可见
- start_new_run / record / get_recent / clear 全部接 user_id
关键字参数;未传时落入 __default__ 桶兼容旧调用
- 新增 clear_all() 用于跨用户清理(仅测试用)
curator.py
- 状态文件路径从 skills/.curator_state 改为
.deer-flow/users/{user_id}/.curator_state(per-user 隔离)
- 所有公开 API 接受 user_id 参数:should_run_now /
apply_automatic_transitions / _render_candidate_list /
run_curator_review / _curator_thread_target /
get_curator_status / _make_skill_archive_tool / _run_llm_review
- 新增 _list_user_owned_skill_names(user_id):通过 SkillStore
查询当前用户拥有的 skill 名集合,用于过滤 transitions / LLM
candidates,保证 user A 跑 curator 不会动 user B 的 skill;
SkillStore 不可达时退化为"全部 agent-created skill"(兼容旧部署)
- _get_curator_config(user_id) 通过 load_skill_evolution_config_for_user
叠加 per-user override,让阈值/间隔/moderation_model 也是 per-user
- maybe_run_curator 启动时枚举 UserRow,按用户检查 should_run_now
并各起一个 daemon 线程跑
skill_evolution_runtime.py
- 全局 backend/skill_evolution.json 语义保留作为 fallback
- 新增 per-user API:resolve_user_path / load_user_overrides /
save_user_overrides / get_user_overrides_mtime,文件落到
.deer-flow/users/{user_id}/skill_evolution.json
- 新增 load_skill_evolution_config_for_user(user_id):读全局
SkillEvolutionConfig 作为 baseline,浅合并 per-user JSON,
返回 Pydantic 实例。验证失败时回落到 baseline
routers/skills.py
- /api/curator/status / run / config GET+PUT / logs 全部接
_current_user_id 注入运行上下文
- update_curator_config 改为 per-user:保存到
users/{user_id}/skill_evolution.json,不再 reset_app_config
- _build_curator_config_response 接受 SkillEvolutionConfig 实例
(而不是 AppConfig)以适配 per-user 合并结果
旧 19 个测试继续通过;未引入新依赖。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cf191ef874
commit
4ef96c7930
4 files changed
+358
-121
No files matched your search
@@ -780,23 +780,32 @@ async def pin_skill(skill_name: str, request: SkillPinRequest, config: AppConfig
|
||||
|
||||
|
||||
@router.get("/curator/status", summary="Curator Status")
|
||||
async def curator_status() -> dict:
|
||||
"""Return the current curator state and per-lifecycle skill counts."""
|
||||
async def curator_status(request: Request) -> dict:
|
||||
"""Return the current curator state and per-lifecycle skill counts for the
|
||||
authenticated user."""
|
||||
try:
|
||||
from deerflow.skills.curator import get_curator_status
|
||||
return get_curator_status()
|
||||
return get_curator_status(_current_user_id(request))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get curator status: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get curator status: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/curator/run", summary="Trigger Curator Run")
|
||||
async def run_curator() -> dict:
|
||||
"""Manually trigger an immediate curator review pass (bypasses interval check, runs in background thread)."""
|
||||
async def run_curator(request: Request) -> dict:
|
||||
"""Manually trigger a curator review pass for the authenticated user
|
||||
(bypasses interval check, runs in a background thread)."""
|
||||
try:
|
||||
import threading
|
||||
from deerflow.skills.curator import _curator_thread_target
|
||||
thread = threading.Thread(target=_curator_thread_target, daemon=True, name="skill-curator-manual")
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
thread = threading.Thread(
|
||||
target=_curator_thread_target,
|
||||
args=(user_id,),
|
||||
daemon=True,
|
||||
name=f"skill-curator-manual-{user_id}",
|
||||
)
|
||||
thread.start()
|
||||
return {"triggered": True, "message": "Curator run started in background"}
|
||||
except Exception as e:
|
||||
@@ -845,9 +854,12 @@ class CuratorLogsResponse(BaseModel):
|
||||
last_run_at: str | None
|
||||
|
||||
|
||||
def _build_curator_config_response(config: AppConfig) -> CuratorConfigResponse:
|
||||
"""Pure helper so both the GET route and the post-PUT refresh share one shape."""
|
||||
evo = config.skill_evolution
|
||||
def _build_curator_config_response(evo) -> CuratorConfigResponse:
|
||||
"""Pure helper so both the GET route and the post-PUT refresh share one shape.
|
||||
|
||||
``evo`` is a ``SkillEvolutionConfig`` instance (already merged with per-user
|
||||
overrides if any).
|
||||
"""
|
||||
return CuratorConfigResponse(
|
||||
enabled=evo.enabled,
|
||||
moderation_model_name=evo.moderation_model_name,
|
||||
@@ -861,38 +873,37 @@ def _build_curator_config_response(config: AppConfig) -> CuratorConfigResponse:
|
||||
|
||||
|
||||
@router.get("/curator/config", response_model=CuratorConfigResponse, summary="Get Skill Evolution Config")
|
||||
async def get_curator_config(config: AppConfig = Depends(get_config)) -> CuratorConfigResponse:
|
||||
"""Return the current effective skill_evolution config (yaml defaults + skill_evolution.json overrides)."""
|
||||
async def get_curator_config(request: Request) -> CuratorConfigResponse:
|
||||
"""Return the current curator config for the authenticated user (yaml
|
||||
defaults + that user's overrides)."""
|
||||
try:
|
||||
return _build_curator_config_response(config)
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user
|
||||
|
||||
evo = load_skill_evolution_config_for_user(_current_user_id(request))
|
||||
return _build_curator_config_response(evo)
|
||||
except Exception as e:
|
||||
logger.error("Failed to read curator config: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to read curator config: {str(e)}")
|
||||
|
||||
|
||||
@router.put("/curator/config", response_model=CuratorConfigResponse, summary="Update Skill Evolution Config")
|
||||
async def update_curator_config(body: CuratorConfigRequest) -> CuratorConfigResponse:
|
||||
"""Persist overrides to skill_evolution.json and force AppConfig to reload on next read.
|
||||
async def update_curator_config(body: CuratorConfigRequest, request: Request) -> CuratorConfigResponse:
|
||||
"""Persist per-user overrides to ``users/{user_id}/skill_evolution.json``
|
||||
and return the effective merged config.
|
||||
|
||||
The payload is validated against the SkillEvolutionConfig shape; unknown
|
||||
fields are rejected by the Pydantic model_validate call.
|
||||
The payload is validated against the ``SkillEvolutionConfig`` shape; unknown
|
||||
fields are rejected.
|
||||
"""
|
||||
try:
|
||||
from deerflow.config.app_config import get_app_config, reset_app_config
|
||||
from deerflow.config.skill_evolution_config import SkillEvolutionConfig
|
||||
from deerflow.config.skill_evolution_runtime import save_overrides
|
||||
from deerflow.config.skill_evolution_runtime import load_skill_evolution_config_for_user, save_user_overrides
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
payload = body.model_dump()
|
||||
# Round-trip through SkillEvolutionConfig to ensure persisted shape stays valid.
|
||||
SkillEvolutionConfig.model_validate(payload)
|
||||
save_overrides(payload)
|
||||
|
||||
# Reset cache so the next get_app_config() picks up the new overrides
|
||||
# (the mtime check would catch it too, but resetting makes the response
|
||||
# below deterministically reflect the new state).
|
||||
reset_app_config()
|
||||
fresh_config = get_app_config()
|
||||
return _build_curator_config_response(fresh_config)
|
||||
save_user_overrides(user_id, payload)
|
||||
return _build_curator_config_response(load_skill_evolution_config_for_user(user_id))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -901,8 +912,9 @@ async def update_curator_config(body: CuratorConfigRequest) -> CuratorConfigResp
|
||||
|
||||
|
||||
@router.get("/curator/logs", response_model=CuratorLogsResponse, summary="Recent Curator Logs")
|
||||
async def get_curator_logs() -> CuratorLogsResponse:
|
||||
"""Return in-memory log records from the most recent curator run.
|
||||
async def get_curator_logs(request: Request) -> CuratorLogsResponse:
|
||||
"""Return in-memory log records from the most recent curator run for the
|
||||
authenticated user.
|
||||
|
||||
The buffer is process-local and cleared when curator starts a new run or
|
||||
when the backend restarts. Records are ordered oldest → newest.
|
||||
@@ -911,8 +923,9 @@ async def get_curator_logs() -> CuratorLogsResponse:
|
||||
from deerflow.skills import curator_log
|
||||
from deerflow.skills.curator import get_curator_status
|
||||
|
||||
records = curator_log.get_recent()
|
||||
status = get_curator_status()
|
||||
user_id = _current_user_id(request)
|
||||
records = curator_log.get_recent(user_id)
|
||||
status = get_curator_status(user_id)
|
||||
return CuratorLogsResponse(
|
||||
records=[CuratorLogRecord(**r) for r in records],
|
||||
run_count=int(status.get("run_count", 0)),
|
||||
|
||||
Reference in new issue
Block a user