@ganju/utils 0.0.12 → 0.0.13

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.
@@ -15,7 +15,7 @@ export type ToolLintRule = (typeof TOOL_LINT_RULES)[number];
15
15
  export interface ToolLintFinding {
16
16
  rule: ToolLintRule;
17
17
  tool?: string;
18
- other?: string;
18
+ alike?: string[];
19
19
  paths?: string[];
20
20
  count?: number;
21
21
  limit?: number;
@@ -1 +1 @@
1
- {"version":3,"file":"toolLint.d.ts","sourceRoot":"","sources":["../src/toolLint.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE;QACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,eAAe,+JAQlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IAGnB,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAI9B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IAGxB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAKnD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAuHtC;;;GAGG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,YAAY,EAAE,EACrB,UAAS,eAAoB,KAC5B,eAAe,EA4EjB,CAAC"}
1
+ {"version":3,"file":"toolLint.d.ts","sourceRoot":"","sources":["../src/toolLint.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE;QACZ,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,OAAO,CAAC;QAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;QACzB,aAAa,CAAC,EAAE,OAAO,CAAC;KACzB,CAAC;CACH;AAED,eAAO,MAAM,eAAe,+JAQlB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAE5D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,YAAY,CAAC;IAGnB,IAAI,CAAC,EAAE,MAAM,CAAC;IAGd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IAEjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IAGf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAI9B,MAAM,CAAC,EAAE,YAAY,EAAE,CAAC;IAGxB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,eAAO,MAAM,gCAAgC,KAAK,CAAC;AAKnD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AA2ItC;;;GAGG;AACH,eAAO,MAAM,SAAS,GACpB,OAAO,YAAY,EAAE,EACrB,UAAS,eAAoB,KAC5B,eAAe,EA8FjB,CAAC"}
package/dist/toolLint.js CHANGED
@@ -29,13 +29,18 @@ exports.TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
29
29
  // start choosing among them badly. A channel turn hard-caps its list at 40
30
30
  // (CHANNEL_MAX_TOOLS); this warns well before that.
31
31
  exports.TOOL_LINT_MAX_TOOLS = 25;
32
- // Two descriptions sharing at least this much of their vocabulary (Jaccard,
33
- // over content words) read as the same tool to a model.
32
+ // Two descriptions sharing at least this much of their vocabulary read as the
33
+ // same tool to a model. Words are weighted by how rare they are among the
34
+ // server's tools (see descriptionWeights), so tools written from one template
35
+ // — "Use when someone asks about X" — are told apart by their X.
34
36
  const DESCRIPTION_OVERLAP = 0.6;
35
37
  // Too few content words and two unrelated descriptions can share most of them.
36
38
  const MIN_OVERLAP_WORDS = 5;
37
39
  // Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
38
- const fold = (text) => text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
40
+ const fold = (text) => text
41
+ .toLowerCase()
42
+ .normalize('NFKD')
43
+ .replace(/[\u0300-\u036f]/g, '');
39
44
  // Wording that tells a model WHEN to call a tool rather than only what it does
40
45
  // — English, Spanish and Portuguese, the languages descriptions here are
41
46
  // written in. Loose on purpose: a false "says when" costs nothing, a false
@@ -90,23 +95,32 @@ const nameTokens = (name) => name
90
95
  .filter(Boolean)
91
96
  .sort()
92
97
  .join(' ');
93
- const jaccard = (a, b) => {
98
+ // How much each word says about which tool a description belongs to: a word
99
+ // in every description (the template, the domain) next to nothing, a word in
100
+ // one of them a lot. Smoothed, so with two tools a shared word still counts.
101
+ const descriptionWeights = (descriptions) => {
102
+ const frequency = new Map();
103
+ for (const words of descriptions) {
104
+ for (const word of words) {
105
+ frequency.set(word, (frequency.get(word) ?? 0) + 1);
106
+ }
107
+ }
108
+ const total = descriptions.length;
109
+ return word => Math.log(1 + total / (frequency.get(word) ?? 1));
110
+ };
111
+ // Jaccard, with each word counted by its weight.
112
+ const weightedJaccard = (a, b, weight) => {
94
113
  let shared = 0;
95
- for (const word of a)
114
+ let union = 0;
115
+ for (const word of a) {
116
+ union += weight(word);
96
117
  if (b.has(word))
97
- shared++;
98
- const union = a.size + b.size - shared;
99
- return union === 0 ? 0 : shared / union;
100
- };
101
- const overlaps = (a, b) => {
102
- if (nameTokens(a.name) === nameTokens(b.name))
103
- return true;
104
- const aWords = contentWords(a.description ?? '');
105
- const bWords = contentWords(b.description ?? '');
106
- if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
107
- return false;
118
+ shared += weight(word);
108
119
  }
109
- return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
120
+ for (const word of b)
121
+ if (!a.has(word))
122
+ union += weight(word);
123
+ return union === 0 ? 0 : shared / union;
110
124
  };
111
125
  const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
112
126
  // Every property the model may fill in that doesn't say what it is for, down
@@ -140,6 +154,19 @@ const quoted = (name) => `"${name}"`;
140
154
  const lintTools = (tools, options = {}) => {
141
155
  const others = options.others ?? [];
142
156
  const findings = [];
157
+ const everyTool = [...tools, ...others];
158
+ const wordsOf = new Map(everyTool.map(tool => [tool, contentWords(tool.description ?? '')]));
159
+ const weight = descriptionWeights([...wordsOf.values()]);
160
+ const overlaps = (a, b) => {
161
+ if (nameTokens(a.name) === nameTokens(b.name))
162
+ return true;
163
+ const aWords = wordsOf.get(a);
164
+ const bWords = wordsOf.get(b);
165
+ if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
166
+ return false;
167
+ }
168
+ return weightedJaccard(aWords, bWords, weight) >= DESCRIPTION_OVERLAP;
169
+ };
143
170
  tools.forEach((tool, index) => {
144
171
  const description = (tool.description ?? '').trim();
145
172
  if (!description) {
@@ -184,16 +211,18 @@ const lintTools = (tools, options = {}) => {
184
211
  message: `${quoted(tool.name)} has input properties with no description: ${paths.join(', ')}. A model has to guess what to pass.`
185
212
  });
186
213
  }
187
- // Each pair once: against the tools after this one, then the rest of the
188
- // server.
189
- for (const other of [...tools.slice(index + 1), ...others]) {
190
- if (other.name === tool.name || !overlaps(tool, other))
191
- continue;
214
+ // Each pair once — against the tools after this one, then the rest of the
215
+ // server — and one finding per tool, so twenty look-alikes are nineteen
216
+ // lines rather than a hundred and ninety.
217
+ const alike = [...tools.slice(index + 1), ...others]
218
+ .filter(other => other.name !== tool.name && overlaps(tool, other))
219
+ .map(other => other.name);
220
+ if (alike.length > 0) {
192
221
  findings.push({
193
222
  rule: 'overlapping-tools',
194
223
  tool: tool.name,
195
- other: other.name,
196
- message: `${quoted(tool.name)} and ${quoted(other.name)} look alike in name or description, so a model may call one for the other. Make each description say what sets it apart.`
224
+ alike,
225
+ message: `${quoted(tool.name)} looks like ${alike.map(quoted).join(', ')} in name or description, so a model may call one for another. Make each description say what sets it apart.`
197
226
  });
198
227
  }
199
228
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
package/src/toolLint.ts CHANGED
@@ -43,8 +43,9 @@ export interface ToolLintFinding {
43
43
  // The tool the finding is about. Absent only for `too-many-tools`, which is
44
44
  // about the server as a whole.
45
45
  tool?: string;
46
- // `overlapping-tools`: the tool it is confused with.
47
- other?: string;
46
+ // `overlapping-tools`: the tools it may be confused with. Each look-alike
47
+ // pair is reported once, on the first of the two.
48
+ alike?: string[];
48
49
  // `undescribed-input`: the properties with no description, as dotted paths.
49
50
  paths?: string[];
50
51
  // `too-many-tools`: how many are enabled, and the number past which it warns.
@@ -74,15 +75,20 @@ export const TOOL_LINT_MIN_DESCRIPTION_LENGTH = 40;
74
75
  // (CHANNEL_MAX_TOOLS); this warns well before that.
75
76
  export const TOOL_LINT_MAX_TOOLS = 25;
76
77
 
77
- // Two descriptions sharing at least this much of their vocabulary (Jaccard,
78
- // over content words) read as the same tool to a model.
78
+ // Two descriptions sharing at least this much of their vocabulary read as the
79
+ // same tool to a model. Words are weighted by how rare they are among the
80
+ // server's tools (see descriptionWeights), so tools written from one template
81
+ // — "Use when someone asks about X" — are told apart by their X.
79
82
  const DESCRIPTION_OVERLAP = 0.6;
80
83
  // Too few content words and two unrelated descriptions can share most of them.
81
84
  const MIN_OVERLAP_WORDS = 5;
82
85
 
83
86
  // Lowercased, accents off, so "Úsala cuando" and "usala cuando" are one text.
84
87
  const fold = (text: string): string =>
85
- text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '');
88
+ text
89
+ .toLowerCase()
90
+ .normalize('NFKD')
91
+ .replace(/[\u0300-\u036f]/g, '');
86
92
 
87
93
  // Wording that tells a model WHEN to call a tool rather than only what it does
88
94
  // — English, Spanish and Portuguese, the languages descriptions here are
@@ -147,21 +153,36 @@ const nameTokens = (name: string): string =>
147
153
  .sort()
148
154
  .join(' ');
149
155
 
150
- const jaccard = (a: Set<string>, b: Set<string>): number => {
151
- let shared = 0;
152
- for (const word of a) if (b.has(word)) shared++;
153
- const union = a.size + b.size - shared;
154
- return union === 0 ? 0 : shared / union;
156
+ // How much each word says about which tool a description belongs to: a word
157
+ // in every description (the template, the domain) next to nothing, a word in
158
+ // one of them a lot. Smoothed, so with two tools a shared word still counts.
159
+ const descriptionWeights = (
160
+ descriptions: Set<string>[]
161
+ ): ((word: string) => number) => {
162
+ const frequency = new Map<string, number>();
163
+ for (const words of descriptions) {
164
+ for (const word of words) {
165
+ frequency.set(word, (frequency.get(word) ?? 0) + 1);
166
+ }
167
+ }
168
+ const total = descriptions.length;
169
+ return word => Math.log(1 + total / (frequency.get(word) ?? 1));
155
170
  };
156
171
 
157
- const overlaps = (a: LintableTool, b: LintableTool): boolean => {
158
- if (nameTokens(a.name) === nameTokens(b.name)) return true;
159
- const aWords = contentWords(a.description ?? '');
160
- const bWords = contentWords(b.description ?? '');
161
- if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
162
- return false;
172
+ // Jaccard, with each word counted by its weight.
173
+ const weightedJaccard = (
174
+ a: Set<string>,
175
+ b: Set<string>,
176
+ weight: (word: string) => number
177
+ ): number => {
178
+ let shared = 0;
179
+ let union = 0;
180
+ for (const word of a) {
181
+ union += weight(word);
182
+ if (b.has(word)) shared += weight(word);
163
183
  }
164
- return jaccard(aWords, bWords) >= DESCRIPTION_OVERLAP;
184
+ for (const word of b) if (!a.has(word)) union += weight(word);
185
+ return union === 0 ? 0 : shared / union;
165
186
  };
166
187
 
167
188
  const isObject = (value: unknown): value is Record<string, unknown> =>
@@ -202,6 +223,21 @@ export const lintTools = (
202
223
  const others = options.others ?? [];
203
224
  const findings: ToolLintFinding[] = [];
204
225
 
226
+ const everyTool = [...tools, ...others];
227
+ const wordsOf = new Map(
228
+ everyTool.map(tool => [tool, contentWords(tool.description ?? '')])
229
+ );
230
+ const weight = descriptionWeights([...wordsOf.values()]);
231
+ const overlaps = (a: LintableTool, b: LintableTool): boolean => {
232
+ if (nameTokens(a.name) === nameTokens(b.name)) return true;
233
+ const aWords = wordsOf.get(a)!;
234
+ const bWords = wordsOf.get(b)!;
235
+ if (aWords.size < MIN_OVERLAP_WORDS || bWords.size < MIN_OVERLAP_WORDS) {
236
+ return false;
237
+ }
238
+ return weightedJaccard(aWords, bWords, weight) >= DESCRIPTION_OVERLAP;
239
+ };
240
+
205
241
  tools.forEach((tool, index) => {
206
242
  const description = (tool.description ?? '').trim();
207
243
 
@@ -250,15 +286,18 @@ export const lintTools = (
250
286
  });
251
287
  }
252
288
 
253
- // Each pair once: against the tools after this one, then the rest of the
254
- // server.
255
- for (const other of [...tools.slice(index + 1), ...others]) {
256
- if (other.name === tool.name || !overlaps(tool, other)) continue;
289
+ // Each pair once — against the tools after this one, then the rest of the
290
+ // server — and one finding per tool, so twenty look-alikes are nineteen
291
+ // lines rather than a hundred and ninety.
292
+ const alike = [...tools.slice(index + 1), ...others]
293
+ .filter(other => other.name !== tool.name && overlaps(tool, other))
294
+ .map(other => other.name);
295
+ if (alike.length > 0) {
257
296
  findings.push({
258
297
  rule: 'overlapping-tools',
259
298
  tool: tool.name,
260
- other: other.name,
261
- message: `${quoted(tool.name)} and ${quoted(other.name)} look alike in name or description, so a model may call one for the other. Make each description say what sets it apart.`
299
+ alike,
300
+ message: `${quoted(tool.name)} looks like ${alike.map(quoted).join(', ')} in name or description, so a model may call one for another. Make each description say what sets it apart.`
262
301
  });
263
302
  }
264
303
  });