kushi-agents 6.4.0 → 6.6.0
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 +1 -1
- package/plugin/runners/bootstrap.mjs +2 -2
- package/plugin/runners/discover.mjs +88 -7
- package/plugin/runners/lib/discover-ado.mjs +73 -0
- package/plugin/runners/lib/discover-crm.mjs +60 -0
- package/plugin/runners/lib/fuzzy-match.mjs +95 -0
- package/plugin/runners/refresh.mjs +3 -2
- package/plugin/runners/test/unit/discover-rest.test.mjs +81 -0
- package/plugin/runners/test/unit/fuzzy-match.test.mjs +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kushi-agents",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
4
4
|
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -234,8 +234,8 @@ async function interactiveSetup({ workspace, dryRun }) {
|
|
|
234
234
|
function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
235
235
|
|
|
236
236
|
const INTEGRATIONS_TEMPLATE = {
|
|
237
|
-
crm: { instance: 'https://iscrm.crm.dynamics.com', table: 'incidents', request_id:
|
|
238
|
-
ado: { organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1', engagement_id:
|
|
237
|
+
crm: { instance: 'https://iscrm.crm.dynamics.com', table: 'incidents', request_id: '<__FILL_ME_IN__>', record_id: '<__FILL_ME_IN__>' },
|
|
238
|
+
ado: { organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1', engagement_id: '<__FILL_ME_IN__>' },
|
|
239
239
|
sharepoint: { allowed_tenants: [] },
|
|
240
240
|
};
|
|
241
241
|
|
|
@@ -21,6 +21,8 @@ import { projectRoot, evidenceRoot, aliasRoot, projectSharedFile, userFile } fro
|
|
|
21
21
|
import { writeAtomic, pathExists } from './lib/evidence.mjs';
|
|
22
22
|
import { ask as workiqAsk, resolveWorkiqBin } from './lib/workiq.mjs';
|
|
23
23
|
import { loadM365Auth, scopeForSource } from './lib/m365-auth.mjs';
|
|
24
|
+
import { discoverCrm } from './lib/discover-crm.mjs';
|
|
25
|
+
import { discoverAdo } from './lib/discover-ado.mjs';
|
|
24
26
|
|
|
25
27
|
const ALL_SOURCES = ['email', 'teams', 'meetings', 'onenote', 'sharepoint', 'crm', 'ado'];
|
|
26
28
|
|
|
@@ -252,17 +254,26 @@ function applyRows(source, rows, currentBounds, currentInteg) {
|
|
|
252
254
|
}
|
|
253
255
|
if (source === 'meetings') {
|
|
254
256
|
const existing = currentBounds.meetings?.joinUrls || [];
|
|
257
|
+
// v6.5.0: meeting boundaries MUST be real http(s) join URLs — pull-meetings
|
|
258
|
+
// can't resolve a subject string into a meeting at the WorkIQ layer (unlike
|
|
259
|
+
// teams chat topics). Reject anything that isn't a URL; track rejected
|
|
260
|
+
// subjects in `accepted` log via reason field for discover-report visibility.
|
|
261
|
+
const rejectedSubjects = [];
|
|
255
262
|
const incoming = rows.map(r => {
|
|
256
263
|
const url = r.join_url;
|
|
257
|
-
if (url && !isPlaceholder(url) && isValidValueFor('meetings', 'join_url', url) && url
|
|
264
|
+
if (url && !isPlaceholder(url) && isValidValueFor('meetings', 'join_url', url) && /^https?:\/\//.test(url)) return url;
|
|
258
265
|
const subj = r.subject;
|
|
259
|
-
if (subj && !isPlaceholder(subj))
|
|
266
|
+
if (subj && !isPlaceholder(subj)) rejectedSubjects.push(subj);
|
|
260
267
|
return null;
|
|
261
268
|
}).filter(Boolean);
|
|
262
269
|
const merged = dedup([...existing, ...incoming]);
|
|
263
270
|
const added = merged.filter(v => !existing.includes(v));
|
|
264
271
|
if (added.length) accepted.push(...added);
|
|
265
|
-
return {
|
|
272
|
+
return {
|
|
273
|
+
boundariesPatch: added.length ? { meetings: { joinUrls: merged } } : null,
|
|
274
|
+
accepted,
|
|
275
|
+
rejected: rejectedSubjects.length ? rejectedSubjects.map(s => ({ subject: s, reason: 'no-join-url' })) : undefined,
|
|
276
|
+
};
|
|
266
277
|
}
|
|
267
278
|
if (source === 'onenote') {
|
|
268
279
|
const existing = currentBounds.onenote?.section_file_ids || [];
|
|
@@ -303,7 +314,7 @@ function applyRows(source, rows, currentBounds, currentInteg) {
|
|
|
303
314
|
isValidValueFor('crm', 'request_id', r.request_id) ||
|
|
304
315
|
isValidValueFor('crm', 'incident_number', r.incident_number)
|
|
305
316
|
);
|
|
306
|
-
if (!top) return { integrationsPatch: null, accepted: [] };
|
|
317
|
+
if (!top) return { integrationsPatch: null, accepted: [], unresolved: 'crm.request_id' };
|
|
307
318
|
const id = isValidValueFor('crm', 'request_id', top.request_id) ? top.request_id : top.incident_number;
|
|
308
319
|
const patch = { crm: { ...cur, request_id: id } };
|
|
309
320
|
accepted.push(id);
|
|
@@ -318,7 +329,7 @@ function applyRows(source, rows, currentBounds, currentInteg) {
|
|
|
318
329
|
isValidValueFor('ado', 'engagement_id', r.engagement_id) ||
|
|
319
330
|
isValidValueFor('ado', 'work_item_id', r.work_item_id)
|
|
320
331
|
);
|
|
321
|
-
if (!top) return { integrationsPatch: null, accepted: [] };
|
|
332
|
+
if (!top) return { integrationsPatch: null, accepted: [], unresolved: 'ado.engagement_id' };
|
|
322
333
|
const id = isValidValueFor('ado', 'engagement_id', top.engagement_id) ? top.engagement_id : top.work_item_id;
|
|
323
334
|
const patch = { ado: { ...cur, engagement_id: id } };
|
|
324
335
|
accepted.push(id);
|
|
@@ -398,6 +409,76 @@ async function main() {
|
|
|
398
409
|
sourceResults.push({ source, asked: false, found: 0, accepted: [], skipped_reason: 'disabled-in-config' });
|
|
399
410
|
continue;
|
|
400
411
|
}
|
|
412
|
+
// CRM and ADO are NEVER WorkIQ — they use mapped REST endpoints + fuzzy match
|
|
413
|
+
// (per fuzzy-disambiguation.instructions.md, v4.4.7+). Handle them inline.
|
|
414
|
+
if (source === 'crm' || source === 'ado') {
|
|
415
|
+
const projectName = path.basename(root);
|
|
416
|
+
const t0r = Date.now();
|
|
417
|
+
log(`[${idx}/${total}] ${source}: REST fuzzy-match against ${source === 'crm' ? 'Dataverse' : 'Azure DevOps'} (mapped settings)`);
|
|
418
|
+
const result = source === 'crm'
|
|
419
|
+
? await discoverCrm(projectName, integ.crm, {})
|
|
420
|
+
: await discoverAdo(projectName, integ.ado, {});
|
|
421
|
+
const elapsed = Date.now() - t0r;
|
|
422
|
+
// Persist raw result so users can inspect candidates that didn't auto-pick.
|
|
423
|
+
if (!args.dryRun) {
|
|
424
|
+
try {
|
|
425
|
+
const discoveryDir = path.join(aliasRoot(args.project, args.alias), '_discovery');
|
|
426
|
+
await fs.mkdir(discoveryDir, { recursive: true });
|
|
427
|
+
const header = `# discover ${source} @ ${new Date().toISOString()}\n# elapsed=${elapsed}ms decision=${result.decision} confidence=${result.confidence || ''} candidates=${result.candidates?.length || 0}${result.error ? ' error=' + result.error : ''}\n`;
|
|
428
|
+
await fs.writeFile(
|
|
429
|
+
path.join(discoveryDir, `${source}-raw.txt`),
|
|
430
|
+
header + JSON.stringify({ picked: result.picked, candidates: result.candidates, error: result.error }, null, 2),
|
|
431
|
+
'utf8',
|
|
432
|
+
);
|
|
433
|
+
} catch { /* best-effort */ }
|
|
434
|
+
}
|
|
435
|
+
let accepted = [];
|
|
436
|
+
let unresolved = null;
|
|
437
|
+
let integrationsPatch = null;
|
|
438
|
+
if (result.decision === 'auto' && result.picked) {
|
|
439
|
+
if (source === 'crm') {
|
|
440
|
+
const cur = integ.crm || {};
|
|
441
|
+
const id = result.picked.id;
|
|
442
|
+
integrationsPatch = { crm: { ...cur, request_id: id } };
|
|
443
|
+
accepted.push(id);
|
|
444
|
+
log(` crm: ✓ resolved → "${result.picked.title}" (${id}) [score=${result.picked.score}, confidence=${result.confidence}]`);
|
|
445
|
+
} else {
|
|
446
|
+
const cur = integ.ado || {};
|
|
447
|
+
const id = result.picked.id;
|
|
448
|
+
integrationsPatch = { ado: { ...cur, engagement_id: id } };
|
|
449
|
+
accepted.push(id);
|
|
450
|
+
log(` ado: ✓ resolved → "${result.picked.title}" (${id}) [score=${result.picked.score}, confidence=${result.confidence}]`);
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
unresolved = source === 'crm' ? 'crm.request_id' : 'ado.engagement_id';
|
|
454
|
+
if (result.error) {
|
|
455
|
+
log(` ${source}: ✗ ${result.error}`);
|
|
456
|
+
} else if (result.candidates?.length) {
|
|
457
|
+
log(` ${source}: ⚠ ${result.candidates.length} candidate(s) but none confident enough — see _discovery/${source}-raw.txt`);
|
|
458
|
+
for (const c of result.candidates.slice(0, 5)) {
|
|
459
|
+
log(` • ${c.id} · "${c.title}" [score=${c.score}, kind=${c.kind}]`);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
log(` ${source}: ⚠ no candidates matched project name`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (integrationsPatch) {
|
|
466
|
+
Object.assign(integ, mergeShallow(integ, integrationsPatch));
|
|
467
|
+
integDirty = true;
|
|
468
|
+
}
|
|
469
|
+
sourceResults.push({
|
|
470
|
+
source,
|
|
471
|
+
asked: true,
|
|
472
|
+
found: result.candidates?.length || 0,
|
|
473
|
+
accepted,
|
|
474
|
+
unresolved,
|
|
475
|
+
candidates: result.candidates?.slice(0, 5) || [],
|
|
476
|
+
error: result.error,
|
|
477
|
+
skipped_reason: result.error ? 'rest-error' : (unresolved ? 'no-confident-match' : null),
|
|
478
|
+
});
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
401
482
|
const prompt = buildPrompt(source, path.basename(root), scope);
|
|
402
483
|
let rows = [];
|
|
403
484
|
let asked = true;
|
|
@@ -448,7 +529,7 @@ async function main() {
|
|
|
448
529
|
: `${skipReason} after ${elapsed}ms: ${(e.message || '').split('\n')[0].slice(0, 200)}`;
|
|
449
530
|
log(` ${source}: ✗ ${detail}`);
|
|
450
531
|
}
|
|
451
|
-
const { boundariesPatch, integrationsPatch, accepted } = applyRows(source, rows, bounds, integ);
|
|
532
|
+
const { boundariesPatch, integrationsPatch, accepted, rejected, unresolved } = applyRows(source, rows, bounds, integ);
|
|
452
533
|
if (boundariesPatch) {
|
|
453
534
|
Object.assign(bounds, mergeShallow(bounds, boundariesPatch));
|
|
454
535
|
boundsDirty = true;
|
|
@@ -457,7 +538,7 @@ async function main() {
|
|
|
457
538
|
Object.assign(integ, mergeShallow(integ, integrationsPatch));
|
|
458
539
|
integDirty = true;
|
|
459
540
|
}
|
|
460
|
-
sourceResults.push({ source, asked, found: rows.length, accepted, skipped_reason: skipReason });
|
|
541
|
+
sourceResults.push({ source, asked, found: rows.length, accepted, rejected, unresolved, skipped_reason: skipReason });
|
|
461
542
|
}
|
|
462
543
|
|
|
463
544
|
log(`done: ${sourceResults.filter(r => r.found > 0).length}/${total} sources returned data`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// plugin/runners/lib/discover-ado.mjs
|
|
2
|
+
// Direct ADO WIQL fuzzy search for project -> Engagement work item.
|
|
3
|
+
// Replaces WorkIQ for ado discovery — uses mapped org/project from integrations.yml.
|
|
4
|
+
|
|
5
|
+
import { fetchWithRetry } from './http.mjs';
|
|
6
|
+
import { authHeader, SCOPES } from './identity.mjs';
|
|
7
|
+
import { rank, decide } from './fuzzy-match.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} projectName
|
|
11
|
+
* @param {{organization?:string, project?:string, apiVersion?:string, workItemType?:string}} adoConfig
|
|
12
|
+
* @param {{fetcher?:Function, headersOverride?:object}} [opts]
|
|
13
|
+
*/
|
|
14
|
+
export async function discoverAdo(projectName, adoConfig, opts = {}) {
|
|
15
|
+
const org = adoConfig?.organization;
|
|
16
|
+
const project = adoConfig?.project;
|
|
17
|
+
const apiVersion = adoConfig?.apiVersion || '7.1';
|
|
18
|
+
const witType = adoConfig?.workItemTypes?.engagement || 'Engagement';
|
|
19
|
+
if (!org || !project || /<.*>/.test(org) || /<.*>/.test(project)) {
|
|
20
|
+
return { decision: 'unresolved', candidates: [], error: 'ado organization/project not configured', source: 'ado' };
|
|
21
|
+
}
|
|
22
|
+
const wiql = `SELECT [System.Id],[System.Title],[System.WorkItemType] FROM workitems WHERE [System.TeamProject]='${escapeWiql(project)}' AND [System.WorkItemType]='${escapeWiql(witType)}' AND [System.Title] CONTAINS '${escapeWiql(projectName)}'`;
|
|
23
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/wit/wiql?api-version=${apiVersion}`;
|
|
24
|
+
let headers;
|
|
25
|
+
try {
|
|
26
|
+
headers = opts.headersOverride || await authHeader(SCOPES.ado, { 'Content-Type': 'application/json' });
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { decision: 'unresolved', candidates: [], error: `ado auth failed: ${e.message}`, source: 'ado' };
|
|
29
|
+
}
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetchWithRetry(url, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers,
|
|
35
|
+
body: JSON.stringify({ query: wiql }),
|
|
36
|
+
fetcher: opts.fetcher,
|
|
37
|
+
retries: 2,
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return { decision: 'unresolved', candidates: [], error: `ado wiql fetch failed: ${e.message}`, source: 'ado' };
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const body = await safeText(res);
|
|
44
|
+
return { decision: 'unresolved', candidates: [], error: `ado http ${res.status}: ${body.slice(0, 200)}`, source: 'ado' };
|
|
45
|
+
}
|
|
46
|
+
const wiqlData = await res.json().catch(() => ({}));
|
|
47
|
+
const ids = (wiqlData.workItems || []).map(w => w.id).filter(Boolean).slice(0, 50);
|
|
48
|
+
if (!ids.length) return { decision: 'unresolved', candidates: [], source: 'ado' };
|
|
49
|
+
|
|
50
|
+
// Hydrate titles via /workitems?ids=...
|
|
51
|
+
const detailsUrl = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/wit/workitems?ids=${ids.join(',')}&fields=System.Id,System.Title,System.WorkItemType,System.State&api-version=${apiVersion}`;
|
|
52
|
+
let dres;
|
|
53
|
+
try {
|
|
54
|
+
dres = await fetchWithRetry(detailsUrl, { headers, fetcher: opts.fetcher, retries: 2 });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { decision: 'unresolved', candidates: [], error: `ado workitems fetch failed: ${e.message}`, source: 'ado' };
|
|
57
|
+
}
|
|
58
|
+
if (!dres.ok) {
|
|
59
|
+
const body = await safeText(dres);
|
|
60
|
+
return { decision: 'unresolved', candidates: [], error: `ado workitems http ${dres.status}: ${body.slice(0, 200)}`, source: 'ado' };
|
|
61
|
+
}
|
|
62
|
+
const detData = await dres.json().catch(() => ({}));
|
|
63
|
+
const candidates = (detData.value || []).map(w => ({
|
|
64
|
+
id: String(w.id),
|
|
65
|
+
title: w.fields?.['System.Title'] || '',
|
|
66
|
+
secondary: `${w.fields?.['System.WorkItemType'] || ''} · ${w.fields?.['System.State'] || ''}`,
|
|
67
|
+
})).filter(c => c.title);
|
|
68
|
+
const ranked = rank(projectName, candidates);
|
|
69
|
+
return { ...decide(ranked), source: 'ado' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function escapeWiql(v) { return String(v).replace(/'/g, "''"); }
|
|
73
|
+
async function safeText(res) { try { return await res.text(); } catch { return ''; } }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// plugin/runners/lib/discover-crm.mjs
|
|
2
|
+
// Direct Dataverse OData fuzzy search for project -> CRM record.
|
|
3
|
+
// Replaces WorkIQ for crm discovery — uses mapped instance from integrations.yml.
|
|
4
|
+
|
|
5
|
+
import { fetchWithRetry, encodeODataOp } from './http.mjs';
|
|
6
|
+
import { authHeader } from './identity.mjs';
|
|
7
|
+
import { SCOPES } from './identity.mjs';
|
|
8
|
+
import { rank, decide } from './fuzzy-match.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} projectName
|
|
12
|
+
* @param {{instance?:string, table?:string}} integ.crm
|
|
13
|
+
* @param {{fetcher?:Function, headersOverride?:object}} [opts]
|
|
14
|
+
* @returns {Promise<{decision, picked?, candidates, error?, source:'crm'}>}
|
|
15
|
+
*/
|
|
16
|
+
export async function discoverCrm(projectName, crmConfig, opts = {}) {
|
|
17
|
+
const instance = (crmConfig?.instance || '').replace(/\/$/, '');
|
|
18
|
+
if (!instance || /<.*>/.test(instance)) {
|
|
19
|
+
return { decision: 'unresolved', candidates: [], error: 'crm.instance not configured', source: 'crm' };
|
|
20
|
+
}
|
|
21
|
+
const table = crmConfig?.table || 'incidents';
|
|
22
|
+
const titleField = table === 'incidents' ? 'title' : 'name';
|
|
23
|
+
const filter = `contains(${titleField},'${escapeOData(projectName)}') or contains(description,'${escapeOData(projectName)}')`;
|
|
24
|
+
const select = table === 'incidents'
|
|
25
|
+
? 'incidentid,title,ticketnumber,description'
|
|
26
|
+
: `${table}id,${titleField}`;
|
|
27
|
+
const url = `${instance}/api/data/v9.2/${table}?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}&${encodeODataOp('$select')}=${encodeURIComponent(select)}&${encodeODataOp('$top')}=20`;
|
|
28
|
+
let headers;
|
|
29
|
+
try {
|
|
30
|
+
headers = opts.headersOverride || await authHeader(SCOPES.dataverse(instance), {
|
|
31
|
+
'OData-MaxVersion': '4.0',
|
|
32
|
+
'OData-Version': '4.0',
|
|
33
|
+
Prefer: 'odata.include-annotations="*"',
|
|
34
|
+
});
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { decision: 'unresolved', candidates: [], error: `crm auth failed: ${e.message}`, source: 'crm' };
|
|
37
|
+
}
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetchWithRetry(url, { headers, fetcher: opts.fetcher, retries: 2 });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { decision: 'unresolved', candidates: [], error: `crm fetch failed: ${e.message}`, source: 'crm' };
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const body = await safeText(res);
|
|
46
|
+
return { decision: 'unresolved', candidates: [], error: `crm http ${res.status}: ${body.slice(0, 200)}`, source: 'crm' };
|
|
47
|
+
}
|
|
48
|
+
const data = await res.json().catch(() => ({}));
|
|
49
|
+
const rows = Array.isArray(data.value) ? data.value : [];
|
|
50
|
+
const candidates = rows.map(r => ({
|
|
51
|
+
id: r.ticketnumber || r.incidentid || r[`${table}id`] || '',
|
|
52
|
+
title: r.title || r[titleField] || '',
|
|
53
|
+
secondary: r.ticketnumber ? `incidentid=${r.incidentid}` : '',
|
|
54
|
+
})).filter(c => c.id && c.title);
|
|
55
|
+
const ranked = rank(projectName, candidates);
|
|
56
|
+
return { ...decide(ranked), source: 'crm' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeOData(v) { return String(v).replace(/'/g, "''"); }
|
|
60
|
+
async function safeText(res) { try { return await res.text(); } catch { return ''; } }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// plugin/runners/lib/fuzzy-match.mjs
|
|
2
|
+
// Universal fuzzy disambiguation per
|
|
3
|
+
// plugin/instructions/fuzzy-disambiguation.instructions.md (v4.4.7+).
|
|
4
|
+
//
|
|
5
|
+
// Score lexicographic order:
|
|
6
|
+
// exact (case-insensitive) ........... 1000
|
|
7
|
+
// prefix (target startsWith query) ... 800 - max(0, target.length - query.length)
|
|
8
|
+
// contains (target includes query) ... 500 - distance-from-start
|
|
9
|
+
// token-overlap (>=1 shared token) ... 300 + matchedTokens * 10
|
|
10
|
+
// levenshtein (<=2 edits) ............ 100 + (3 - distance) * 10
|
|
11
|
+
// else ............................... 0
|
|
12
|
+
//
|
|
13
|
+
// Decision rule (top 5):
|
|
14
|
+
// top >= 800 AND #2 <= top * 0.7 -> auto-pick (confidence: 'high')
|
|
15
|
+
// top >= 300 AND #2 <= top * 0.7 -> auto-pick warn (confidence: 'medium')
|
|
16
|
+
// else -> unresolved + return ranked candidates
|
|
17
|
+
|
|
18
|
+
const norm = (s) => String(s ?? '').trim().toLowerCase();
|
|
19
|
+
|
|
20
|
+
function levenshtein(a, b) {
|
|
21
|
+
if (a === b) return 0;
|
|
22
|
+
const m = a.length, n = b.length;
|
|
23
|
+
if (!m) return n;
|
|
24
|
+
if (!n) return m;
|
|
25
|
+
if (Math.abs(m - n) > 2) return Infinity; // we only care about <=2
|
|
26
|
+
const prev = new Array(n + 1);
|
|
27
|
+
const curr = new Array(n + 1);
|
|
28
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
29
|
+
for (let i = 1; i <= m; i++) {
|
|
30
|
+
curr[0] = i;
|
|
31
|
+
let row = i;
|
|
32
|
+
for (let j = 1; j <= n; j++) {
|
|
33
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
34
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
35
|
+
if (curr[j] < row) row = curr[j];
|
|
36
|
+
}
|
|
37
|
+
if (row > 2) return Infinity; // early-exit
|
|
38
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j];
|
|
39
|
+
}
|
|
40
|
+
return prev[n];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Score a single candidate name against a query.
|
|
45
|
+
* @param {string} query
|
|
46
|
+
* @param {string} target
|
|
47
|
+
* @returns {{score:number, kind:string}}
|
|
48
|
+
*/
|
|
49
|
+
export function scoreOne(query, target) {
|
|
50
|
+
const q = norm(query);
|
|
51
|
+
const t = norm(target);
|
|
52
|
+
if (!q || !t) return { score: 0, kind: 'empty' };
|
|
53
|
+
if (q === t) return { score: 1000, kind: 'exact' };
|
|
54
|
+
if (t.startsWith(q)) return { score: 800 - Math.max(0, t.length - q.length), kind: 'prefix' };
|
|
55
|
+
const idx = t.indexOf(q);
|
|
56
|
+
if (idx >= 0) return { score: 500 - idx, kind: 'contains' };
|
|
57
|
+
const qTokens = q.split(/\s+/).filter(Boolean);
|
|
58
|
+
const tTokens = t.split(/\s+/).filter(Boolean);
|
|
59
|
+
const shared = qTokens.filter(x => tTokens.includes(x)).length;
|
|
60
|
+
if (shared >= 1) return { score: 300 + shared * 10, kind: 'token-overlap' };
|
|
61
|
+
const d = levenshtein(q, t);
|
|
62
|
+
if (d <= 2) return { score: 100 + (3 - d) * 10, kind: `levenshtein-${d}` };
|
|
63
|
+
return { score: 0, kind: 'none' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Score and rank an array of candidates.
|
|
68
|
+
* @param {string} query
|
|
69
|
+
* @param {Array<{id:string, title:string, [k:string]:any}>} candidates
|
|
70
|
+
* @returns {Array} sorted desc by score (input shape preserved + score + kind)
|
|
71
|
+
*/
|
|
72
|
+
export function rank(query, candidates) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const c of candidates) {
|
|
75
|
+
const { score, kind } = scoreOne(query, c.title || c.name || '');
|
|
76
|
+
if (score > 0) out.push({ ...c, score, kind });
|
|
77
|
+
}
|
|
78
|
+
out.sort((a, b) => b.score - a.score);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Apply the v4.4.7 decision rule.
|
|
84
|
+
* @returns {{decision:'auto'|'unresolved', confidence?:string, picked?:object, candidates:Array}}
|
|
85
|
+
*/
|
|
86
|
+
export function decide(ranked) {
|
|
87
|
+
const top5 = ranked.slice(0, 5);
|
|
88
|
+
if (!top5.length) return { decision: 'unresolved', candidates: [] };
|
|
89
|
+
const top = top5[0];
|
|
90
|
+
const second = top5[1];
|
|
91
|
+
const gapOk = !second || second.score <= top.score * 0.7;
|
|
92
|
+
if (top.score >= 800 && gapOk) return { decision: 'auto', confidence: 'high', picked: top, candidates: top5 };
|
|
93
|
+
if (top.score >= 300 && gapOk) return { decision: 'auto', confidence: 'medium', picked: top, candidates: top5 };
|
|
94
|
+
return { decision: 'unresolved', candidates: top5 };
|
|
95
|
+
}
|
|
@@ -73,13 +73,14 @@ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
|
73
73
|
*/
|
|
74
74
|
export function buildTargets(merged) {
|
|
75
75
|
const targets = [];
|
|
76
|
+
const isPlaceholder = (v) => v == null || /^<.*>$/.test(String(v).trim()) || /^(unknown|n\/a|none|null|tbd|todo)$/i.test(String(v).trim());
|
|
76
77
|
// crm: from integrations
|
|
77
78
|
const crm = merged.crm || {};
|
|
78
79
|
const crmEntity = crm.request_id || crm.record_id;
|
|
79
|
-
if (crmEntity) targets.push({ source: 'crm', entity: String(crmEntity) });
|
|
80
|
+
if (crmEntity && !isPlaceholder(crmEntity)) targets.push({ source: 'crm', entity: String(crmEntity) });
|
|
80
81
|
// ado
|
|
81
82
|
const ado = merged.ado || {};
|
|
82
|
-
if (ado.engagement_id) targets.push({ source: 'ado', entity: String(ado.engagement_id) });
|
|
83
|
+
if (ado.engagement_id && !isPlaceholder(ado.engagement_id)) targets.push({ source: 'ado', entity: String(ado.engagement_id) });
|
|
83
84
|
// email: per-user mailbox folders
|
|
84
85
|
const email = merged.email || {};
|
|
85
86
|
for (const f of (email.folders || [])) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// plugin/runners/test/unit/discover-rest.test.mjs
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { discoverCrm } from '../../lib/discover-crm.mjs';
|
|
5
|
+
import { discoverAdo } from '../../lib/discover-ado.mjs';
|
|
6
|
+
|
|
7
|
+
function mockResponse({ status = 200, json = {} } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
ok: status >= 200 && status < 300,
|
|
10
|
+
status,
|
|
11
|
+
statusText: 'mock',
|
|
12
|
+
headers: { get: () => null },
|
|
13
|
+
json: async () => json,
|
|
14
|
+
text: async () => JSON.stringify(json),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('discoverCrm: returns unresolved when instance not configured', async () => {
|
|
19
|
+
const r = await discoverCrm('HCA', { instance: '<__FILL_ME_IN__>' });
|
|
20
|
+
assert.equal(r.decision, 'unresolved');
|
|
21
|
+
assert.match(r.error, /not configured/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('discoverCrm: auto-picks high-confidence match', async () => {
|
|
25
|
+
const fetcher = async () => mockResponse({
|
|
26
|
+
json: { value: [
|
|
27
|
+
{ incidentid: 'g-1', title: 'HCA Agentic Engineering', ticketnumber: 'FE-2026-001458' },
|
|
28
|
+
{ incidentid: 'g-2', title: 'unrelated', ticketnumber: 'FE-2025-000001' },
|
|
29
|
+
] },
|
|
30
|
+
});
|
|
31
|
+
const r = await discoverCrm('HCA Agentic Engineering', { instance: 'https://iscrm.crm.dynamics.com' }, {
|
|
32
|
+
fetcher, headersOverride: { Authorization: 'Bearer fake' },
|
|
33
|
+
});
|
|
34
|
+
assert.equal(r.decision, 'auto');
|
|
35
|
+
assert.equal(r.picked.id, 'FE-2026-001458');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('discoverCrm: returns unresolved on 401', async () => {
|
|
39
|
+
const fetcher = async () => mockResponse({ status: 401, json: { error: 'unauth' } });
|
|
40
|
+
const r = await discoverCrm('HCA', { instance: 'https://iscrm.crm.dynamics.com' }, {
|
|
41
|
+
fetcher, headersOverride: { Authorization: 'Bearer fake' },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(r.decision, 'unresolved');
|
|
44
|
+
assert.match(r.error, /401/i);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('discoverAdo: returns unresolved when org not configured', async () => {
|
|
48
|
+
const r = await discoverAdo('HCA', { organization: '<x>', project: 'IS Engagements' });
|
|
49
|
+
assert.equal(r.decision, 'unresolved');
|
|
50
|
+
assert.match(r.error, /not configured/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('discoverAdo: auto-picks from WIQL + workitems hydrate', async () => {
|
|
54
|
+
let call = 0;
|
|
55
|
+
const fetcher = async (url) => {
|
|
56
|
+
call++;
|
|
57
|
+
if (call === 1) {
|
|
58
|
+
// WIQL POST -> ids only
|
|
59
|
+
return mockResponse({ json: { workItems: [{ id: 12345 }, { id: 67890 }] } });
|
|
60
|
+
}
|
|
61
|
+
// hydrate
|
|
62
|
+
return mockResponse({ json: { value: [
|
|
63
|
+
{ id: 12345, fields: { 'System.Title': 'HCA Engagement', 'System.WorkItemType': 'Engagement', 'System.State': 'Active' } },
|
|
64
|
+
{ id: 67890, fields: { 'System.Title': 'Different Thing', 'System.WorkItemType': 'Engagement', 'System.State': 'Closed' } },
|
|
65
|
+
] } });
|
|
66
|
+
};
|
|
67
|
+
const r = await discoverAdo('HCA', {
|
|
68
|
+
organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1',
|
|
69
|
+
}, { fetcher, headersOverride: { Authorization: 'Bearer fake', 'Content-Type': 'application/json' } });
|
|
70
|
+
assert.equal(r.decision, 'auto');
|
|
71
|
+
assert.equal(r.picked.id, '12345');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('discoverAdo: empty WIQL result -> unresolved', async () => {
|
|
75
|
+
const fetcher = async () => mockResponse({ json: { workItems: [] } });
|
|
76
|
+
const r = await discoverAdo('HCA', {
|
|
77
|
+
organization: 'IndustrySolutions', project: 'IS Engagements',
|
|
78
|
+
}, { fetcher, headersOverride: { Authorization: 'Bearer fake', 'Content-Type': 'application/json' } });
|
|
79
|
+
assert.equal(r.decision, 'unresolved');
|
|
80
|
+
assert.equal(r.candidates.length, 0);
|
|
81
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// plugin/runners/test/unit/fuzzy-match.test.mjs
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { scoreOne, rank, decide } from '../../lib/fuzzy-match.mjs';
|
|
5
|
+
|
|
6
|
+
test('scoreOne: exact match scores 1000', () => {
|
|
7
|
+
assert.equal(scoreOne('HCA', 'hca').score, 1000);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('scoreOne: prefix beats contains', () => {
|
|
11
|
+
const p = scoreOne('HCA', 'HCA Agentic Engineering').score;
|
|
12
|
+
const c = scoreOne('Agentic', 'HCA Agentic Engineering').score;
|
|
13
|
+
assert.ok(p > c, `prefix=${p} should be > contains=${c}`);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('scoreOne: contains scores below prefix but above token-overlap', () => {
|
|
17
|
+
assert.ok(scoreOne('foo', 'bar foo baz').score >= 300);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('scoreOne: token-overlap when no substring match', () => {
|
|
21
|
+
const r = scoreOne('northwind acme', 'acme corp engagement');
|
|
22
|
+
assert.ok(r.score >= 300 && r.score < 500, `score=${r.score} kind=${r.kind}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('scoreOne: levenshtein within 2 edits', () => {
|
|
26
|
+
const r = scoreOne('hca', 'hsa');
|
|
27
|
+
assert.ok(r.score >= 100 && r.score <= 130, `score=${r.score}`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('scoreOne: zero on no relation', () => {
|
|
31
|
+
assert.equal(scoreOne('aaaa', 'zzzzzzzz').score, 0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('rank: drops zero scores and sorts desc', () => {
|
|
35
|
+
const out = rank('HCA', [
|
|
36
|
+
{ id: '1', title: 'unrelated thing' },
|
|
37
|
+
{ id: '2', title: 'HCA Agentic Engineering' },
|
|
38
|
+
{ id: '3', title: 'hca' },
|
|
39
|
+
]);
|
|
40
|
+
assert.equal(out.length, 2);
|
|
41
|
+
assert.equal(out[0].id, '3'); // exact
|
|
42
|
+
assert.equal(out[1].id, '2'); // prefix
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('decide: auto-pick high when top exact and gap big', () => {
|
|
46
|
+
const ranked = rank('HCA', [
|
|
47
|
+
{ id: 'A', title: 'hca' },
|
|
48
|
+
{ id: 'B', title: 'something else' },
|
|
49
|
+
]);
|
|
50
|
+
const d = decide(ranked);
|
|
51
|
+
assert.equal(d.decision, 'auto');
|
|
52
|
+
assert.equal(d.confidence, 'high');
|
|
53
|
+
assert.equal(d.picked.id, 'A');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('decide: unresolved when two strong candidates close', () => {
|
|
57
|
+
const ranked = rank('HCA', [
|
|
58
|
+
{ id: 'A', title: 'HCA West' },
|
|
59
|
+
{ id: 'B', title: 'HCA East' },
|
|
60
|
+
]);
|
|
61
|
+
const d = decide(ranked);
|
|
62
|
+
// both are prefix-scored equally (or close) -> gap fails -> unresolved
|
|
63
|
+
assert.equal(d.decision, 'unresolved');
|
|
64
|
+
assert.equal(d.candidates.length, 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('decide: unresolved when no candidates', () => {
|
|
68
|
+
const d = decide([]);
|
|
69
|
+
assert.equal(d.decision, 'unresolved');
|
|
70
|
+
assert.equal(d.candidates.length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('decide: medium-confidence auto for token-overlap with clear gap', () => {
|
|
74
|
+
const ranked = rank('northwind project', [
|
|
75
|
+
{ id: 'A', title: 'northwind delivery' }, // token-overlap on northwind
|
|
76
|
+
{ id: 'B', title: 'totally different thing' }, // 0
|
|
77
|
+
]);
|
|
78
|
+
const d = decide(ranked);
|
|
79
|
+
assert.equal(d.decision, 'auto');
|
|
80
|
+
assert.equal(d.confidence, 'medium');
|
|
81
|
+
assert.equal(d.picked.id, 'A');
|
|
82
|
+
});
|