@useody/detectors 0.0.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.
Files changed (57) hide show
  1. package/LICENSE +198 -0
  2. package/README.md +42 -0
  3. package/dist/claim-comparison.d.ts +12 -0
  4. package/dist/claim-comparison.d.ts.map +1 -0
  5. package/dist/claim-comparison.js +108 -0
  6. package/dist/claim-nli.d.ts +15 -0
  7. package/dist/claim-nli.d.ts.map +1 -0
  8. package/dist/claim-nli.js +181 -0
  9. package/dist/consensus.d.ts +26 -0
  10. package/dist/consensus.d.ts.map +1 -0
  11. package/dist/consensus.js +211 -0
  12. package/dist/consultant-analysis.d.ts +20 -0
  13. package/dist/consultant-analysis.d.ts.map +1 -0
  14. package/dist/consultant-analysis.js +69 -0
  15. package/dist/consultant-prompts.d.ts +83 -0
  16. package/dist/consultant-prompts.d.ts.map +1 -0
  17. package/dist/consultant-prompts.js +135 -0
  18. package/dist/contradiction-helpers.d.ts +40 -0
  19. package/dist/contradiction-helpers.d.ts.map +1 -0
  20. package/dist/contradiction-helpers.js +163 -0
  21. package/dist/contradictions.d.ts +20 -0
  22. package/dist/contradictions.d.ts.map +1 -0
  23. package/dist/contradictions.js +235 -0
  24. package/dist/duplicates.d.ts +14 -0
  25. package/dist/duplicates.d.ts.map +1 -0
  26. package/dist/duplicates.js +95 -0
  27. package/dist/health-score.d.ts +13 -0
  28. package/dist/health-score.d.ts.map +1 -0
  29. package/dist/health-score.js +53 -0
  30. package/dist/helpers/index.d.ts +7 -0
  31. package/dist/helpers/index.d.ts.map +1 -0
  32. package/dist/helpers/index.js +7 -0
  33. package/dist/helpers/llm-timeout.d.ts +14 -0
  34. package/dist/helpers/llm-timeout.d.ts.map +1 -0
  35. package/dist/helpers/llm-timeout.js +30 -0
  36. package/dist/helpers/text-utils.d.ts +21 -0
  37. package/dist/helpers/text-utils.d.ts.map +1 -0
  38. package/dist/helpers/text-utils.js +63 -0
  39. package/dist/index.d.ts +17 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +14 -0
  42. package/dist/run-detection.d.ts +29 -0
  43. package/dist/run-detection.d.ts.map +1 -0
  44. package/dist/run-detection.js +46 -0
  45. package/dist/staleness.d.ts +21 -0
  46. package/dist/staleness.d.ts.map +1 -0
  47. package/dist/staleness.js +128 -0
  48. package/dist/time-bomb-utils.d.ts +23 -0
  49. package/dist/time-bomb-utils.d.ts.map +1 -0
  50. package/dist/time-bomb-utils.js +161 -0
  51. package/dist/time-bombs.d.ts +14 -0
  52. package/dist/time-bombs.d.ts.map +1 -0
  53. package/dist/time-bombs.js +113 -0
  54. package/dist/undocumented.d.ts +13 -0
  55. package/dist/undocumented.d.ts.map +1 -0
  56. package/dist/undocumented.js +84 -0
  57. package/package.json +48 -0
@@ -0,0 +1,163 @@
1
+ const NUMBER_UNIT_PATTERN = /\$?(\d[\d,]*(?:\.\d+)?)\s*\+?\s*(?:per\s+)?(requests?|min|minutes?|hour|hours?|day|days?|month|months?|week|weeks?|\/\w+|calls?|users?|people|persons?|employees?|members?|%|percent|gpus?|nodes?|cores?|GBs?|instances?|seconds?|tokens?)/gi;
2
+ const STOPWORDS = new Set([
3
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
4
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
5
+ 'should', 'may', 'might', 'can', 'in', 'on', 'at', 'to', 'for', 'of',
6
+ 'and', 'or', 'but', 'not', 'with', 'from', 'by', 'into', 'it', 'its',
7
+ 'this', 'that', 'we', 'our', 'all', 'use', 'uses', 'used', 'than',
8
+ ]);
9
+ const CONTEXT_STOPWORDS = new Set([
10
+ 'per', 'hour', 'hours', 'min', 'minute', 'minutes',
11
+ 'day', 'days', 'week', 'weeks', 'month', 'months', 'second', 'seconds',
12
+ ]);
13
+ /** Extract number+unit pairs from text. */
14
+ export function extractNumbers(text) {
15
+ const results = [];
16
+ const re = new RegExp(NUMBER_UNIT_PATTERN.source, NUMBER_UNIT_PATTERN.flags);
17
+ let m;
18
+ while ((m = re.exec(text)) !== null) {
19
+ const value = parseFloat(m[1].replace(/,/g, ''));
20
+ const unit = m[2].toLowerCase().replace(/s$/, '');
21
+ results.push({ value, unit, raw: m[0], index: m.index });
22
+ }
23
+ return results;
24
+ }
25
+ /** Normalize unit names for comparison. */
26
+ export function normalizeUnit(u) {
27
+ const map = {
28
+ min: 'minute', minute: 'minute',
29
+ hour: 'hour', day: 'day', week: 'week', month: 'month',
30
+ request: 'request', call: 'call',
31
+ user: 'user', people: 'person', person: 'person',
32
+ employee: 'person', member: 'person',
33
+ };
34
+ return map[u] ?? u;
35
+ }
36
+ /** Get raw text from a node for content-based heuristics. */
37
+ export function getNodeText(node) {
38
+ const raw = node.content.raw ?? '';
39
+ const facts = (node.content.facts ?? []).join(' ');
40
+ return `${node.title} ${node.content.summary} ${facts} ${raw}`;
41
+ }
42
+ /** Extract the sentence containing the match at matchIndex. */
43
+ export function extractSentence(text, matchIndex) {
44
+ let start = matchIndex;
45
+ while (start > 0 && !/[.!?\n]/.test(text[start - 1]))
46
+ start--;
47
+ let end = matchIndex;
48
+ while (end < text.length && !/[.!?\n]/.test(text[end]))
49
+ end++;
50
+ return text.slice(start, end).trim();
51
+ }
52
+ /** Capitalize the first letter of a string. */
53
+ export function capitalizeFirst(s) {
54
+ return s.charAt(0).toUpperCase() + s.slice(1);
55
+ }
56
+ /** Extract topic keywords from a node's raw content only. */
57
+ export function rawKeywords(node) {
58
+ const text = node.content.raw ?? '';
59
+ return new Set(text.toLowerCase().split(/\W+/)
60
+ .filter((w) => w.length > 2 && !STOPWORDS.has(w) && !CONTEXT_STOPWORDS.has(w)));
61
+ }
62
+ /** Canonical pair key for deduplication. */
63
+ export function pairKey(id1, id2) {
64
+ return id1 < id2 ? `${id1}:${id2}` : `${id2}:${id1}`;
65
+ }
66
+ /** Return true if two nodes plausibly discuss the same topic. */
67
+ export function areSameTopic(a, b) {
68
+ const srcA = a.content.source?.sourceId ?? '';
69
+ const srcB = b.content.source?.sourceId ?? '';
70
+ if (srcA && srcB && srcA === srcB)
71
+ return true;
72
+ const wA = new Set(a.title.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
73
+ if (b.title.toLowerCase().split(/\W+/).some((w) => w.length > 3 && wA.has(w)))
74
+ return true;
75
+ const kwA = rawKeywords(a);
76
+ const kwB = rawKeywords(b);
77
+ const shared = [...kwA].filter((k) => kwB.has(k));
78
+ return shared.length >= 4;
79
+ }
80
+ /** Return true if both texts share an identical long sentence. */
81
+ export function shareExactSentence(textA, textB) {
82
+ const sA = textA.split(/[.!?\n]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.length > 20);
83
+ const sB = new Set(textB.split(/[.!?\n]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.length > 20));
84
+ return sA.some((s) => sB.has(s));
85
+ }
86
+ /** Build set of entities appearing in >50% of nodes. */
87
+ export function buildHighFreqEntities(nodes) {
88
+ if (nodes.length <= 4)
89
+ return new Set();
90
+ const freq = new Map();
91
+ for (const node of nodes) {
92
+ for (const ent of node.content.entities ?? []) {
93
+ const name = ent.name.toLowerCase();
94
+ freq.set(name, (freq.get(name) ?? 0) + 1);
95
+ }
96
+ }
97
+ const threshold = nodes.length * 0.3;
98
+ const result = new Set();
99
+ for (const [name, count] of freq) {
100
+ if (count > threshold)
101
+ result.add(name);
102
+ }
103
+ return result;
104
+ }
105
+ // Boolean/policy pairs: phrase A in doc 1 contradicts phrase B in doc 2.
106
+ /** @internal */
107
+ export const BOOLEAN_PAIRS = [
108
+ [/\bremote[- ]first\b/i, /\b(?:in[- ]office|office[- ](?:first|required|days?)|monday|tuesday|wednesday|thursday|friday)\b/i,
109
+ 'remote-first', 'office requirement'],
110
+ [/\b(?:allowed|can)\s+(?:to\s+)?work\s+remote/i, /\bremote\s+work\s+is\s+not\s+permitted\b/i,
111
+ 'remote work allowed', 'remote work not permitted'],
112
+ [/\b(?:allowed|can)\s+(?:to\s+)?work\s+remote/i, /\bmust\s+work\s+from\s+the\s+office\s+full[- ]time\b/i,
113
+ 'remote work allowed', 'must work from office full-time'],
114
+ [/\bwork\s+remote(?:ly)?\b/i, /\boffice\s+full[- ]?time\b/i,
115
+ 'remote work', 'full-time office'],
116
+ [/\bdeprecated\b/i, /\bcurrent(?:ly)?\s+(?:supported|active|used)\b/i,
117
+ 'deprecated', 'current'],
118
+ [/\bunder\s+a\s+minute\b/i, /\ba\s+few\s+minutes\b/i,
119
+ 'under a minute', 'a few minutes'],
120
+ [/\bunlimited\s+(?:vacation|pto|time\s+off)\b/i, /\b\d+\s+days?\s+(?:of\s+)?(?:paid\s+)?(?:vacation|pto|time\s+off)\b/i,
121
+ 'unlimited vacation', 'limited vacation days'],
122
+ ];
123
+ /** Words near enabled/disabled or required/optional that indicate a spec, not a policy. */
124
+ export const CONFIG_CONTEXT = /\b(toggle|setting|flag|config|checkbox|option|parameter|property|attribute|button|switch|mode|state|default|value|field|prerequisite|dependency|component|install|version|package|library|sdk|module)\b/i;
125
+ /** Detect contradictions from shared entities with different facts. */
126
+ export function detectFactContradiction(a, b, out, highFreqEntities) {
127
+ const factsA = a.content.facts ?? [];
128
+ const factsB = b.content.facts ?? [];
129
+ if (factsA.length === 0 || factsB.length === 0)
130
+ return;
131
+ const entA = new Set((a.content.entities ?? []).map((e) => e.name.toLowerCase()));
132
+ const entB = new Set((b.content.entities ?? []).map((e) => e.name.toLowerCase()));
133
+ const shared = [...entA].filter((e) => entB.has(e) && !highFreqEntities.has(e));
134
+ const meaningful = shared.filter((e) => e.length > 3);
135
+ if (meaningful.length < 3)
136
+ return;
137
+ const setA = new Set(factsA.map((f) => f.toLowerCase()));
138
+ const setB = new Set(factsB.map((f) => f.toLowerCase()));
139
+ if ([...setA].every((f) => setB.has(f)))
140
+ return;
141
+ const kwA = new Set(factsA.join(' ').toLowerCase().split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w)));
142
+ const kwB = new Set(factsB.join(' ').toLowerCase().split(/\W+/).filter((w) => w.length > 2 && !STOPWORDS.has(w)));
143
+ const onlyA = factsA.filter((f) => !setB.has(f.toLowerCase())).slice(0, 2);
144
+ const onlyB = factsB.filter((f) => !setA.has(f.toLowerCase())).slice(0, 2);
145
+ // Both sides must have unique facts — one-sided differences are gaps, not contradictions
146
+ if (onlyA.length === 0 || onlyB.length === 0)
147
+ return;
148
+ if (![...kwA].some((k) => kwB.has(k)))
149
+ return;
150
+ const quoteA = onlyA.join('; ');
151
+ const quoteB = onlyB.join('; ');
152
+ const topic = meaningful.slice(0, 3).join(', ');
153
+ out.push({
154
+ type: 'contradiction', severity: 'info', nodeIds: [a.id, b.id],
155
+ description: `When discussing ${topic}, "${a.title}" states: ${quoteA} — while "${b.title}" states: ${quoteB}`,
156
+ suggestedAction: `Review "${a.title}" and "${b.title}" for consistency.`,
157
+ metadata: {
158
+ sharedEntities: meaningful, factsA: onlyA, factsB: onlyB,
159
+ claimA: quoteA, claimB: quoteB, topic,
160
+ },
161
+ });
162
+ }
163
+ //# sourceMappingURL=contradiction-helpers.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Contradiction detector.
3
+ * Finds knowledge nodes that contradict each other via edges, LLM claim
4
+ * comparison, or content heuristics (fallback when no LLM is available).
5
+ * @module contradictions
6
+ */
7
+ import type { KnowledgeNode, DetectorFn } from '@useody/platform-core';
8
+ import { extractNumbers } from './contradiction-helpers.js';
9
+ /** Check if two nodes are siblings (share the same parent chain prefix). */
10
+ declare function areSiblings(a: KnowledgeNode, b: KnowledgeNode): boolean;
11
+ /** Check if both nodes have authoritative analysis hints. */
12
+ declare function areBothAuthoritative(a: KnowledgeNode, b: KnowledgeNode): boolean;
13
+ /**
14
+ * Detect contradictions between knowledge nodes.
15
+ * Uses edges first, then LLM claim comparison (if available),
16
+ * otherwise falls back to heuristic detection.
17
+ */
18
+ declare const detectContradictions: DetectorFn;
19
+ export { detectContradictions, extractNumbers, areSiblings, areBothAuthoritative, };
20
+ //# sourceMappingURL=contradictions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contradictions.d.ts","sourceRoot":"","sources":["../src/contradictions.ts"],"names":[],"mappings":"AACA;;;;;GAKG;AACH,OAAO,KAAK,EACV,aAAa,EAGb,UAAU,EAEX,MAAM,uBAAuB,CAAC;AAG/B,OAAO,EACL,cAAc,EAaf,MAAM,4BAA4B,CAAC;AAqKpC,4EAA4E;AAC5E,iBAAS,WAAW,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,GAAG,OAAO,CAchE;AAED,6DAA6D;AAC7D,iBAAS,oBAAoB,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,EAAE,aAAa,GAAG,OAAO,CAMzE;AA6BD;;;;GAIG;AACH,QAAA,MAAM,oBAAoB,EAAE,UAiC3B,CAAC;AAOF,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,WAAW,EACX,oBAAoB,GACrB,CAAC"}
@@ -0,0 +1,235 @@
1
+ import { detectClaimContradictions } from './claim-comparison.js';
2
+ import { detectClaimNliContradictions } from './claim-nli.js';
3
+ import { extractNumbers, normalizeUnit, getNodeText, extractSentence, capitalizeFirst, rawKeywords, pairKey, areSameTopic, shareExactSentence, buildHighFreqEntities, detectFactContradiction, BOOLEAN_PAIRS, CONFIG_CONTEXT, } from './contradiction-helpers.js';
4
+ /** Detect edge-based contradictions. */
5
+ function detectEdgeContradictions(nodes, edges, out, seen) {
6
+ const contradictEdges = edges.filter((e) => e.type === 'contradicts');
7
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
8
+ for (const edge of contradictEdges) {
9
+ const nodeA = nodeMap.get(edge.sourceId);
10
+ const nodeB = nodeMap.get(edge.targetId);
11
+ if (!nodeA || !nodeB)
12
+ continue;
13
+ const key = pairKey(nodeA.id, nodeB.id);
14
+ seen.add(key);
15
+ out.push({
16
+ type: 'contradiction',
17
+ severity: edge.confidence >= 0.8 ? 'critical' : 'warning',
18
+ nodeIds: [nodeA.id, nodeB.id],
19
+ description: edge.reason,
20
+ suggestedAction: `Resolve which is current: "${nodeA.title}" or "${nodeB.title}"`,
21
+ });
22
+ }
23
+ }
24
+ /** Heuristic fallback: number, boolean, and fact-based detection. */
25
+ function detectHeuristicContradictions(nodes, out, seen) {
26
+ const highFreqEntities = buildHighFreqEntities(nodes);
27
+ const MAX_FACT_INFO = 10;
28
+ let factInfoCount = 0;
29
+ for (let i = 0; i < nodes.length; i++) {
30
+ for (let j = i + 1; j < nodes.length; j++) {
31
+ const a = nodes[i];
32
+ const b = nodes[j];
33
+ const key = pairKey(a.id, b.id);
34
+ if (seen.has(key))
35
+ continue;
36
+ const pairDets = [];
37
+ if (factInfoCount < MAX_FACT_INFO) {
38
+ detectFactContradiction(a, b, pairDets, highFreqEntities);
39
+ if (pairDets.some((d) => d.severity === 'info'))
40
+ factInfoCount++;
41
+ }
42
+ if (pairDets.length === 0)
43
+ detectNumberContradiction(a, b, pairDets);
44
+ if (pairDets.length === 0)
45
+ detectBooleanContradiction(a, b, pairDets);
46
+ if (pairDets.length > 0) {
47
+ seen.add(key);
48
+ out.push(...pairDets);
49
+ }
50
+ }
51
+ }
52
+ }
53
+ /** Detect number+unit contradictions with surrounding sentence context. */
54
+ function detectNumberContradiction(a, b, out) {
55
+ if (!areSameTopic(a, b))
56
+ return;
57
+ const srcA = a.content.source?.sourceId ?? '';
58
+ const srcB = b.content.source?.sourceId ?? '';
59
+ if (srcA && srcB && srcA === srcB)
60
+ return;
61
+ const textA = getNodeText(a);
62
+ const textB = getNodeText(b);
63
+ if (shareExactSentence(textA, textB))
64
+ return;
65
+ const COLLOQUIAL_RE = /\b(sure|certain|confident|probably|maybe)\b/i;
66
+ const numsA = extractNumbers(textA).filter((n) => !COLLOQUIAL_RE.test(extractSentence(textA, n.index)));
67
+ const numsB = extractNumbers(textB).filter((n) => !COLLOQUIAL_RE.test(extractSentence(textB, n.index)));
68
+ if (numsA.length === 0 || numsB.length === 0)
69
+ return;
70
+ const SKIP_UNITS = new Set(['percent', '%']);
71
+ const HTTP_STATUS_RE = /\b(status|response|http|code|error)\b/i;
72
+ const TIME_UNITS = new Set(['day', 'hour', 'minute', 'week', 'month', 'second']);
73
+ for (const na of numsA) {
74
+ if (SKIP_UNITS.has(normalizeUnit(na.unit)))
75
+ continue;
76
+ if (na.value >= 100 && na.value <= 599 && HTTP_STATUS_RE.test(extractSentence(textA, na.index)))
77
+ continue;
78
+ for (const nb of numsB) {
79
+ if (normalizeUnit(na.unit) !== normalizeUnit(nb.unit))
80
+ continue;
81
+ if (na.value === nb.value)
82
+ continue;
83
+ const sentA = extractSentence(textA, na.index);
84
+ const sentB = extractSentence(textB, nb.index);
85
+ if (sentA === sentB)
86
+ continue;
87
+ if (TIME_UNITS.has(normalizeUnit(na.unit))) {
88
+ const sentKwA = new Set(sentA.toLowerCase().split(/\W+/).filter((w) => w.length >= 4));
89
+ const sentKwB = new Set(sentB.toLowerCase().split(/\W+/).filter((w) => w.length >= 4));
90
+ const sentShared = [...sentKwA].filter((w) => sentKwB.has(w));
91
+ if (sentShared.length < 2)
92
+ continue;
93
+ }
94
+ const kwA = rawKeywords(a);
95
+ const kwB = rawKeywords(b);
96
+ const sharedCount = [...kwA].filter((k) => kwB.has(k)).length;
97
+ if (sharedCount < 2)
98
+ continue;
99
+ const unitLabel = capitalizeFirst(normalizeUnit(na.unit));
100
+ out.push({
101
+ type: 'contradiction', severity: 'warning', nodeIds: [a.id, b.id],
102
+ description: `${unitLabel} inconsistency between ${a.title} and ${b.title}: "${na.raw}" vs "${nb.raw}"`,
103
+ suggestedAction: `Review "${a.title}" and "${b.title}" for consistency.`,
104
+ metadata: {
105
+ nodeExcerpts: { [a.id]: sentA, [b.id]: sentB },
106
+ claimA: na.raw, claimB: nb.raw, topic: `${unitLabel} values`,
107
+ },
108
+ });
109
+ return;
110
+ }
111
+ }
112
+ }
113
+ /** Detect boolean/opposing concept contradictions from raw content. */
114
+ function detectBooleanContradiction(a, b, out) {
115
+ if (!areSameTopic(a, b))
116
+ return;
117
+ const srcA = a.content.source?.sourceId ?? '';
118
+ const srcB = b.content.source?.sourceId ?? '';
119
+ if (srcA && srcB && srcA === srcB)
120
+ return;
121
+ const textA = getNodeText(a);
122
+ const textB = getNodeText(b);
123
+ if (shareExactSentence(textA, textB))
124
+ return;
125
+ for (const [pA, pB, lA, lB] of BOOLEAN_PAIRS) {
126
+ const mAA = pA.exec(textA);
127
+ const mBB = pB.exec(textB);
128
+ const mAB = (!mAA || !mBB) ? pB.exec(textA) : null;
129
+ const mBA = mAB ? pA.exec(textB) : null;
130
+ const skipLabels = new Set(['enabled', 'disabled', 'required', 'optional', 'mandatory', 'not required']);
131
+ if (skipLabels.has(lA) || skipLabels.has(lB)) {
132
+ const ctxA = mAA ? extractSentence(textA, mAA.index) : mAB ? extractSentence(textA, mAB.index) : '';
133
+ const ctxB = mBB ? extractSentence(textB, mBB.index) : mBA ? extractSentence(textB, mBA.index) : '';
134
+ if (CONFIG_CONTEXT.test(ctxA) || CONFIG_CONTEXT.test(ctxB))
135
+ continue;
136
+ }
137
+ const match = mAA && mBB
138
+ ? { m1: mAA, m2: mBB, la: lA, lb: lB }
139
+ : mAB && mBA ? { m1: mAB, m2: mBA, la: lB, lb: lA } : null;
140
+ if (!match)
141
+ continue;
142
+ const sentA = extractSentence(textA, match.m1.index);
143
+ const sentB = extractSentence(textB, match.m2.index);
144
+ out.push({
145
+ type: 'contradiction', severity: 'warning', nodeIds: [a.id, b.id],
146
+ description: `Policy conflict between ${a.title} and ${b.title}: "${match.la}" vs "${match.lb}"`,
147
+ suggestedAction: `Review "${a.title}" and "${b.title}" for consistency.`,
148
+ metadata: {
149
+ nodeExcerpts: { [a.id]: sentA, [b.id]: sentB },
150
+ claimA: match.la, claimB: match.lb,
151
+ topic: `${match.la} vs ${match.lb}`,
152
+ },
153
+ });
154
+ return;
155
+ }
156
+ }
157
+ /** Check if two nodes are siblings (share the same parent chain prefix). */
158
+ function areSiblings(a, b) {
159
+ const chainA = a.metadata?.['parentChain'];
160
+ const chainB = b.metadata?.['parentChain'];
161
+ if (!chainA?.length || !chainB?.length)
162
+ return false;
163
+ // Compare all but the last element (the document itself)
164
+ const parentA = chainA.slice(0, -1);
165
+ const parentB = chainB.slice(0, -1);
166
+ if (parentA.length === 0 || parentB.length === 0)
167
+ return false;
168
+ if (parentA.length !== parentB.length)
169
+ return false;
170
+ return parentA.every((p, i) => p.type === parentB[i].type && p.name === parentB[i].name);
171
+ }
172
+ /** Check if both nodes have authoritative analysis hints. */
173
+ function areBothAuthoritative(a, b) {
174
+ const hintsA = a.metadata?.['analysisHints'];
175
+ const hintsB = b.metadata?.['analysisHints'];
176
+ return hintsA?.authoritative === true && hintsB?.authoritative === true;
177
+ }
178
+ /** Apply enrichment-based severity boosts to detections. */
179
+ function applyEnrichmentBoosts(detections, nodes) {
180
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
181
+ for (const det of detections) {
182
+ if (det.nodeIds.length < 2)
183
+ continue;
184
+ const a = nodeMap.get(det.nodeIds[0]);
185
+ const b = nodeMap.get(det.nodeIds[1]);
186
+ if (!a || !b)
187
+ continue;
188
+ // Both authoritative → always critical
189
+ if (areBothAuthoritative(a, b)) {
190
+ det.severity = 'critical';
191
+ det.metadata = { ...det.metadata, boostReason: 'both-authoritative' };
192
+ continue;
193
+ }
194
+ // Sibling docs (same parent) → upgrade info→warning, warning→critical
195
+ if (areSiblings(a, b) && det.severity !== 'critical') {
196
+ det.severity = det.severity === 'info' ? 'warning' : 'critical';
197
+ det.metadata = { ...det.metadata, boostReason: 'sibling-docs' };
198
+ }
199
+ }
200
+ }
201
+ /**
202
+ * Detect contradictions between knowledge nodes.
203
+ * Uses edges first, then LLM claim comparison (if available),
204
+ * otherwise falls back to heuristic detection.
205
+ */
206
+ const detectContradictions = async (nodes, edges, llm) => {
207
+ const detections = [];
208
+ const seenPairs = new Set();
209
+ // 1. Edge-based contradictions (always)
210
+ detectEdgeContradictions(nodes, edges, detections, seenPairs);
211
+ // 2. Claim-based NLI detection (primary LLM path)
212
+ if (llm) {
213
+ const beforeNli = detections.length;
214
+ await detectClaimNliContradictions(nodes, llm, detections, seenPairs);
215
+ if (detections.length === beforeNli) {
216
+ await detectClaimContradictions(nodes, llm, detections, seenPairs);
217
+ }
218
+ }
219
+ // 3. Heuristic fallback (always runs for pairs not yet covered)
220
+ const beforeHeuristic = detections.length;
221
+ detectHeuristicContradictions(nodes, detections, seenPairs);
222
+ const MAX_HEURISTIC = 50;
223
+ if (detections.length - beforeHeuristic > MAX_HEURISTIC) {
224
+ detections.splice(beforeHeuristic + MAX_HEURISTIC);
225
+ }
226
+ // 4. Enrichment boosts (parentChain + analysisHints)
227
+ applyEnrichmentBoosts(detections, nodes);
228
+ return detections;
229
+ };
230
+ detectContradictions.preFilter = {
231
+ similarityThreshold: 0.6,
232
+ topK: 10,
233
+ };
234
+ export { detectContradictions, extractNumbers, areSiblings, areBothAuthoritative, };
235
+ //# sourceMappingURL=contradictions.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Duplicate / split-truth detector.
3
+ * Finds knowledge nodes covering the same topic with different information.
4
+ * @module duplicates
5
+ */
6
+ import type { DetectorFn } from '@useody/platform-core';
7
+ /**
8
+ * Detect split truth between knowledge nodes using LLM analysis.
9
+ * Pre-filters by fact overlap before sending to LLM.
10
+ * Returns empty if no LLM is provided.
11
+ */
12
+ declare const detectDuplicates: DetectorFn;
13
+ export { detectDuplicates };
14
+ //# sourceMappingURL=duplicates.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplicates.d.ts","sourceRoot":"","sources":["../src/duplicates.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAIV,UAAU,EAEX,MAAM,uBAAuB,CAAC;AAmB/B;;;;GAIG;AACH,QAAA,MAAM,gBAAgB,EAAE,UA8FvB,CAAC;AAOF,OAAO,EAAE,gBAAgB,EAAE,CAAC"}
@@ -0,0 +1,95 @@
1
+ import { parseLlmJsonResponse } from '@useody/platform-core';
2
+ import { completeWithTimeout } from './helpers/llm-timeout.js';
3
+ import { tokenize, sharedTokens } from './helpers/text-utils.js';
4
+ /** Minimum shared tokens between two nodes to consider them topically related. */
5
+ const MIN_SHARED_TOKENS = 1;
6
+ /** Build a canonical pair key for deduplication. */
7
+ function pairKey(a, b) {
8
+ return a < b ? `${a}:${b}` : `${b}:${a}`;
9
+ }
10
+ /** Extract fact tokens from a node for overlap checking. */
11
+ function factTokens(node) {
12
+ const facts = node.content.facts ?? [];
13
+ return tokenize(`${node.title} ${facts.join(' ')}`);
14
+ }
15
+ /**
16
+ * Detect split truth between knowledge nodes using LLM analysis.
17
+ * Pre-filters by fact overlap before sending to LLM.
18
+ * Returns empty if no LLM is provided.
19
+ */
20
+ const detectDuplicates = async (nodes, edges, llm) => {
21
+ const detections = [];
22
+ if (!llm)
23
+ return detections;
24
+ const seen = new Set();
25
+ let llmCalls = 0;
26
+ const MAX_LLM_CALLS = 20;
27
+ for (let i = 0; i < nodes.length && llmCalls < MAX_LLM_CALLS; i++) {
28
+ for (let j = i + 1; j < nodes.length && llmCalls < MAX_LLM_CALLS; j++) {
29
+ const a = nodes[i];
30
+ const b = nodes[j];
31
+ const key = pairKey(a.id, b.id);
32
+ if (seen.has(key))
33
+ continue;
34
+ seen.add(key);
35
+ const hasEdge = edges.some((e) => (e.sourceId === a.id && e.targetId === b.id) ||
36
+ (e.sourceId === b.id && e.targetId === a.id));
37
+ if (hasEdge)
38
+ continue;
39
+ const factsA = (a.content.facts ?? []).join('\n- ');
40
+ const factsB = (b.content.facts ?? []).join('\n- ');
41
+ if (!factsA && !factsB)
42
+ continue;
43
+ const tokensA = factTokens(a);
44
+ const tokensB = factTokens(b);
45
+ const overlap = sharedTokens(tokensA, tokensB);
46
+ if (overlap.length < MIN_SHARED_TOKENS)
47
+ continue;
48
+ llmCalls++;
49
+ const response = await completeWithTimeout(llm, [
50
+ {
51
+ role: 'system',
52
+ content: [
53
+ 'You detect SPLIT TRUTH: two documents about the SAME specific topic',
54
+ 'that state DIFFERENT or CONFLICTING facts.',
55
+ 'Rules:',
56
+ '- Two documents about DIFFERENT topics = NOT split truth.',
57
+ '- Two documents that AGREE on the same topic = NOT split truth.',
58
+ '- Only flag when the SAME topic has CONFLICTING specific claims.',
59
+ 'Reply JSON only: {"splitTruth":boolean,"conflictA":string,"conflictB":string,"explanation":string}',
60
+ 'conflictA/B: the specific conflicting claim from each document (under 20 words each).',
61
+ 'If not split truth, set conflictA and conflictB to empty strings.',
62
+ 'Keep explanation under 25 words.',
63
+ ].join(' '),
64
+ },
65
+ {
66
+ role: 'user',
67
+ content: `Document A: "${a.title}"\nFacts:\n- ${factsA || '(none)'}` +
68
+ `\n\nDocument B: "${b.title}"\nFacts:\n- ${factsB || '(none)'}`,
69
+ },
70
+ ], { temperature: 0, maxTokens: 200 });
71
+ if (!response)
72
+ continue;
73
+ const parsed = parseLlmJsonResponse(response);
74
+ if (parsed.data?.splitTruth && parsed.data.explanation) {
75
+ const desc = parsed.data.conflictA && parsed.data.conflictB
76
+ ? `Split truth: "${parsed.data.conflictA}" vs "${parsed.data.conflictB}"`
77
+ : parsed.data.explanation;
78
+ detections.push({
79
+ type: 'duplicate',
80
+ severity: 'warning',
81
+ nodeIds: [a.id, b.id],
82
+ description: desc,
83
+ suggestedAction: 'Review and consolidate into a single source of truth.',
84
+ });
85
+ }
86
+ }
87
+ }
88
+ return detections;
89
+ };
90
+ detectDuplicates.preFilter = {
91
+ similarityThreshold: 0.75,
92
+ topK: 5,
93
+ };
94
+ export { detectDuplicates };
95
+ //# sourceMappingURL=duplicates.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Deterministic health scoring from findings.
3
+ * Computes health scores without LLM — purely from the set of findings.
4
+ * @module health-score
5
+ */
6
+ import type { ConsultingFinding, HealthScore } from './consultant-analysis.js';
7
+ /**
8
+ * Compute a deterministic health score from findings.
9
+ * The LLM only identifies findings — this function derives
10
+ * repeatable scores purely from their categories and severities.
11
+ */
12
+ export declare function computeDeterministicHealthScore(findings: ReadonlyArray<ConsultingFinding>): HealthScore;
13
+ //# sourceMappingURL=health-score.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health-score.d.ts","sourceRoot":"","sources":["../src/health-score.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAmB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAmDhG;;;;GAIG;AACH,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,aAAa,CAAC,iBAAiB,CAAC,GACzC,WAAW,CAWb"}
@@ -0,0 +1,53 @@
1
+ /** Severity penalty points. */
2
+ const SEVERITY_PENALTY = {
3
+ critical: 25,
4
+ warning: 8,
5
+ info: 3,
6
+ };
7
+ /** Categories that penalize the consistency dimension. */
8
+ const CONSISTENCY_CATEGORIES = new Set([
9
+ 'contradiction',
10
+ 'duplicate_truth',
11
+ ]);
12
+ /** Categories that penalize the freshness dimension. */
13
+ const FRESHNESS_CATEGORIES = new Set([
14
+ 'stale_commitment',
15
+ 'commitment_without_followthrough',
16
+ ]);
17
+ /** Categories that penalize the ownership dimension. */
18
+ const OWNERSHIP_CATEGORIES = new Set([
19
+ 'ownership_gap',
20
+ 'decision_without_context',
21
+ ]);
22
+ /** Categories that penalize the coverage dimension. */
23
+ const COVERAGE_CATEGORIES = new Set([
24
+ 'tribal_knowledge',
25
+ ]);
26
+ /** Clamp a value to the 0-100 range. */
27
+ function clamp(v) {
28
+ return Math.max(0, Math.min(100, Math.round(v)));
29
+ }
30
+ /** Compute the penalty for a set of findings against a dimension. */
31
+ function dimensionScore(findings, categories) {
32
+ let penalty = 0;
33
+ for (const f of findings) {
34
+ if (categories.has(f.category)) {
35
+ penalty += SEVERITY_PENALTY[f.severity] ?? 0;
36
+ }
37
+ }
38
+ return clamp(100 - penalty);
39
+ }
40
+ /**
41
+ * Compute a deterministic health score from findings.
42
+ * The LLM only identifies findings — this function derives
43
+ * repeatable scores purely from their categories and severities.
44
+ */
45
+ export function computeDeterministicHealthScore(findings) {
46
+ const consistency = dimensionScore(findings, CONSISTENCY_CATEGORIES);
47
+ const freshness = dimensionScore(findings, FRESHNESS_CATEGORIES);
48
+ const ownership = dimensionScore(findings, OWNERSHIP_CATEGORIES);
49
+ const coverage = dimensionScore(findings, COVERAGE_CATEGORIES);
50
+ const overall = clamp(consistency * 0.35 + freshness * 0.25 + ownership * 0.2 + coverage * 0.2);
51
+ return { overall, consistency, freshness, ownership, coverage };
52
+ }
53
+ //# sourceMappingURL=health-score.js.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Helper utilities for detectors.
3
+ * @module helpers
4
+ */
5
+ export { completeWithTimeout } from './llm-timeout.js';
6
+ export { normalize, tokenize, sharedTokens, lexicalScore, buildSignal, STOP_WORDS, } from './text-utils.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/helpers/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AACvD,OAAO,EACL,SAAS,EACT,QAAQ,EACR,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,UAAU,GACX,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Helper utilities for detectors.
3
+ * @module helpers
4
+ */
5
+ export { completeWithTimeout } from './llm-timeout.js';
6
+ export { normalize, tokenize, sharedTokens, lexicalScore, buildSignal, STOP_WORDS, } from './text-utils.js';
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * LLM completion with timeout guard.
3
+ * Returns empty string on transient errors (timeout, 5xx).
4
+ * Re-throws fatal errors (auth, quota) so the caller can surface them.
5
+ * @module helpers/llm-timeout
6
+ */
7
+ import type { ChatMessage, LLMCompletionOptions, LLMProvider } from '@useody/platform-core';
8
+ /**
9
+ * Call LLM with a timeout.
10
+ * Returns empty string on transient failures (timeout, 5xx).
11
+ * Re-throws fatal errors (401, 403) so the scan can fail fast.
12
+ */
13
+ export declare function completeWithTimeout(llm: LLMProvider, messages: ChatMessage[], options: LLMCompletionOptions, timeoutMs?: number): Promise<string>;
14
+ //# sourceMappingURL=llm-timeout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"llm-timeout.d.ts","sourceRoot":"","sources":["../../src/helpers/llm-timeout.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EACV,WAAW,EACX,oBAAoB,EACpB,WAAW,EACZ,MAAM,uBAAuB,CAAC;AAY/B;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,WAAW,EAChB,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,EAAE,oBAAoB,EAC7B,SAAS,SAAqB,GAC7B,OAAO,CAAC,MAAM,CAAC,CAejB"}