fmea-api-mcp-server 1.1.13 → 1.1.15

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/index.js CHANGED
@@ -28,7 +28,7 @@ class ApiDocsServer {
28
28
  constructor() {
29
29
  this.server = new Server({
30
30
  name: "api-docs-mcp",
31
- version: "1.1.13",
31
+ version: "1.1.15",
32
32
  }, {
33
33
  capabilities: {
34
34
  resources: {},
package/dist/intents.js CHANGED
@@ -13,7 +13,7 @@ export const ACTION_KEYWORDS = {
13
13
  'MONITOR': ['monitor', 'track', 'audit', 'inspect', 'history', 'logs', 'usage', 'metrics', 'stats'],
14
14
  // CRUD
15
15
  'create': ['create', 'add', 'new', 'make', 'generate', 'register'],
16
- 'update': ['update', 'edit', 'change', 'modify', 'save', 'set', 'reset', 'password', 'refresh'],
16
+ 'update': ['update', 'edit', 'change', 'modify', 'save', 'set', 'reset', 'password', 'refresh', 'approve', 'reject'],
17
17
  'delete': ['delete', 'remove', 'drop', 'erase'],
18
18
  'list': ['list', 'get', 'show', 'fetch', 'all', 'view', 'files', 'status', 'check'],
19
19
  };
@@ -1,9 +1,11 @@
1
1
  import { create, load, search } from '@orama/orama';
2
2
  import { pipeline } from '@xenova/transformers';
3
3
  import fs from 'fs/promises';
4
+ import path from 'path';
4
5
  import { paginateResults, DEFAULT_PAGE_SIZE } from '../../utils/search-helper.js';
5
6
  import { endpointSchema } from './schema.js';
6
7
  import { RESOURCE_ALIASES } from '../../synonyms.js';
8
+ import { getSynonyms } from '../../synonyms.js';
7
9
  import { ACTION_KEYWORDS, ACTION_PRIORITY } from '../../intents.js';
8
10
  import { ScoreCalculator } from '../../utils/ScoreCalculator.js';
9
11
  export class OramaSearchService {
@@ -14,6 +16,7 @@ export class OramaSearchService {
14
16
  knownResources = ['project', 'block-diagram', 'worksheet', 'failure-mode', 'action', 'block-node', 'division', 'user', 'document', 'file', 'api-key', 'fault-tree', 'oauth'];
15
17
  debugMode;
16
18
  scoreCalculator;
19
+ lookup = new Map();
17
20
  constructor(indexPath, debugMode = false) {
18
21
  this.indexPath = indexPath;
19
22
  this.debugMode = debugMode;
@@ -35,14 +38,25 @@ export class OramaSearchService {
35
38
  });
36
39
  // Load data into the DB instance
37
40
  await load(this.db, JSON.parse(data));
38
- // Initialize model lazily or here. Here is better to fail fast.
41
+ // Load endpoint-lookup.json
42
+ const lookupPath = path.join(path.dirname(this.indexPath), 'endpoint-lookup.json');
43
+ try {
44
+ const lookupData = await fs.readFile(lookupPath, 'utf-8');
45
+ const lookupObj = JSON.parse(lookupData);
46
+ for (const [key, value] of Object.entries(lookupObj)) {
47
+ this.lookup.set(key, value);
48
+ }
49
+ console.error(`✅ OramaSearchService: Loaded ${this.lookup.size} endpoint lookups.`);
50
+ }
51
+ catch (lookupError) {
52
+ console.error('⚠️ OramaSearchService: endpoint-lookup.json not found, falling back to index-only mode.');
53
+ }
39
54
  // Initialize model lazily or here. Here is better to fail fast.
40
55
  this.extractor = await pipeline('feature-extraction', 'Xenova/bge-small-en-v1.5');
41
56
  console.error('✅ OramaSearchService: Index loaded and model ready.');
42
57
  }
43
58
  catch (error) {
44
59
  console.error('❌ OramaSearchService initialization failed:', error);
45
- // Fallback strategy could be implemented here or handled by the caller
46
60
  }
47
61
  }
48
62
  async search(query, filterMethod, filterVersion, page = 1, explicitPath) {
@@ -51,13 +65,10 @@ export class OramaSearchService {
51
65
  return { results: [], meta: { total: 0, page, totalPages: 0 }, warning: 'Search service not initialized' };
52
66
  }
53
67
  if (!query || query.trim() === '') {
54
- // Without query, we could verify behavior. Usually empty query means "list all" or "list categories"
55
- // But per interface, search() expects a query string.
56
68
  return { results: [], meta: { total: 0, page, totalPages: 0 } };
57
69
  }
58
70
  try {
59
71
  // 1. Explicit Path Search (Exact/Contains) - High Priority
60
- // [R16] Path Mode Detection: If query looks like a path, treat as explicit path search
61
72
  const isPathQuery = query.startsWith('/') || query.startsWith('api/');
62
73
  if (explicitPath || isPathQuery) {
63
74
  const targetPath = explicitPath || query;
@@ -72,11 +83,12 @@ export class OramaSearchService {
72
83
  // Sort by path length to prioritize exact match (shortest) over children
73
84
  hits.sort((a, b) => a.document.path.length - b.document.path.length);
74
85
  const results = hits.map((hit) => {
75
- const sourceData = JSON.parse(hit.document.jsonBlob);
86
+ const id = hit.document.id;
87
+ const sourceData = this.lookup.get(id) || {};
76
88
  const result = {
77
89
  path: hit.document.path,
78
90
  method: hit.document.method,
79
- summary: hit.document.summary,
91
+ summary: sourceData.summary || '',
80
92
  description: hit.document.description,
81
93
  score: 1.0, // Forced high score for explicit match
82
94
  };
@@ -100,14 +112,27 @@ export class OramaSearchService {
100
112
  console.error(`[Orama] Detected Action: ${intent.action}`);
101
113
  if (intent.resource)
102
114
  console.error(`[Orama] Detected Resource: ${intent.resource}`);
115
+ // Query-time synonym expansion (targeted, not exhaustive)
103
116
  let expandedQuery = safeQuery;
117
+ // Resource name expansion from intent detection
104
118
  if (intent.resource) {
105
119
  const resourceNameStr = intent.resource.replace(/-/g, ' ');
106
120
  if (!expandedQuery.toLowerCase().includes(resourceNameStr)) {
107
121
  expandedQuery += ` ${resourceNameStr}`;
108
- console.error(`[Orama] Expanded Query: "${expandedQuery}"`);
109
122
  }
110
123
  }
124
+ // Limited token-level synonym expansion:
125
+ // Add only direct synonyms (max 3 per token) to preserve BM25 signal
126
+ const tokens = safeQuery.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
127
+ const extraSynonyms = [];
128
+ for (const token of tokens) {
129
+ const syns = getSynonyms(token).filter(s => s !== token && !tokens.includes(s));
130
+ extraSynonyms.push(...syns.slice(0, 3));
131
+ }
132
+ if (extraSynonyms.length > 0) {
133
+ expandedQuery += ' ' + extraSynonyms.join(' ');
134
+ }
135
+ console.error(`[Orama] Expanded Query: "${expandedQuery}"`);
111
136
  const output = await this.extractor(safeQuery, { pooling: 'mean', normalize: true });
112
137
  const embedding = Array.from(output.data);
113
138
  // 3. Build Where Clauses
@@ -116,37 +141,30 @@ export class OramaSearchService {
116
141
  where.method = filterMethod;
117
142
  if (filterVersion)
118
143
  where.version = filterVersion;
119
- // 4. Search
144
+ // 4. Search — description-centric with path support
120
145
  const searchResult = await search(this.db, {
121
146
  term: expandedQuery,
122
- tolerance: 2, // [Fix] Enable fuzzy matching for typos (e.g. "creaet projetc")
123
- // [Fix] Explicitly search in keywords
124
- properties: ['path', 'summary', 'description', 'keywords'],
147
+ tolerance: 2,
148
+ properties: ['description', 'path'],
149
+ boost: {
150
+ description: 5.0,
151
+ path: 10.0
152
+ },
125
153
  vector: {
126
154
  value: embedding,
127
155
  property: 'embedding'
128
156
  },
129
157
  mode: 'hybrid',
130
- similarity: 0, // [Fix] Disable vector threshold completely to depend on gating logic
131
- boost: {
132
- path: 15.0,
133
- keywords: 30.0,
134
- summary: 15.0,
135
- description: 5.0
136
- },
158
+ similarity: 0,
137
159
  where,
138
- limit: 1000, // [Fix R15] Fetch more candidates to ensure re-ranking targets are included (low IDF fix)
160
+ limit: 1000,
139
161
  offset: 0
140
162
  });
141
- // (Debug removed)
142
163
  // 5. Threshold Filtering & Mapping with Structural Analysis
143
164
  const results = [];
144
- // Intent detection moved up.
145
165
  for (const hit of searchResult.hits) {
146
166
  let score = hit.score;
147
- // (Debug removed)
148
167
  // Structural Metadata from Index
149
- // Note: These fields are only available if the index was built with the new schema & logic
150
168
  const docResource = hit.document.resourceType || 'unknown';
151
169
  const docAction = hit.document.actionType || 'other';
152
170
  // A. & B. Intent Scoring via ScoreCalculator
@@ -155,28 +173,7 @@ export class OramaSearchService {
155
173
  actionType: docAction,
156
174
  score: score
157
175
  });
158
- // Legacy Logic Preservation (optional, maybe reduce weights)
159
- // ... (Existing explicit path logic is skipped here as it's separate block)
160
- // [Final Plan] Hybrid Gating (Gray Zone Filtering)
161
- // Check if we should KEEP or DROP based on score brackets
162
- // [Final Plan] Hybrid Gating (Gray Zone Filtering)
163
- // Check if we should KEEP or DROP based on score brackets
164
- // Normalized brackets:
165
- // HIGH_PASS = 0.55 / 600 * 600 ??? No, wait.
166
- // Old scores were raw. New scores are normalized (0.0 - 1.0).
167
- // Max score ~600.
168
- // Old HIGH_PASS = 0.55 (Wait, 0.55 was for normalized?? No, previously it wasn't normalized)
169
- // Actually, looking at previous code, Orama scores were ~10-15 base.
170
- // 0.55 seems VERY low for HIGH_PASS if boosts are +500.
171
- // It seems previous threshold logic might have been based on raw Orama scores BEFORE boosts?
172
- // Let's re-read: "score = hit.score" then boosts added.
173
- // But "score > 10.0" check suggests intents were boosting > 10.
174
- // With normalization:
175
- // High Pass: Strong intent match (~500+) -> ~0.83
176
- // Medium Pass: Partial match (~50+) -> ~0.1
177
- // Base score (~15) -> ~0.025
178
176
  // [Final Plan] Intent-Based Thresholding
179
- // Goal: Remove noise (Action-only matches) when Intent is clear.
180
177
  let THRESHOLD_SCORE = 0.05; // Default (Loose)
181
178
  if (intent.resource) {
182
179
  THRESHOLD_SCORE = 0.2;
@@ -185,39 +182,21 @@ export class OramaSearchService {
185
182
  THRESHOLD_SCORE = 0.08;
186
183
  }
187
184
  else {
188
- // [Fix] Low threshold for No Intent (Fuzzy/Typo handling)
189
- // Unboosted vector matches (0.8) have normalized scores ~0.0013.
190
- // 0.005 is too high. 0.001 allows strong vector matches.
191
- // [Round 18] Increased to 0.01 to filter garbage like 'UPPERCASE QUERY' or 'very long...'
192
- // Real keyword matches (boost=30) will be > 0.05.
193
185
  THRESHOLD_SCORE = 0.01;
194
186
  }
195
187
  let keep = false;
196
- // [Fix] Short queries (e.g. "x") can have high IDF/score due to noise.
197
- // Force them to pass strict token matching.
198
188
  const isShortQuery = safeQuery.length <= 2;
199
189
  if (score >= THRESHOLD_SCORE && !isShortQuery) {
200
- // Passed Threshold -> Keep
201
190
  keep = true;
202
191
  }
203
192
  else if (isShortQuery) {
204
- // Short Query Logic (same as before)
205
- const keywordsBag = (hit.document.keywords || '').toLowerCase();
193
+ // Short Query Logic: check description for token match
194
+ const descBag = (hit.document.description || '').toLowerCase();
206
195
  const inputTokens = safeQuery.toLowerCase().split(/\s+/).filter(t => t.length > 0);
207
196
  const queryTokens = new Set(inputTokens);
208
197
  if (queryTokens.size > 0) {
209
- // ... Fuzzy logic reuse or simplified check ...
210
- // For simplicity in this replacement, let's just check direct inclusion for short queries to avoid bloat
211
- // Or keep existing fuzzy logic if possible.
212
- // Let's assume the previous fuzzy logic block was good. I will try to preserve it or simplify.
213
- // Given tool limitations, I will rewrite the essential fuzzy check here.
214
- const docTokens = new Set(keywordsBag.split(/\s+/));
215
- const match = Array.from(queryTokens).some(token => {
216
- if (docTokens.has(token))
217
- return true;
218
- // Minimal fuzzy for 3-char fallback?
219
- return false;
220
- });
198
+ const docTokens = new Set(descBag.split(/\s+/));
199
+ const match = Array.from(queryTokens).some(token => docTokens.has(token));
221
200
  if (match)
222
201
  keep = true;
223
202
  }
@@ -227,11 +206,12 @@ export class OramaSearchService {
227
206
  }
228
207
  if (!keep)
229
208
  continue;
230
- const sourceData = JSON.parse(hit.document.jsonBlob);
209
+ const id = hit.document.id;
210
+ const sourceData = this.lookup.get(id) || {};
231
211
  const result = {
232
212
  path: hit.document.path,
233
213
  method: hit.document.method,
234
- summary: hit.document.summary,
214
+ summary: sourceData.summary || '',
235
215
  description: hit.document.description,
236
216
  score: score,
237
217
  resourceType: docResource,
@@ -240,20 +220,14 @@ export class OramaSearchService {
240
220
  tags: sourceData.tags,
241
221
  warning: sourceData['x-dependency-warning'] || sourceData.warning,
242
222
  };
243
- // Include score in debug mode (already included above, but for consistency)
244
- if (!this.debugMode) {
245
- // In non-debug mode, exclude heavy fields
246
- // (already excluded by not spreading sourceData)
247
- }
248
223
  results.push(result);
249
224
  }
250
225
  // Re-sort because scores changed
251
226
  results.sort((a, b) => (b.score || 0) - (a.score || 0));
252
227
  // 6. Pagination (Slicing)
253
- const filteredTotal = results.length; // True count of matching items
228
+ const filteredTotal = results.length;
254
229
  const { results: slice, meta } = paginateResults(results, page, DEFAULT_PAGE_SIZE);
255
230
  // 7. Warning System
256
- // If query contains '/' but no explicit path provided, warn user.
257
231
  let warning = undefined;
258
232
  if (query.includes('/') && searchResult.count > 0 && !explicitPath && !isPathQuery) {
259
233
  warning = "Query contains path characters. For exact path matching, use the 'path' parameter.";
@@ -262,8 +236,6 @@ export class OramaSearchService {
262
236
  results: slice,
263
237
  meta: {
264
238
  ...meta,
265
- // [Fix] Report the actual filtered count, not the Orama raw candidate count.
266
- // This allows clients (and tests) to know how many "relevant" items exist.
267
239
  total: filteredTotal
268
240
  },
269
241
  warning
@@ -279,7 +251,35 @@ export class OramaSearchService {
279
251
  if (!this.db)
280
252
  return null;
281
253
  try {
282
- // [Fix] Use 'term' search on 'path' property + manual filtering
254
+ // Try lookup first (fast path)
255
+ if (method) {
256
+ const id = `${method}:${path}`;
257
+ const content = this.lookup.get(id);
258
+ if (content) {
259
+ const metadata = {
260
+ resourceType: undefined,
261
+ actionType: undefined,
262
+ score: 1.0,
263
+ operationId: content.operationId,
264
+ tags: content.tags
265
+ };
266
+ // Get resourceType/actionType from index
267
+ const result = await search(this.db, {
268
+ term: path,
269
+ properties: ['path'],
270
+ limit: 20
271
+ });
272
+ for (const hit of result.hits) {
273
+ if (hit.document.path === path && hit.document.method === method) {
274
+ metadata.resourceType = hit.document.resourceType;
275
+ metadata.actionType = hit.document.actionType;
276
+ break;
277
+ }
278
+ }
279
+ return { content, metadata };
280
+ }
281
+ }
282
+ // Fallback: search index
283
283
  const result = await search(this.db, {
284
284
  term: path,
285
285
  properties: ['path'],
@@ -288,11 +288,12 @@ export class OramaSearchService {
288
288
  if (result.count > 0) {
289
289
  for (const hit of result.hits) {
290
290
  if (hit.document.path === path && (!method || hit.document.method === method)) {
291
- const content = JSON.parse(hit.document.jsonBlob);
291
+ const id = hit.document.id;
292
+ const content = this.lookup.get(id) || { path, method: hit.document.method, description: hit.document.description };
292
293
  const metadata = {
293
294
  resourceType: hit.document.resourceType,
294
295
  actionType: hit.document.actionType,
295
- score: 1.0, // Exact lookups have max confidence
296
+ score: 1.0,
296
297
  operationId: content.operationId,
297
298
  tags: content.tags
298
299
  };
@@ -311,26 +312,14 @@ export class OramaSearchService {
311
312
  await this.initPromise;
312
313
  if (!this.db)
313
314
  return [];
314
- // Quick implementation: Since Orama facets might need schema support,
315
- // and we have small data, we can just aggregate manually or return hardcoded list if known.
316
- // For now, let's return a placeholder or scan top results.
317
- // Ideally, we should have indexed 'category'.
318
- // Let's try to return unique tags from all docs? Too expensive.
319
- // Returning empty or basic list for now as per "Progressive Exploration" requires categories.
320
- // Let's hack it: search everything (limit 1000) and aggregate tags/categories.
321
- const result = await search(this.db, {
322
- limit: 1000 // Get all
323
- });
324
315
  const categories = new Set();
325
- result.hits.forEach((hit) => {
326
- const data = JSON.parse(hit.document.jsonBlob);
327
- // data.tags is array, data.category is string (if injected, but we didn't inject category into individual endpoints)
328
- // Check core.json structure again. "tags" are available in endpoint.
329
- if (data.tags && Array.isArray(data.tags)) {
330
- data.tags.forEach((t) => categories.add(t));
316
+ // Use lookup map to aggregate tags
317
+ for (const endpoint of this.lookup.values()) {
318
+ if (endpoint.tags && Array.isArray(endpoint.tags)) {
319
+ endpoint.tags.forEach((t) => categories.add(t));
331
320
  }
332
- });
333
- return Array.from(categories).map(c => ({ name: c, count: 0 })); // Count not implemented
321
+ }
322
+ return Array.from(categories).map(c => ({ name: c, count: 0 }));
334
323
  }
335
324
  detectIntent(query) {
336
325
  const lower = query.toLowerCase();
@@ -344,12 +333,8 @@ export class OramaSearchService {
344
333
  }
345
334
  }
346
335
  // Resource Detection
347
- // Simple lookup against known headers or extracting main noun
348
- let resource = undefined; // [Fix] Explicit declaration for scope visibility
336
+ let resource = undefined;
349
337
  // [Round 13 Refactoring] Longest Match Strategy
350
- // Issue: "project members" contains "project" (7 chars) and "member" (6 chars used in logic).
351
- // If we just loop, we might hit 'project' first and stop.
352
- // Solution: Sort all candidate matches by length desc, or pick the longest one.
353
338
  const potentialResources = [];
354
339
  // [Final Plan] Reverse Lookup from RESOURCE_ALIASES
355
340
  for (const [resType, aliases] of Object.entries(RESOURCE_ALIASES)) {
@@ -370,9 +355,16 @@ export class OramaSearchService {
370
355
  if (potentialResources.length > 0) {
371
356
  resource = potentialResources[0].type;
372
357
  }
358
+ // [Heuristic] Compound resource detection:
359
+ // "worksheet" + "excel" → prefer 'worksheet-excel' over either alone
360
+ if (lower.includes('worksheet') && lower.includes('excel')) {
361
+ resource = 'worksheet-excel';
362
+ }
363
+ // "worksheet" + "failure mode" → keep as 'worksheet' (not failure-mode)
364
+ if (lower.includes('worksheet') && (lower.includes('failure mode') || lower.includes('failuremode'))) {
365
+ resource = 'worksheet';
366
+ }
373
367
  // [Heuristic] If resource is found but action is ambiguous, default to 'list'
374
- // This prioritizes "List <Resource>" endpoints (v1) over "Get <Resource>" (v2)
375
- // which helps disambiguate queries like "organization" (Noun) -> Show me the list.
376
368
  if (!action && resource) {
377
369
  action = 'list';
378
370
  }
@@ -381,9 +373,6 @@ export class OramaSearchService {
381
373
  resource = 'project';
382
374
  }
383
375
  // [Fix] Auth Actions imply 'auth' resource, overriding 'user'
384
- // Example: "user login", "user sign out" -> Should target 'auth', not 'user'
385
- // [Fix] Auth Actions imply 'auth' resource, overriding 'user'
386
- // Example: "user login" -> 'auth'. But "third party login" -> 'oauth' (Respect explicit oauth intent)
387
376
  if (['login', 'logout'].includes(action || '') && resource !== 'oauth') {
388
377
  resource = 'auth';
389
378
  }
@@ -1,17 +1,10 @@
1
1
  export const endpointSchema = {
2
- id: 'string', // Unique ID: method + path
2
+ id: 'string',
3
3
  path: 'string',
4
4
  method: 'string',
5
- version: 'string', // v1, v2
6
- summary: 'string',
5
+ version: 'string',
7
6
  description: 'string',
8
- // Vector embedding (384 dimensions for Xenova/all-MiniLM-L6-v2)
9
7
  embedding: 'vector[384]',
10
- // Keywords including synonyms for better text matching
11
- keywords: 'string',
12
- // Full JSON content stored as string to avoid schema complexity
13
- jsonBlob: 'string',
14
- // Structural Metadata for precise filtering
15
- resourceType: 'string', // e.g. 'project', 'block-diagram'
16
- actionType: 'string' // e.g. 'list', 'create', 'read', 'update', 'delete', 'other'
8
+ resourceType: 'string',
9
+ actionType: 'string',
17
10
  };
package/dist/synonyms.js CHANGED
@@ -14,7 +14,8 @@ export const RESOURCE_ALIASES = {
14
14
  'division': ['organization', 'org', 'department', 'group', 'sector', 'unit'],
15
15
  'knowledge': ['knowledge', 'knowledge base', 'category'], // [R17] New Resource Support + Category Mapping
16
16
  'project': ['workspace', 'repo', 'application', 'app'], // [R13] Removed 'system' to avoid conflict
17
- 'system': ['system', 'health', 'status'], // [R13] Added distinct system resource
17
+ 'system': ['system', 'health', 'status', 'health check', 'heartbeat', 'ping', 'deployment'],
18
+ 'context-version': ['context version', 'context-version', 'build version', 'deployment version'], // Dedicated sub-resource
18
19
  'auth': ['security', 'credential', 'token', 'access', 'password'], // [R17] Removed 'login' to avoid conflict with action
19
20
  'oauth': ['third party', 'social login', 'google', 'github', 'oauth'], // [R17] Dedicated OAuth resource
20
21
  // FMEA Domain
@@ -27,12 +28,15 @@ export const RESOURCE_ALIASES = {
27
28
  'user': ['person', 'account', 'profile'], // Removed 'member' from here to avoid conflict
28
29
  'member': ['member', 'project member', 'team'], // [R13] Distinct resource type matching indexer
29
30
  'document': ['file', 'attachment', 'upload', 'paperwork', 'files'], // [R13] Added plural 'files'
30
- 'api-key': ['key', 'secret', 'token', 'credential', 'usage', 'metrics', 'logs', 'stats'], // [R15] Added usage metrics aliases
31
+ 'api-key': ['key', 'secret', 'credential'], // [R15] Cleaned: monitoring keywords (usage/metrics/logs/stats) moved to ACTION_KEYWORDS only
31
32
  'worksheet-template': ['template', 'sheet structure'], // [R18] Keep clean - column/header expansion handled by SYNONYM_GROUPS
32
33
  'action-status-template': ['status template', 'action status'], // [R15]
33
- 'join-request': ['join', 'invite', 'add user', 'member request'], // [R16]
34
+ 'join-request': ['join', 'invite', 'add user', 'member request', 'pending', 'join request'], // [R16]
34
35
  'excel': ['excel', 'spreadsheet', 'csv'], // [R16]
36
+ 'worksheet-excel': ['worksheet excel', 'excel worksheet', 'worksheet export', 'worksheet download'], // Worksheet Excel import/export
35
37
  'synonym': ['synonyms', 'synonym'], // [Fix] Key must match singular resourceType for boosting
38
+ 'fourm': ['4m', 'four m', '4m analysis', 'man machine material environment'], // 4M Analysis
39
+ 'term': ['term', 'terms', 'taxonomy', 'terminology', 'classification', 'glossary'], // Term tree management
36
40
  };
37
41
  export const SYNONYM_GROUPS = {
38
42
  // Read / Retrieve
@@ -20,7 +20,10 @@ export class ScoreCalculator {
20
20
  'knowledge': ['category'],
21
21
  'category': ['knowledge'],
22
22
  'oauth': ['auth'],
23
- 'auth': ['oauth']
23
+ 'auth': ['oauth'],
24
+ 'context-version': ['system'],
25
+ 'system': ['context-version'],
26
+ 'join-request': ['project', 'user', 'member']
24
27
  // Note: Inverse is NOT necessarily true (searching for 'document' shouldn't show 'project' root unless necessary, but let's be strict for now)
25
28
  };
26
29
  /**