@stigmer/runner 3.40.0 → 3.40.1
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/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +70 -35
- package/dist/activities/execute-deep-agent/prompt-builder.js +70 -55
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-setup.d.ts +18 -13
- package/dist/activities/execute-deep-agent/turn-setup.js +35 -29
- package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
- package/dist/shared/prompt-sections.d.ts +6 -28
- package/dist/shared/prompt-sections.js +6 -35
- package/dist/shared/prompt-sections.js.map +1 -1
- package/dist/shared/skill-resolver.d.ts +2 -3
- package/dist/shared/skill-resolver.js +2 -3
- package/dist/shared/skill-resolver.js.map +1 -1
- package/dist/shared/workspace/sources/git.js +13 -6
- package/dist/shared/workspace/sources/git.js.map +1 -1
- package/package.json +5 -5
- package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.build-from-plan.prompt.md +1 -27
- package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.everything.prompt.md +26 -30
- package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.plan-mode.prompt.md +1 -26
- package/src/activities/execute-deep-agent/__tests__/goldens/user-message.everything.md +36 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +127 -199
- package/src/activities/execute-deep-agent/__tests__/prompt-goldens.test.ts +31 -26
- package/src/activities/execute-deep-agent/prompt-builder.ts +104 -92
- package/src/activities/execute-deep-agent/turn-setup.ts +36 -31
- package/src/shared/__tests__/prompt-sections.test.ts +2 -54
- package/src/shared/prompt-sections.ts +6 -55
- package/src/shared/skill-resolver.ts +2 -3
- package/src/shared/workspace/__tests__/git-source.test.ts +11 -5
- package/src/shared/workspace/sources/git.ts +14 -8
- package/dist/shared/skill-relevance.d.ts +0 -65
- package/dist/shared/skill-relevance.js +0 -175
- package/dist/shared/skill-relevance.js.map +0 -1
- package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.skills-below-threshold.prompt.md +0 -146
- package/src/shared/__tests__/skill-relevance.test.ts +0 -292
- package/src/shared/skill-relevance.ts +0 -222
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
tokenize,
|
|
4
|
-
scoreSkills,
|
|
5
|
-
filterSkills,
|
|
6
|
-
SKILL_COUNT_THRESHOLD,
|
|
7
|
-
type ScoredSkill,
|
|
8
|
-
type SkillFilterResult,
|
|
9
|
-
} from "../skill-relevance.js";
|
|
10
|
-
|
|
11
|
-
// ─── Tokenisation ────────────────────────────────────────────────────────
|
|
12
|
-
|
|
13
|
-
describe("tokenize", () => {
|
|
14
|
-
it("extracts meaningful tokens from a sentence", () => {
|
|
15
|
-
const tokens = tokenize("Deploy Kubernetes pods to the cluster");
|
|
16
|
-
expect(tokens).toContain("deploy");
|
|
17
|
-
expect(tokens).toContain("kubernetes");
|
|
18
|
-
expect(tokens).toContain("pods");
|
|
19
|
-
expect(tokens).toContain("cluster");
|
|
20
|
-
expect(tokens).not.toContain("the");
|
|
21
|
-
expect(tokens).not.toContain("to");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("strips punctuation and splits on non-alphanumeric", () => {
|
|
25
|
-
const tokens = tokenize("hello-world! (v2.0)");
|
|
26
|
-
expect(tokens).toContain("hello");
|
|
27
|
-
expect(tokens).toContain("world");
|
|
28
|
-
expect(tokens).toContain("v2");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("drops single-character tokens", () => {
|
|
32
|
-
const tokens = tokenize("a b c deploy x");
|
|
33
|
-
expect(tokens).toContain("deploy");
|
|
34
|
-
expect(tokens).not.toContain("a");
|
|
35
|
-
expect(tokens).not.toContain("b");
|
|
36
|
-
expect(tokens).not.toContain("x");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("returns empty for empty string", () => {
|
|
40
|
-
expect(tokenize("")).toEqual([]);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("returns empty for stop-words-only input", () => {
|
|
44
|
-
expect(tokenize("the a is to in for")).toEqual([]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("preserves numbers within tokens", () => {
|
|
48
|
-
const tokens = tokenize("python3 version 42");
|
|
49
|
-
expect(tokens).toContain("python3");
|
|
50
|
-
expect(tokens).toContain("version");
|
|
51
|
-
expect(tokens).toContain("42");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("splits kebab-case into individual tokens", () => {
|
|
55
|
-
const tokens = tokenize("code-review-best-practices");
|
|
56
|
-
expect(tokens).toContain("code");
|
|
57
|
-
expect(tokens).toContain("review");
|
|
58
|
-
expect(tokens).toContain("best");
|
|
59
|
-
expect(tokens).toContain("practices");
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// ─── BM25 scoring ────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
describe("scoreSkills", () => {
|
|
66
|
-
it("scores exact match highest", () => {
|
|
67
|
-
const scored = scoreSkills(
|
|
68
|
-
"kubernetes deployment",
|
|
69
|
-
["kubernetes-operator", "code-review", "docker-expert"],
|
|
70
|
-
[
|
|
71
|
-
"Manages Kubernetes deployments and services",
|
|
72
|
-
"Reviews code changes for quality",
|
|
73
|
-
"Docker container management and Dockerfile optimization",
|
|
74
|
-
],
|
|
75
|
-
);
|
|
76
|
-
expect(scored[0].name).toBe("kubernetes-operator");
|
|
77
|
-
expect(scored[0].score).toBeGreaterThan(0);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("gives zero score when no term overlap", () => {
|
|
81
|
-
const scored = scoreSkills(
|
|
82
|
-
"kubernetes deployment",
|
|
83
|
-
["code-review"],
|
|
84
|
-
["Reviews code changes for quality"],
|
|
85
|
-
);
|
|
86
|
-
expect(scored[0].score).toBe(0.0);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("returns all zero scores for empty query", () => {
|
|
90
|
-
const scored = scoreSkills(
|
|
91
|
-
"",
|
|
92
|
-
["skill-a", "skill-b"],
|
|
93
|
-
["description a", "description b"],
|
|
94
|
-
);
|
|
95
|
-
expect(scored.every(s => s.score === 0.0)).toBe(true);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("returns empty for empty skills list", () => {
|
|
99
|
-
const scored = scoreSkills("some query", [], []);
|
|
100
|
-
expect(scored).toEqual([]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("returns all skills", () => {
|
|
104
|
-
const scored = scoreSkills(
|
|
105
|
-
"deploy",
|
|
106
|
-
["a", "b", "c"],
|
|
107
|
-
["deploy", "review", "test"],
|
|
108
|
-
);
|
|
109
|
-
expect(scored).toHaveLength(3);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("returns results in descending score order", () => {
|
|
113
|
-
const scored = scoreSkills(
|
|
114
|
-
"kubernetes cluster deployment management",
|
|
115
|
-
["kubernetes-operator", "generic-helper", "k8s-deploy"],
|
|
116
|
-
[
|
|
117
|
-
"Manages Kubernetes clusters and deployments",
|
|
118
|
-
"Generic task helper with no specialization",
|
|
119
|
-
"Kubernetes deployment automation",
|
|
120
|
-
],
|
|
121
|
-
);
|
|
122
|
-
const scores = scored.map(s => s.score);
|
|
123
|
-
expect(scores).toEqual([...scores].sort((a, b) => b - a));
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("ranks partial-match skill higher than no-match", () => {
|
|
127
|
-
const scored = scoreSkills(
|
|
128
|
-
"review code quality",
|
|
129
|
-
["code-review", "deploy-agent"],
|
|
130
|
-
[
|
|
131
|
-
"Reviews code for quality and security issues",
|
|
132
|
-
"Deploys applications to cloud infrastructure",
|
|
133
|
-
],
|
|
134
|
-
);
|
|
135
|
-
expect(scored[0].name).toBe("code-review");
|
|
136
|
-
expect(scored[0].score).toBeGreaterThan(scored[1].score);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("preserves original index", () => {
|
|
140
|
-
const scored = scoreSkills(
|
|
141
|
-
"review",
|
|
142
|
-
["deploy", "review", "test"],
|
|
143
|
-
["deploy", "review", "test"],
|
|
144
|
-
);
|
|
145
|
-
const reviewEntry = scored.find(s => s.name === "review");
|
|
146
|
-
expect(reviewEntry).toBeDefined();
|
|
147
|
-
expect(reviewEntry!.index).toBe(1);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("ignores stop words in query", () => {
|
|
151
|
-
const scoredWithStops = scoreSkills(
|
|
152
|
-
"the kubernetes is a deployment",
|
|
153
|
-
["k8s"],
|
|
154
|
-
["kubernetes deployment manager"],
|
|
155
|
-
);
|
|
156
|
-
const scoredWithoutStops = scoreSkills(
|
|
157
|
-
"kubernetes deployment",
|
|
158
|
-
["k8s"],
|
|
159
|
-
["kubernetes deployment manager"],
|
|
160
|
-
);
|
|
161
|
-
expect(scoredWithStops[0].score).toBe(scoredWithoutStops[0].score);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ─── Filtering ───────────────────────────────────────────────────────────
|
|
166
|
-
|
|
167
|
-
describe("filterSkills", () => {
|
|
168
|
-
function makeNamesAndDescs(n: number): { names: string[]; descs: string[] } {
|
|
169
|
-
const names = Array.from({ length: n }, (_, i) => `skill-${i}`);
|
|
170
|
-
const descs = Array.from({ length: n }, (_, i) => `Description for skill number ${i}`);
|
|
171
|
-
return { names, descs };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
it("includes all when below threshold", () => {
|
|
175
|
-
const { names, descs } = makeNamesAndDescs(5);
|
|
176
|
-
const result = filterSkills("any query", names, descs, { threshold: 8 });
|
|
177
|
-
expect(result.includedIndices).toEqual([0, 1, 2, 3, 4]);
|
|
178
|
-
expect(result.excludedIndices).toEqual([]);
|
|
179
|
-
expect(result.excludedNames).toEqual([]);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("includes all at threshold boundary (count < threshold)", () => {
|
|
183
|
-
const { names, descs } = makeNamesAndDescs(7);
|
|
184
|
-
const result = filterSkills("any query", names, descs, { threshold: 8 });
|
|
185
|
-
expect(result.includedIndices).toHaveLength(7);
|
|
186
|
-
expect(result.excludedIndices).toEqual([]);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("filters irrelevant skills above threshold", () => {
|
|
190
|
-
const names = [
|
|
191
|
-
"kubernetes-operator", "docker-expert", "code-review",
|
|
192
|
-
"terraform-iac", "postgres-admin", "redis-cache",
|
|
193
|
-
"monitoring-agent", "security-scanner", "api-tester", "log-analyzer",
|
|
194
|
-
];
|
|
195
|
-
const descs = [
|
|
196
|
-
"Manages Kubernetes clusters and deployments",
|
|
197
|
-
"Docker container management",
|
|
198
|
-
"Reviews code changes",
|
|
199
|
-
"Terraform infrastructure as code",
|
|
200
|
-
"PostgreSQL database administration",
|
|
201
|
-
"Redis cache management",
|
|
202
|
-
"Monitoring and alerting setup",
|
|
203
|
-
"Security vulnerability scanning",
|
|
204
|
-
"API endpoint testing",
|
|
205
|
-
"Log analysis and aggregation",
|
|
206
|
-
];
|
|
207
|
-
const result = filterSkills("deploy kubernetes pods", names, descs, { threshold: 8 });
|
|
208
|
-
expect(result.includedIndices).toContain(0); // kubernetes-operator
|
|
209
|
-
expect(result.excludedNames.length).toBeGreaterThan(0);
|
|
210
|
-
expect(result.excludedNames).not.toContain("kubernetes-operator");
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it("enforces safety floor (at least half included)", () => {
|
|
214
|
-
const names = Array.from({ length: 10 }, (_, i) => `skill-${i}`);
|
|
215
|
-
const descs = Array.from({ length: 10 }, () => "completely unrelated");
|
|
216
|
-
const result = filterSkills("xyzzy nonsense query", names, descs, { threshold: 8 });
|
|
217
|
-
expect(result.includedIndices.length).toBeGreaterThanOrEqual(5);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("sorts excluded names alphabetically", () => {
|
|
221
|
-
const names = [
|
|
222
|
-
"zebra-skill", "alpha-skill", "middle-skill",
|
|
223
|
-
"beta-skill", "gamma-skill", "delta-skill",
|
|
224
|
-
"epsilon-skill", "zeta-skill", "eta-skill", "theta-skill",
|
|
225
|
-
];
|
|
226
|
-
const descs = Array.from({ length: 10 }, () => "unrelated");
|
|
227
|
-
const result = filterSkills("something specific to alpha", names, descs, { threshold: 8 });
|
|
228
|
-
expect(result.excludedNames).toEqual([...result.excludedNames].sort());
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("maintains original order for included indices", () => {
|
|
232
|
-
const names = Array.from({ length: 10 }, (_, i) => `skill-${i}`);
|
|
233
|
-
const descs = [
|
|
234
|
-
"kubernetes deployment", "code review", "kubernetes pods",
|
|
235
|
-
"docker containers", "unrelated thing", "another unrelated",
|
|
236
|
-
"still unrelated", "more unrelated stuff", "kubernetes services",
|
|
237
|
-
"very unrelated",
|
|
238
|
-
];
|
|
239
|
-
const result = filterSkills("kubernetes deployment", names, descs, { threshold: 8 });
|
|
240
|
-
expect(result.includedIndices).toEqual([...result.includedIndices].sort((a, b) => a - b));
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it("handles empty skills list", () => {
|
|
244
|
-
const result = filterSkills("query", [], []);
|
|
245
|
-
expect(result.includedIndices).toEqual([]);
|
|
246
|
-
expect(result.excludedIndices).toEqual([]);
|
|
247
|
-
expect(result.excludedNames).toEqual([]);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("keeps most skills when all are relevant", () => {
|
|
251
|
-
const names = [
|
|
252
|
-
"k8s-deploy", "k8s-monitor", "k8s-scale",
|
|
253
|
-
"k8s-network", "k8s-storage", "k8s-secrets",
|
|
254
|
-
"k8s-rbac", "k8s-helm", "k8s-ingress", "k8s-cronjob",
|
|
255
|
-
];
|
|
256
|
-
const descs = names.map(n => `Kubernetes ${n.split("-")[1]} management`);
|
|
257
|
-
const result = filterSkills("kubernetes cluster management", names, descs, { threshold: 8 });
|
|
258
|
-
expect(result.excludedNames.length).toBeLessThan(names.length);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("returns correct types", () => {
|
|
262
|
-
const { names, descs } = makeNamesAndDescs(10);
|
|
263
|
-
const result = filterSkills("query", names, descs, { threshold: 8 });
|
|
264
|
-
expect(Array.isArray(result.includedIndices)).toBe(true);
|
|
265
|
-
expect(Array.isArray(result.excludedIndices)).toBe(true);
|
|
266
|
-
expect(Array.isArray(result.excludedNames)).toBe(true);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it("produces a disjoint partition of all indices", () => {
|
|
270
|
-
const names = Array.from({ length: 12 }, (_, i) => `skill-${i}`);
|
|
271
|
-
const descs = [
|
|
272
|
-
"kubernetes deploy", "code review", "docker build",
|
|
273
|
-
"terraform plan", "postgres query", "redis cache",
|
|
274
|
-
"monitoring alert", "security scan", "api test",
|
|
275
|
-
"log analysis", "network debug", "storage manage",
|
|
276
|
-
];
|
|
277
|
-
const result = filterSkills("kubernetes deployment", names, descs, { threshold: 8 });
|
|
278
|
-
const allIndices = new Set([...result.includedIndices, ...result.excludedIndices]);
|
|
279
|
-
expect(allIndices.size).toBe(12);
|
|
280
|
-
expect(allIndices).toEqual(new Set(Array.from({ length: 12 }, (_, i) => i)));
|
|
281
|
-
// Verify disjoint
|
|
282
|
-
const intersection = result.includedIndices.filter(i => result.excludedIndices.includes(i));
|
|
283
|
-
expect(intersection).toEqual([]);
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it("uses default threshold constant", () => {
|
|
287
|
-
expect(SKILL_COUNT_THRESHOLD).toBe(8);
|
|
288
|
-
const { names, descs } = makeNamesAndDescs(7);
|
|
289
|
-
const result = filterSkills("any query", names, descs);
|
|
290
|
-
expect(result.includedIndices).toHaveLength(7);
|
|
291
|
-
});
|
|
292
|
-
});
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Skill relevance scoring for smart context filtering.
|
|
3
|
-
*
|
|
4
|
-
* Uses a BM25-inspired algorithm to score each skill's relevance
|
|
5
|
-
* against the user message. When an agent has many skills configured,
|
|
6
|
-
* low-relevance skills are excluded from the system prompt to improve
|
|
7
|
-
* signal quality.
|
|
8
|
-
*
|
|
9
|
-
* The progressive disclosure model means only skill metadata
|
|
10
|
-
* (~50-70 tokens per skill) lives in the prompt, so filtering
|
|
11
|
-
* primarily reduces noise rather than saving tokens.
|
|
12
|
-
*
|
|
13
|
-
* BM25 (Best Matching 25) is a lightweight, well-understood ranking
|
|
14
|
-
* function from information retrieval. It uses term frequency saturation
|
|
15
|
-
* and document length normalisation — both important when matching a
|
|
16
|
-
* short user message (query) against short skill metadata (documents of
|
|
17
|
-
* 5-20 tokens each).
|
|
18
|
-
*
|
|
19
|
-
* No external dependencies required.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** Relevance filtering activates only when the agent has at least this many skills. */
|
|
23
|
-
export const SKILL_COUNT_THRESHOLD = 8;
|
|
24
|
-
|
|
25
|
-
/** Term-frequency saturation. Higher values weight repeated terms more. */
|
|
26
|
-
const BM25_K1 = 1.5;
|
|
27
|
-
|
|
28
|
-
/** Length normalisation. 0 = none, 1 = full relative to average doc length. */
|
|
29
|
-
const BM25_B = 0.75;
|
|
30
|
-
|
|
31
|
-
const STOP_WORDS: ReadonlySet<string> = new Set([
|
|
32
|
-
"a", "an", "the", "is", "are", "was", "were", "be", "been",
|
|
33
|
-
"being", "have", "has", "had", "do", "does", "did", "will",
|
|
34
|
-
"would", "could", "should", "may", "might", "shall", "can",
|
|
35
|
-
"to", "of", "in", "for", "on", "with", "at", "by", "from",
|
|
36
|
-
"as", "into", "through", "during", "before", "after", "above",
|
|
37
|
-
"below", "between", "and", "but", "or", "nor", "not", "so",
|
|
38
|
-
"yet", "both", "either", "neither", "each", "every", "all",
|
|
39
|
-
"any", "few", "more", "most", "some", "such", "no", "only",
|
|
40
|
-
"own", "same", "than", "too", "very", "just", "because",
|
|
41
|
-
"about", "up", "out", "if", "then", "that", "this", "these",
|
|
42
|
-
"those", "it", "its", "i", "me", "my", "we", "our", "you",
|
|
43
|
-
"your", "he", "she", "they", "them", "their", "what", "which",
|
|
44
|
-
"who", "whom", "how", "when", "where", "why",
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
const TOKEN_PATTERN = /[a-z0-9]+/g;
|
|
48
|
-
|
|
49
|
-
// ─── Tokenisation ────────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Split text into lowercase tokens, dropping stop words and
|
|
53
|
-
* single-character fragments.
|
|
54
|
-
*/
|
|
55
|
-
export function tokenize(text: string): string[] {
|
|
56
|
-
const matches = text.toLowerCase().match(TOKEN_PATTERN);
|
|
57
|
-
if (!matches) return [];
|
|
58
|
-
return matches.filter(tok => !STOP_WORDS.has(tok) && tok.length > 1);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ─── Data structures ─────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
export interface ScoredSkill {
|
|
64
|
-
readonly index: number;
|
|
65
|
-
readonly name: string;
|
|
66
|
-
readonly score: number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface SkillFilterResult {
|
|
70
|
-
/** Indices of skills to include in the prompt (original order). */
|
|
71
|
-
readonly includedIndices: number[];
|
|
72
|
-
/** Indices of skills excluded from the prompt (original order). */
|
|
73
|
-
readonly excludedIndices: number[];
|
|
74
|
-
/** Names of excluded skills (sorted alphabetically). */
|
|
75
|
-
readonly excludedNames: string[];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ─── BM25 scoring ────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Score each skill's relevance to the user message using BM25.
|
|
82
|
-
*
|
|
83
|
-
* Each skill's name and description are concatenated into a document
|
|
84
|
-
* and scored against the query terms extracted from the user message.
|
|
85
|
-
*
|
|
86
|
-
* Returns ScoredSkill list ordered by descending score (ties broken alphabetically).
|
|
87
|
-
*/
|
|
88
|
-
export function scoreSkills(
|
|
89
|
-
userMessage: string,
|
|
90
|
-
skillNames: readonly string[],
|
|
91
|
-
skillDescriptions: readonly string[],
|
|
92
|
-
options?: { k1?: number; b?: number },
|
|
93
|
-
): ScoredSkill[] {
|
|
94
|
-
const k1 = options?.k1 ?? BM25_K1;
|
|
95
|
-
const b = options?.b ?? BM25_B;
|
|
96
|
-
|
|
97
|
-
const queryTerms = tokenize(userMessage);
|
|
98
|
-
const n = skillNames.length;
|
|
99
|
-
|
|
100
|
-
if (n === 0) return [];
|
|
101
|
-
|
|
102
|
-
if (queryTerms.length === 0) {
|
|
103
|
-
return skillNames.map((name, index) => ({ index, name, score: 0.0 }));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Build per-skill term-frequency maps.
|
|
107
|
-
const docs: Map<string, number>[] = [];
|
|
108
|
-
const docLengths: number[] = [];
|
|
109
|
-
|
|
110
|
-
for (let i = 0; i < n; i++) {
|
|
111
|
-
const tokens = tokenize(`${skillNames[i]} ${skillDescriptions[i]}`);
|
|
112
|
-
const tf = new Map<string, number>();
|
|
113
|
-
for (const tok of tokens) {
|
|
114
|
-
tf.set(tok, (tf.get(tok) ?? 0) + 1);
|
|
115
|
-
}
|
|
116
|
-
docs.push(tf);
|
|
117
|
-
docLengths.push(tokens.length);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const avgdl = docLengths.reduce((sum, l) => sum + l, 0) / n;
|
|
121
|
-
|
|
122
|
-
// IDF per unique query term.
|
|
123
|
-
const uniqueQueryTerms = new Set(queryTerms);
|
|
124
|
-
const idf = new Map<string, number>();
|
|
125
|
-
for (const term of uniqueQueryTerms) {
|
|
126
|
-
let df = 0;
|
|
127
|
-
for (const doc of docs) {
|
|
128
|
-
if (doc.has(term)) df++;
|
|
129
|
-
}
|
|
130
|
-
idf.set(term, Math.max(0.0, Math.log((n - df + 0.5) / (df + 0.5) + 1.0)));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Score each document.
|
|
134
|
-
const results: ScoredSkill[] = [];
|
|
135
|
-
for (let i = 0; i < n; i++) {
|
|
136
|
-
const docTf = docs[i];
|
|
137
|
-
const dl = docLengths[i];
|
|
138
|
-
let score = 0.0;
|
|
139
|
-
for (const term of queryTerms) {
|
|
140
|
-
const tfVal = docTf.get(term);
|
|
141
|
-
if (tfVal === undefined) continue;
|
|
142
|
-
const numerator = tfVal * (k1 + 1);
|
|
143
|
-
const denominator = tfVal + k1 * (1 - b + b * dl / avgdl);
|
|
144
|
-
score += (idf.get(term) ?? 0.0) * numerator / denominator;
|
|
145
|
-
}
|
|
146
|
-
results.push({ index: i, name: skillNames[i], score });
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
results.sort((a, b_) => {
|
|
150
|
-
if (b_.score !== a.score) return b_.score - a.score;
|
|
151
|
-
return a.name.localeCompare(b_.name);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
return results;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ─── Filtering ───────────────────────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Partition skills into included / excluded based on relevance.
|
|
161
|
-
*
|
|
162
|
-
* Below the threshold, every skill is included unconditionally.
|
|
163
|
-
* Above the threshold, skills whose BM25 score is zero (no query
|
|
164
|
-
* term overlap at all) are moved to the excluded set.
|
|
165
|
-
*
|
|
166
|
-
* A safety floor guarantees that at least half the skills are
|
|
167
|
-
* always included, regardless of scores.
|
|
168
|
-
*/
|
|
169
|
-
export function filterSkills(
|
|
170
|
-
userMessage: string,
|
|
171
|
-
skillNames: readonly string[],
|
|
172
|
-
skillDescriptions: readonly string[],
|
|
173
|
-
options?: { threshold?: number },
|
|
174
|
-
): SkillFilterResult {
|
|
175
|
-
const threshold = options?.threshold ?? SKILL_COUNT_THRESHOLD;
|
|
176
|
-
const n = skillNames.length;
|
|
177
|
-
const allIndices = Array.from({ length: n }, (_, i) => i);
|
|
178
|
-
|
|
179
|
-
if (n < threshold) {
|
|
180
|
-
return {
|
|
181
|
-
includedIndices: allIndices,
|
|
182
|
-
excludedIndices: [],
|
|
183
|
-
excludedNames: [],
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const scored = scoreSkills(userMessage, skillNames, skillDescriptions);
|
|
188
|
-
|
|
189
|
-
const included: number[] = [];
|
|
190
|
-
const excluded: number[] = [];
|
|
191
|
-
const excludedNames: string[] = [];
|
|
192
|
-
|
|
193
|
-
for (const s of scored) {
|
|
194
|
-
if (s.score > 0.0) {
|
|
195
|
-
included.push(s.index);
|
|
196
|
-
} else {
|
|
197
|
-
excluded.push(s.index);
|
|
198
|
-
excludedNames.push(s.name);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Safety: always keep at least half the skills.
|
|
203
|
-
const minIncluded = Math.max(1, Math.floor(n / 2));
|
|
204
|
-
if (included.length < minIncluded) {
|
|
205
|
-
const deficit = minIncluded - included.length;
|
|
206
|
-
const reInclude = excluded.splice(0, deficit);
|
|
207
|
-
included.push(...reInclude);
|
|
208
|
-
for (const idx of reInclude) {
|
|
209
|
-
const nameIdx = excludedNames.indexOf(skillNames[idx]);
|
|
210
|
-
if (nameIdx !== -1) excludedNames.splice(nameIdx, 1);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
included.sort((a, b) => a - b);
|
|
215
|
-
excluded.sort((a, b) => a - b);
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
includedIndices: included,
|
|
219
|
-
excludedIndices: excluded,
|
|
220
|
-
excludedNames: excludedNames.sort(),
|
|
221
|
-
};
|
|
222
|
-
}
|