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:
xuhdandClaude Opus 4.7 committed 2026-05-14 17:09:05 +08:00
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)),