From 5fdeb1c026745291bc34fde187eaf3810bbf44bf Mon Sep 17 00:00:00 2001 From: yumoqing Date: Mon, 10 Aug 2026 17:07:22 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20SkillConfig=20=E4=B8=89=E7=BA=A7?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=20(global/org/user)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 1054 bytes .../__pycache__/init.cpython-310.pyc | Bin 0 -> 6722 bytes pipeline_core/agent_config.py | 19 +- pipeline_core/skill_loader.py | 285 ++++++++++-------- 4 files changed, 180 insertions(+), 124 deletions(-) create mode 100644 pipeline_core/__pycache__/__init__.cpython-310.pyc create mode 100644 pipeline_core/__pycache__/init.cpython-310.pyc diff --git a/pipeline_core/__pycache__/__init__.cpython-310.pyc b/pipeline_core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d0e725c1c2dc41a860d75ac2a1cd827bd588379 GIT binary patch literal 1054 zcmZ9LOK;jh5Xa5Kyo`C803pw+7o@gadTi51kW`guB9-(qmlcgS#AOG&YmqZZT3{t8Cn<_(gN-F9VHwI;feKckiZ!TV9qQ_7*P;0TZ5IMz&Yf~RnbXK;q+aIUq?$mnMOBy^iy zOO@{>A4i-8UciMn?q9Om z*uQ7JQ3>W>g%>{lH&7G)*xiKe-j}?aSJvf(&!WCRd+;Wz+V?S#JfxZ{srZSbFBhxG z8cjVRRzc~`_r-uecr!1eChMcYu+Iip*Oy<0cdUPVbN%sys_lq(x3|NQs>Yp+sjzNi zSved(akedWQ>sV>KJ$qeMslI56CSZhD@?8>d%5B=)^&y%_otR^4z*t{RfY0G>YGtr zF7-KU>FR1m7q5a*q~FN$G{&Cwf1;I}?lYApi;^Z;>Jc|&nqU$#ge)OP$P?&$kQSk+ zp^DqCq)mE>P$pCeRYHwWCpd(LhO&3;$^+VH5?Zud+%_fKq#qJG@#eM%c|`g#;Y8z? zA)nKl@b_Ks(MR4};P>7SUru}Lf!{1%(xhyncy4-((HmjxRj^RGH|V=_!7u2`3CaBH M#EY_HIcCfJ2TZs`djJ3c literal 0 HcmV?d00001 diff --git a/pipeline_core/__pycache__/init.cpython-310.pyc b/pipeline_core/__pycache__/init.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e43a94c7613b9ed110874dc9231c63236677a28 GIT binary patch literal 6722 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 literal 0 HcmV?d00001 diff --git a/pipeline_core/agent_config.py b/pipeline_core/agent_config.py index efefedc..0cc1601 100644 --- a/pipeline_core/agent_config.py +++ b/pipeline_core/agent_config.py @@ -49,10 +49,13 @@ class MemoryConfig: @dataclass class SkillConfig: - """技能配置""" + """技能配置 — 三级隔离:全局 + 组织 + 用户""" enabled: bool = True - dirs: List[str] = field(default_factory=lambda: ["skills/common", "skills/sdlc"]) + base_dir: str = "skills" # 技能根目录,其下为 global/orgs/users/ max_skills_per_turn: int = 5 + enable_global: bool = True # 启用全局技能 + enable_org: bool = True # 启用组织技能 + enable_user: bool = True # 启用用户技能 @dataclass @@ -110,8 +113,11 @@ class AgentConfig: }, "skills": { "enabled": self.skills.enabled, - "dirs": self.skills.dirs, "max_skills_per_turn": self.skills.max_skills_per_turn, + "base_dir": self.skills.base_dir, + "enable_global": self.skills.enable_global, + "enable_org": self.skills.enable_org, + "enable_user": self.skills.enable_user, }, "tools": [ { @@ -155,8 +161,11 @@ class AgentConfig: ), skills=SkillConfig( enabled=sk.get("enabled", True), - dirs=sk.get("dirs", ["skills/common", "skills/sdlc"]), + base_dir=sk.get("base_dir", "skills"), max_skills_per_turn=sk.get("max_skills_per_turn", 5), + enable_global=sk.get("enable_global", True), + enable_org=sk.get("enable_org", True), + enable_user=sk.get("enable_user", True), ), tools=[ToolDefinition(**t) if isinstance(t, dict) else t for t in data.get("tools", [])], session_isolation=data.get("session_isolation", "project"), @@ -317,7 +326,7 @@ SDLC_DEFAULT_CONFIG = AgentConfig( history_limit=50, compression=CompressionConfig(enabled=True, threshold=0.50, target_ratio=0.20, keep_recent=6), memory=MemoryConfig(enabled=True), - skills=SkillConfig(enabled=True, dirs=["skills/common", "skills/sdlc"]), + skills=SkillConfig(enabled=True, base_dir="skills"), ) diff --git a/pipeline_core/skill_loader.py b/pipeline_core/skill_loader.py index 1ede6f1..f76ba3c 100644 --- a/pipeline_core/skill_loader.py +++ b/pipeline_core/skill_loader.py @@ -1,23 +1,22 @@ """ -pipeline-core: Skill Loader — 技能加载与注入 +pipeline-core: Skill Loader v2 — 三级技能隔离 -对照 Hermes Agent 的 skills 系统: -- 从目录扫描 SKILL.md 文件 -- 解析 YAML frontmatter -- 按产线/角色注入到 system prompt -- 支持热更新(agent 执行时可重新扫描) - -目录结构(与 HA 一致): +技能目录结构: skills/ - common/ ← 所有角色共用 - code-style/SKILL.md - sdlc/ ← SDLC 专用 - git-workflow/SKILL.md - requirement/ ← 角色专用 - design/ - develop/ - test/ - deploy/ + global/ ← 全局技能(所有人可见) + skill-name/ + SKILL.md + orgs/ ← 组织技能 + {org_id}/ + skill-name/ + SKILL.md + users/ ← 用户个人技能 + {user_id}/ + skill-name/ + SKILL.md + +加载优先级:用户 > 组织 > 全局 +同名技能:用户级覆盖组织级覆盖全局级 """ import json @@ -29,6 +28,7 @@ from typing import Dict, List, Optional logger = logging.getLogger("pipeline.skill_loader") +# ── 技能数据模型 ── @dataclass class Skill: @@ -36,13 +36,15 @@ class Skill: name: str path: str # SKILL.md 绝对路径 description: str = "" - content: str = "" # SKILL.md 完整内容 - category: str = "common" # common / sdlc / requirement / design / ... + content: str = "" + scope: str = "global" # global / org / user + scope_id: str = "" # org_id 或 user_id trigger_keywords: List[str] = field(default_factory=list) version: str = "1.0.0" @classmethod - def from_file(cls, filepath: str, category: str = "common") -> "Skill": + def from_file(cls, filepath: str, scope: str = "global", + scope_id: str = "") -> "Skill": """从 SKILL.md 文件加载技能""" name = os.path.basename(os.path.dirname(filepath)) content = "" @@ -55,16 +57,17 @@ class Skill: content = f.read() except Exception as e: logger.warning(f"Skill read failed: {filepath} err={e}") - return cls(name=name, path=filepath, category=category) + return cls(name=name, path=filepath, scope=scope, scope_id=scope_id) - # 解析 YAML frontmatter(如果存在) + # 解析 YAML frontmatter fm = _parse_frontmatter(content) if fm: description = fm.get("description", description) trigger_keywords = fm.get("trigger_keywords", trigger_keywords) version = fm.get("version", version) + # frontmatter 中的 scope 优先 + scope = fm.get("scope", scope) - # 无 frontmatter 时从内容首行提取描述 if not description: for line in content.split("\n"): line = line.strip() @@ -73,18 +76,15 @@ class Skill: break return cls( - name=name, - path=filepath, - description=description, - content=content, - category=category, - trigger_keywords=trigger_keywords, - version=version, + name=name, path=filepath, description=description, + content=content, scope=scope, scope_id=scope_id, + trigger_keywords=trigger_keywords, version=version, ) def to_prompt_block(self) -> str: """生成注入到 system prompt 的技能文本块""" - return f"""## 技能: {self.name} + scope_tag = {"global": "全局", "org": "组织", "user": "个人"}.get(self.scope, "") + return f"""## [{scope_tag}] {self.name} {self.description} {self.content} @@ -92,145 +92,189 @@ class Skill: def _parse_frontmatter(content: str) -> Optional[dict]: - """解析 YAML frontmatter(--- 之间的内容)""" + """解析 YAML frontmatter""" if not content.startswith("---"): return None - end = content.find("---", 3) if end == -1: return None - fm_text = content[3:end].strip() result = {} - - # 简单的 YAML 解析(不依赖 PyYAML) for line in fm_text.split("\n"): line = line.strip() if ":" in line: key, _, val = line.partition(":") key = key.strip() val = val.strip().strip('"').strip("'") - # 处理列表 if val.startswith("[") and val.endswith("]"): - val = [v.strip().strip('"').strip("'") for v in val[1:-1].split(",")] + val = [v.strip().strip('"').strip("'") + for v in val[1:-1].split(",") if v.strip()] result[key] = val - return result +# ── 三级技能加载器 ── + class SkillLoader: - """技能加载器 — 管理技能目录并注入到 prompt""" + """三级技能加载器:global → org → user""" - def __init__(self): - self._skills: Dict[str, Dict[str, Skill]] = {} # {category: {name: Skill}} - self._base_dirs: List[str] = [] + def __init__(self, base_dir: str = ""): + self.base_dir = base_dir + # 三级存储: {scope: {name: Skill}} + self._global: Dict[str, Skill] = {} + self._orgs: Dict[str, Dict[str, Skill]] = {} # {org_id: {name: Skill}} + self._users: Dict[str, Dict[str, Skill]] = {} # {user_id: {name: Skill}} - def add_skill_dir(self, base_dir: str): - """添加技能目录。目录下每个子目录是一个技能。""" - if base_dir not in self._base_dirs: - self._base_dirs.append(base_dir) - self._scan_dir(base_dir) + def set_base_dir(self, base_dir: str): + """设置技能根目录并扫描""" + self.base_dir = base_dir + if base_dir: + self.reload() - def _scan_dir(self, base_dir: str): - """扫描目录下的所有 SKILL.md""" - if not os.path.isdir(base_dir): - logger.debug(f"Skill dir not found: {base_dir}") + # ── 目录扫描 ── + + def reload(self): + """重新扫描所有技能目录""" + self._global = {} + self._orgs = {} + self._users = {} + + if not self.base_dir or not os.path.isdir(self.base_dir): return - # base_dir 下的每个子目录是一个技能分类 - for category in os.listdir(base_dir): - cat_path = os.path.join(base_dir, category) - if not os.path.isdir(cat_path): - continue + # 全局技能: skills/global/ + global_dir = os.path.join(self.base_dir, "global") + if os.path.isdir(global_dir): + self._global = self._scan_scope_dir(global_dir, "global") - # 直接是 SKILL.md - skill_file = os.path.join(cat_path, "SKILL.md") + # 组织技能: skills/orgs/{org_id}/ + orgs_dir = os.path.join(self.base_dir, "orgs") + if os.path.isdir(orgs_dir): + for org_id in os.listdir(orgs_dir): + org_path = os.path.join(orgs_dir, org_id) + if os.path.isdir(org_path): + self._orgs[org_id] = self._scan_scope_dir(org_path, "org", org_id) + + # 用户技能: skills/users/{user_id}/ + users_dir = os.path.join(self.base_dir, "users") + if os.path.isdir(users_dir): + for user_id in os.listdir(users_dir): + user_path = os.path.join(users_dir, user_id) + if os.path.isdir(user_path): + self._users[user_id] = self._scan_scope_dir(user_path, "user", user_id) + + logger.debug(f"SkillLoader reloaded: global={len(self._global)}, " + f"orgs={len(self._orgs)}, users={len(self._users)}") + + def _scan_scope_dir(self, scope_dir: str, scope: str, + scope_id: str = "") -> Dict[str, Skill]: + """扫描单个 scope 目录下的所有技能""" + skills = {} + for entry in os.listdir(scope_dir): + entry_path = os.path.join(scope_dir, entry) + if not os.path.isdir(entry_path): + continue + skill_file = os.path.join(entry_path, "SKILL.md") if os.path.isfile(skill_file): - skill = Skill.from_file(skill_file, category) - self._register(skill) - continue - - # 子目录下可能有多个技能 - for sub in os.listdir(cat_path): - sub_path = os.path.join(cat_path, sub) + skill = Skill.from_file(skill_file, scope, scope_id) + skills[skill.name] = skill + # 也支持子目录嵌套 + for sub in os.listdir(entry_path): + sub_path = os.path.join(entry_path, sub) if os.path.isdir(sub_path): - skill_file = os.path.join(sub_path, "SKILL.md") - if os.path.isfile(skill_file): - skill = Skill.from_file(skill_file, category) - self._register(skill) + sf = os.path.join(sub_path, "SKILL.md") + if os.path.isfile(sf): + skill = Skill.from_file(sf, scope, scope_id) + skills[skill.name] = skill + return skills - def _register(self, skill: Skill): - """注册技能到内存""" - if skill.category not in self._skills: - self._skills[skill.category] = {} - self._skills[skill.category][skill.name] = skill - logger.debug(f"SkillLoader: loaded {skill.category}/{skill.name}") + # ── 查询 ── - def get_all(self, categories: List[str] = None) -> List[Skill]: - """获取指定分类的所有技能""" - result = [] - cats = categories or list(self._skills.keys()) - for cat in cats: - if cat in self._skills: - result.extend(self._skills[cat].values()) - return result + def get_merged(self, org_id: str = "", user_id: str = "") -> Dict[str, Skill]: + """按优先级合并:全局 + 组织 + 用户。同名技能后者覆盖前者。""" + merged = dict(self._global) # 全局为基础 - def get_by_trigger(self, user_input: str, max_skills: int = 5) -> List[Skill]: + # 组织技能覆盖 + if org_id and org_id in self._orgs: + merged.update(self._orgs[org_id]) + + # 用户技能覆盖(最高优先级) + if user_id and user_id in self._users: + merged.update(self._users[user_id]) + + return merged + + def get_by_trigger(self, user_input: str, org_id: str = "", + user_id: str = "", max_skills: int = 5) -> List[Skill]: """根据用户输入匹配相关技能(基于 trigger_keywords)""" + all_skills = list(self.get_merged(org_id, user_id).values()) scored = [] user_lower = user_input.lower() - for cat_skills in self._skills.values(): - for skill in cat_skills.values(): - score = 0 - for kw in skill.trigger_keywords: - if kw.lower() in user_lower: - score += 1 - # 标题匹配加分 - if skill.name.lower() in user_lower: - score += 2 - if score > 0: - scored.append((score, skill)) + for skill in all_skills: + score = 0 + for kw in skill.trigger_keywords: + if kw.lower() in user_lower: + score += 1 + if skill.name.lower() in user_lower: + score += 2 + # scope 加分:用户 > 组织 > 全局 + if skill.scope == "user": + score += 3 + elif skill.scope == "org": + score += 2 + elif skill.scope == "global": + score += 1 + if score > 0: + scored.append((score, skill)) scored.sort(key=lambda x: x[0], reverse=True) return [s[1] for s in scored[:max_skills]] - def get_skill_count(self) -> int: - """已加载技能总数""" - return sum(len(skills) for skills in self._skills.values()) + def get_skill_count(self, org_id: str = "", user_id: str = "") -> int: + """可见技能总数""" + return len(self.get_merged(org_id, user_id)) - def reload(self): - """重新扫描所有技能目录(热更新)""" - self._skills = {} - for d in self._base_dirs: - self._scan_dir(d) + def list_by_scope(self) -> Dict[str, int]: + """各 scope 的技能数量""" + return { + "global": len(self._global), + "orgs": sum(len(s) for s in self._orgs.values()), + "users": sum(len(s) for s in self._users.values()), + } - def build_prompt_block(self, categories: List[str] = None, user_input: str = None, - max_skills: int = 5) -> str: + # ── Prompt 构建 ── + + def build_prompt_block(self, org_id: str = "", user_id: str = "", + user_input: str = None, max_skills: int = 5) -> str: """构建注入 system prompt 的技能段落。 - 如果有 user_input,优先匹配相关技能。 + 有 user_input 时按 trigger 匹配,否则取全部(受限 max_skills)。 """ if user_input: - skills = self.get_by_trigger(user_input, max_skills) + skills = self.get_by_trigger(user_input, org_id, user_id, max_skills) else: - skills = self.get_all(categories)[:max_skills] + merged = self.get_merged(org_id, user_id) + # 优先用户级 + skills = sorted(merged.values(), + key=lambda s: {"user": 0, "org": 1, "global": 2}.get(s.scope, 9)) + skills = skills[:max_skills] if not skills: return "" blocks = ["## 可用技能\n"] for s in skills: - # 只注入技能摘要,不注入完整内容(节省 token) - blocks.append(f"- **{s.category}/{s.name}**: {s.description[:200]}") + blocks.append(f"- **[{s.scope}] {s.name}**: {s.description[:200]}") blocks.append("") return "\n".join(blocks) - def build_full_prompt_block(self, skill_names: List[str]) -> str: - """注入指定技能的完整内容到 system prompt""" + def build_full_prompt_block(self, skill_names: List[str], + org_id: str = "", user_id: str = "") -> str: + """注入指定技能的完整内容""" + merged = self.get_merged(org_id, user_id) blocks = [] - for cat_skills in self._skills.values(): - for name, skill in cat_skills.items(): - if name in skill_names: - blocks.append(skill.to_prompt_block()) + for name in skill_names: + if name in merged: + blocks.append(merged[name].to_prompt_block()) return "\n".join(blocks) @@ -239,8 +283,11 @@ class SkillLoader: _loader: Optional[SkillLoader] = None -def get_skill_loader() -> SkillLoader: +def get_skill_loader(base_dir: str = "") -> SkillLoader: + """获取全局技能加载器单例。首次调用可传入 base_dir。""" global _loader if _loader is None: - _loader = SkillLoader() + _loader = SkillLoader(base_dir) + elif base_dir and base_dir != _loader.base_dir: + _loader.set_base_dir(base_dir) return _loader