@ai-sdlc/orchestrator 0.10.0 → 0.14.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/dist/adapters.d.ts +42 -3
- package/dist/adapters.js +133 -3
- package/dist/admission-composite.d.ts +112 -1
- package/dist/admission-composite.js +85 -4
- package/dist/admission-enrichment.d.ts +12 -3
- package/dist/admission-enrichment.js +64 -13
- package/dist/admission-score.d.ts +30 -0
- package/dist/admission-score.js +4 -1
- package/dist/analysis/file-walker.js +5 -0
- package/dist/backlog-adapter.d.ts +45 -0
- package/dist/backlog-adapter.js +145 -1
- package/dist/calibration.d.ts +81 -0
- package/dist/calibration.js +76 -0
- package/dist/cli/commands/git-remote.js +1 -1
- package/dist/cli/commands/init-features.d.ts +301 -2
- package/dist/cli/commands/init-features.js +647 -7
- package/dist/cli/commands/init-templates.d.ts +198 -1
- package/dist/cli/commands/init-templates.js +943 -1
- package/dist/cli/commands/init.d.ts +45 -0
- package/dist/cli/commands/init.js +147 -5
- package/dist/cli/commands/run.js +9 -1
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.js +42 -8
- package/dist/compliance/composer.d.ts +79 -0
- package/dist/compliance/composer.js +258 -0
- package/dist/compliance/errors.d.ts +64 -0
- package/dist/compliance/errors.js +85 -0
- package/dist/compliance/loader.d.ts +52 -0
- package/dist/compliance/loader.js +124 -0
- package/dist/compliance/types.d.ts +184 -0
- package/dist/compliance/types.js +41 -0
- package/dist/compliance-clearance.d.ts +269 -0
- package/dist/compliance-clearance.js +269 -0
- package/dist/config.js +17 -0
- package/dist/cost-tracker.d.ts +22 -0
- package/dist/cost-tracker.js +41 -0
- package/dist/cycle-utils.js +14 -1
- package/dist/database/adapters/external.js +5 -1
- package/dist/embedding/adapters/openai-text-embedding-3-small.d.ts +71 -0
- package/dist/embedding/adapters/openai-text-embedding-3-small.js +190 -0
- package/dist/embedding/consumers/tessellation-drift.d.ts +74 -0
- package/dist/embedding/consumers/tessellation-drift.js +76 -0
- package/dist/embedding/cross-provider.d.ts +78 -0
- package/dist/embedding/cross-provider.js +75 -0
- package/dist/embedding/deprecation.d.ts +151 -0
- package/dist/embedding/deprecation.js +229 -0
- package/dist/embedding/errors.d.ts +90 -0
- package/dist/embedding/errors.js +150 -0
- package/dist/embedding/index.d.ts +29 -0
- package/dist/embedding/index.js +24 -0
- package/dist/embedding/pipeline-load.d.ts +146 -0
- package/dist/embedding/pipeline-load.js +178 -0
- package/dist/embedding/registry.d.ts +45 -0
- package/dist/embedding/registry.js +61 -0
- package/dist/embedding/stale-vector.d.ts +110 -0
- package/dist/embedding/stale-vector.js +92 -0
- package/dist/embedding/storage/index.d.ts +51 -0
- package/dist/embedding/storage/index.js +43 -0
- package/dist/embedding/storage/jsonl-backend.d.ts +150 -0
- package/dist/embedding/storage/jsonl-backend.js +332 -0
- package/dist/embedding/storage/types.d.ts +135 -0
- package/dist/embedding/storage/types.js +13 -0
- package/dist/embedding/types.d.ts +180 -0
- package/dist/embedding/types.js +10 -0
- package/dist/execute.d.ts +29 -2
- package/dist/execute.js +200 -38
- package/dist/fix-review.js +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.js +19 -2
- package/dist/journey/inheritance-validator.d.ts +396 -0
- package/dist/journey/inheritance-validator.js +370 -0
- package/dist/journey/metric-snapshot.d.ts +403 -0
- package/dist/journey/metric-snapshot.js +370 -0
- package/dist/journey/state-id-drift-rule.d.ts +137 -0
- package/dist/journey/state-id-drift-rule.js +245 -0
- package/dist/journey-sa2-router.d.ts +395 -0
- package/dist/journey-sa2-router.js +308 -0
- package/dist/otel-exporter.js +0 -3
- package/dist/runners/claude-code.js +3 -3
- package/dist/runners/review-agent.js +6 -2
- package/dist/runners/runner-registry.d.ts +36 -0
- package/dist/runners/runner-registry.js +90 -0
- package/dist/runtime/attestations.d.ts +173 -13
- package/dist/runtime/attestations.js +265 -43
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/sa-scoring/exemplar-bank.js +3 -1
- package/dist/sa-scoring/layer3-llm.js +6 -1
- package/dist/sa-scoring/revision-proposal-config.d.ts +178 -0
- package/dist/sa-scoring/revision-proposal-config.js +198 -0
- package/dist/sa-scoring/revision-proposal.d.ts +285 -0
- package/dist/sa-scoring/revision-proposal.js +417 -0
- package/dist/shared.d.ts +28 -0
- package/dist/shared.js +37 -0
- package/dist/signal-ingestion/adapters/community-thread.d.ts +43 -0
- package/dist/signal-ingestion/adapters/community-thread.js +55 -0
- package/dist/signal-ingestion/adapters/in-app-feedback.d.ts +67 -0
- package/dist/signal-ingestion/adapters/in-app-feedback.js +51 -0
- package/dist/signal-ingestion/adapters/manual.d.ts +78 -0
- package/dist/signal-ingestion/adapters/manual.js +112 -0
- package/dist/signal-ingestion/adapters/support-ticket.d.ts +47 -0
- package/dist/signal-ingestion/adapters/support-ticket.js +51 -0
- package/dist/signal-ingestion/classifier.d.ts +205 -0
- package/dist/signal-ingestion/classifier.js +494 -0
- package/dist/signal-ingestion/clustering-types.d.ts +36 -0
- package/dist/signal-ingestion/clustering-types.js +14 -0
- package/dist/signal-ingestion/clustering.d.ts +200 -0
- package/dist/signal-ingestion/clustering.js +413 -0
- package/dist/signal-ingestion/config.d.ts +351 -0
- package/dist/signal-ingestion/config.js +587 -0
- package/dist/signal-ingestion/d1.d.ts +252 -0
- package/dist/signal-ingestion/d1.js +235 -0
- package/dist/signal-ingestion/errors.d.ts +73 -0
- package/dist/signal-ingestion/errors.js +108 -0
- package/dist/signal-ingestion/governance-events.d.ts +181 -0
- package/dist/signal-ingestion/governance-events.js +189 -0
- package/dist/signal-ingestion/index.d.ts +35 -0
- package/dist/signal-ingestion/index.js +53 -0
- package/dist/signal-ingestion/manual-share-metric.d.ts +93 -0
- package/dist/signal-ingestion/manual-share-metric.js +106 -0
- package/dist/signal-ingestion/registry.d.ts +40 -0
- package/dist/signal-ingestion/registry.js +137 -0
- package/dist/signal-ingestion/residency.d.ts +227 -0
- package/dist/signal-ingestion/residency.js +238 -0
- package/dist/signal-ingestion/significance.d.ts +554 -0
- package/dist/signal-ingestion/significance.js +555 -0
- package/dist/signal-ingestion/types.d.ts +191 -0
- package/dist/signal-ingestion/types.js +8 -0
- package/dist/substrate/drift-composition.d.ts +270 -0
- package/dist/substrate/drift-composition.js +306 -0
- package/dist/substrate/drift-tui-surface.d.ts +61 -0
- package/dist/substrate/drift-tui-surface.js +102 -0
- package/dist/substrate/identity-class.d.ts +176 -0
- package/dist/substrate/identity-class.js +201 -0
- package/dist/tessellation/cross-soul-provenance-rule.d.ts +133 -0
- package/dist/tessellation/cross-soul-provenance-rule.js +171 -0
- package/dist/tessellation/inter-soul-embedding-distance-rule.d.ts +61 -0
- package/dist/tessellation/inter-soul-embedding-distance-rule.js +67 -0
- package/dist/tessellation/rule-registry.d.ts +269 -0
- package/dist/tessellation/rule-registry.js +92 -0
- package/dist/tessellation/soul-slug-ast-scan-rule.d.ts +90 -0
- package/dist/tessellation/soul-slug-ast-scan-rule.js +158 -0
- package/dist/tessellation-admission.d.ts +162 -0
- package/dist/tessellation-admission.js +146 -0
- package/dist/tessellation-drift.d.ts +246 -0
- package/dist/tessellation-drift.js +250 -0
- package/dist/validate-config.js +13 -0
- package/dist/validate-issue.js +2 -2
- package/dist/variant/cardinality-activation.d.ts +126 -0
- package/dist/variant/cardinality-activation.js +101 -0
- package/dist/variant/deprecation-lifecycle.d.ts +184 -0
- package/dist/variant/deprecation-lifecycle.js +208 -0
- package/dist/variant/drift-extension.d.ts +136 -0
- package/dist/variant/drift-extension.js +164 -0
- package/dist/variant/engineering-review.d.ts +185 -0
- package/dist/variant/engineering-review.js +142 -0
- package/dist/variant/index.d.ts +32 -0
- package/dist/variant/index.js +32 -0
- package/dist/variant/inheritance-validator.d.ts +165 -0
- package/dist/variant/inheritance-validator.js +139 -0
- package/dist/variant/internal-adopter/index.d.ts +11 -0
- package/dist/variant/internal-adopter/index.js +10 -0
- package/dist/variant/internal-adopter/products.d.ts +156 -0
- package/dist/variant/internal-adopter/products.js +366 -0
- package/dist/variant-admission.d.ts +316 -0
- package/dist/variant-admission.js +247 -0
- package/dist/webhook-manager.js +7 -1
- package/package.json +15 -12
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-0030 Phase 2 — Signal classifier.
|
|
3
|
+
*
|
|
4
|
+
* Classifies raw signals on three deterministic axes and applies the
|
|
5
|
+
* English-only language gate (OQ-13.2 resolution):
|
|
6
|
+
*
|
|
7
|
+
* 1. **Tier** — enterprise / mid / smb / free / churned (metadata-driven).
|
|
8
|
+
* Inference order: adapter-provided `customerTier` → customerId lookup →
|
|
9
|
+
* adapter's `defaultTier` (mapped to smb for Tier 1, free for Tier 2).
|
|
10
|
+
*
|
|
11
|
+
* 2. **ICP resonance** — strong / partial / weak (BM25 default; embedding
|
|
12
|
+
* when RFC-0019 adapter is configured — currently always BM25 in v1).
|
|
13
|
+
* For v1 the classifier receives an explicit `icpSegments` list from the
|
|
14
|
+
* caller. BM25 matching measures how closely the signal payload matches
|
|
15
|
+
* the declared ICP segments.
|
|
16
|
+
*
|
|
17
|
+
* 3. **Recency decay** — exponential decay: exp(-age_days × ln(2) / half_life).
|
|
18
|
+
* Applied at scoring time so the pipeline doesn't need to re-compute as
|
|
19
|
+
* time passes (age is passed as input). Half-life is read from config
|
|
20
|
+
* (default 30 days).
|
|
21
|
+
*
|
|
22
|
+
* **Language gate** (OQ-13.2 v0.3 re-walkthrough): non-accepted-language signal
|
|
23
|
+
* payloads are detected via the `franc` library (deterministic; <10ms per
|
|
24
|
+
* signal; JS-native; MIT-licensed; 95%+ accuracy on text >50 chars; returns
|
|
25
|
+
* ISO 639-3 codes). Signals in unsupported languages are dropped and logged as
|
|
26
|
+
* `Decision: signal-language-unsupported`. Per-org `acceptedLanguages` config
|
|
27
|
+
* is respected (default `['en']`); multi-language opt-in (e.g. `['en', 'fr',
|
|
28
|
+
* 'es']`) accepts those languages knowingly — adopters take on documented BM25
|
|
29
|
+
* quality degradation (~15-30% precision drop without per-language stopwords)
|
|
30
|
+
* in exchange for non-English signal coverage.
|
|
31
|
+
*
|
|
32
|
+
* All multipliers and weights are read from `SignalIngestionConfig`; they are
|
|
33
|
+
* NOT hardcoded, satisfying AC #6.
|
|
34
|
+
*
|
|
35
|
+
* @module signal-ingestion/classifier
|
|
36
|
+
*/
|
|
37
|
+
import { franc, francAll } from 'franc';
|
|
38
|
+
import { DEFAULT_SIGNAL_INGESTION_CONFIG } from './config.js';
|
|
39
|
+
// ── Main entry point ────────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* Classify a batch of raw signals.
|
|
42
|
+
*
|
|
43
|
+
* Per RFC-0030 §6 inference order for tier:
|
|
44
|
+
* 1. `signal.customerTier` (adapter-provided structured field)
|
|
45
|
+
* 2. `tierRegistry.resolve(signal.customerId)` (registry lookup)
|
|
46
|
+
* 3. Adapter `defaultTier` (mapped: Tier 1 → smb, Tier 2 → free)
|
|
47
|
+
*
|
|
48
|
+
* Per RFC-0030 §13.2: non-accepted-language signals are dropped and
|
|
49
|
+
* returned in `languageDecisions`.
|
|
50
|
+
*/
|
|
51
|
+
export function classifySignals(signals, options = {}) {
|
|
52
|
+
const config = options.config ?? DEFAULT_SIGNAL_INGESTION_CONFIG;
|
|
53
|
+
const asOf = options.asOf ?? new Date();
|
|
54
|
+
const icpSegments = options.icpSegments ?? [];
|
|
55
|
+
const tierRegistry = options.tierRegistry;
|
|
56
|
+
const adapterTiers = options.adapterTiers ?? new Map();
|
|
57
|
+
const acceptedLanguages = config.acceptedLanguages;
|
|
58
|
+
const languageDetection = config.languageDetection;
|
|
59
|
+
const classified = [];
|
|
60
|
+
const languageDecisions = [];
|
|
61
|
+
for (const signal of signals) {
|
|
62
|
+
// Language gate — drop non-accepted-language signals
|
|
63
|
+
const langCheck = checkLanguage(signal.payload, acceptedLanguages, languageDetection);
|
|
64
|
+
if (!langCheck.accepted) {
|
|
65
|
+
languageDecisions.push({
|
|
66
|
+
type: 'Decision',
|
|
67
|
+
decision: 'signal-language-unsupported',
|
|
68
|
+
sourceId: signal.sourceId,
|
|
69
|
+
detectedLanguage: langCheck.detectedLanguage,
|
|
70
|
+
detectedScript: langCheck.detectedScript,
|
|
71
|
+
acceptedLanguages,
|
|
72
|
+
message: `Signal ${signal.sourceId} dropped: detected language '${langCheck.detectedLanguage}' (script '${langCheck.detectedScript}') not in accepted languages ${JSON.stringify(acceptedLanguages)}`,
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const customerTier = resolveCustomerTier(signal, tierRegistry, adapterTiers);
|
|
77
|
+
const icpResonance = resolveIcpResonance(signal.payload, icpSegments);
|
|
78
|
+
const recencyDecay = computeRecencyDecay(signal.sourceTimestamp, asOf, config.recencyHalfLifeDays);
|
|
79
|
+
const tierMultiplier = config.tierMultipliers[customerTier];
|
|
80
|
+
const icpResonanceWeight = config.icpResonanceWeights[icpResonance];
|
|
81
|
+
// Resolve adapter tier for base weight (Phase 4 will refine with significance threshold)
|
|
82
|
+
const sourceAdapterTier = adapterTiers.get(signal.metadata?.['adapterName']) ?? 1;
|
|
83
|
+
const baseWeight = sourceAdapterTier === 1 ? 1.0 : 0.3;
|
|
84
|
+
classified.push({
|
|
85
|
+
signal,
|
|
86
|
+
customerTier,
|
|
87
|
+
icpResonance,
|
|
88
|
+
recencyDecay,
|
|
89
|
+
tierMultiplier,
|
|
90
|
+
icpResonanceWeight,
|
|
91
|
+
baseWeight,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { classified, languageDecisions };
|
|
95
|
+
}
|
|
96
|
+
// ── Tier resolution ─────────────────────────────────────────────────────────
|
|
97
|
+
/**
|
|
98
|
+
* Resolve customer tier per RFC-0030 §6.1 inference order:
|
|
99
|
+
* 1. `signal.customerTier`
|
|
100
|
+
* 2. `tierRegistry.resolve(signal.customerId)`
|
|
101
|
+
* 3. Adapter defaultTier → smb (Tier 1) or free (Tier 2)
|
|
102
|
+
*/
|
|
103
|
+
export function resolveCustomerTier(signal, tierRegistry, adapterTiers) {
|
|
104
|
+
// Step 1: adapter-provided structured tier
|
|
105
|
+
if (signal.customerTier !== undefined) {
|
|
106
|
+
return signal.customerTier;
|
|
107
|
+
}
|
|
108
|
+
// Step 2: customerId registry lookup
|
|
109
|
+
if (tierRegistry && signal.customerId !== undefined) {
|
|
110
|
+
const resolved = tierRegistry.resolve(signal.customerId);
|
|
111
|
+
if (resolved !== undefined)
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
// Step 3: adapter defaultTier mapping
|
|
115
|
+
const adapterName = signal.metadata?.['adapterName'];
|
|
116
|
+
const signalTier = adapterName ? (adapterTiers?.get(adapterName) ?? 1) : 1;
|
|
117
|
+
// Tier 1 adapters (support tickets, CRM) → smb as baseline customer tier
|
|
118
|
+
// Tier 2 adapters (community, competitive) → free as baseline
|
|
119
|
+
return signalTier === 1 ? 'smb' : 'free';
|
|
120
|
+
}
|
|
121
|
+
// ── ICP resonance (BM25) ────────────────────────────────────────────────────
|
|
122
|
+
/**
|
|
123
|
+
* Classify ICP resonance for a signal payload against the declared ICP
|
|
124
|
+
* segment descriptors using BM25 similarity.
|
|
125
|
+
*
|
|
126
|
+
* When `icpSegments` is empty, returns `partial` (no basis for strong/weak).
|
|
127
|
+
*
|
|
128
|
+
* BM25 parameters (k1=1.5, b=0.75) are standard.
|
|
129
|
+
* Score mapping:
|
|
130
|
+
* - ≥ 0.5 → strong
|
|
131
|
+
* - ≥ 0.15 → partial
|
|
132
|
+
* - < 0.15 → weak
|
|
133
|
+
*/
|
|
134
|
+
export function resolveIcpResonance(payload, icpSegments) {
|
|
135
|
+
if (icpSegments.length === 0)
|
|
136
|
+
return 'partial';
|
|
137
|
+
const score = bm25MaxScore(payload, icpSegments);
|
|
138
|
+
if (score >= 0.5)
|
|
139
|
+
return 'strong';
|
|
140
|
+
if (score >= 0.15)
|
|
141
|
+
return 'partial';
|
|
142
|
+
return 'weak';
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Compute the BM25-based ICP match score between a signal payload and the
|
|
146
|
+
* ICP segment corpus.
|
|
147
|
+
*
|
|
148
|
+
* **Scoring direction**: ICP segments are the "documents"; the payload tokens
|
|
149
|
+
* are the "query". We score each ICP segment against the payload terms, then
|
|
150
|
+
* take the maximum across segments. This measures how closely the signal
|
|
151
|
+
* payload covers the ICP segment vocabulary — the deterministic-first
|
|
152
|
+
* approach per RFC-0029 Principle 2.
|
|
153
|
+
*
|
|
154
|
+
* **Normalisation**: we compute the self-score of each ICP segment (score
|
|
155
|
+
* when the document perfectly matches the query — i.e., we pretend the
|
|
156
|
+
* segment itself is the payload) and use the maximum self-score as the
|
|
157
|
+
* denominator. This produces a well-bounded [0, 1] score.
|
|
158
|
+
*
|
|
159
|
+
* Implementation: simplified BM25 without external deps so the orchestrator
|
|
160
|
+
* stays self-contained.
|
|
161
|
+
*/
|
|
162
|
+
function bm25MaxScore(payload, corpus) {
|
|
163
|
+
const K1 = 1.5;
|
|
164
|
+
const B = 0.75;
|
|
165
|
+
const payloadTokens = tokenize(payload);
|
|
166
|
+
if (payloadTokens.length === 0)
|
|
167
|
+
return 0;
|
|
168
|
+
// Build term frequency maps for both the corpus docs and the payload
|
|
169
|
+
const corpusTfs = corpus.map((doc) => termFrequency(tokenize(doc)));
|
|
170
|
+
const avgDocLen = corpus.reduce((sum, doc) => sum + tokenize(doc).length, 0) / corpus.length;
|
|
171
|
+
const N = corpus.length;
|
|
172
|
+
/**
|
|
173
|
+
* IDF of a term over the ICP corpus.
|
|
174
|
+
* Smoothed to avoid negatives: log((N - df + 0.5) / (df + 0.5) + 1)
|
|
175
|
+
*/
|
|
176
|
+
const idf = (term) => {
|
|
177
|
+
const df = corpusTfs.filter((tf) => (tf.get(term) ?? 0) > 0).length;
|
|
178
|
+
return Math.log((N - df + 0.5) / (df + 0.5) + 1);
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* BM25 score for one ICP segment against a set of query terms.
|
|
182
|
+
* query terms come from the entity we're scoring (payload OR the segment
|
|
183
|
+
* itself for self-score normalisation).
|
|
184
|
+
*/
|
|
185
|
+
const scoreSegment = (segTf, queryTerms) => {
|
|
186
|
+
const docLen = Array.from(segTf.values()).reduce((a, b) => a + b, 0);
|
|
187
|
+
let score = 0;
|
|
188
|
+
for (const term of new Set(queryTerms)) {
|
|
189
|
+
const freq = segTf.get(term) ?? 0;
|
|
190
|
+
if (freq === 0)
|
|
191
|
+
continue;
|
|
192
|
+
const termIdf = idf(term);
|
|
193
|
+
const numerator = freq * (K1 + 1);
|
|
194
|
+
const denominator = freq + K1 * (1 - B + B * (docLen / Math.max(avgDocLen, 1)));
|
|
195
|
+
score += termIdf * (numerator / denominator);
|
|
196
|
+
}
|
|
197
|
+
return score;
|
|
198
|
+
};
|
|
199
|
+
// Score each ICP segment using payload tokens as the query
|
|
200
|
+
let maxScore = 0;
|
|
201
|
+
let maxSelfScore = 0;
|
|
202
|
+
for (let i = 0; i < corpus.length; i++) {
|
|
203
|
+
const segTf = corpusTfs[i];
|
|
204
|
+
const segTokens = tokenize(corpus[i]);
|
|
205
|
+
// Self-score: how much would this segment score if queried by its own tokens?
|
|
206
|
+
const selfScore = scoreSegment(segTf, segTokens);
|
|
207
|
+
if (selfScore > maxSelfScore)
|
|
208
|
+
maxSelfScore = selfScore;
|
|
209
|
+
// Payload score: score this segment using the payload tokens
|
|
210
|
+
const payloadScore = scoreSegment(segTf, payloadTokens);
|
|
211
|
+
if (payloadScore > maxScore)
|
|
212
|
+
maxScore = payloadScore;
|
|
213
|
+
}
|
|
214
|
+
if (maxSelfScore <= 0)
|
|
215
|
+
return 0;
|
|
216
|
+
// Normalise by the maximum achievable score (self-match score)
|
|
217
|
+
return Math.min(1, maxScore / maxSelfScore);
|
|
218
|
+
}
|
|
219
|
+
/** Tokenize a string to lowercase alphanumeric tokens, minimum length 2. */
|
|
220
|
+
export function tokenize(text) {
|
|
221
|
+
return text.toLowerCase().match(/[a-z0-9]{2,}/g) ?? [];
|
|
222
|
+
}
|
|
223
|
+
/** Count term frequencies in a token list. */
|
|
224
|
+
function termFrequency(tokens) {
|
|
225
|
+
const tf = new Map();
|
|
226
|
+
for (const t of tokens)
|
|
227
|
+
tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
228
|
+
return tf;
|
|
229
|
+
}
|
|
230
|
+
// ── Recency decay ───────────────────────────────────────────────────────────
|
|
231
|
+
/**
|
|
232
|
+
* Compute exponential recency decay for a signal.
|
|
233
|
+
*
|
|
234
|
+
* `decay = exp(-age_days * ln(2) / halfLifeDays)`
|
|
235
|
+
*
|
|
236
|
+
* At age = halfLifeDays, decay = 0.5.
|
|
237
|
+
* At age = 0, decay = 1.0.
|
|
238
|
+
* Future-dated signals (age < 0) clamp to 1.0.
|
|
239
|
+
*/
|
|
240
|
+
export function computeRecencyDecay(sourceTimestamp, asOf, halfLifeDays) {
|
|
241
|
+
if (halfLifeDays <= 0)
|
|
242
|
+
return 1.0;
|
|
243
|
+
const ageDays = (asOf.getTime() - sourceTimestamp.getTime()) / (1000 * 60 * 60 * 24);
|
|
244
|
+
if (ageDays <= 0)
|
|
245
|
+
return 1.0; // future or now → no decay
|
|
246
|
+
return Math.exp((-ageDays * Math.LN2) / halfLifeDays);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Language gate using the `franc` trigram-based detector (RFC-0030 OQ-13.2
|
|
250
|
+
* v0.3 re-walkthrough resolution).
|
|
251
|
+
*
|
|
252
|
+
* `franc` returns ISO 639-3 three-letter codes (`'eng'`, `'cmn'`, `'fra'`,
|
|
253
|
+
* `'spa'`, etc.) or `'und'` for undetermined text (too short / no script
|
|
254
|
+
* match). It is deterministic, MIT-licensed, JS-native (no model download),
|
|
255
|
+
* runs in <10ms per signal, and is 95%+ accurate on text >50 characters.
|
|
256
|
+
*
|
|
257
|
+
* Behaviour:
|
|
258
|
+
* - The language-tag matcher accepts BCP-47 / ISO 639-1 / ISO 639-3 forms in
|
|
259
|
+
* `acceptedLanguages` (e.g. `'en'`, `'eng'`, `'en-US'` all match English).
|
|
260
|
+
* - Empty payloads return `accepted: true` (nothing to gate on; downstream
|
|
261
|
+
* classifier handles empty-payload signals).
|
|
262
|
+
* - When `franc` returns `'und'` (text too short, no script matched, or
|
|
263
|
+
* `library: 'none'`), behaviour is controlled by
|
|
264
|
+
* `languageDetection.onUndetermined` — `'accept'` (default) is conservative
|
|
265
|
+
* because the dominant short-payload case is legitimate (e.g. "Crash on
|
|
266
|
+
* save", "BUG: 500"); `'drop'` is strict.
|
|
267
|
+
* - When `library: 'none'`, the gate is disabled entirely — all signals
|
|
268
|
+
* accepted regardless of `acceptedLanguages`. Useful for testing or for
|
|
269
|
+
* adopters that pre-filter signals upstream.
|
|
270
|
+
* - **Fuzzy acceptance via francAll top-N**: short technical English (under
|
|
271
|
+
* ~50 chars; e.g. "auth login failure when SAML callback returns no nonce")
|
|
272
|
+
* can misdetect as Nordic / Latin-script neighbours (nno, nld, dan) due to
|
|
273
|
+
* uncommon trigrams. To avoid false-drops on legitimate English, we accept
|
|
274
|
+
* when ANY accepted language appears in the top-N candidates with a relative
|
|
275
|
+
* score ≥ `TOP_N_ACCEPT_THRESHOLD` of the top hit. This is the standard
|
|
276
|
+
* franc-misclassification workaround documented in
|
|
277
|
+
* https://github.com/wooorm/franc#data
|
|
278
|
+
*/
|
|
279
|
+
function checkLanguage(payload, acceptedLanguages, languageDetection) {
|
|
280
|
+
// Empty payload — nothing to gate on.
|
|
281
|
+
if (!payload || payload.length === 0) {
|
|
282
|
+
return { accepted: true, detectedLanguage: 'und', detectedScript: 'unknown' };
|
|
283
|
+
}
|
|
284
|
+
// `library: 'none'` disables the gate entirely.
|
|
285
|
+
if (languageDetection.library === 'none') {
|
|
286
|
+
return { accepted: true, detectedLanguage: 'und', detectedScript: 'unknown' };
|
|
287
|
+
}
|
|
288
|
+
// Top language for reporting purposes (Decision payload + script hint).
|
|
289
|
+
const detectedLanguage = franc(payload, { minLength: languageDetection.minDetectionLength });
|
|
290
|
+
const detectedScript = scriptHintFor(detectedLanguage);
|
|
291
|
+
// 'und' — text too short OR no script matched — handle per config policy.
|
|
292
|
+
if (detectedLanguage === 'und') {
|
|
293
|
+
if (languageDetection.onUndetermined === 'accept') {
|
|
294
|
+
return { accepted: true, detectedLanguage, detectedScript };
|
|
295
|
+
}
|
|
296
|
+
return { accepted: false, detectedLanguage, detectedScript };
|
|
297
|
+
}
|
|
298
|
+
// Top-of-list match — fast path.
|
|
299
|
+
if (languageMatchesAccepted(detectedLanguage, acceptedLanguages)) {
|
|
300
|
+
return { accepted: true, detectedLanguage, detectedScript };
|
|
301
|
+
}
|
|
302
|
+
// Fuzzy acceptance via script-prefiltered candidate list. `francAll` only
|
|
303
|
+
// returns languages whose SCRIPT matches the input — Chinese text returns
|
|
304
|
+
// only CJK languages; Cyrillic text returns only Cyrillic languages. So if
|
|
305
|
+
// any accepted language appears at all in the candidate list (above a low
|
|
306
|
+
// relative-score floor), the input shares a script family with an accepted
|
|
307
|
+
// language — which is the load-bearing signal we care about. Short technical
|
|
308
|
+
// English may misclassify the TOP slot as nno/dan/nld, but `eng` will still
|
|
309
|
+
// appear in the list with a high relative score because the script
|
|
310
|
+
// (Latin-script) matches. Genuinely-foreign signals (Chinese, Arabic, Hindi)
|
|
311
|
+
// exclude `eng` from the list entirely via the script prefilter.
|
|
312
|
+
const allCandidates = francAll(payload, {
|
|
313
|
+
minLength: languageDetection.minDetectionLength,
|
|
314
|
+
});
|
|
315
|
+
for (const [lang, score] of allCandidates) {
|
|
316
|
+
if (score < FUZZY_ACCEPT_SCORE_FLOOR)
|
|
317
|
+
break;
|
|
318
|
+
if (languageMatchesAccepted(lang, acceptedLanguages)) {
|
|
319
|
+
return { accepted: true, detectedLanguage: lang, detectedScript: scriptHintFor(lang) };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { accepted: false, detectedLanguage, detectedScript };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Minimum relative score (normalised against the top hit's 1.0) for a
|
|
326
|
+
* candidate to be considered for fuzzy acceptance. 0.80 is permissive enough
|
|
327
|
+
* to catch English misclassified as Nordic in short technical bug reports
|
|
328
|
+
* (typical eng-score for sub-60-char English is 0.85-0.98) while still
|
|
329
|
+
* excluding genuinely-distant guesses (franc rarely puts distant-script
|
|
330
|
+
* languages above 0.5 because the script prefilter already excludes them).
|
|
331
|
+
*
|
|
332
|
+
* The load-bearing protection against false-accepts of foreign-script text is
|
|
333
|
+
* franc's built-in SCRIPT PREFILTER: Chinese text returns only CJK
|
|
334
|
+
* candidates, Arabic text returns only Arabic candidates, etc. The fuzzy
|
|
335
|
+
* threshold here only matters for Latin-script close-runners-up.
|
|
336
|
+
*/
|
|
337
|
+
const FUZZY_ACCEPT_SCORE_FLOOR = 0.8;
|
|
338
|
+
/**
|
|
339
|
+
* Check whether a franc-detected ISO 639-3 code matches any of the
|
|
340
|
+
* adopter-configured accepted-language tags.
|
|
341
|
+
*
|
|
342
|
+
* Accepts the common forms in `acceptedLanguages`:
|
|
343
|
+
* - ISO 639-3 three-letter codes: `'eng'`, `'fra'`, `'spa'`, `'cmn'`, …
|
|
344
|
+
* - ISO 639-1 two-letter codes: `'en'`, `'fr'`, `'es'`, `'zh'`, …
|
|
345
|
+
* - BCP-47 regional tags: `'en-US'`, `'fr-CA'`, `'zh-Hans'`, … (prefix matched
|
|
346
|
+
* against the two-letter base code)
|
|
347
|
+
*
|
|
348
|
+
* The two-letter ↔ three-letter mapping covers the most common languages
|
|
349
|
+
* adopters opt into; uncommon languages can be specified directly in their
|
|
350
|
+
* ISO 639-3 form to bypass the mapping.
|
|
351
|
+
*/
|
|
352
|
+
function languageMatchesAccepted(detectedIso6393, acceptedLanguages) {
|
|
353
|
+
for (const raw of acceptedLanguages) {
|
|
354
|
+
const tag = raw.toLowerCase();
|
|
355
|
+
// Exact ISO 639-3 match (e.g. 'eng' === 'eng').
|
|
356
|
+
if (tag === detectedIso6393)
|
|
357
|
+
return true;
|
|
358
|
+
// Two-letter ISO 639-1 (or BCP-47 prefix) match via the mapping table.
|
|
359
|
+
const base = tag.split('-')[0]; // 'en' from 'en-US', 'en' from 'en'
|
|
360
|
+
const mapped = ISO_639_1_TO_639_3[base];
|
|
361
|
+
if (mapped && mapped === detectedIso6393)
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Two-letter ISO 639-1 → three-letter ISO 639-3 mapping for the most common
|
|
368
|
+
* languages adopters opt into. Not exhaustive — uncommon languages should be
|
|
369
|
+
* configured directly in their ISO 639-3 form (e.g. `'tgl'` for Tagalog).
|
|
370
|
+
*
|
|
371
|
+
* Source: ISO 639 standard cross-reference. The list intentionally includes
|
|
372
|
+
* the languages most likely to appear in multi-language customer-support
|
|
373
|
+
* pipelines (top ~30 languages by speaker count plus EU + East-Asian
|
|
374
|
+
* majors).
|
|
375
|
+
*/
|
|
376
|
+
const ISO_639_1_TO_639_3 = {
|
|
377
|
+
en: 'eng',
|
|
378
|
+
fr: 'fra',
|
|
379
|
+
es: 'spa',
|
|
380
|
+
de: 'deu',
|
|
381
|
+
it: 'ita',
|
|
382
|
+
pt: 'por',
|
|
383
|
+
nl: 'nld',
|
|
384
|
+
ru: 'rus',
|
|
385
|
+
pl: 'pol',
|
|
386
|
+
uk: 'ukr',
|
|
387
|
+
cs: 'ces',
|
|
388
|
+
sk: 'slk',
|
|
389
|
+
hu: 'hun',
|
|
390
|
+
ro: 'ron',
|
|
391
|
+
bg: 'bul',
|
|
392
|
+
el: 'ell',
|
|
393
|
+
tr: 'tur',
|
|
394
|
+
sv: 'swe',
|
|
395
|
+
no: 'nob',
|
|
396
|
+
da: 'dan',
|
|
397
|
+
fi: 'fin',
|
|
398
|
+
is: 'isl',
|
|
399
|
+
ga: 'gle',
|
|
400
|
+
zh: 'cmn',
|
|
401
|
+
ja: 'jpn',
|
|
402
|
+
ko: 'kor',
|
|
403
|
+
vi: 'vie',
|
|
404
|
+
th: 'tha',
|
|
405
|
+
id: 'ind',
|
|
406
|
+
ms: 'zlm',
|
|
407
|
+
tl: 'tgl',
|
|
408
|
+
hi: 'hin',
|
|
409
|
+
bn: 'ben',
|
|
410
|
+
ta: 'tam',
|
|
411
|
+
te: 'tel',
|
|
412
|
+
ur: 'urd',
|
|
413
|
+
fa: 'pes',
|
|
414
|
+
ar: 'arb',
|
|
415
|
+
he: 'heb',
|
|
416
|
+
sw: 'swh',
|
|
417
|
+
am: 'amh',
|
|
418
|
+
};
|
|
419
|
+
/**
|
|
420
|
+
* Coarse script-family hint for a franc-detected ISO 639-3 code. Returned in
|
|
421
|
+
* `SignalLanguageUnsupportedDecision.detectedScript` for backwards compat with
|
|
422
|
+
* v0.2 dashboards that key on script family rather than language.
|
|
423
|
+
*
|
|
424
|
+
* This is a small lookup table — only covers the languages we expect to
|
|
425
|
+
* surface in customer-signal pipelines. Unknown codes get `'unknown'`.
|
|
426
|
+
*/
|
|
427
|
+
function scriptHintFor(iso6393) {
|
|
428
|
+
if (iso6393 === 'und')
|
|
429
|
+
return 'unknown';
|
|
430
|
+
if (CJK_LANGUAGES.has(iso6393))
|
|
431
|
+
return 'cjk';
|
|
432
|
+
if (CYRILLIC_LANGUAGES.has(iso6393))
|
|
433
|
+
return 'cyrillic';
|
|
434
|
+
if (ARABIC_LANGUAGES.has(iso6393))
|
|
435
|
+
return 'arabic';
|
|
436
|
+
if (iso6393 === 'heb' || iso6393 === 'ydd')
|
|
437
|
+
return 'hebrew';
|
|
438
|
+
if (DEVANAGARI_LANGUAGES.has(iso6393))
|
|
439
|
+
return 'devanagari';
|
|
440
|
+
if (iso6393 === 'tha')
|
|
441
|
+
return 'thai';
|
|
442
|
+
if (iso6393 === 'ell')
|
|
443
|
+
return 'greek';
|
|
444
|
+
if (iso6393 === 'hye')
|
|
445
|
+
return 'armenian';
|
|
446
|
+
if (iso6393 === 'kat')
|
|
447
|
+
return 'georgian';
|
|
448
|
+
// Default: assume Latin-script for everything else (most languages franc
|
|
449
|
+
// detects in our coverage are Latin-script — fr/es/de/pt/it/nl/etc.).
|
|
450
|
+
return 'latin';
|
|
451
|
+
}
|
|
452
|
+
const CJK_LANGUAGES = new Set(['cmn', 'jpn', 'kor', 'yue', 'wuu', 'nan', 'hak']);
|
|
453
|
+
const CYRILLIC_LANGUAGES = new Set([
|
|
454
|
+
'rus',
|
|
455
|
+
'ukr',
|
|
456
|
+
'bel',
|
|
457
|
+
'bul',
|
|
458
|
+
'mkd',
|
|
459
|
+
'srp',
|
|
460
|
+
'tat',
|
|
461
|
+
'kaz',
|
|
462
|
+
'kir',
|
|
463
|
+
'tgk',
|
|
464
|
+
'mon',
|
|
465
|
+
'khk',
|
|
466
|
+
]);
|
|
467
|
+
const ARABIC_LANGUAGES = new Set(['arb', 'pes', 'urd', 'pbu', 'prs', 'ckb', 'uig']);
|
|
468
|
+
const DEVANAGARI_LANGUAGES = new Set([
|
|
469
|
+
'hin',
|
|
470
|
+
'mar',
|
|
471
|
+
'nep',
|
|
472
|
+
'npi',
|
|
473
|
+
'mai',
|
|
474
|
+
'bho',
|
|
475
|
+
'awa',
|
|
476
|
+
'mag',
|
|
477
|
+
'san',
|
|
478
|
+
]);
|
|
479
|
+
// ── Weight computation (convenience) ─────────────────────────────────────────
|
|
480
|
+
/**
|
|
481
|
+
* Compute the composite signal weight from a `ClassifiedSignal`.
|
|
482
|
+
*
|
|
483
|
+
* `weight = baseWeight × tierMultiplier × icpResonanceWeight × recencyDecay`
|
|
484
|
+
*
|
|
485
|
+
* Note: SA resonance filter (RFC-0030 §9) is applied at the cluster level in
|
|
486
|
+
* Phase 5, not at the signal level here.
|
|
487
|
+
*/
|
|
488
|
+
export function computeSignalWeight(classified) {
|
|
489
|
+
return (classified.baseWeight *
|
|
490
|
+
classified.tierMultiplier *
|
|
491
|
+
classified.icpResonanceWeight *
|
|
492
|
+
classified.recencyDecay);
|
|
493
|
+
}
|
|
494
|
+
//# sourceMappingURL=classifier.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input shape consumed by `clusterSignals()` per RFC-0030 Phase 3.
|
|
3
|
+
*
|
|
4
|
+
* `ClusteredSignalInput` is a subset of `ClassifiedSignal` (Phase 2 output)
|
|
5
|
+
* carried through the clusterer plus optional adapter-tier metadata used
|
|
6
|
+
* during cluster aggregation. Kept in a separate file so consumers can
|
|
7
|
+
* import the input shape without pulling in the clusterer's full surface
|
|
8
|
+
* (algorithm types, options) — and so the classifier doesn't depend on
|
|
9
|
+
* clustering.ts (avoid circular imports).
|
|
10
|
+
*
|
|
11
|
+
* @module signal-ingestion/clustering-types
|
|
12
|
+
*/
|
|
13
|
+
import type { ICPResonance } from './classifier.js';
|
|
14
|
+
import type { CustomerTier, RawSignal, SignalTier } from './types.js';
|
|
15
|
+
/**
|
|
16
|
+
* Per-signal input to the clusterer.
|
|
17
|
+
*
|
|
18
|
+
* A subset of `ClassifiedSignal` (Phase 2) chosen to keep clustering
|
|
19
|
+
* decoupled from Phase 2's full output shape:
|
|
20
|
+
* - `signal`, `customerTier`, `icpResonance`, `recencyDecay` are carried
|
|
21
|
+
* through and surfaced on the resulting `DemandCluster.members` so
|
|
22
|
+
* Phase 4/5 consumers retain access without re-running classification.
|
|
23
|
+
* - `adapterTier` is an OPTIONAL hint that lets Phase 4 sum `tier1` /
|
|
24
|
+
* `tier2` member counts without re-deriving from `signal.metadata`.
|
|
25
|
+
* When absent, the clusterer falls back to `signal.metadata.adapterTier`
|
|
26
|
+
* then to Tier 1 (matches classifier convention).
|
|
27
|
+
*/
|
|
28
|
+
export interface ClusteredSignalInput {
|
|
29
|
+
signal: RawSignal;
|
|
30
|
+
customerTier: CustomerTier;
|
|
31
|
+
icpResonance: ICPResonance;
|
|
32
|
+
recencyDecay: number;
|
|
33
|
+
/** Optional adapter tier hint (Tier 1 default when absent). */
|
|
34
|
+
adapterTier?: SignalTier;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=clustering-types.d.ts.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input shape consumed by `clusterSignals()` per RFC-0030 Phase 3.
|
|
3
|
+
*
|
|
4
|
+
* `ClusteredSignalInput` is a subset of `ClassifiedSignal` (Phase 2 output)
|
|
5
|
+
* carried through the clusterer plus optional adapter-tier metadata used
|
|
6
|
+
* during cluster aggregation. Kept in a separate file so consumers can
|
|
7
|
+
* import the input shape without pulling in the clusterer's full surface
|
|
8
|
+
* (algorithm types, options) — and so the classifier doesn't depend on
|
|
9
|
+
* clustering.ts (avoid circular imports).
|
|
10
|
+
*
|
|
11
|
+
* @module signal-ingestion/clustering-types
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=clustering-types.js.map
|