@atlaskit/editor-plugin-autocomplete 2.0.0 → 2.2.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.
@@ -1,6 +1,7 @@
1
1
  import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
3
3
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
4
+ import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
4
5
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
5
6
  import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
6
7
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
@@ -61,6 +62,7 @@ var setAutocompleteMeta = function setAutocompleteMeta(tr, meta) {
61
62
  /**
62
63
  * Apply a ghost text suggestion to the editor state.
63
64
  */
65
+ var lastShownGhostText = '';
64
66
  var showGhostText = function showGhostText(view, text, position) {
65
67
  var state = view.state,
66
68
  dispatch = view.dispatch;
@@ -139,7 +141,7 @@ var buildSlowLaneText = function buildSlowLaneText(docText, context) {
139
141
  lines.push("reply ".concat(nextReplyNumber, ": ").concat(docText));
140
142
  return lines.join('\n');
141
143
  };
142
- export var createAutocompletePlugin = function createAutocompletePlugin(options) {
144
+ export var createAutocompletePlugin = function createAutocompletePlugin(options, api) {
143
145
  var debounceTimer = null;
144
146
  var hasIngestedPage = false;
145
147
  var resolvedContext;
@@ -197,6 +199,15 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options)
197
199
  var prediction = predict(textBefore);
198
200
  if (prediction && prediction.length > 0) {
199
201
  showGhostText(view, prediction, selection.from);
202
+ if (prediction !== lastShownGhostText) {
203
+ var _api$analytics;
204
+ lastShownGhostText = prediction;
205
+ api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 || _api$analytics.actions.fireAnalyticsEvent({
206
+ action: ACTION.SUGGESTION_VIEWED,
207
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
208
+ eventType: EVENT_TYPE.TRACK
209
+ });
210
+ }
200
211
  }
201
212
  }, DEBOUNCE_MS);
202
213
  };
@@ -268,12 +279,30 @@ export var createAutocompletePlugin = function createAutocompletePlugin(options)
268
279
  handleKeyDown: keydownHandler({
269
280
  Tab: function Tab(state, dispatch) {
270
281
  var accepted = acceptGhostText(state, dispatch);
271
- if (accepted) justAccepted = true;
282
+ if (accepted) {
283
+ var _api$analytics2;
284
+ justAccepted = true;
285
+ lastShownGhostText = '';
286
+ api === null || api === void 0 || (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 || _api$analytics2.actions.fireAnalyticsEvent({
287
+ action: ACTION.SUGGESTION_INSERTED,
288
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
289
+ eventType: EVENT_TYPE.TRACK
290
+ });
291
+ }
272
292
  return accepted;
273
293
  },
274
294
  ArrowRight: function ArrowRight(state, dispatch) {
275
295
  var accepted = acceptGhostText(state, dispatch);
276
- if (accepted) justAccepted = true;
296
+ if (accepted) {
297
+ var _api$analytics3;
298
+ justAccepted = true;
299
+ lastShownGhostText = '';
300
+ api === null || api === void 0 || (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 || _api$analytics3.actions.fireAnalyticsEvent({
301
+ action: ACTION.SUGGESTION_INSERTED,
302
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
303
+ eventType: EVENT_TYPE.TRACK
304
+ });
305
+ }
277
306
  return accepted;
278
307
  },
279
308
  Escape: function Escape(state, dispatch) {
@@ -22,10 +22,15 @@ import grammarTransitionsData from './data/grammar_transitions_10k.json';
22
22
  var ALPHA = 0.5;
23
23
  var BETA = 0.5;
24
24
  var NEUTRAL_SCORE = 0.5;
25
- var STAGE1_WEIGHT = 0.6;
26
- var STAGE2_WEIGHT = 0.4;
27
- var MIN_STAGE1_SCORE = 0.35;
25
+ export var STAGE1_WEIGHT = 0.35;
26
+ export var STAGE2_WEIGHT = 0.65;
27
+ export var MIN_STAGE1_SCORE = 0.35;
28
28
  var L1_SESSION_CAP = 1.2;
29
+ // Minimum prefix-payload max LM probability before Stage 2 activates.
30
+ // Below this threshold the LM signal is too weak to suppress Stage 1 — finalScore
31
+ // falls back to stage1Score directly. Prevents weak prefixes (e.g. "ins" → "instances"
32
+ // at 0.00024) from triggering re-ranking.
33
+ var LM_GATE_THRESHOLD = 0.0005;
29
34
 
30
35
  // ─── Grammar Data (loaded once on import) ───────────────────
31
36
 
@@ -189,6 +194,7 @@ function getLmScore(word, lmLogits) {
189
194
  // ─── Public API ─────────────────────────────────────────────
190
195
 
191
196
  export function rankCandidates(candidates, contextVector, getWordVector, lmLogits, maxTenantFreq, previousWord) {
197
+ var _grammarMeta$dropped;
192
198
  // Stage 1
193
199
  var stage1Results = candidates.map(function (candidate) {
194
200
  var _scoreStage = scoreStage1(candidate, contextVector, getWordVector, maxTenantFreq),
@@ -202,33 +208,48 @@ export function rankCandidates(candidates, contextVector, getWordVector, lmLogit
202
208
  stage1Score: stage1Score
203
209
  };
204
210
  });
205
- var stage1Survivors = stage1Results.filter(function (entry) {
206
- return entry.stage1Score >= MIN_STAGE1_SCORE;
207
- });
211
+ var stage1Survivors = [];
212
+ var stage1Rejected = [];
213
+ var _iterator3 = _createForOfIteratorHelper(stage1Results),
214
+ _step3;
215
+ try {
216
+ for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
217
+ var entry = _step3.value;
218
+ if (entry.stage1Score >= MIN_STAGE1_SCORE) {
219
+ stage1Survivors.push(entry);
220
+ } else {
221
+ stage1Rejected.push(entry.candidate.word);
222
+ }
223
+ }
208
224
 
209
- // Grammar Filter
225
+ // Grammar Filter
226
+ } catch (err) {
227
+ _iterator3.e(err);
228
+ } finally {
229
+ _iterator3.f();
230
+ }
210
231
  var _applyGrammarFilter = applyGrammarFilter(stage1Survivors, previousWord),
211
232
  filtered = _applyGrammarFilter.filtered,
212
233
  grammarMeta = _applyGrammarFilter.grammarMeta;
213
234
 
214
235
  // Stage 2 + final assembly
215
236
  var lmMax = 0;
216
- if (lmLogits && Object.keys(lmLogits).length > 0) {
237
+ if (lmLogits) {
217
238
  var values = Object.values(lmLogits);
218
- lmMax = Math.max.apply(Math, values);
239
+ if (values.length > 0) lmMax = Math.max.apply(Math, values);
219
240
  }
220
241
  var scored = filtered.map(function (entry) {
221
242
  var lmScore = 0;
222
243
  var finalScore = entry.stage1Score;
223
- if (lmLogits && Object.keys(lmLogits).length > 0) {
244
+ if (lmLogits && lmMax >= LM_GATE_THRESHOLD) {
224
245
  var rawLm = getLmScore(entry.candidate.word, lmLogits);
225
246
  if (rawLm !== 0) {
226
- // The word was in the top_k! Score it normally.
227
247
  var logitDiff = Math.log(rawLm) - Math.log(lmMax);
228
248
  lmScore = Math.exp(logitDiff);
229
- } else {
230
- lmScore = 0.05;
231
249
  }
250
+ // Words absent from the prefix-filtered payload get lmScore = 0,
251
+ // not 0.05, so they don't outrank genuine LM predictions.
252
+
232
253
  finalScore = STAGE1_WEIGHT * entry.stage1Score + STAGE2_WEIGHT * lmScore;
233
254
  }
234
255
  return {
@@ -240,13 +261,17 @@ export function rankCandidates(candidates, contextVector, getWordVector, lmLogit
240
261
  };
241
262
  });
242
263
  scored.sort(function (a, b) {
243
- if (b.finalScore !== a.finalScore) {
244
- return b.finalScore - a.finalScore;
245
- }
264
+ if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
246
265
  return a.word.length - b.word.length;
247
266
  });
248
267
  return {
249
268
  candidates: scored,
250
- grammarMeta: grammarMeta
269
+ grammarMeta: grammarMeta,
270
+ pipelineDebug: {
271
+ initial: candidates.length,
272
+ stage1Rejected: stage1Rejected,
273
+ grammarRejected: (_grammarMeta$dropped = grammarMeta === null || grammarMeta === void 0 ? void 0 : grammarMeta.dropped) !== null && _grammarMeta$dropped !== void 0 ? _grammarMeta$dropped : [],
274
+ final: scored.length
275
+ }
251
276
  };
252
277
  }
@@ -29,7 +29,7 @@ import l3VocabularyData from './data/l3_vocabulary.json';
29
29
  import vocabularyData from './data/vocabulary_10k.json';
30
30
  import wordIndexData from './data/word_index_10k.json';
31
31
  // import { rankCandidates, isGrammarAllowed } from './scoring-pipeline';
32
- import { rankCandidates } from './scoring-pipeline';
32
+ import { rankCandidates, STAGE1_WEIGHT, STAGE2_WEIGHT, MIN_STAGE1_SCORE } from './scoring-pipeline';
33
33
  import { getStoredContextVector, getStoredLmLogits } from './slow-lane-client';
34
34
 
35
35
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -570,11 +570,21 @@ export var predict = function predict(textBefore) {
570
570
  sessionFreq: node.sessionFreq
571
571
  };
572
572
  });
573
+
574
+ // Filter the LM payload to only words matching the current prefix so that
575
+ // lmMax in rankCandidates reflects prefix-relevant signal, not the global distribution.
576
+ var prefix = currentWord.toLowerCase();
577
+ var prefixLmLogits = lmLogits ? Object.fromEntries(Object.entries(lmLogits).filter(function (_ref4) {
578
+ var _ref5 = _slicedToArray(_ref4, 1),
579
+ word = _ref5[0];
580
+ return word.startsWith(prefix);
581
+ })) : null;
573
582
  var _rankCandidates = rankCandidates(scoringCandidates, contextVector, function (w) {
574
583
  return getWordVector(w);
575
- }, lmLogits, wordTrie.maxTenantFreq, previousWord),
584
+ }, prefixLmLogits, wordTrie.maxTenantFreq, previousWord),
576
585
  ranked = _rankCandidates.candidates,
577
- grammarMeta = _rankCandidates.grammarMeta;
586
+ grammarMeta = _rankCandidates.grammarMeta,
587
+ pipelineDebug = _rankCandidates.pipelineDebug;
578
588
  var best = ranked[0];
579
589
  var suggestion = best && best.finalScore >= MIN_SCORE_THRESHOLD ? best.word.slice(currentWord.length) : null;
580
590
  if (debugMode) {
@@ -621,7 +631,7 @@ export var predict = function predict(textBefore) {
621
631
  console.log('%cSlow Lane:', 'color: #888; font-style: italic;', vectorStatus, '|', logitsStatus);
622
632
 
623
633
  // 4. Scoring formula active this prediction
624
- var formulaLabel = lmLogits && Object.keys(lmLogits).length > 0 ? 'Stage1(×0.6) + LM(×0.4)' : 'Stage1 only (no LM logits)';
634
+ var formulaLabel = lmLogits && Object.keys(lmLogits).length > 0 ? "Stage1(\xD7".concat(STAGE1_WEIGHT, ") + LM(\xD7").concat(STAGE2_WEIGHT, ")") : 'Stage1 only (no LM logits)';
625
635
  // eslint-disable-next-line no-console
626
636
  console.log('%cFormula:', 'color: #888; font-style: italic;', formulaLabel);
627
637
 
@@ -635,17 +645,21 @@ export var predict = function predict(textBefore) {
635
645
  }
636
646
  }
637
647
 
638
- // 6. Candidate table
648
+ // 6. Pipeline funnel
649
+ // eslint-disable-next-line no-console
650
+ console.log("%c[Pipeline Funnel] %c\uD83D\uDCE5 In: ".concat(pipelineDebug.initial, " | \u274C Stage 1 (< ").concat(MIN_STAGE1_SCORE, "): -").concat(pipelineDebug.stage1Rejected.length, " | \u274C Grammar: -").concat(pipelineDebug.grammarRejected.length, " | \u2705 Final: ").concat(pipelineDebug.final), 'color: #9c27b0; font-weight: bold;', 'color: inherit;');
651
+
652
+ // 7. Candidate table
639
653
  if (ranked.length > 0) {
640
654
  var lmCoverage = ranked.slice(0, 10).filter(function (r) {
641
- return r.lmScore > 0.05;
655
+ return r.lmScore > 0;
642
656
  }).length;
643
657
  // eslint-disable-next-line no-console
644
658
  console.log("%cLM coverage: ".concat(lmCoverage, "/").concat(Math.min(ranked.length, 10), " candidates had real logit scores"), 'color: #888; font-style: italic;');
645
659
  var tableData = ranked.slice(0, 10).map(function (r) {
646
660
  var rawLogit = 'Not in Payload';
647
- if (lmLogits) {
648
- var val = lmLogits[r.word.toLowerCase()];
661
+ if (prefixLmLogits) {
662
+ var val = prefixLmLogits[r.word.toLowerCase()];
649
663
  if (val !== undefined) {
650
664
  rawLogit = Number(val.toFixed(5));
651
665
  }
@@ -691,7 +705,7 @@ export var predict = function predict(textBefore) {
691
705
  // ─── Data Loading ────────────────────────────────────────────────────────────
692
706
 
693
707
  export var loadVectorsAsync = /*#__PURE__*/function () {
694
- var _ref4 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(options) {
708
+ var _ref6 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(options) {
695
709
  var url, res, buffer, float32, wordIndex, nWords, dim;
696
710
  return _regeneratorRuntime.wrap(function _callee$(_context) {
697
711
  while (1) switch (_context.prev = _context.next) {
@@ -777,7 +791,7 @@ export var loadVectorsAsync = /*#__PURE__*/function () {
777
791
  }, _callee, null, [[6, 12], [17, 37]]);
778
792
  }));
779
793
  return function loadVectorsAsync(_x) {
780
- return _ref4.apply(this, arguments);
794
+ return _ref6.apply(this, arguments);
781
795
  };
782
796
  }();
783
797
  export var initVectors = function initVectors(store) {
@@ -786,10 +800,10 @@ export var initVectors = function initVectors(store) {
786
800
  export var loadDefaultVocabulary = function loadDefaultVocabulary() {
787
801
  // 1. Load the Atlassian Domain (L2)
788
802
  var data = vocabularyData;
789
- var terms = Object.entries(data.words).map(function (_ref5) {
790
- var _ref6 = _slicedToArray(_ref5, 2),
791
- word = _ref6[0],
792
- stats = _ref6[1];
803
+ var terms = Object.entries(data.words).map(function (_ref7) {
804
+ var _ref8 = _slicedToArray(_ref7, 2),
805
+ word = _ref8[0],
806
+ stats = _ref8[1];
793
807
  return {
794
808
  word: word,
795
809
  freq: stats.freq,
@@ -1,6 +1,8 @@
1
- import type { NextEditorPlugin } from '@atlaskit/editor-common/types';
1
+ import type { NextEditorPlugin, OptionalPlugin } from '@atlaskit/editor-common/types';
2
+ import type { AnalyticsPlugin } from '@atlaskit/editor-plugin-analytics';
2
3
  import type { AutocompletePluginOptions, AutocompletePluginState } from './pm-plugins/autocomplete-plugin';
3
4
  export type AutocompletePlugin = NextEditorPlugin<'autocomplete', {
4
5
  pluginConfiguration?: AutocompletePluginOptions | undefined;
5
6
  sharedState: AutocompletePluginState | undefined;
7
+ dependencies: [OptionalPlugin<AnalyticsPlugin>];
6
8
  }>;
@@ -1,6 +1,8 @@
1
1
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
2
+ import type { ExtractInjectionAPI } from '@atlaskit/editor-common/types';
2
3
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
3
4
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
5
+ import type { AutocompletePlugin } from '../autocompletePluginType';
4
6
  export declare const autocompletePluginKey: PluginKey<any>;
5
7
  export interface AutocompletePluginState {
6
8
  /** The decoration set containing the ghost text widget */
@@ -39,4 +41,4 @@ export interface AutocompletePluginOptions {
39
41
  */
40
42
  getVectorsBinaryUrl?: () => Promise<string>;
41
43
  }
42
- export declare const createAutocompletePlugin: (options?: AutocompletePluginOptions) => SafePlugin<AutocompletePluginState>;
44
+ export declare const createAutocompletePlugin: (options?: AutocompletePluginOptions, api?: ExtractInjectionAPI<AutocompletePlugin>) => SafePlugin<AutocompletePluginState>;
@@ -26,8 +26,18 @@ export interface GrammarFilterMeta {
26
26
  prevTags: string[];
27
27
  prevWord: string;
28
28
  }
29
+ export declare const STAGE1_WEIGHT = 0.35;
30
+ export declare const STAGE2_WEIGHT = 0.65;
31
+ export declare const MIN_STAGE1_SCORE = 0.35;
32
+ export interface PipelineDebug {
33
+ final: number;
34
+ grammarRejected: string[];
35
+ initial: number;
36
+ stage1Rejected: string[];
37
+ }
29
38
  export interface RankCandidatesResult {
30
39
  candidates: ScoredCandidate[];
31
40
  grammarMeta: GrammarFilterMeta | null;
41
+ pipelineDebug: PipelineDebug;
32
42
  }
33
43
  export declare function rankCandidates(candidates: ScoringCandidate[], contextVector: Float32Array | null, getWordVector: (word: string) => Float32Array | null, lmLogits: Record<string, number> | null, maxTenantFreq: number, previousWord: string): RankCandidatesResult;
@@ -1,6 +1,10 @@
1
- import type { NextEditorPlugin } from '@atlaskit/editor-common/types';
1
+ import type { NextEditorPlugin, OptionalPlugin } from '@atlaskit/editor-common/types';
2
+ import type { AnalyticsPlugin } from '@atlaskit/editor-plugin-analytics';
2
3
  import type { AutocompletePluginOptions, AutocompletePluginState } from './pm-plugins/autocomplete-plugin';
3
4
  export type AutocompletePlugin = NextEditorPlugin<'autocomplete', {
4
5
  pluginConfiguration?: AutocompletePluginOptions | undefined;
5
6
  sharedState: AutocompletePluginState | undefined;
7
+ dependencies: [
8
+ OptionalPlugin<AnalyticsPlugin>
9
+ ];
6
10
  }>;
@@ -1,6 +1,8 @@
1
1
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
2
+ import type { ExtractInjectionAPI } from '@atlaskit/editor-common/types';
2
3
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
3
4
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
5
+ import type { AutocompletePlugin } from '../autocompletePluginType';
4
6
  export declare const autocompletePluginKey: PluginKey<any>;
5
7
  export interface AutocompletePluginState {
6
8
  /** The decoration set containing the ghost text widget */
@@ -39,4 +41,4 @@ export interface AutocompletePluginOptions {
39
41
  */
40
42
  getVectorsBinaryUrl?: () => Promise<string>;
41
43
  }
42
- export declare const createAutocompletePlugin: (options?: AutocompletePluginOptions) => SafePlugin<AutocompletePluginState>;
44
+ export declare const createAutocompletePlugin: (options?: AutocompletePluginOptions, api?: ExtractInjectionAPI<AutocompletePlugin>) => SafePlugin<AutocompletePluginState>;
@@ -26,8 +26,18 @@ export interface GrammarFilterMeta {
26
26
  prevTags: string[];
27
27
  prevWord: string;
28
28
  }
29
+ export declare const STAGE1_WEIGHT = 0.35;
30
+ export declare const STAGE2_WEIGHT = 0.65;
31
+ export declare const MIN_STAGE1_SCORE = 0.35;
32
+ export interface PipelineDebug {
33
+ final: number;
34
+ grammarRejected: string[];
35
+ initial: number;
36
+ stage1Rejected: string[];
37
+ }
29
38
  export interface RankCandidatesResult {
30
39
  candidates: ScoredCandidate[];
31
40
  grammarMeta: GrammarFilterMeta | null;
41
+ pipelineDebug: PipelineDebug;
32
42
  }
33
43
  export declare function rankCandidates(candidates: ScoringCandidate[], contextVector: Float32Array | null, getWordVector: (word: string) => Float32Array | null, lmLogits: Record<string, number> | null, maxTenantFreq: number, previousWord: string): RankCandidatesResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-autocomplete",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Client-side text autocomplete plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -33,7 +33,8 @@
33
33
  "wink-nlp": "^2.4.0"
34
34
  },
35
35
  "peerDependencies": {
36
- "@atlaskit/editor-common": "^114.0.0",
36
+ "@atlaskit/editor-common": "^114.5.0",
37
+ "@atlaskit/editor-plugin-analytics": "^10.0.0",
37
38
  "react": "^18.2.0"
38
39
  },
39
40
  "techstack": {
@@ -2,7 +2,7 @@ import type { AutocompletePlugin } from './autocompletePluginType';
2
2
  import { autocompletePluginKey, createAutocompletePlugin } from './pm-plugins/autocomplete-plugin';
3
3
  import type { AutocompletePluginState } from './pm-plugins/autocomplete-plugin';
4
4
 
5
- export const autocompletePlugin: AutocompletePlugin = ({ config: options }) => {
5
+ export const autocompletePlugin: AutocompletePlugin = ({ config: options, api }) => {
6
6
  return {
7
7
  name: 'autocomplete',
8
8
  getSharedState(editorState): AutocompletePluginState | undefined {
@@ -16,7 +16,7 @@ export const autocompletePlugin: AutocompletePlugin = ({ config: options }) => {
16
16
  return [
17
17
  {
18
18
  name: 'autocomplete',
19
- plugin: () => createAutocompletePlugin(options),
19
+ plugin: () => createAutocompletePlugin(options, api),
20
20
  },
21
21
  ];
22
22
  },
@@ -1,4 +1,5 @@
1
- import type { NextEditorPlugin } from '@atlaskit/editor-common/types';
1
+ import type { NextEditorPlugin, OptionalPlugin } from '@atlaskit/editor-common/types';
2
+ import type { AnalyticsPlugin } from '@atlaskit/editor-plugin-analytics';
2
3
 
3
4
  import type {
4
5
  AutocompletePluginOptions,
@@ -10,5 +11,6 @@ export type AutocompletePlugin = NextEditorPlugin<
10
11
  {
11
12
  pluginConfiguration?: AutocompletePluginOptions | undefined;
12
13
  sharedState: AutocompletePluginState | undefined;
14
+ dependencies: [OptionalPlugin<AnalyticsPlugin>];
13
15
  }
14
16
  >;
@@ -1,4 +1,6 @@
1
+ import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
1
2
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
3
+ import type { ExtractInjectionAPI } from '@atlaskit/editor-common/types';
2
4
  import { keydownHandler } from '@atlaskit/editor-prosemirror/keymap';
3
5
  import { PluginKey } from '@atlaskit/editor-prosemirror/state';
4
6
  import type {
@@ -9,6 +11,8 @@ import type {
9
11
  import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
10
12
  import type { EditorView } from '@atlaskit/editor-prosemirror/view';
11
13
 
14
+ import type { AutocompletePlugin } from '../autocompletePluginType';
15
+
12
16
  import { createGhostTextDecorationSet } from './ghost-text-decoration';
13
17
  import { createSlowLaneClient, setDefaultSlowLaneClient, isWordBoundary } from './slow-lane-client';
14
18
  import {
@@ -90,6 +94,8 @@ const setAutocompleteMeta = (
90
94
  /**
91
95
  * Apply a ghost text suggestion to the editor state.
92
96
  */
97
+ let lastShownGhostText = '';
98
+
93
99
  const showGhostText = (view: EditorView, text: string, position: number): void => {
94
100
  const { state, dispatch } = view;
95
101
  const decorationSet = createGhostTextDecorationSet(state, position, text);
@@ -196,7 +202,7 @@ const buildSlowLaneText = (docText: string, context?: AutocompleteContext): stri
196
202
  return lines.join('\n');
197
203
  };
198
204
 
199
- export const createAutocompletePlugin = (options?: AutocompletePluginOptions) => {
205
+ export const createAutocompletePlugin = (options?: AutocompletePluginOptions, api?: ExtractInjectionAPI<AutocompletePlugin>) => {
200
206
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
201
207
  let hasIngestedPage = false;
202
208
  let resolvedContext: AutocompleteContext | undefined;
@@ -258,6 +264,14 @@ export const createAutocompletePlugin = (options?: AutocompletePluginOptions) =>
258
264
 
259
265
  if (prediction && prediction.length > 0) {
260
266
  showGhostText(view, prediction, selection.from);
267
+ if (prediction !== lastShownGhostText) {
268
+ lastShownGhostText = prediction;
269
+ api?.analytics?.actions.fireAnalyticsEvent({
270
+ action: ACTION.SUGGESTION_VIEWED,
271
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
272
+ eventType: EVENT_TYPE.TRACK,
273
+ });
274
+ }
261
275
  }
262
276
  }, DEBOUNCE_MS);
263
277
  };
@@ -346,12 +360,28 @@ export const createAutocompletePlugin = (options?: AutocompletePluginOptions) =>
346
360
  handleKeyDown: keydownHandler({
347
361
  Tab: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
348
362
  const accepted = acceptGhostText(state, dispatch);
349
- if (accepted) justAccepted = true;
363
+ if (accepted) {
364
+ justAccepted = true;
365
+ lastShownGhostText = '';
366
+ api?.analytics?.actions.fireAnalyticsEvent({
367
+ action: ACTION.SUGGESTION_INSERTED,
368
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
369
+ eventType: EVENT_TYPE.TRACK,
370
+ });
371
+ }
350
372
  return accepted;
351
373
  },
352
374
  ArrowRight: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
353
375
  const accepted = acceptGhostText(state, dispatch);
354
- if (accepted) justAccepted = true;
376
+ if (accepted) {
377
+ justAccepted = true;
378
+ lastShownGhostText = '';
379
+ api?.analytics?.actions.fireAnalyticsEvent({
380
+ action: ACTION.SUGGESTION_INSERTED,
381
+ actionSubject: ACTION_SUBJECT.CONTEXTUAL_TYPEAHEAD,
382
+ eventType: EVENT_TYPE.TRACK,
383
+ });
384
+ }
355
385
  return accepted;
356
386
  },
357
387
  Escape: (state: EditorState, dispatch?: (tr: Transaction) => void) => {
@@ -49,10 +49,15 @@ interface GrammarTransitions {
49
49
  const ALPHA = 0.5;
50
50
  const BETA = 0.5;
51
51
  const NEUTRAL_SCORE = 0.5;
52
- const STAGE1_WEIGHT = 0.6;
53
- const STAGE2_WEIGHT = 0.4;
54
- const MIN_STAGE1_SCORE = 0.35;
52
+ export const STAGE1_WEIGHT = 0.35;
53
+ export const STAGE2_WEIGHT = 0.65;
54
+ export const MIN_STAGE1_SCORE = 0.35;
55
55
  const L1_SESSION_CAP = 1.2;
56
+ // Minimum prefix-payload max LM probability before Stage 2 activates.
57
+ // Below this threshold the LM signal is too weak to suppress Stage 1 — finalScore
58
+ // falls back to stage1Score directly. Prevents weak prefixes (e.g. "ins" → "instances"
59
+ // at 0.00024) from triggering re-ranking.
60
+ const LM_GATE_THRESHOLD = 0.0005;
56
61
 
57
62
  // ─── Grammar Data (loaded once on import) ───────────────────
58
63
 
@@ -217,9 +222,17 @@ function getLmScore(word: string, lmLogits: Record<string, number> | null): numb
217
222
 
218
223
  // ─── Public API ─────────────────────────────────────────────
219
224
 
225
+ export interface PipelineDebug {
226
+ final: number;
227
+ grammarRejected: string[];
228
+ initial: number;
229
+ stage1Rejected: string[];
230
+ }
231
+
220
232
  export interface RankCandidatesResult {
221
233
  candidates: ScoredCandidate[];
222
234
  grammarMeta: GrammarFilterMeta | null;
235
+ pipelineDebug: PipelineDebug;
223
236
  }
224
237
 
225
238
  export function rankCandidates(
@@ -240,33 +253,39 @@ export function rankCandidates(
240
253
  );
241
254
  return { candidate, semanticScore, freqScore, stage1Score };
242
255
  });
243
-
244
- const stage1Survivors = stage1Results.filter((entry) => entry.stage1Score >= MIN_STAGE1_SCORE);
256
+ const stage1Survivors: typeof stage1Results = [];
257
+ const stage1Rejected: string[] = [];
258
+ for (const entry of stage1Results) {
259
+ if (entry.stage1Score >= MIN_STAGE1_SCORE) {
260
+ stage1Survivors.push(entry);
261
+ } else {
262
+ stage1Rejected.push(entry.candidate.word);
263
+ }
264
+ }
245
265
 
246
266
  // Grammar Filter
247
267
  const { filtered, grammarMeta } = applyGrammarFilter(stage1Survivors, previousWord);
248
268
 
249
269
  // Stage 2 + final assembly
250
270
  let lmMax = 0;
251
- if (lmLogits && Object.keys(lmLogits).length > 0) {
271
+ if (lmLogits) {
252
272
  const values = Object.values(lmLogits);
253
- lmMax = Math.max(...values);
273
+ if (values.length > 0) lmMax = Math.max(...values);
254
274
  }
255
275
 
256
276
  const scored: ScoredCandidate[] = filtered.map((entry) => {
257
277
  let lmScore = 0;
258
278
  let finalScore = entry.stage1Score;
259
279
 
260
- if (lmLogits && Object.keys(lmLogits).length > 0) {
280
+ if (lmLogits && lmMax >= LM_GATE_THRESHOLD) {
261
281
  const rawLm = getLmScore(entry.candidate.word, lmLogits);
262
282
 
263
283
  if (rawLm !== 0) {
264
- // The word was in the top_k! Score it normally.
265
284
  const logitDiff = Math.log(rawLm) - Math.log(lmMax);
266
285
  lmScore = Math.exp(logitDiff);
267
- } else {
268
- lmScore = 0.05;
269
286
  }
287
+ // Words absent from the prefix-filtered payload get lmScore = 0,
288
+ // not 0.05, so they don't outrank genuine LM predictions.
270
289
 
271
290
  finalScore = STAGE1_WEIGHT * entry.stage1Score + STAGE2_WEIGHT * lmScore;
272
291
  }
@@ -281,11 +300,18 @@ export function rankCandidates(
281
300
  });
282
301
 
283
302
  scored.sort((a, b) => {
284
- if (b.finalScore !== a.finalScore) {
285
- return b.finalScore - a.finalScore;
286
- }
303
+ if (b.finalScore !== a.finalScore) return b.finalScore - a.finalScore;
287
304
  return a.word.length - b.word.length;
288
305
  });
289
306
 
290
- return { candidates: scored, grammarMeta };
307
+ return {
308
+ candidates: scored,
309
+ grammarMeta,
310
+ pipelineDebug: {
311
+ initial: candidates.length,
312
+ stage1Rejected,
313
+ grammarRejected: grammarMeta?.dropped ?? [],
314
+ final: scored.length,
315
+ },
316
+ };
291
317
  }