neo-skill 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neo-skill",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "A multi-assistant skill generator (Claude/Windsurf/Cursor/GitHub Skills) driven by a canonical SkillSpec.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -0,0 +1,52 @@
1
+ # skill-finder
2
+
3
+ 发现/匹配/安装第三方 skill 库。
4
+
5
+ ## 功能
6
+
7
+ - **主动搜索**:通过两级提问收集需求,匹配第三方能力单元
8
+ - **被动推荐**:供 skill-creator 调用,推荐高置信度第三方 unit
9
+ - **两种安装模式**:自动安装(默认)/ 手动安装(仅输出命令)
10
+ - **可追溯性**:所有安装行为记录到 `~/.omni-skill/install_records.json`
11
+ - **诊断工具**:提供匹配 trace 和安装记录分析
12
+
13
+ ## 触发方式
14
+
15
+ - "找第三方 skill"
16
+ - "有没有现成的工具"
17
+ - "推荐一个能力库"
18
+ - "安装第三方能力"
19
+ - "search for third-party skill"
20
+
21
+ ## 使用方式
22
+
23
+ ### CLI 模式
24
+
25
+ ```bash
26
+ python -m skill_finder.cli
27
+ ```
28
+
29
+ ### 编程接口
30
+
31
+ ```python
32
+ from skill_finder.recommender import Recommender
33
+ from skill_finder.models import SearchQuery
34
+
35
+ recommender = Recommender()
36
+ query = SearchQuery(goal="AI 代码编辑", tags=["code-generation"], ide="windsurf")
37
+ result = recommender.recommend(query)
38
+ ```
39
+
40
+ ## 设计原则
41
+
42
+ - **宁缺毋滥**:置信度 < 60% 必须拒绝
43
+ - **诚实反馈**:明确告知拒绝原因(4 种分类)
44
+ - **可追溯**:install_record 记录所有安装行为
45
+ - **轻量集成**:skill-creator 集成不打断主流程
46
+
47
+ ## 详细文档
48
+
49
+ 参见:
50
+ - `DESIGN_SKILL_FINDER.md` - 完整设计文档
51
+ - `SKILL_FINDER_USAGE.md` - 使用指南
52
+ - `references/matching-algorithm.md` - 匹配算法详解
@@ -0,0 +1,13 @@
1
+ # skill-finder 安装模式
2
+
3
+ ## 两种安装模式
4
+
5
+ ### 模式 1: 自动安装(默认)
6
+ - 执行 `package.install.auto_install_cmd`
7
+ - 捕获输出并记录结果
8
+ - 写入 install_record (executed=true)
9
+
10
+ ### 模式 2: 手动安装
11
+ - 输出 `package.install.manual_install_cmd`
12
+ - 记录指令 (executed=false, result=skipped)
13
+ - 任务结束,不执行安装
@@ -0,0 +1,172 @@
1
+ # skill-finder 匹配算法
2
+
3
+ ## 一、两阶段匹配流程
4
+
5
+ ### 阶段 1: 粗筛(Coarse Filtering)
6
+ **目标**: 快速缩小候选范围
7
+
8
+ **输入**: SearchQuery (goal, tags, keywords, ide, env, constraints)
9
+
10
+ **方法**: 倒排索引命中
11
+ 1. **Tag 倒排**: 从 `indexes/units.by_tag.json` 查找匹配 `query.tags` 的 unit_id
12
+ 2. **Keyword 倒排**: 从 `indexes/units.by_keyword.json` 查找匹配 `query.keywords` 的 unit_id
13
+ 3. **IDE 倒排**: 从 `indexes/units.by_ide.json` 查找匹配 `query.ide` 的 unit_id
14
+ 4. **合并**: 取并集(tag OR keyword)与 IDE 交集
15
+
16
+ **输出**: 候选 unit_id 列表
17
+
18
+ **拒绝条件**: 若候选为空,返回 `no_candidates_by_tag`
19
+
20
+ ---
21
+
22
+ ### 阶段 2: 精排(Fine Ranking)
23
+ **目标**: 计算置信度分数,过滤低质量匹配
24
+
25
+ **评分公式**:
26
+ ```
27
+ score = tag_coverage * 0.6 + keyword_match * 0.3 + ide_bonus * 0.1
28
+ ```
29
+
30
+ **评分细节**:
31
+
32
+ 1. **Tag 覆盖率** (权重 0.6)
33
+ ```
34
+ tag_coverage = len(matched_tags) / len(query.tags)
35
+ matched_tags = set(query.tags) ∩ set(unit.capability_tags)
36
+ ```
37
+
38
+ 2. **Keyword 匹配** (权重 0.3)
39
+ ```
40
+ keyword_match = len(matched_keywords) / len(query.keywords)
41
+ matched_keywords = set(query.keywords) ∩ set(unit.keywords)
42
+ ```
43
+
44
+ 3. **IDE 支持** (权重 0.1)
45
+ - 支持: +0.1
46
+ - 不支持: score *= 0.2 (显著降权)
47
+
48
+ 4. **约束过滤** (降权因子)
49
+ - **env 冲突**: 若 `unit.conflicts` 提及 `query.env` → score *= 0.3
50
+ - **constraints 违反**: 若 unit 描述与约束冲突 → score *= 0.5
51
+ - 示例: `no-network` 约束 + unit 描述含 "network" → 降权
52
+
53
+ **输出**: 按 score 降序排列的 MatchResult 列表
54
+
55
+ ---
56
+
57
+ ## 二、置信门槛与拒绝策略
58
+
59
+ ### 置信门槛
60
+ ```python
61
+ MIN_SCORE = 0.6 # 60%
62
+ ```
63
+
64
+ **规则**:
65
+ - `score >= 0.6`: 返回 Top 1-3
66
+ - `score < 0.6`: **必须拒绝**,不得返回低质量结果
67
+
68
+ ---
69
+
70
+ ### 拒绝原因分类
71
+
72
+ #### 1. `no_candidates_by_tag`
73
+ **条件**: 粗筛阶段无候选
74
+ **原因**: 未找到匹配的能力标签或关键词
75
+ **建议**: 补充更多标签/关键词,或使用 skill-creator 创建自研 skill
76
+
77
+ #### 2. `candidates_but_no_ide_support`
78
+ **条件**: 有候选但所有 unit 均不支持目标 IDE
79
+ **原因**: 找到候选能力,但均不支持 {query.ide}
80
+ **建议**: 更换 IDE 或使用 generic 模式
81
+
82
+ #### 3. `candidates_but_incompatible_env`
83
+ **条件**: 有候选但所有 unit 均因 env/constraints 被降权至 < 0.6
84
+ **原因**: 找到候选能力,但均不满足环境或约束条件
85
+ **建议**: 放宽约束或调整环境
86
+
87
+ #### 4. `insufficient_info`
88
+ **条件**: 有候选但最高分 < 0.6,且无明确冲突
89
+ **原因**: 候选能力置信度不足(< 60%),建议补充更多需求信息
90
+ **建议**: 回答 Level 2 问题,提供更多细节
91
+
92
+ ---
93
+
94
+ ## 三、匹配示例
95
+
96
+ ### 示例 1: 高置信匹配
97
+ ```
98
+ Query:
99
+ goal: "AI 辅助多文件代码重构"
100
+ tags: ["code-refactoring", "multi-file-editing"]
101
+ ide: "windsurf"
102
+
103
+ 候选: gh:aider-chat/aider#code-edit
104
+ capability_tags: ["code-generation", "code-refactoring", "multi-file-editing"]
105
+ ide_support: ["windsurf", "cursor"]
106
+
107
+ 评分:
108
+ tag_coverage = 2/2 = 1.0 → 0.6
109
+ keyword_match = 0 (未提供) → 0
110
+ ide_bonus = 0.1
111
+ score = 0.7 ✓ (通过)
112
+
113
+ 结果: 返回 Top 1
114
+ ```
115
+
116
+ ### 示例 2: IDE 不支持拒绝
117
+ ```
118
+ Query:
119
+ goal: "生成 commit message"
120
+ tags: ["git-commit"]
121
+ ide: "vscode"
122
+
123
+ 候选: gh:aider-chat/aider#git-commit
124
+ capability_tags: ["git-commit"]
125
+ ide_support: ["windsurf", "cursor", "generic"]
126
+
127
+ 评分:
128
+ tag_coverage = 1/1 = 1.0 → 0.6
129
+ ide_bonus = 0 (不支持 vscode)
130
+ 降权: score *= 0.2 → 0.12 ✗
131
+
132
+ 拒绝原因: candidates_but_no_ide_support
133
+ ```
134
+
135
+ ### 示例 3: 约束冲突拒绝
136
+ ```
137
+ Query:
138
+ goal: "自动化代码编辑"
139
+ tags: ["code-generation"]
140
+ constraints: ["no-network"]
141
+ ide: "windsurf"
142
+
143
+ 候选: gh:aider-chat/aider#code-edit
144
+ description: "AI-powered ... (需要 LLM API)"
145
+ conflicts: "需要网络访问 LLM provider"
146
+
147
+ 评分:
148
+ tag_coverage = 1/1 = 1.0 → 0.6
149
+ ide_bonus = 0.1
150
+ 约束冲突: score *= 0.5 → 0.35 ✗
151
+
152
+ 拒绝原因: candidates_but_incompatible_env
153
+ ```
154
+
155
+ ---
156
+
157
+ ## 四、实现要点
158
+
159
+ ### 宁缺毋滥原则
160
+ - **禁止强行匹配**: 低于阈值必须拒绝
161
+ - **禁止虚构结果**: 不得返回不存在的 unit
162
+ - **诚实反馈**: 明确告知拒绝原因与缺口信息
163
+
164
+ ### 性能优化
165
+ - **按需加载**: 粗筛仅加载索引,精排才加载完整 unit/package 数据
166
+ - **Top-K 限制**: 最多返回 Top 3,避免信息过载
167
+ - **索引预构建**: 使用 `build_third_party_indexes.py` 预生成倒排索引
168
+
169
+ ### 可扩展性
170
+ - **插件化评分**: 可新增评分因子(如 popularity, last_updated)
171
+ - **动态阈值**: 可根据 query 复杂度调整 MIN_SCORE
172
+ - **多语言支持**: 索引构建时归一化大小写,支持中英文关键词
@@ -0,0 +1,77 @@
1
+ {
2
+ "version": 1,
3
+ "name": "skill-finder",
4
+ "description": "发现/匹配/安装第三方 skill 库。支持两种模式:(1) 主动搜索(最小两级提问);(2) 被动推荐(skill-creator 集成)。宁缺毋滥,未达阈值必须拒绝并说明原因。",
5
+ "primary_target": "windsurf",
6
+ "backward_compat": ["claude", "cursor", "github"],
7
+ "questions": [
8
+ "你想实现什么目标?(一句话描述任务)",
9
+ "输入是什么?(文件/代码/数据/命令等)",
10
+ "运行环境?(OS/Shell/Runtime)",
11
+ "使用哪个 IDE/编辑器?(windsurf/cursor/vscode/generic)"
12
+ ],
13
+ "triggers": [
14
+ "找第三方 skill",
15
+ "有没有现成的工具",
16
+ "推荐一个能力库",
17
+ "安装第三方能力",
18
+ "search for third-party skill",
19
+ "find existing tools"
20
+ ],
21
+ "freedom_level": "low",
22
+ "workflow": {
23
+ "type": "sequential",
24
+ "steps": [
25
+ {
26
+ "id": "interview",
27
+ "title": "两级提问收集需求",
28
+ "kind": "action",
29
+ "commands": [],
30
+ "notes": "Level 1 固定 3-4 问(goal/input/env/ide);Level 2 仅不确定时追问 1-2 个。详见 references/interview-protocol.md"
31
+ },
32
+ {
33
+ "id": "match",
34
+ "title": "匹配第三方 unit",
35
+ "kind": "action",
36
+ "commands": [],
37
+ "notes": "两阶段:粗筛(倒排索引)+ 精排(tag 覆盖率 + 约束过滤)。置信度 < 60% 必须拒绝并说明原因。详见 references/matching-algorithm.md"
38
+ },
39
+ {
40
+ "id": "present",
41
+ "title": "展示结果(Top 1-3 或拒绝)",
42
+ "kind": "gate",
43
+ "commands": [],
44
+ "notes": "命中:列出 unit + package + 匹配原因 + README 链接 + 使用方式。未命中:明确拒绝原因分类(no_candidates_by_tag/no_ide_support/incompatible_env/insufficient_info)"
45
+ },
46
+ {
47
+ "id": "install",
48
+ "title": "安装(默认自动/可选手动)",
49
+ "kind": "action",
50
+ "commands": [],
51
+ "notes": "默认:执行 auto_install_cmd 并记录。手动:输出 manual_install_cmd 并记录(executed=false)。详见 references/install-modes.md"
52
+ },
53
+ {
54
+ "id": "record",
55
+ "title": "记录安装行为",
56
+ "kind": "action",
57
+ "commands": [],
58
+ "notes": "写入 ~/.omni-skill/install_records.json(或配置路径)。字段:timestamp/unit_id/package_id/mode/executed/result/error_summary/docs_links"
59
+ },
60
+ {
61
+ "id": "output",
62
+ "title": "输出使用方式",
63
+ "kind": "action",
64
+ "commands": [],
65
+ "notes": "展示 unit.entrypoints(原生命令/参数/示例)+ README 链接 + usage_notes"
66
+ }
67
+ ]
68
+ },
69
+ "references": [
70
+ "references/matching-algorithm.md",
71
+ "references/install-modes.md",
72
+ "references/interview-protocol.md",
73
+ "references/registry-schema.md"
74
+ ],
75
+ "scripts": [],
76
+ "assets": []
77
+ }
@@ -0,0 +1,21 @@
1
+ """skill-finder - 发现/匹配/安装第三方 skill 库"""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .models import (
6
+ SkillPackage,
7
+ SkillUnit,
8
+ SearchQuery,
9
+ SearchResult,
10
+ MatchResult,
11
+ InstallRecord,
12
+ )
13
+
14
+ __all__ = [
15
+ "SkillPackage",
16
+ "SkillUnit",
17
+ "SearchQuery",
18
+ "SearchResult",
19
+ "MatchResult",
20
+ "InstallRecord",
21
+ ]
@@ -0,0 +1,106 @@
1
+ """主动搜索入口 - CLI 接口"""
2
+
3
+ import sys
4
+ from typing import Optional
5
+ from .interview import Interview
6
+ from .registry import Registry
7
+ from .matcher import Matcher
8
+ from .installer import Installer
9
+ from .doctor import Doctor
10
+
11
+
12
+ class SkillFinderCLI:
13
+ """skill-finder CLI 主入口"""
14
+
15
+ def __init__(self):
16
+ self.registry = Registry()
17
+ self.matcher = Matcher(self.registry)
18
+ self.installer = Installer()
19
+ self.doctor = Doctor(self.registry)
20
+
21
+ def run(self):
22
+ """主流程"""
23
+ print("=== skill-finder ===")
24
+ print("发现/匹配/安装第三方 skill 库\n")
25
+
26
+ interview = Interview()
27
+ query = interview.collect()
28
+
29
+ print("\n=== 匹配结果 ===")
30
+ result = self.matcher.match(query)
31
+
32
+ if not result.matches:
33
+ print(f"✗ 未找到匹配的第三方能力\n")
34
+ print(f"拒绝原因: {result.rejection_reason}")
35
+ print(f"建议: 尝试使用 skill-creator 创建自研 skill")
36
+ return
37
+
38
+ print(f"找到 {len(result.matches)} 个匹配的能力:\n")
39
+
40
+ for i, match in enumerate(result.matches, 1):
41
+ print(f"【{i}】{match.unit.name} ({match.package.name})")
42
+ print(f"- 描述: {match.unit.description}")
43
+ print(f"- 匹配原因: {'; '.join(match.reasons)}")
44
+ print(f"- 置信度: {match.score:.2f}")
45
+ print(f"- README: {match.package.docs.readme}")
46
+
47
+ if match.warnings:
48
+ print(f"- ⚠ 警告: {'; '.join(match.warnings)}")
49
+ print()
50
+
51
+ choice = input("选择要安装的能力 [1]: ").strip() or "1"
52
+
53
+ try:
54
+ idx = int(choice) - 1
55
+ if idx < 0 or idx >= len(result.matches):
56
+ print("无效选择")
57
+ return
58
+ except ValueError:
59
+ print("无效选择")
60
+ return
61
+
62
+ selected = result.matches[idx]
63
+
64
+ print("\n=== 安装方式 ===")
65
+ print("[1] 自动安装(默认)")
66
+ print("[2] 手动安装(仅输出命令)")
67
+
68
+ mode_choice = input("选择 [1]: ").strip() or "1"
69
+ mode = "manual" if mode_choice == "2" else "auto"
70
+
71
+ record = self.installer.install(selected, mode=mode)
72
+
73
+ if record.result == "success" or record.result == "skipped":
74
+ print("\n=== 使用方式 ===")
75
+ for ep in selected.unit.entrypoints:
76
+ print(f"命令: {ep.command}")
77
+ if ep.args:
78
+ print(f"参数: {ep.args}")
79
+ if ep.cwd:
80
+ print(f"工作目录: {ep.cwd}")
81
+ if ep.examples:
82
+ print("示例:")
83
+ for example in ep.examples:
84
+ print(f" {example}")
85
+ print()
86
+
87
+ print(f"详细文档: {selected.package.docs.readme}")
88
+
89
+ if selected.unit.usage_notes:
90
+ print(f"\n使用说明:\n{selected.unit.usage_notes}")
91
+
92
+
93
+ def main():
94
+ """CLI 入口"""
95
+ if len(sys.argv) > 1 and sys.argv[1] == "doctor":
96
+ from .models import SearchQuery
97
+ doctor = Doctor()
98
+ query = SearchQuery(goal="测试", ide="windsurf")
99
+ print(doctor.show_install_records())
100
+ else:
101
+ cli = SkillFinderCLI()
102
+ cli.run()
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,105 @@
1
+ """诊断与 trace"""
2
+
3
+ from typing import Optional
4
+ from .models import SearchQuery, SearchResult
5
+ from .registry import Registry
6
+ from .matcher import Matcher
7
+ from .install_record import InstallRecordManager
8
+
9
+
10
+ class Doctor:
11
+ """诊断工具 - 提供匹配 trace 和安装记录分析"""
12
+
13
+ def __init__(self, registry: Optional[Registry] = None,
14
+ record_manager: Optional[InstallRecordManager] = None):
15
+ if registry is None:
16
+ registry = Registry()
17
+ if record_manager is None:
18
+ record_manager = InstallRecordManager()
19
+
20
+ self.registry = registry
21
+ self.matcher = Matcher(registry)
22
+ self.record_manager = record_manager
23
+
24
+ def trace_match(self, query: SearchQuery) -> str:
25
+ """输出匹配 trace"""
26
+ output = ["=== skill-finder Doctor Trace ===\n"]
27
+
28
+ output.append("【匹配 Trace】")
29
+ output.append(f"- Query: goal=\"{query.goal}\", tags={query.tags}, ide=\"{query.ide}\"")
30
+
31
+ candidates = self.matcher._coarse_filter(query)
32
+ output.append(f"- 粗筛命中: {len(candidates)} 个候选")
33
+
34
+ if candidates:
35
+ for tag in (query.tags or []):
36
+ units = self.registry.search_by_tag(tag)
37
+ if units:
38
+ output.append(f" - Tag '{tag}': {len(units)} 个 unit")
39
+
40
+ result = self.matcher.match(query)
41
+
42
+ if result.matches:
43
+ output.append("- 精排结果:")
44
+ for i, match in enumerate(result.matches[:3], 1):
45
+ output.append(f" {i}. {match.unit.unit_id} (score={match.score:.2f})")
46
+ output.append(f" - {', '.join(match.reasons)}")
47
+ output.append(f"- 最终返回: Top {len(result.matches)}")
48
+ else:
49
+ output.append(f"- 拒绝原因: {result.rejection_reason}")
50
+ output.append(f"- 拒绝分类: {result.rejection_category}")
51
+
52
+ return "\n".join(output)
53
+
54
+ def show_install_records(self, n: int = 5) -> str:
55
+ """显示最近的安装记录"""
56
+ output = ["\n【安装记录(最近 {} 条)】".format(n)]
57
+
58
+ records = self.record_manager.get_recent(n)
59
+
60
+ if not records:
61
+ output.append("- 暂无安装记录")
62
+ return "\n".join(output)
63
+
64
+ for i, record in enumerate(records, 1):
65
+ status_icon = "✓" if record.result == "success" else "✗" if record.result == "failed" else "○"
66
+ output.append(f"{i}. {record.timestamp[:10]} {record.timestamp[11:19]} | {record.unit_id} | {record.install_mode} | {status_icon} {record.result}")
67
+
68
+ if record.result == "failed" and record.error_summary:
69
+ error_preview = record.error_summary[:100].replace("\n", " ")
70
+ output.append(f" 错误: {error_preview}...")
71
+
72
+ return "\n".join(output)
73
+
74
+ def suggest_fix(self, package_id: str) -> str:
75
+ """针对安装失败提供修复建议"""
76
+ output = ["\n【冲突/异常建议】"]
77
+
78
+ records = self.record_manager.filter_by_package(package_id)
79
+ failed = [r for r in records if r.result == "failed"]
80
+
81
+ if not failed:
82
+ output.append(f"- Package {package_id} 无失败记录")
83
+ return "\n".join(output)
84
+
85
+ last_failed = failed[-1]
86
+
87
+ package = self.registry.get_package(package_id)
88
+ if package and package.install.uninstall_cmd:
89
+ output.append(f"- 若安装失败,尝试重装:")
90
+ output.append(f" {package.install.uninstall_cmd} && {package.install.auto_install_cmd}")
91
+ else:
92
+ output.append(f"- 建议手动检查错误信息并重试安装")
93
+
94
+ if last_failed.error_summary:
95
+ output.append(f"\n最近错误摘要:")
96
+ output.append(f" {last_failed.error_summary[:200]}")
97
+
98
+ return "\n".join(output)
99
+
100
+ def full_report(self, query: SearchQuery) -> str:
101
+ """完整诊断报告"""
102
+ output = []
103
+ output.append(self.trace_match(query))
104
+ output.append(self.show_install_records())
105
+ return "\n".join(output)
@@ -0,0 +1,58 @@
1
+ """安装记录管理"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+ from datetime import datetime
7
+ from .models import InstallRecord
8
+
9
+
10
+ class InstallRecordManager:
11
+ """安装记录管理器"""
12
+
13
+ def __init__(self, record_path: Optional[Path] = None):
14
+ if record_path is None:
15
+ record_path = Path.home() / ".omni-skill" / "install_records.json"
16
+
17
+ self.record_path = Path(record_path)
18
+ self.record_path.parent.mkdir(parents=True, exist_ok=True)
19
+
20
+ if not self.record_path.exists():
21
+ self._save_records([])
22
+
23
+ def add(self, record: InstallRecord):
24
+ """添加安装记录"""
25
+ records = self.load_all()
26
+ records.append(record.dict())
27
+ self._save_records(records)
28
+
29
+ def load_all(self) -> List[dict]:
30
+ """加载所有记录"""
31
+ try:
32
+ with open(self.record_path, 'r', encoding='utf-8') as f:
33
+ return json.load(f)
34
+ except Exception:
35
+ return []
36
+
37
+ def get_recent(self, n: int = 10) -> List[InstallRecord]:
38
+ """获取最近 N 条记录"""
39
+ records = self.load_all()
40
+ recent = records[-n:] if len(records) > n else records
41
+ return [InstallRecord(**r) for r in reversed(recent)]
42
+
43
+ def filter_by_package(self, package_id: str) -> List[InstallRecord]:
44
+ """按 package_id 过滤"""
45
+ records = self.load_all()
46
+ filtered = [r for r in records if r.get("package_id") == package_id]
47
+ return [InstallRecord(**r) for r in filtered]
48
+
49
+ def filter_by_unit(self, unit_id: str) -> List[InstallRecord]:
50
+ """按 unit_id 过滤"""
51
+ records = self.load_all()
52
+ filtered = [r for r in records if r.get("unit_id") == unit_id]
53
+ return [InstallRecord(**r) for r in filtered]
54
+
55
+ def _save_records(self, records: List[dict]):
56
+ """保存记录"""
57
+ with open(self.record_path, 'w', encoding='utf-8') as f:
58
+ json.dump(records, f, indent=2, ensure_ascii=False)
@@ -0,0 +1,117 @@
1
+ """安装执行器"""
2
+
3
+ import subprocess
4
+ from datetime import datetime
5
+ from typing import Literal, Optional
6
+ from .models import MatchResult, InstallRecord
7
+ from .install_record import InstallRecordManager
8
+
9
+
10
+ class Installer:
11
+ """安装执行器 - 支持自动/手动两种模式"""
12
+
13
+ def __init__(self, record_manager: Optional[InstallRecordManager] = None):
14
+ if record_manager is None:
15
+ record_manager = InstallRecordManager()
16
+ self.record_manager = record_manager
17
+
18
+ def install(self, match: MatchResult, mode: Literal["auto", "manual"] = "auto") -> InstallRecord:
19
+ """执行安装"""
20
+ package = match.package
21
+ unit = match.unit
22
+
23
+ if mode == "manual":
24
+ return self._manual_install(package, unit)
25
+ else:
26
+ return self._auto_install(package, unit)
27
+
28
+ def _manual_install(self, package, unit) -> InstallRecord:
29
+ """手动安装模式 - 仅输出命令"""
30
+ print(f"\n=== 手动安装 {package.name} ===")
31
+ print(package.install.manual_install_cmd)
32
+
33
+ if package.install.notes:
34
+ print(f"\n注意事项:\n{package.install.notes}")
35
+
36
+ print("\n已记录安装指令。请手动执行上述命令。")
37
+
38
+ record = InstallRecord(
39
+ timestamp=datetime.now().isoformat(),
40
+ unit_id=unit.unit_id,
41
+ package_id=package.package_id,
42
+ install_mode="manual",
43
+ executed=False,
44
+ method=package.install.method,
45
+ commands=package.install.manual_install_cmd,
46
+ result="skipped",
47
+ error_summary=None,
48
+ docs_links=package.docs
49
+ )
50
+
51
+ self.record_manager.add(record)
52
+ return record
53
+
54
+ def _auto_install(self, package, unit) -> InstallRecord:
55
+ """自动安装模式 - 执行命令"""
56
+ print(f"\n=== 自动安装 {package.name} ===")
57
+
58
+ if package.install.notes:
59
+ print(f"注意事项: {package.install.notes}")
60
+
61
+ print(f"\n执行命令:\n{package.install.auto_install_cmd}\n")
62
+
63
+ try:
64
+ result = subprocess.run(
65
+ package.install.auto_install_cmd,
66
+ shell=True,
67
+ capture_output=True,
68
+ text=True,
69
+ timeout=300
70
+ )
71
+
72
+ if result.returncode == 0:
73
+ print("✓ 安装成功")
74
+ record = InstallRecord(
75
+ timestamp=datetime.now().isoformat(),
76
+ unit_id=unit.unit_id,
77
+ package_id=package.package_id,
78
+ install_mode="auto",
79
+ executed=True,
80
+ method=package.install.method,
81
+ commands=package.install.auto_install_cmd,
82
+ result="success",
83
+ error_summary=None,
84
+ docs_links=package.docs
85
+ )
86
+ else:
87
+ print(f"✗ 安装失败\n{result.stderr}")
88
+ record = InstallRecord(
89
+ timestamp=datetime.now().isoformat(),
90
+ unit_id=unit.unit_id,
91
+ package_id=package.package_id,
92
+ install_mode="auto",
93
+ executed=True,
94
+ method=package.install.method,
95
+ commands=package.install.auto_install_cmd,
96
+ result="failed",
97
+ error_summary=result.stderr[:500] if result.stderr else None,
98
+ docs_links=package.docs
99
+ )
100
+
101
+ except Exception as e:
102
+ print(f"✗ 安装异常:{e}")
103
+ record = InstallRecord(
104
+ timestamp=datetime.now().isoformat(),
105
+ unit_id=unit.unit_id,
106
+ package_id=package.package_id,
107
+ install_mode="auto",
108
+ executed=True,
109
+ method=package.install.method,
110
+ commands=package.install.auto_install_cmd,
111
+ result="failed",
112
+ error_summary=str(e)[:500],
113
+ docs_links=package.docs
114
+ )
115
+
116
+ self.record_manager.add(record)
117
+ return record
@@ -0,0 +1,101 @@
1
+ """两级提问逻辑"""
2
+
3
+ from typing import Dict, List, Optional
4
+ from .models import SearchQuery
5
+
6
+
7
+ class Interview:
8
+ """最小两级提问收集需求"""
9
+
10
+ LEVEL1_QUESTIONS = [
11
+ {
12
+ "id": "goal",
13
+ "text": "你想实现什么目标?(一句话描述任务)",
14
+ "required": True
15
+ },
16
+ {
17
+ "id": "input",
18
+ "text": "输入是什么?(文件/代码/数据/命令等)",
19
+ "required": False,
20
+ "default": "任意"
21
+ },
22
+ {
23
+ "id": "env",
24
+ "text": "运行环境?(OS/Shell/Runtime,如 Windows/Linux/Python/Node)",
25
+ "required": False,
26
+ "default": "通用"
27
+ },
28
+ {
29
+ "id": "ide",
30
+ "text": "使用哪个 IDE/编辑器?(windsurf/cursor/vscode/generic)",
31
+ "required": True
32
+ }
33
+ ]
34
+
35
+ def collect(self) -> SearchQuery:
36
+ """收集需求"""
37
+ answers = {}
38
+
39
+ print("=== 需求收集(Level 1)===\n")
40
+ for q in self.LEVEL1_QUESTIONS:
41
+ if q["required"]:
42
+ answer = input(f"{q['text']}: ").strip()
43
+ while not answer:
44
+ answer = input(f"{q['text']}(必填): ").strip()
45
+ answers[q["id"]] = answer
46
+ else:
47
+ default_hint = f" [默认: {q.get('default', '跳过')}]" if q.get('default') else ""
48
+ answer = input(f"{q['text']}{default_hint}: ").strip()
49
+ answers[q["id"]] = answer if answer else q.get("default")
50
+
51
+ if self._needs_clarification(answers):
52
+ print("\n=== 补充信息(Level 2)===\n")
53
+
54
+ if len(answers["goal"].split()) < 3:
55
+ detail = input("能否详细描述一下具体要做什么?: ").strip()
56
+ if detail:
57
+ answers["goal"] += f" - {detail}"
58
+
59
+ constraints = input("有什么硬约束吗?(如不可联网/必须离线/不可修改文件等,无则回车): ").strip()
60
+ if constraints:
61
+ answers["constraints"] = [c.strip() for c in constraints.split(",")]
62
+
63
+ return SearchQuery(
64
+ goal=answers["goal"],
65
+ input_type=answers.get("input"),
66
+ env=answers.get("env"),
67
+ ide=answers["ide"],
68
+ constraints=answers.get("constraints"),
69
+ tags=self._extract_tags(answers["goal"]),
70
+ keywords=self._extract_keywords(answers["goal"])
71
+ )
72
+
73
+ def _needs_clarification(self, answers: Dict) -> bool:
74
+ """判断是否需要 Level 2"""
75
+ return len(answers["goal"].split()) < 5
76
+
77
+ def _extract_tags(self, goal: str) -> List[str]:
78
+ """从 goal 提取 tags(简单关键词映射)"""
79
+ tag_map = {
80
+ "代码": "code-generation",
81
+ "生成": "code-generation",
82
+ "重构": "code-refactoring",
83
+ "git": "git-integration",
84
+ "commit": "git-commit",
85
+ "多文件": "multi-file-editing",
86
+ "测试": "testing",
87
+ "文档": "documentation",
88
+ "编辑": "code-generation",
89
+ }
90
+ tags = []
91
+ goal_lower = goal.lower()
92
+ for keyword, tag in tag_map.items():
93
+ if keyword in goal_lower:
94
+ if tag not in tags:
95
+ tags.append(tag)
96
+ return tags
97
+
98
+ def _extract_keywords(self, goal: str) -> List[str]:
99
+ """提取关键词"""
100
+ words = goal.split()
101
+ return [w.strip() for w in words if len(w.strip()) > 2]
@@ -0,0 +1,159 @@
1
+ """匹配算法实现 - 两阶段匹配"""
2
+
3
+ from typing import List, Dict, Set
4
+ from .models import SearchQuery, SearchResult, MatchResult
5
+ from .registry import Registry
6
+
7
+
8
+ class Matcher:
9
+ """匹配器 - 两阶段匹配算法"""
10
+
11
+ def __init__(self, registry: Registry, min_score: float = 0.6):
12
+ self.registry = registry
13
+ self.min_score = min_score
14
+
15
+ def match(self, query: SearchQuery) -> SearchResult:
16
+ """执行两阶段匹配"""
17
+ candidates = self._coarse_filter(query)
18
+
19
+ if not candidates:
20
+ return SearchResult(
21
+ query=query,
22
+ matches=[],
23
+ rejection_reason="未找到匹配的能力标签或关键词",
24
+ rejection_category="no_candidates_by_tag"
25
+ )
26
+
27
+ scored = self._fine_rank(candidates, query)
28
+ filtered = [m for m in scored if m.score >= self.min_score]
29
+
30
+ if not filtered:
31
+ rejection = self._analyze_rejection(candidates, query)
32
+ return SearchResult(
33
+ query=query,
34
+ matches=[],
35
+ rejection_reason=rejection["reason"],
36
+ rejection_category=rejection["category"]
37
+ )
38
+
39
+ return SearchResult(
40
+ query=query,
41
+ matches=filtered[:3],
42
+ rejection_reason=None,
43
+ rejection_category=None
44
+ )
45
+
46
+ def _coarse_filter(self, query: SearchQuery) -> List[str]:
47
+ """阶段 1: 粗筛 - 倒排索引命中"""
48
+ unit_ids = set()
49
+
50
+ if query.tags:
51
+ for tag in query.tags:
52
+ unit_ids.update(self.registry.search_by_tag(tag))
53
+
54
+ if query.keywords:
55
+ for kw in query.keywords:
56
+ unit_ids.update(self.registry.search_by_keyword(kw))
57
+
58
+ if query.ide:
59
+ ide_units = set(self.registry.search_by_ide(query.ide))
60
+ if unit_ids:
61
+ unit_ids &= ide_units
62
+ else:
63
+ unit_ids = ide_units
64
+
65
+ return list(unit_ids)
66
+
67
+ def _fine_rank(self, unit_ids: List[str], query: SearchQuery) -> List[MatchResult]:
68
+ """阶段 2: 精排 - 计算置信度分数"""
69
+ results = []
70
+
71
+ for uid in unit_ids:
72
+ unit = self.registry.get_unit(uid)
73
+ if not unit:
74
+ continue
75
+
76
+ package = self.registry.get_package(unit.package_id)
77
+ if not package:
78
+ continue
79
+
80
+ score = 0.0
81
+ reasons = []
82
+ warnings = []
83
+
84
+ if query.tags:
85
+ matched_tags = set(t.lower() for t in query.tags) & set(t.lower() for t in unit.capability_tags)
86
+ if matched_tags:
87
+ tag_coverage = len(matched_tags) / len(query.tags)
88
+ score += tag_coverage * 0.6
89
+ reasons.append(f"匹配标签: {', '.join(matched_tags)}")
90
+
91
+ if query.keywords:
92
+ matched_kw = set(kw.lower() for kw in query.keywords) & set(kw.lower() for kw in unit.keywords)
93
+ if matched_kw:
94
+ kw_score = len(matched_kw) / len(query.keywords)
95
+ score += kw_score * 0.3
96
+ reasons.append(f"匹配关键词: {', '.join(matched_kw)}")
97
+
98
+ if query.ide:
99
+ if query.ide.lower() in [ide.lower() for ide in unit.ide_support]:
100
+ score += 0.1
101
+ reasons.append(f"支持 {query.ide}")
102
+ else:
103
+ score *= 0.2
104
+ warnings.append(f"不支持 {query.ide}(仅支持 {', '.join(unit.ide_support)})")
105
+
106
+ if query.env and unit.conflicts:
107
+ if query.env.lower() in unit.conflicts.lower():
108
+ score *= 0.3
109
+ warnings.append(f"可能与 {query.env} 环境冲突")
110
+
111
+ if query.constraints:
112
+ for constraint in query.constraints:
113
+ if "no-network" in constraint.lower() and "network" in unit.description.lower():
114
+ score *= 0.5
115
+ warnings.append("可能需要网络访问,与约束冲突")
116
+
117
+ results.append(MatchResult(
118
+ unit=unit,
119
+ package=package,
120
+ score=score,
121
+ reasons=reasons,
122
+ warnings=warnings if warnings else None
123
+ ))
124
+
125
+ results.sort(key=lambda x: x.score, reverse=True)
126
+ return results
127
+
128
+ def _analyze_rejection(self, candidates: List[str], query: SearchQuery) -> Dict:
129
+ """分析拒绝原因"""
130
+ if not candidates:
131
+ return {
132
+ "reason": "未找到匹配的能力标签或关键词",
133
+ "category": "no_candidates_by_tag"
134
+ }
135
+
136
+ if query.ide:
137
+ ide_supported = False
138
+ for uid in candidates:
139
+ unit = self.registry.get_unit(uid)
140
+ if unit and query.ide.lower() in [ide.lower() for ide in unit.ide_support]:
141
+ ide_supported = True
142
+ break
143
+
144
+ if not ide_supported:
145
+ return {
146
+ "reason": f"找到候选能力,但均不支持 {query.ide}",
147
+ "category": "candidates_but_no_ide_support"
148
+ }
149
+
150
+ if query.env or query.constraints:
151
+ return {
152
+ "reason": "找到候选能力,但均不满足环境或约束条件",
153
+ "category": "candidates_but_incompatible_env"
154
+ }
155
+
156
+ return {
157
+ "reason": "候选能力置信度不足(< 60%),建议补充更多需求信息",
158
+ "category": "insufficient_info"
159
+ }
@@ -0,0 +1,117 @@
1
+ """skill-finder 核心数据结构定义"""
2
+
3
+ from typing import Literal, Optional, List, Dict
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class InstallerSpec(BaseModel):
8
+ """安装器规范"""
9
+ method: Literal["git", "pip", "npm", "release_asset"]
10
+ auto_install_cmd: str = Field(..., description="自动安装执行的命令(可多行)")
11
+ manual_install_cmd: str = Field(..., description="手动安装给用户的命令(可多行)")
12
+ uninstall_cmd: Optional[str] = Field(None, description="卸载命令(可选)")
13
+ notes: Optional[str] = Field(None, description="安装注意事项")
14
+
15
+
16
+ class SourceSpec(BaseModel):
17
+ """源信息"""
18
+ type: Literal["git", "pip", "npm", "release_asset"]
19
+ repo: Optional[str] = None
20
+ url: Optional[str] = None
21
+ package: Optional[str] = None
22
+ ref: Optional[str] = None
23
+ tag: Optional[str] = None
24
+ commit: Optional[str] = None
25
+ version: Optional[str] = None
26
+
27
+
28
+ class DocsSpec(BaseModel):
29
+ """文档链接"""
30
+ readme: str = Field(..., description="README 链接(必须)")
31
+ homepage: Optional[str] = None
32
+ issues: Optional[str] = None
33
+
34
+
35
+ class SkillPackage(BaseModel):
36
+ """Package 定义(安装载体)"""
37
+ package_id: str = Field(..., description="唯一标识:gh:owner/repo | pip:pkg | npm:pkg")
38
+ name: str
39
+ description: str
40
+ source: SourceSpec
41
+ docs: DocsSpec
42
+ supported_ides: List[str] = Field(..., description="包级 IDE 支持")
43
+ install: InstallerSpec
44
+ units: List[str] = Field(..., description="包含的 unit_id 列表")
45
+ trust_level: Optional[Literal["trusted", "experimental", "deprecated"]] = "trusted"
46
+ notes: Optional[str] = None
47
+
48
+
49
+ class EntrypointSpec(BaseModel):
50
+ """使用入口"""
51
+ command: str = Field(..., description="原生命令")
52
+ args: Optional[str] = Field(None, description="参数说明")
53
+ cwd: Optional[str] = Field(None, description="工作目录要求")
54
+ examples: Optional[List[str]] = Field(None, description="使用示例")
55
+
56
+
57
+ class SkillUnit(BaseModel):
58
+ """Unit 定义(能力单元,匹配维度)"""
59
+ unit_id: str = Field(..., description="唯一标识:gh:owner/repo#skill-a")
60
+ package_id: str = Field(..., description="所属 package")
61
+ name: str
62
+ description: str
63
+ capability_tags: List[str] = Field(..., description="能力标签")
64
+ keywords: List[str] = Field(..., description="关键词")
65
+ ide_support: List[str] = Field(..., description="IDE 支持列表")
66
+ entrypoints: List[EntrypointSpec] = Field(..., description="使用入口")
67
+ docs_override: Optional[DocsSpec] = None
68
+ usage_notes: Optional[str] = Field(None, description="10-20 行微摘要")
69
+ conflicts: Optional[str] = Field(None, description="已知冲突/注意事项")
70
+
71
+
72
+ class SearchQuery(BaseModel):
73
+ """搜索查询"""
74
+ goal: str
75
+ input_type: Optional[str] = None
76
+ output_type: Optional[str] = None
77
+ env: Optional[str] = None
78
+ ide: Optional[str] = None
79
+ constraints: Optional[List[str]] = None
80
+ tags: Optional[List[str]] = None
81
+ keywords: Optional[List[str]] = None
82
+
83
+
84
+ class MatchResult(BaseModel):
85
+ """匹配结果"""
86
+ unit: SkillUnit
87
+ package: SkillPackage
88
+ score: float
89
+ reasons: List[str] = Field(..., description="匹配原因")
90
+ warnings: Optional[List[str]] = None
91
+
92
+
93
+ class SearchResult(BaseModel):
94
+ """搜索结果"""
95
+ query: SearchQuery
96
+ matches: List[MatchResult]
97
+ rejection_reason: Optional[str] = None
98
+ rejection_category: Optional[Literal[
99
+ "no_candidates_by_tag",
100
+ "candidates_but_no_ide_support",
101
+ "candidates_but_incompatible_env",
102
+ "insufficient_info"
103
+ ]] = None
104
+
105
+
106
+ class InstallRecord(BaseModel):
107
+ """安装记录"""
108
+ timestamp: str
109
+ unit_id: str
110
+ package_id: str
111
+ install_mode: Literal["auto", "manual"]
112
+ executed: bool
113
+ method: str
114
+ commands: str
115
+ result: Literal["success", "failed", "skipped"]
116
+ error_summary: Optional[str] = None
117
+ docs_links: DocsSpec
@@ -0,0 +1,51 @@
1
+ """被动推荐接口 - 供 skill-creator 调用"""
2
+
3
+ from typing import Optional
4
+ from .models import SearchQuery, SearchResult
5
+ from .registry import Registry
6
+ from .matcher import Matcher
7
+
8
+
9
+ class Recommender:
10
+ """推荐器 - 被动推荐接口"""
11
+
12
+ def __init__(self, registry: Optional[Registry] = None, min_score: float = 0.7):
13
+ """
14
+ 初始化推荐器
15
+
16
+ Args:
17
+ registry: Registry 实例(可选)
18
+ min_score: 最小置信度(推荐模式使用更高阈值 0.7)
19
+ """
20
+ if registry is None:
21
+ registry = Registry()
22
+
23
+ self.registry = registry
24
+ self.matcher = Matcher(registry, min_score=min_score)
25
+
26
+ def recommend(self, query: SearchQuery) -> SearchResult:
27
+ """
28
+ 推荐第三方 unit(高置信度)
29
+
30
+ Args:
31
+ query: 搜索查询
32
+
33
+ Returns:
34
+ SearchResult: 仅返回高置信度结果(>= 0.7),否则返回空
35
+ """
36
+ result = self.matcher.match(query)
37
+
38
+ if result.matches and result.matches[0].score >= 0.7:
39
+ return SearchResult(
40
+ query=query,
41
+ matches=result.matches[:1],
42
+ rejection_reason=None,
43
+ rejection_category=None
44
+ )
45
+
46
+ return SearchResult(
47
+ query=query,
48
+ matches=[],
49
+ rejection_reason="未找到高置信度推荐(< 70%)",
50
+ rejection_category="insufficient_info"
51
+ )
@@ -0,0 +1,108 @@
1
+ """Registry 加载与索引查询"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional
6
+ from .models import SkillPackage, SkillUnit
7
+
8
+
9
+ class Registry:
10
+ """Registry 管理器 - 加载 packages/units 和索引"""
11
+
12
+ def __init__(self, registry_root: Optional[Path] = None):
13
+ if registry_root is None:
14
+ registry_root = Path(__file__).parent.parent.parent / "data" / "third_party"
15
+
16
+ self.registry_root = Path(registry_root)
17
+ self.packages_dir = self.registry_root / "packages"
18
+ self.units_dir = self.registry_root / "units"
19
+ self.indexes_dir = self.registry_root / "indexes"
20
+
21
+ self._packages_cache: Dict[str, SkillPackage] = {}
22
+ self._units_cache: Dict[str, SkillUnit] = {}
23
+ self.indexes: Dict[str, dict] = {}
24
+
25
+ self._load_indexes()
26
+
27
+ def _load_indexes(self):
28
+ """加载所有索引文件"""
29
+ if not self.indexes_dir.exists():
30
+ raise FileNotFoundError(f"索引目录不存在: {self.indexes_dir}")
31
+
32
+ index_files = {
33
+ "by_tag": "units.by_tag.json",
34
+ "by_keyword": "units.by_keyword.json",
35
+ "by_ide": "units.by_ide.json",
36
+ "packages_all": "packages.all.json",
37
+ "units_all": "units.all.json",
38
+ }
39
+
40
+ for key, filename in index_files.items():
41
+ index_path = self.indexes_dir / filename
42
+ if index_path.exists():
43
+ with open(index_path, 'r', encoding='utf-8') as f:
44
+ self.indexes[key] = json.load(f)
45
+ else:
46
+ self.indexes[key] = {} if key != "packages_all" and key != "units_all" else []
47
+
48
+ def get_package(self, package_id: str) -> Optional[SkillPackage]:
49
+ """获取 package(带缓存)"""
50
+ if package_id in self._packages_cache:
51
+ return self._packages_cache[package_id]
52
+
53
+ sanitized_id = package_id.replace(":", "_").replace("/", "_")
54
+ package_path = self.packages_dir / f"{sanitized_id}.json"
55
+
56
+ if not package_path.exists():
57
+ return None
58
+
59
+ try:
60
+ with open(package_path, 'r', encoding='utf-8') as f:
61
+ data = json.load(f)
62
+ package = SkillPackage(**data)
63
+ self._packages_cache[package_id] = package
64
+ return package
65
+ except Exception as e:
66
+ print(f"加载 package {package_id} 失败: {e}")
67
+ return None
68
+
69
+ def get_unit(self, unit_id: str) -> Optional[SkillUnit]:
70
+ """获取 unit(带缓存)"""
71
+ if unit_id in self._units_cache:
72
+ return self._units_cache[unit_id]
73
+
74
+ sanitized_id = unit_id.replace(":", "_").replace("/", "_")
75
+ unit_path = self.units_dir / f"{sanitized_id}.json"
76
+
77
+ if not unit_path.exists():
78
+ return None
79
+
80
+ try:
81
+ with open(unit_path, 'r', encoding='utf-8') as f:
82
+ data = json.load(f)
83
+ unit = SkillUnit(**data)
84
+ self._units_cache[unit_id] = unit
85
+ return unit
86
+ except Exception as e:
87
+ print(f"加载 unit {unit_id} 失败: {e}")
88
+ return None
89
+
90
+ def search_by_tag(self, tag: str) -> List[str]:
91
+ """通过 tag 搜索 unit_id 列表"""
92
+ return self.indexes.get("by_tag", {}).get(tag.lower(), [])
93
+
94
+ def search_by_keyword(self, keyword: str) -> List[str]:
95
+ """通过 keyword 搜索 unit_id 列表"""
96
+ return self.indexes.get("by_keyword", {}).get(keyword.lower(), [])
97
+
98
+ def search_by_ide(self, ide: str) -> List[str]:
99
+ """通过 IDE 搜索 unit_id 列表"""
100
+ return self.indexes.get("by_ide", {}).get(ide.lower(), [])
101
+
102
+ def get_all_packages(self) -> List[str]:
103
+ """获取所有 package_id"""
104
+ return self.indexes.get("packages_all", [])
105
+
106
+ def get_all_units(self) -> List[str]:
107
+ """获取所有 unit_id"""
108
+ return self.indexes.get("units_all", [])