@atlaskit/editor-plugin-autocomplete 9.1.0 → 9.2.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.
- package/CHANGELOG.md +115 -0
- package/dist/cjs/analytics/ufo.js +6 -5
- package/dist/cjs/pm-plugins/autocomplete-plugin.js +496 -50
- package/dist/cjs/pm-plugins/debug-mode.js +14 -3
- package/dist/cjs/pm-plugins/inline-code-harvester.js +576 -0
- package/dist/cjs/pm-plugins/text-predictor.js +298 -151
- package/dist/es2019/analytics/ufo.js +2 -1
- package/dist/es2019/pm-plugins/autocomplete-plugin.js +457 -36
- package/dist/es2019/pm-plugins/debug-mode.js +13 -2
- package/dist/es2019/pm-plugins/inline-code-harvester.js +455 -0
- package/dist/es2019/pm-plugins/text-predictor.js +136 -4
- package/dist/esm/analytics/ufo.js +2 -1
- package/dist/esm/pm-plugins/autocomplete-plugin.js +494 -50
- package/dist/esm/pm-plugins/debug-mode.js +13 -2
- package/dist/esm/pm-plugins/inline-code-harvester.js +572 -0
- package/dist/esm/pm-plugins/text-predictor.js +297 -150
- package/dist/types/analytics/ufo.d.ts +1 -1
- package/dist/types/pm-plugins/autocomplete-plugin.d.ts +37 -0
- package/dist/types/pm-plugins/debug-mode.d.ts +19 -4
- package/dist/types/pm-plugins/inline-code-harvester.d.ts +146 -0
- package/dist/types/pm-plugins/text-predictor.d.ts +70 -3
- package/package.json +2 -2
- package/src/analytics/ufo.ts +3 -6
- package/src/pm-plugins/autocomplete-plugin.ts +526 -31
- package/src/pm-plugins/debug-mode.ts +26 -5
- package/src/pm-plugins/inline-code-harvester.ts +561 -0
- package/src/pm-plugins/text-predictor.ts +162 -7
|
@@ -89,7 +89,7 @@ const printLegend = verbose => {
|
|
|
89
89
|
// eslint-disable-next-line no-console
|
|
90
90
|
console.log('%cPlanes%c [CTC:init] loads · [CTC:signal] semantic/network · [CTC:model] primes/exact · [CTC:model-cost] prefill/decode · [CTC:readiness] deadline progress', CTC_STYLES.section, CTC_STYLES.body);
|
|
91
91
|
// eslint-disable-next-line no-console
|
|
92
|
-
console.log('%cInspect%c __atlCtcDebug__.enable("verbose") · .disable() · .session() (L1 boosts
|
|
92
|
+
console.log('%cInspect%c __atlCtcDebug__.enable("verbose") · .disable() · .harvest() (session inline code) · .session() (L1 boosts) — both narrow to a prefix, e.g. .session("poll")', CTC_STYLES.section, CTC_STYLES.body);
|
|
93
93
|
// eslint-disable-next-line no-console
|
|
94
94
|
console.groupEnd();
|
|
95
95
|
};
|
|
@@ -134,13 +134,24 @@ export const isAutocompleteDebugVerbose = () => {
|
|
|
134
134
|
};
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
|
-
* Hang the
|
|
137
|
+
* Hang the harvest snapshot off the console API.
|
|
138
138
|
*
|
|
139
139
|
* Unlike the log helpers this is available whether or not debug is enabled:
|
|
140
140
|
* inspecting state on demand is not logging, and asking someone to turn on
|
|
141
141
|
* logging and retype to find out what the session already holds defeats the
|
|
142
142
|
* point of being able to ask.
|
|
143
143
|
*/
|
|
144
|
+
export const registerCtcHarvestInspector = inspect => {
|
|
145
|
+
const api = getDebugApi();
|
|
146
|
+
if (api) {
|
|
147
|
+
api.harvest = inspect;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Hang the L1 session-boost snapshot off the console API, on the same terms as
|
|
153
|
+
* `registerCtcHarvestInspector`: available whether or not logging is on.
|
|
154
|
+
*/
|
|
144
155
|
export const registerCtcSessionInspector = inspect => {
|
|
145
156
|
const api = getDebugApi();
|
|
146
157
|
if (api) {
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-code harvester — the Class B candidate source.
|
|
3
|
+
*
|
|
4
|
+
* Class A terms (Atlassian, Confluence) already carry tenant frequencies, a word
|
|
5
|
+
* vector and canonical token ids, so they compete inside the scored pool. Class B
|
|
6
|
+
* terms — `ml-studio` and friends — exist only in the session and cannot be
|
|
7
|
+
* suggested at all today: `incrementSessionFreq` only touches trie nodes that
|
|
8
|
+
* already exist, so an unknown session word is silently discarded.
|
|
9
|
+
*
|
|
10
|
+
* Two sources carry the signal. Assistant replies arrive as markdown strings and
|
|
11
|
+
* keep their backticks; the live document has had its backticks consumed by the
|
|
12
|
+
* text-formatting input rule, so its spans are found through the `code` mark
|
|
13
|
+
* instead. Human chat messages arrive as ADF and are flattened to bare text, so
|
|
14
|
+
* they contribute no spans — which is why no author check is needed here.
|
|
15
|
+
*
|
|
16
|
+
* A harvested surface cannot be scored: there is no frequency to rank it, no
|
|
17
|
+
* vector to place it in context and no canonical token ids to price it under the
|
|
18
|
+
* model. Being marked as code is the whole of the evidence, so what stands in for
|
|
19
|
+
* a score is where the surface is allowed to speak — only on a prefix no
|
|
20
|
+
* vocabulary reached, and only once the scored path has finished with it.
|
|
21
|
+
*
|
|
22
|
+
* Sightings are still counted, per source and by different rules, but they do
|
|
23
|
+
* not admit a surface. They order the set under eviction, and they pick between
|
|
24
|
+
* two surfaces that complete the same typed prefix — see `HarvestedTerm` and
|
|
25
|
+
* `findHarvestedCompletion`.
|
|
26
|
+
*
|
|
27
|
+
* What the session is holding is visible at any time from the console:
|
|
28
|
+
* `__atlCtcDebug__.harvest()`, or `__atlCtcDebug__.harvest('ml-s')` to ask what
|
|
29
|
+
* a prefix would be offered — see `inspectInlineCodeHarvest`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { CTC_STYLES, ctcTag, isAutocompleteDebugEnabled, isAutocompleteDebugVerbose, registerCtcHarvestInspector } from './debug-mode';
|
|
33
|
+
import { lookupVocabularySource } from './text-predictor';
|
|
34
|
+
|
|
35
|
+
/** Longer than this is a command or a path, not a term anyone wants completed. */
|
|
36
|
+
const MAX_IDENTIFIER_LENGTH = 50;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Longer than the predictor's DISPLAY_MIN_PREFIX_LENGTH of 3.
|
|
40
|
+
*
|
|
41
|
+
* The scored path can afford three characters because it has a posterior and a
|
|
42
|
+
* winner margin to answer for the fourth. This path has neither, so it asks the
|
|
43
|
+
* user to commit further before it will guess.
|
|
44
|
+
*/
|
|
45
|
+
const MIN_TYPED_PREFIX_LENGTH = 4;
|
|
46
|
+
|
|
47
|
+
/** Mirrors the predictor's MIN_SUGGESTION_LENGTH. */
|
|
48
|
+
const MIN_GHOST_LENGTH = 3;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sightings needed before a surface may be offered, counting both sources.
|
|
52
|
+
*
|
|
53
|
+
* One, because asking for two reads the replies this exists for as no evidence
|
|
54
|
+
* at all: a reply answering "what are our service names" names each one exactly
|
|
55
|
+
* once, and ten terms mentioned once each is the normal shape of the answer, not
|
|
56
|
+
* a weak signal. What keeps noise out is not a count — see `isDisplayEligible`.
|
|
57
|
+
*/
|
|
58
|
+
const MIN_OCCURRENCES = 1;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cap on the harvested set.
|
|
62
|
+
*
|
|
63
|
+
* Not a memory bound — surfaces are capped at 50 characters. It is there so a
|
|
64
|
+
* long session's early terms cannot sit in the set competing with what is being
|
|
65
|
+
* discussed now, and so the display path's scan stays bounded. Provisional:
|
|
66
|
+
* size it off the funnel numbers from a branch deploy rather than this guess.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_HARVESTED_TERMS = 200;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A surface shorter than a qualifying prefix plus a qualifying ghost can never
|
|
72
|
+
* be shown. Recorded rather than filtered, so the funnel shows how much of the
|
|
73
|
+
* harvest is unusable for that reason alone.
|
|
74
|
+
*/
|
|
75
|
+
const DISPLAYABLE_MIN_LENGTH = MIN_TYPED_PREFIX_LENGTH + MIN_GHOST_LENGTH;
|
|
76
|
+
const FENCED_REGION_REGEX = /```[\s\S]*?```/gu;
|
|
77
|
+
const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/gu;
|
|
78
|
+
const WHITESPACE_REGEX = /\s/u;
|
|
79
|
+
const STARTS_WITH_LETTER_REGEX = /^\p{L}/u;
|
|
80
|
+
|
|
81
|
+
/** Why this surface was chosen over the others that completed the prefix. */
|
|
82
|
+
|
|
83
|
+
const emptyFunnel = () => ({
|
|
84
|
+
accepted: 0,
|
|
85
|
+
rejectedInFence: 0,
|
|
86
|
+
rejectedKnownL2: 0,
|
|
87
|
+
rejectedKnownL3: 0,
|
|
88
|
+
rejectedNonAlphaStart: 0,
|
|
89
|
+
rejectedTooLong: 0,
|
|
90
|
+
rejectedWhitespace: 0,
|
|
91
|
+
spansFound: 0
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Session-scoped, and only correct because the plugin resets it on destroy: the
|
|
95
|
+
// map holds user and assistant content, so surviving an unmount would leak one
|
|
96
|
+
// conversation's terms into the next.
|
|
97
|
+
const harvested = new Map();
|
|
98
|
+
// Two funnels because the two sources are read on different schedules. Ingested
|
|
99
|
+
// text is seen once, so its funnel accumulates; the document is re-walked on
|
|
100
|
+
// every word boundary, so its funnel is replaced to stay a description of the
|
|
101
|
+
// document rather than of how long the session has run.
|
|
102
|
+
let contextFunnel = emptyFunnel();
|
|
103
|
+
let documentFunnel = emptyFunnel();
|
|
104
|
+
let evictedTerms = 0;
|
|
105
|
+
let lastLoggedSignature = '';
|
|
106
|
+
const collectFencedRanges = text => {
|
|
107
|
+
const ranges = [];
|
|
108
|
+
for (const match of text.matchAll(FENCED_REGION_REGEX)) {
|
|
109
|
+
if (match.index !== undefined) {
|
|
110
|
+
ranges.push([match.index, match.index + match[0].length]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return ranges;
|
|
114
|
+
};
|
|
115
|
+
const isInsideFence = (ranges, index) => ranges.some(([start, end]) => index >= start && index < end);
|
|
116
|
+
|
|
117
|
+
/** The cleaned surface if it survives intake, or null. Records the rejection. */
|
|
118
|
+
const admitCandidate = (raw, funnel) => {
|
|
119
|
+
// Markdown permits one space of padding inside the ticks, so trim before
|
|
120
|
+
// testing for the internal whitespace that separates a term from a command.
|
|
121
|
+
const candidate = raw.trim();
|
|
122
|
+
if (candidate.length === 0 || WHITESPACE_REGEX.test(candidate)) {
|
|
123
|
+
funnel.rejectedWhitespace++;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
if (candidate.length > MAX_IDENTIFIER_LENGTH) {
|
|
127
|
+
funnel.rejectedTooLong++;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
// Flags (`-d`, `--open-url`) match every other rule and are worthless to
|
|
131
|
+
// complete, so the leading character has to be a letter.
|
|
132
|
+
if (!STARTS_WITH_LETTER_REGEX.test(candidate)) {
|
|
133
|
+
funnel.rejectedNonAlphaStart++;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
// A surface the scored path can already serve does not belong here. Harvesting
|
|
137
|
+
// it would put a candidate with no frequencies, no vector and no canonical
|
|
138
|
+
// token ids up against one that has all three.
|
|
139
|
+
const known = lookupVocabularySource(candidate);
|
|
140
|
+
if (known === 'l2') {
|
|
141
|
+
funnel.rejectedKnownL2++;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (known === 'l3') {
|
|
145
|
+
funnel.rejectedKnownL3++;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return candidate;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Total mentions behind a surface, across both sources. */
|
|
152
|
+
const sightingsOf = term => term.documentSpans + term.replyOccurrences;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Order two surfaces that are competing for the same typed prefix, best first.
|
|
156
|
+
*
|
|
157
|
+
* Sightings lead, because the number of times the session named a thing is the
|
|
158
|
+
* only quantity this module has that says anything about which one is being
|
|
159
|
+
* discussed. The alphabetical tie-break is arbitrary and chosen for being
|
|
160
|
+
* arbitrary in a stable way: on a tie there is nothing to prefer, and a rule
|
|
161
|
+
* that always resolves the same way means the same prefix produces the same
|
|
162
|
+
* ghost every time rather than one that follows harvest order.
|
|
163
|
+
*
|
|
164
|
+
* Numeric-aware so `model@v2` sorts before `model@v10` — versioned identifiers
|
|
165
|
+
* are common enough in this set to be worth not reading as strings.
|
|
166
|
+
*/
|
|
167
|
+
const byCollapseOrder = (a, b) => sightingsOf(b) - sightingsOf(a) || a.surface.localeCompare(b.surface, 'en', {
|
|
168
|
+
numeric: true,
|
|
169
|
+
sensitivity: 'base'
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Drop the weakest terms until the set is within its cap.
|
|
174
|
+
*
|
|
175
|
+
* Least evidence first, oldest sighting to break the tie. FIFO is the local
|
|
176
|
+
* precedent (MAX_INGESTED_CONTEXT_TEXTS in the plugin) but it is the wrong
|
|
177
|
+
* policy here: the earliest-harvested term may be the one the conversation is
|
|
178
|
+
* actually about, and the sighting count is the only ranking this module has.
|
|
179
|
+
*/
|
|
180
|
+
const evictWeakestTerms = () => {
|
|
181
|
+
if (harvested.size <= MAX_HARVESTED_TERMS) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const ordered = Array.from(harvested.entries()).sort(([, a], [, b]) => sightingsOf(a) - sightingsOf(b) || a.lastSeenAt - b.lastSeenAt);
|
|
185
|
+
for (const [key] of ordered.slice(0, harvested.size - MAX_HARVESTED_TERMS)) {
|
|
186
|
+
harvested.delete(key);
|
|
187
|
+
evictedTerms++;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** Count each admitted surface once per occurrence within a single pass. */
|
|
192
|
+
const tallySpans = surfaces => {
|
|
193
|
+
const counts = new Map();
|
|
194
|
+
for (const surface of surfaces) {
|
|
195
|
+
const key = surface.toLowerCase();
|
|
196
|
+
const existing = counts.get(key);
|
|
197
|
+
if (existing) {
|
|
198
|
+
existing.count++;
|
|
199
|
+
} else {
|
|
200
|
+
counts.set(key, {
|
|
201
|
+
count: 1,
|
|
202
|
+
surface
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return counts;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Harvest single-backtick spans from a markdown-ish string. Fenced regions are
|
|
211
|
+
* skipped: they hold commands and file paths, and their contents are whitespace
|
|
212
|
+
* separated anyway.
|
|
213
|
+
*/
|
|
214
|
+
export const harvestInlineCodeFromText = text => {
|
|
215
|
+
if (!text) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const fencedRanges = collectFencedRanges(text);
|
|
219
|
+
const admitted = [];
|
|
220
|
+
for (const match of text.matchAll(INLINE_CODE_SPAN_REGEX)) {
|
|
221
|
+
if (match.index === undefined) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
contextFunnel.spansFound++;
|
|
225
|
+
if (isInsideFence(fencedRanges, match.index)) {
|
|
226
|
+
contextFunnel.rejectedInFence++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const surface = admitCandidate(match[1], contextFunnel);
|
|
230
|
+
if (surface) {
|
|
231
|
+
admitted.push(surface);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const now = performance.now();
|
|
235
|
+
for (const [key, {
|
|
236
|
+
count,
|
|
237
|
+
surface
|
|
238
|
+
}] of tallySpans(admitted)) {
|
|
239
|
+
const existing = harvested.get(key);
|
|
240
|
+
if (existing) {
|
|
241
|
+
existing.replyOccurrences += count;
|
|
242
|
+
existing.lastSeenAt = now;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
harvested.set(key, {
|
|
246
|
+
displayable: surface.length >= DISPLAYABLE_MIN_LENGTH,
|
|
247
|
+
documentSpans: 0,
|
|
248
|
+
lastSeenAt: now,
|
|
249
|
+
replyOccurrences: count,
|
|
250
|
+
surface
|
|
251
|
+
});
|
|
252
|
+
contextFunnel.accepted++;
|
|
253
|
+
}
|
|
254
|
+
evictWeakestTerms();
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Harvest code-marked text from the live document. The backtick input rule fires
|
|
259
|
+
* on the closing tick and removes both delimiters, so a finished span is only
|
|
260
|
+
* findable through its mark. Code blocks are skipped for the same reason fenced
|
|
261
|
+
* regions are.
|
|
262
|
+
*
|
|
263
|
+
* The whole document is re-read, and what it finds replaces the previous
|
|
264
|
+
* document counts rather than adding to them.
|
|
265
|
+
*/
|
|
266
|
+
export const harvestInlineCodeFromDoc = doc => {
|
|
267
|
+
documentFunnel = emptyFunnel();
|
|
268
|
+
const admitted = [];
|
|
269
|
+
doc.descendants(node => {
|
|
270
|
+
if (node.type.name === 'codeBlock') {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
if (node.isText && node.text && node.marks.some(mark => mark.type.name === 'code')) {
|
|
274
|
+
documentFunnel.spansFound++;
|
|
275
|
+
const surface = admitCandidate(node.text, documentFunnel);
|
|
276
|
+
if (surface) {
|
|
277
|
+
admitted.push(surface);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return true;
|
|
281
|
+
});
|
|
282
|
+
const spans = tallySpans(admitted);
|
|
283
|
+
// Surfaces the document no longer holds lose their document evidence. They
|
|
284
|
+
// stay in the set on whatever reply evidence they have.
|
|
285
|
+
for (const [key, term] of harvested) {
|
|
286
|
+
if (!spans.has(key)) {
|
|
287
|
+
term.documentSpans = 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const now = performance.now();
|
|
291
|
+
for (const [key, {
|
|
292
|
+
count,
|
|
293
|
+
surface
|
|
294
|
+
}] of spans) {
|
|
295
|
+
const existing = harvested.get(key);
|
|
296
|
+
if (existing) {
|
|
297
|
+
existing.documentSpans = count;
|
|
298
|
+
existing.lastSeenAt = now;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
harvested.set(key, {
|
|
302
|
+
displayable: surface.length >= DISPLAYABLE_MIN_LENGTH,
|
|
303
|
+
documentSpans: count,
|
|
304
|
+
lastSeenAt: now,
|
|
305
|
+
replyOccurrences: 0,
|
|
306
|
+
surface
|
|
307
|
+
});
|
|
308
|
+
documentFunnel.accepted++;
|
|
309
|
+
}
|
|
310
|
+
evictWeakestTerms();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Whether a surface still has a sighting behind it.
|
|
315
|
+
*
|
|
316
|
+
* Recurrence turned out to be the wrong thing to lean on. What separates a term
|
|
317
|
+
* worth offering from a word in prose is that someone marked it as code — the
|
|
318
|
+
* author with a code mark, or the model with backticks — and that is already
|
|
319
|
+
* true of everything in this set. The rules that keep noise out are elsewhere:
|
|
320
|
+
* the surface must be absent from L2 and L3, unique among harvested terms for
|
|
321
|
+
* the typed prefix, and on a prefix the scored path has no claim to.
|
|
322
|
+
*
|
|
323
|
+
* The case this still rejects is a withdrawn sighting: a document span the
|
|
324
|
+
* author deleted drops back to zero, and a term with no reply mention behind it
|
|
325
|
+
* stops being offered.
|
|
326
|
+
*/
|
|
327
|
+
const isDisplayEligible = term => term.documentSpans + term.replyOccurrences >= MIN_OCCURRENCES;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Every harvested surface that could be shown for `typedPrefix`, best first.
|
|
331
|
+
*
|
|
332
|
+
* A surface whose remaining tail is too short to render is not a rival, it is a
|
|
333
|
+
* surface the user has finished typing. Neither is one whose sighting has been
|
|
334
|
+
* withdrawn. Both used to be counted as ambiguity and used to silence the path.
|
|
335
|
+
*/
|
|
336
|
+
const collectCandidates = typedPrefix => {
|
|
337
|
+
const needle = typedPrefix.toLowerCase();
|
|
338
|
+
const candidates = [];
|
|
339
|
+
for (const term of harvested.values()) {
|
|
340
|
+
if (!term.surface.toLowerCase().startsWith(needle)) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (term.surface.length - typedPrefix.length < MIN_GHOST_LENGTH) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (!isDisplayEligible(term)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
candidates.push(term);
|
|
350
|
+
}
|
|
351
|
+
return candidates.sort(byCollapseOrder);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The harvested surface to offer for `typedPrefix`, or null.
|
|
356
|
+
*
|
|
357
|
+
* Rivals are collapsed rather than treated as a reason to stay quiet. Silence
|
|
358
|
+
* was the original rule, on the grounds that two session surfaces sharing a
|
|
359
|
+
* prefix have nothing to separate them, and it was wrong for the case this path
|
|
360
|
+
* exists to serve: a reply that answers "what are our services" names ten of
|
|
361
|
+
* them, several share a stem, and abstaining meant the harvester went quiet on
|
|
362
|
+
* exactly the reply it was built for. Being wrong here costs one keystroke —
|
|
363
|
+
* the ghost is ignored and typing continues — and staying silent costs the
|
|
364
|
+
* feature.
|
|
365
|
+
*
|
|
366
|
+
* `collapsedBy` records which rule settled it so a surprising ghost can be
|
|
367
|
+
* explained after the fact rather than guessed at.
|
|
368
|
+
*/
|
|
369
|
+
export const findHarvestedCompletion = typedPrefix => {
|
|
370
|
+
if (typedPrefix.length < MIN_TYPED_PREFIX_LENGTH) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const [winner, runnerUp, ...rest] = collectCandidates(typedPrefix);
|
|
374
|
+
if (!winner) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
collapsedBy: !runnerUp ? 'sole-match' : sightingsOf(winner) > sightingsOf(runnerUp) ? 'sightings' : 'alphabetical',
|
|
379
|
+
documentSpans: winner.documentSpans,
|
|
380
|
+
ghostText: winner.surface.slice(typedPrefix.length),
|
|
381
|
+
replyOccurrences: winner.replyOccurrences,
|
|
382
|
+
rivalSurfaces: (runnerUp ? [runnerUp, ...rest] : []).map(term => term.surface),
|
|
383
|
+
surface: winner.surface,
|
|
384
|
+
typedPrefixLength: typedPrefix.length
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* Read the session's harvest, optionally asking what `typedPrefix` would get.
|
|
389
|
+
*
|
|
390
|
+
* Installed as `__atlCtcDebug__.harvest()` and returned rather than logged, so
|
|
391
|
+
* the console renders it as an inspectable object and a caller can assert on it.
|
|
392
|
+
*/
|
|
393
|
+
export const inspectInlineCodeHarvest = typedPrefix => ({
|
|
394
|
+
cap: MAX_HARVESTED_TERMS,
|
|
395
|
+
evicted: evictedTerms,
|
|
396
|
+
funnels: {
|
|
397
|
+
context: {
|
|
398
|
+
...contextFunnel
|
|
399
|
+
},
|
|
400
|
+
liveDocument: {
|
|
401
|
+
...documentFunnel
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
...(typedPrefix === undefined ? {} : {
|
|
405
|
+
match: findHarvestedCompletion(typedPrefix)
|
|
406
|
+
}),
|
|
407
|
+
terms: Array.from(harvested.values()).sort(byCollapseOrder).map(term => ({
|
|
408
|
+
documentSpans: term.documentSpans,
|
|
409
|
+
longEnough: term.displayable,
|
|
410
|
+
offerable: isDisplayEligible(term),
|
|
411
|
+
replyOccurrences: term.replyOccurrences,
|
|
412
|
+
sightings: sightingsOf(term),
|
|
413
|
+
surface: term.surface
|
|
414
|
+
}))
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// At module scope so the console API answers before the first keystroke, which
|
|
418
|
+
// is when someone reaching for it usually asks.
|
|
419
|
+
registerCtcHarvestInspector(inspectInlineCodeHarvest);
|
|
420
|
+
export const resetInlineCodeHarvest = () => {
|
|
421
|
+
harvested.clear();
|
|
422
|
+
contextFunnel = emptyFunnel();
|
|
423
|
+
documentFunnel = emptyFunnel();
|
|
424
|
+
evictedTerms = 0;
|
|
425
|
+
lastLoggedSignature = '';
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Print the funnel when it has moved since the last print.
|
|
430
|
+
*
|
|
431
|
+
* Deliberately reports zero-span and all-rejected passes too. Logging only on a
|
|
432
|
+
* successful harvest makes "the replies held no inline code", "every span was
|
|
433
|
+
* filtered out" and "this never ran" indistinguishable, which are the three
|
|
434
|
+
* things worth telling apart.
|
|
435
|
+
*/
|
|
436
|
+
export const logInlineCodeHarvest = trigger => {
|
|
437
|
+
if (!isAutocompleteDebugEnabled()) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const snapshot = inspectInlineCodeHarvest();
|
|
441
|
+
const offerable = snapshot.terms.filter(term => term.offerable && term.longEnough);
|
|
442
|
+
const spansFound = contextFunnel.spansFound + documentFunnel.spansFound;
|
|
443
|
+
const signature = `${spansFound}:${snapshot.terms.length}:${offerable.length}`;
|
|
444
|
+
if (signature === lastLoggedSignature) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
lastLoggedSignature = signature;
|
|
448
|
+
ctcTag('harvest', `${trigger} · ${spansFound} inline spans → ${snapshot.terms.length} kept, ${offerable.length} offerable${evictedTerms > 0 ? `, ${evictedTerms} evicted` : ''} · __atlCtcDebug__.harvest() to inspect`, CTC_STYLES.lm);
|
|
449
|
+
if (isAutocompleteDebugVerbose()) {
|
|
450
|
+
// eslint-disable-next-line no-console
|
|
451
|
+
console.table(snapshot.funnels);
|
|
452
|
+
// eslint-disable-next-line no-console
|
|
453
|
+
console.table(snapshot.terms);
|
|
454
|
+
}
|
|
455
|
+
};
|