@startsimpli/integrations 0.1.5 → 0.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/package.json +2 -2
- package/src/__tests__/corpus-copy.test.tsx +196 -0
- package/src/__tests__/corpus-extract.test.ts +537 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/__tests__/corpus-tracker.test.tsx +250 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +2 -0
- package/src/components/pages/AskPage.tsx +33 -8
- package/src/components/pages/LibraryPage.tsx +10 -2
- package/src/components/pages/TrackerPage.tsx +437 -0
- package/src/corpus/client.ts +206 -18
- package/src/corpus/commitments.ts +416 -0
- package/src/corpus/entities.ts +288 -5
- package/src/corpus/extract.ts +438 -0
- package/src/corpus/fixtures.ts +602 -186
- package/src/corpus/index.ts +57 -1
- package/src/corpus/llm.ts +134 -2
- package/src/corpus/text.ts +283 -0
- package/src/corpus/types.ts +256 -0
package/src/corpus/index.ts
CHANGED
|
@@ -9,6 +9,19 @@ export type {
|
|
|
9
9
|
AnswerDraft,
|
|
10
10
|
AnswerRequest,
|
|
11
11
|
AskMode,
|
|
12
|
+
Commitment,
|
|
13
|
+
CommitmentApi,
|
|
14
|
+
CommitmentDraft,
|
|
15
|
+
CommitmentMention,
|
|
16
|
+
CommitmentQuery,
|
|
17
|
+
CommitmentStatus,
|
|
18
|
+
CommitmentSubtype,
|
|
19
|
+
CommitmentTrackerApi,
|
|
20
|
+
CorpusExtractorLlm,
|
|
21
|
+
ExtractionDraft,
|
|
22
|
+
ExtractionRequest,
|
|
23
|
+
ExtractionResult,
|
|
24
|
+
ResolutionClaim,
|
|
12
25
|
AskRequest,
|
|
13
26
|
AskResult,
|
|
14
27
|
AskStatus,
|
|
@@ -32,8 +45,13 @@ export type {
|
|
|
32
45
|
} from './types';
|
|
33
46
|
|
|
34
47
|
export {
|
|
48
|
+
DEFAULT_PARTICIPANT_EDGES,
|
|
35
49
|
DEFAULT_TYPE_KEYS,
|
|
50
|
+
indexParticipants,
|
|
51
|
+
commitmentToEntity,
|
|
52
|
+
mergeParticipants,
|
|
36
53
|
moneyEventAsDocument,
|
|
54
|
+
toCommitment,
|
|
37
55
|
scopeOf,
|
|
38
56
|
toMoneyEvent,
|
|
39
57
|
toParty,
|
|
@@ -41,7 +59,7 @@ export {
|
|
|
41
59
|
toSections,
|
|
42
60
|
toSourceDocument,
|
|
43
61
|
} from './entities';
|
|
44
|
-
export type { CorpusTypeKeys, TenantEntity } from './entities';
|
|
62
|
+
export type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
|
|
45
63
|
|
|
46
64
|
export {
|
|
47
65
|
enforceScope,
|
|
@@ -63,17 +81,55 @@ export {
|
|
|
63
81
|
} from './ask';
|
|
64
82
|
export type { AskCorpusOptions } from './ask';
|
|
65
83
|
|
|
84
|
+
// Extraction — conversations in, trackable commitments out (bd startsim-768w.38.25).
|
|
85
|
+
export {
|
|
86
|
+
EXTRACTOR_VERSION,
|
|
87
|
+
extractCommitments,
|
|
88
|
+
extractSystemPrompt,
|
|
89
|
+
renderExtractPrompt,
|
|
90
|
+
rulesExtract,
|
|
91
|
+
} from './extract';
|
|
92
|
+
export type { ExtractCommitmentsOptions, ExtractDeps, ExtractRequest } from './extract';
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
applyResolutions,
|
|
96
|
+
commitmentIdentity,
|
|
97
|
+
isSameCommitment,
|
|
98
|
+
mergeDrafts,
|
|
99
|
+
reconcile,
|
|
100
|
+
} from './commitments';
|
|
101
|
+
export type {
|
|
102
|
+
CommitmentIdentity,
|
|
103
|
+
MergedDraft,
|
|
104
|
+
ReconcileContext,
|
|
105
|
+
ReconcileResult,
|
|
106
|
+
ResolutionResult,
|
|
107
|
+
} from './commitments';
|
|
108
|
+
|
|
109
|
+
export { jaccard, outcomeSections, splitSections, stem, words } from './text';
|
|
110
|
+
export type { TextSection } from './text';
|
|
111
|
+
|
|
66
112
|
export { createCorpusApi } from './client';
|
|
67
113
|
export type { CorpusHttp, CreateCorpusApiOptions } from './client';
|
|
68
114
|
|
|
69
115
|
export {
|
|
70
116
|
createFixtureCorpusApi,
|
|
117
|
+
createFixtureCorpusHttp,
|
|
118
|
+
createFixtureExtractor,
|
|
71
119
|
createFixtureLlm,
|
|
72
120
|
FIXTURE_DOCUMENTS,
|
|
73
121
|
FIXTURE_MONEY_EVENTS,
|
|
74
122
|
FIXTURE_ORG,
|
|
75
123
|
FIXTURE_OTHER_ORG,
|
|
124
|
+
FIXTURE_PARTICIPANTS,
|
|
76
125
|
FIXTURE_PARTIES,
|
|
126
|
+
FIXTURE_SOURCE_LABELS,
|
|
77
127
|
FIXTURE_SOURCES,
|
|
128
|
+
FIXTURE_TENANT_DOCUMENTS,
|
|
129
|
+
FIXTURE_TENANT_MONEY_EVENTS,
|
|
130
|
+
FIXTURE_TENANT_PARTIES,
|
|
131
|
+
FIXTURE_TENANT_RELATIONSHIPS,
|
|
132
|
+
FIXTURE_TENANT_TRACKED,
|
|
133
|
+
FIXTURE_TRACKED_DOCUMENTS,
|
|
78
134
|
} from './fixtures';
|
|
79
135
|
export type { FixtureCorpusOptions } from './fixtures';
|
package/src/corpus/llm.ts
CHANGED
|
@@ -18,7 +18,16 @@
|
|
|
18
18
|
import { extractJson, LLMService } from '@startsimpli/llm';
|
|
19
19
|
|
|
20
20
|
import { answerSystemPrompt, renderAnswerPrompt } from './ask';
|
|
21
|
-
import
|
|
21
|
+
import { extractSystemPrompt, renderExtractPrompt } from './extract';
|
|
22
|
+
import type {
|
|
23
|
+
AnswerDraft,
|
|
24
|
+
AnswerRequest,
|
|
25
|
+
CorpusExtractorLlm,
|
|
26
|
+
CorpusLlm,
|
|
27
|
+
ExtractionDraft,
|
|
28
|
+
ExtractionRequest,
|
|
29
|
+
LlmDescription,
|
|
30
|
+
} from './types';
|
|
22
31
|
|
|
23
32
|
export interface CorpusLlmOptions {
|
|
24
33
|
/** Reuse an already-configured service; otherwise one is constructed. */
|
|
@@ -27,6 +36,26 @@ export interface CorpusLlmOptions {
|
|
|
27
36
|
model?: string;
|
|
28
37
|
temperature?: number;
|
|
29
38
|
maxTokens?: number;
|
|
39
|
+
/** Injectable environment, so the configured-model lookup is testable. */
|
|
40
|
+
env?: Record<string, string | undefined>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The model this deployment CONFIGURED for a provider, from its own environment.
|
|
45
|
+
*
|
|
46
|
+
* Only used until a real response reports its own model — see `describe()`.
|
|
47
|
+
* Provider-specific first (`OPENAI_MODEL`), then the provider-agnostic
|
|
48
|
+
* `LLM_MODEL`, because a tenant that sets both means the specific one.
|
|
49
|
+
*/
|
|
50
|
+
export function configuredModel(
|
|
51
|
+
provider: string,
|
|
52
|
+
env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
|
|
53
|
+
): string {
|
|
54
|
+
const specific: Record<string, string | undefined> = {
|
|
55
|
+
openai: env.OPENAI_MODEL,
|
|
56
|
+
anthropic: env.ANTHROPIC_MODEL,
|
|
57
|
+
};
|
|
58
|
+
return (specific[provider] || env.LLM_MODEL || '').trim();
|
|
30
59
|
}
|
|
31
60
|
|
|
32
61
|
/**
|
|
@@ -139,13 +168,19 @@ export function createCorpusLlm(options: CorpusLlmOptions = {}): CorpusLlm {
|
|
|
139
168
|
});
|
|
140
169
|
|
|
141
170
|
let lastModel = options.model ?? '';
|
|
171
|
+
const env = options.env ?? (typeof process !== 'undefined' ? process.env : {});
|
|
142
172
|
|
|
143
173
|
const describe = (): LlmDescription => {
|
|
144
174
|
const available = service.getAvailableProviders();
|
|
145
175
|
const provider = available[0] ?? 'none';
|
|
176
|
+
// WHAT ANSWERED beats WHAT WAS CONFIGURED, always — but before the first
|
|
177
|
+
// call there is no "what answered", and `(provider default)` is a string
|
|
178
|
+
// dressed up as a fact. The tenant's own OPENAI_MODEL is the honest answer
|
|
179
|
+
// to "which model will run", so it is named until a response overrides it
|
|
180
|
+
// (bd startsim-768w.38.21).
|
|
146
181
|
return {
|
|
147
182
|
provider,
|
|
148
|
-
model: lastModel || options.model ||
|
|
183
|
+
model: lastModel || options.model || configuredModel(provider, env) || '',
|
|
149
184
|
live: provider !== 'none' && provider !== 'mock',
|
|
150
185
|
};
|
|
151
186
|
};
|
|
@@ -195,3 +230,100 @@ export function createCorpusLlm(options: CorpusLlmOptions = {}): CorpusLlm {
|
|
|
195
230
|
},
|
|
196
231
|
};
|
|
197
232
|
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// extraction (bd startsim-768w.38.25)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Parse + shape-check the extractor's JSON. Anything unusable returns null so
|
|
240
|
+
* the caller can retry once, and after that the run keeps whatever the
|
|
241
|
+
* deterministic rules pass produced rather than failing outright.
|
|
242
|
+
*/
|
|
243
|
+
export function parseExtractionDraft(raw: string): ExtractionDraft | null {
|
|
244
|
+
const extraction = extractJson(raw);
|
|
245
|
+
if (!extraction.success || !extraction.json || typeof extraction.json !== 'object') return null;
|
|
246
|
+
const json = extraction.json as Record<string, unknown>;
|
|
247
|
+
const items = Array.isArray(json.items) ? json.items : null;
|
|
248
|
+
if (!items) return null;
|
|
249
|
+
// Shape-checking proper happens in `extract.ts`, against the set of document
|
|
250
|
+
// ids the run actually supplied — this only rejects entries that are not
|
|
251
|
+
// objects at all, so a single malformed row cannot discard the whole list.
|
|
252
|
+
const objects = (values: unknown[]) => values.filter((item) => Boolean(item) && typeof item === 'object');
|
|
253
|
+
return {
|
|
254
|
+
items: objects(items) as ExtractionDraft['items'],
|
|
255
|
+
resolutions: objects(Array.isArray(json.resolutions) ? json.resolutions : []) as ExtractionDraft['resolutions'],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A `CorpusExtractorLlm` backed by `@startsimpli/llm`.
|
|
261
|
+
*
|
|
262
|
+
* ONE CALL, N DOCUMENTS, exactly like `createCorpusLlm` — the batching rule is
|
|
263
|
+
* structural, and the only second request that can ever happen is a JSON-repair
|
|
264
|
+
* retry of the same request.
|
|
265
|
+
*
|
|
266
|
+
* TEMPERATURE 0. This is transcription of things people said they would do, not
|
|
267
|
+
* writing. A model that improvises here invents a commitment, which is the one
|
|
268
|
+
* failure the tracking surface cannot render its way out of: an item nobody
|
|
269
|
+
* agreed to, sitting on someone's list with a quote that does not appear in the
|
|
270
|
+
* record. `maxTokens` is large because the output is a list whose length is set
|
|
271
|
+
* by the corpus, and a truncated list is a silently short one.
|
|
272
|
+
*/
|
|
273
|
+
export function createCorpusExtractor(options: CorpusLlmOptions = {}): CorpusExtractorLlm {
|
|
274
|
+
const service =
|
|
275
|
+
options.service ??
|
|
276
|
+
new LLMService<unknown>({
|
|
277
|
+
preferredProvider: options.preferredProvider ?? 'anthropic',
|
|
278
|
+
defaultModel: options.model ?? '',
|
|
279
|
+
defaultTemperature: options.temperature ?? 0,
|
|
280
|
+
defaultMaxTokens: options.maxTokens ?? 8192,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let lastModel = options.model ?? '';
|
|
284
|
+
const env = options.env ?? (typeof process !== 'undefined' ? process.env : {});
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
describe(): LlmDescription {
|
|
288
|
+
const available = service.getAvailableProviders();
|
|
289
|
+
const provider = available[0] ?? 'none';
|
|
290
|
+
return {
|
|
291
|
+
provider,
|
|
292
|
+
model: lastModel || options.model || configuredModel(provider, env) || '',
|
|
293
|
+
live: provider !== 'none' && provider !== 'mock',
|
|
294
|
+
};
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
async extract(input: ExtractionRequest): Promise<ExtractionDraft> {
|
|
298
|
+
if (!service.isAvailable()) {
|
|
299
|
+
// Named, not swallowed. The caller catches this and still writes the
|
|
300
|
+
// rules-derived commitments, so the run degrades to the deterministic
|
|
301
|
+
// half rather than to nothing.
|
|
302
|
+
throw new Error(
|
|
303
|
+
'No LLM provider is reachable. Configure ANTHROPIC_API_KEY for this tenant.',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const system = extractSystemPrompt();
|
|
308
|
+
const user = renderExtractPrompt(input);
|
|
309
|
+
|
|
310
|
+
const first = await service.chat(user, system);
|
|
311
|
+
lastModel = first.model || lastModel;
|
|
312
|
+
const parsed = parseExtractionDraft(first.content);
|
|
313
|
+
if (parsed) return parsed;
|
|
314
|
+
|
|
315
|
+
const repair = await service.chat(
|
|
316
|
+
[
|
|
317
|
+
'Your previous response was not valid JSON in the required shape.',
|
|
318
|
+
'Return ONLY the JSON object, with keys "items" and "resolutions".',
|
|
319
|
+
'',
|
|
320
|
+
'Your previous response was:',
|
|
321
|
+
first.content,
|
|
322
|
+
].join('\n'),
|
|
323
|
+
system,
|
|
324
|
+
);
|
|
325
|
+
lastModel = repair.model || lastModel;
|
|
326
|
+
return parseExtractionDraft(repair.content) ?? { items: [], resolutions: [] };
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text normalisation under the extraction pipeline (bd startsim-768w.38.25).
|
|
3
|
+
*
|
|
4
|
+
* TWO JOBS, both of them boring on purpose:
|
|
5
|
+
*
|
|
6
|
+
* 1. SECTIONS. The documents this runs over are markdown — the tenant's
|
|
7
|
+
* `source_document.summary` arrives with `###` headings, one per topic, and
|
|
8
|
+
* on every one of the sixteen real records on the live tandem tenant the
|
|
9
|
+
* last of those headings is "Next Steps". Splitting on the author's own
|
|
10
|
+
* headings is not a heuristic; it is reading the structure the vendor
|
|
11
|
+
* already produced, which is the cheapest extraction there is.
|
|
12
|
+
*
|
|
13
|
+
* 2. STEMS. The identity of a commitment cannot be a string the model emitted,
|
|
14
|
+
* because the model will emit a different string tomorrow. It has to be a
|
|
15
|
+
* NORMALISED bag of stems, so that "Set a deadline for 2040-01" and
|
|
16
|
+
* "Deadline needs setting on matter 2040-01" collapse to the same key. That
|
|
17
|
+
* is the whole reason a stemmer is in this package.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here knows what a commitment is, and nothing here calls a model.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Terms too common to carry meaning. Deliberately small: an aggressive stoplist
|
|
24
|
+
* silently deletes the distinguishing words. Domain nouns all stay in, because
|
|
25
|
+
* they are exactly what separates one commitment from another.
|
|
26
|
+
*/
|
|
27
|
+
const STOPWORDS = new Set([
|
|
28
|
+
'a', 'about', 'all', 'am', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been',
|
|
29
|
+
'but', 'by', 'can', 'did', 'do', 'does', 'for', 'from', 'had', 'has', 'have',
|
|
30
|
+
'he', 'her', 'his', 'how', 'i', 'if', 'in', 'is', 'it', 'its', 'me', 'my', 'of',
|
|
31
|
+
'on', 'or', 'our', 'she', 'so', 'that', 'the', 'their', 'them', 'then', 'there',
|
|
32
|
+
'these', 'they', 'this', 'to', 'us', 'was', 'we', 'were', 'what', 'when',
|
|
33
|
+
'where', 'which', 'who', 'why', 'will', 'with', 'you', 'your',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Raw lowercase word tokens, before stemming. */
|
|
37
|
+
export function words(text: string): string[] {
|
|
38
|
+
return ((text ?? '').toLowerCase().match(/[a-z0-9][a-z0-9'&.\-/]*/g) ?? [])
|
|
39
|
+
.map((token) => token.replace(/^[.'\-/]+|[.'\-/]+$/g, ''))
|
|
40
|
+
.filter((token) => token.length >= 2 && !STOPWORDS.has(token));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Lowercase STEMS, stopwords removed. The unit identity is computed on. */
|
|
44
|
+
export function tokenize(text: string): string[] {
|
|
45
|
+
return words(text).map(stem);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Overlap of two token sets, 0..1. The near-match half of commitment identity. */
|
|
49
|
+
export function jaccard(a: Iterable<string>, b: Iterable<string>): number {
|
|
50
|
+
const left = new Set(a);
|
|
51
|
+
const right = new Set(b);
|
|
52
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
53
|
+
let shared = 0;
|
|
54
|
+
for (const token of left) if (right.has(token)) shared += 1;
|
|
55
|
+
return shared / (left.size + right.size - shared);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// stemming
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
//
|
|
62
|
+
// Porter, with ONE deliberate departure: `-ment` / `-ement` is stripped at m>0
|
|
63
|
+
// rather than m>1. Vanilla Porter leaves `agreement` as `agreem` while `agree`
|
|
64
|
+
// becomes `agre`, so two mentions of the same commitment — one phrased "we
|
|
65
|
+
// agreed", one "per the agreement" — would land as two items. The measure test
|
|
66
|
+
// stays on every other suffix, which is what keeps `sign`/`signal` and
|
|
67
|
+
// `rent`/`rental` apart; those are different words and collapsing them would
|
|
68
|
+
// merge two commitments that have nothing to do with each other, which is a
|
|
69
|
+
// worse failure than listing one twice.
|
|
70
|
+
//
|
|
71
|
+
// Anything containing a digit is returned untouched: `2040-01` is a matter
|
|
72
|
+
// number, and a matter number has no morphology. That matters more than it
|
|
73
|
+
// sounds — matter numbers are the strongest identity signal in this corpus.
|
|
74
|
+
|
|
75
|
+
const VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);
|
|
76
|
+
|
|
77
|
+
function isConsonant(word: string, index: number): boolean {
|
|
78
|
+
const letter = word[index];
|
|
79
|
+
if (VOWELS.has(letter)) return false;
|
|
80
|
+
if (letter !== 'y') return true;
|
|
81
|
+
return index === 0 ? true : !isConsonant(word, index - 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Porter's m: how many vowel→consonant transitions the word contains. */
|
|
85
|
+
function measure(word: string): number {
|
|
86
|
+
let count = 0;
|
|
87
|
+
let index = 0;
|
|
88
|
+
while (index < word.length && isConsonant(word, index)) index += 1;
|
|
89
|
+
while (index < word.length) {
|
|
90
|
+
while (index < word.length && !isConsonant(word, index)) index += 1;
|
|
91
|
+
if (index >= word.length) break;
|
|
92
|
+
count += 1;
|
|
93
|
+
while (index < word.length && isConsonant(word, index)) index += 1;
|
|
94
|
+
}
|
|
95
|
+
return count;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const hasVowel = (word: string) => [...word].some((_, index) => !isConsonant(word, index));
|
|
99
|
+
|
|
100
|
+
const endsDoubleConsonant = (word: string) =>
|
|
101
|
+
word.length >= 2 &&
|
|
102
|
+
word[word.length - 1] === word[word.length - 2] &&
|
|
103
|
+
isConsonant(word, word.length - 1);
|
|
104
|
+
|
|
105
|
+
/** Porter's *o: ends consonant-vowel-consonant where the last is not w, x or y. */
|
|
106
|
+
function endsCvc(word: string): boolean {
|
|
107
|
+
const size = word.length;
|
|
108
|
+
if (size < 3) return false;
|
|
109
|
+
return (
|
|
110
|
+
isConsonant(word, size - 1) &&
|
|
111
|
+
!isConsonant(word, size - 2) &&
|
|
112
|
+
isConsonant(word, size - 3) &&
|
|
113
|
+
!'wxy'.includes(word[size - 1])
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function replaceIf(word: string, suffix: string, replacement: string, minMeasure: number): string | null {
|
|
118
|
+
if (!word.endsWith(suffix)) return null;
|
|
119
|
+
const base = word.slice(0, word.length - suffix.length);
|
|
120
|
+
return measure(base) > minMeasure ? base + replacement : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const STEP2: Array<[string, string]> = [
|
|
124
|
+
['ational', 'ate'], ['tional', 'tion'], ['enci', 'ence'], ['anci', 'ance'],
|
|
125
|
+
['izer', 'ize'], ['abli', 'able'], ['alli', 'al'], ['entli', 'ent'], ['eli', 'e'],
|
|
126
|
+
['ousli', 'ous'], ['ization', 'ize'], ['ation', 'ate'], ['ator', 'ate'],
|
|
127
|
+
['alism', 'al'], ['iveness', 'ive'], ['fulness', 'ful'], ['ousness', 'ous'],
|
|
128
|
+
['aliti', 'al'], ['iviti', 'ive'], ['biliti', 'ble'],
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const STEP3: Array<[string, string]> = [
|
|
132
|
+
['icate', 'ic'], ['ative', ''], ['alize', 'al'], ['iciti', 'ic'], ['ical', 'ic'],
|
|
133
|
+
['ful', ''], ['ness', ''],
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const STEP4 = [
|
|
137
|
+
'al', 'ance', 'ence', 'er', 'ic', 'able', 'ible', 'ant', 'ou', 'ism', 'ate',
|
|
138
|
+
'iti', 'ous', 'ive', 'ize',
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reduce a word to the form it shares with its inflections.
|
|
143
|
+
*
|
|
144
|
+
* NOT a dictionary and not a lemmatizer: the output is frequently not a word
|
|
145
|
+
* (`invoice` and `invoicing` both become `invoic`). That is fine — it is only
|
|
146
|
+
* ever compared against another stem, and it is never shown to a user. Every
|
|
147
|
+
* string on screen is the original text.
|
|
148
|
+
*/
|
|
149
|
+
export function stem(input: string): string {
|
|
150
|
+
let word = (input ?? '').toLowerCase();
|
|
151
|
+
if (word.length <= 2 || /\d/.test(word)) return word;
|
|
152
|
+
|
|
153
|
+
// Step 1a — plurals.
|
|
154
|
+
if (word.endsWith('sses')) word = word.slice(0, -2);
|
|
155
|
+
else if (word.endsWith('ies')) word = `${word.slice(0, -3)}i`;
|
|
156
|
+
else if (word.endsWith('ss')) { /* keep */ }
|
|
157
|
+
else if (word.endsWith('s')) word = word.slice(0, -1);
|
|
158
|
+
|
|
159
|
+
// Step 1b — past tense and gerund.
|
|
160
|
+
let step1bFired = false;
|
|
161
|
+
if (word.endsWith('eed')) {
|
|
162
|
+
if (measure(word.slice(0, -3)) > 0) word = word.slice(0, -1);
|
|
163
|
+
} else if (word.endsWith('ed') && hasVowel(word.slice(0, -2))) {
|
|
164
|
+
word = word.slice(0, -2);
|
|
165
|
+
step1bFired = true;
|
|
166
|
+
} else if (word.endsWith('ing') && hasVowel(word.slice(0, -3))) {
|
|
167
|
+
word = word.slice(0, -3);
|
|
168
|
+
step1bFired = true;
|
|
169
|
+
}
|
|
170
|
+
if (step1bFired) {
|
|
171
|
+
if (word.endsWith('at') || word.endsWith('bl') || word.endsWith('iz')) word += 'e';
|
|
172
|
+
else if (endsDoubleConsonant(word) && !'lsz'.includes(word[word.length - 1])) word = word.slice(0, -1);
|
|
173
|
+
else if (measure(word) === 1 && endsCvc(word)) word += 'e';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Step 1c — terminal y.
|
|
177
|
+
if (word.endsWith('y') && hasVowel(word.slice(0, -1))) word = `${word.slice(0, -1)}i`;
|
|
178
|
+
|
|
179
|
+
// Steps 2 and 3 — derivational suffixes.
|
|
180
|
+
for (const [suffix, replacement] of STEP2) {
|
|
181
|
+
const next = replaceIf(word, suffix, replacement, 0);
|
|
182
|
+
if (next !== null) { word = next; break; }
|
|
183
|
+
}
|
|
184
|
+
for (const [suffix, replacement] of STEP3) {
|
|
185
|
+
const next = replaceIf(word, suffix, replacement, 0);
|
|
186
|
+
if (next !== null) { word = next; break; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Step 4 — strip a final derivational suffix on a long enough stem.
|
|
190
|
+
// `-ment` and NOT `-ement`, deliberately. Stripping `ement` from `agreement`
|
|
191
|
+
// leaves `agre`, which step 5 then trims again to `agr` — while `agree`
|
|
192
|
+
// itself only reaches `agre`, so the two would never meet. Cutting at `ment`
|
|
193
|
+
// leaves `agree` and lets the ordinary trailing-e rule do the rest.
|
|
194
|
+
const mentStripped = replaceIf(word, 'ment', '', 0);
|
|
195
|
+
if (mentStripped !== null) {
|
|
196
|
+
word = mentStripped;
|
|
197
|
+
} else {
|
|
198
|
+
let stripped = false;
|
|
199
|
+
for (const suffix of STEP4) {
|
|
200
|
+
const next = replaceIf(word, suffix, '', 1);
|
|
201
|
+
if (next !== null) { word = next; stripped = true; break; }
|
|
202
|
+
}
|
|
203
|
+
if (!stripped) {
|
|
204
|
+
const ent = replaceIf(word, 'ent', '', 1);
|
|
205
|
+
if (ent !== null) { word = ent; stripped = true; }
|
|
206
|
+
}
|
|
207
|
+
if (!stripped && word.endsWith('ion')) {
|
|
208
|
+
const base = word.slice(0, -3);
|
|
209
|
+
if (measure(base) > 1 && (base.endsWith('s') || base.endsWith('t'))) word = base;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Step 5 — the trailing e, and a doubled l.
|
|
214
|
+
if (word.endsWith('e')) {
|
|
215
|
+
const base = word.slice(0, -1);
|
|
216
|
+
const m = measure(base);
|
|
217
|
+
if (m > 1 || (m === 1 && !endsCvc(base))) word = base;
|
|
218
|
+
}
|
|
219
|
+
if (measure(word) > 1 && endsDoubleConsonant(word) && word.endsWith('l')) word = word.slice(0, -1);
|
|
220
|
+
|
|
221
|
+
return word;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// markdown sections
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
export interface TextSection {
|
|
229
|
+
/** The heading text, without the leading hashes. Empty for a preamble. */
|
|
230
|
+
heading: string;
|
|
231
|
+
/** Everything under that heading, up to the next one. */
|
|
232
|
+
body: string;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const HEADING = /^\s{0,3}(#{1,6})\s+(\S.*)$/;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Split markdown into its author's own sections.
|
|
239
|
+
*
|
|
240
|
+
* Used for one thing that matters: finding the block the vendor already labelled
|
|
241
|
+
* as the outcome of the meeting. On the live tandem tenant every single one of
|
|
242
|
+
* the sixteen records ends with a `## Next Steps` block of bolded imperatives
|
|
243
|
+
* with owners in parentheses — an action list the summariser already wrote. A
|
|
244
|
+
* pipeline that sends the whole document to a model and asks it to find the
|
|
245
|
+
* action items is redoing that work, less reliably, for money.
|
|
246
|
+
*/
|
|
247
|
+
export function splitSections(text: string): TextSection[] {
|
|
248
|
+
const body = (text ?? '').trim();
|
|
249
|
+
if (!body) return [];
|
|
250
|
+
|
|
251
|
+
const sections: TextSection[] = [];
|
|
252
|
+
let heading = '';
|
|
253
|
+
let buffer: string[] = [];
|
|
254
|
+
|
|
255
|
+
const flush = () => {
|
|
256
|
+
const joined = buffer.join('\n').trim();
|
|
257
|
+
if (heading || joined) sections.push({ heading, body: joined });
|
|
258
|
+
buffer = [];
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (const line of body.split('\n')) {
|
|
262
|
+
const match = HEADING.exec(line);
|
|
263
|
+
if (match) {
|
|
264
|
+
flush();
|
|
265
|
+
heading = match[2].trim();
|
|
266
|
+
} else {
|
|
267
|
+
buffer.push(line);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
flush();
|
|
271
|
+
return sections;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The section whose heading names the outcome of the meeting, if the source
|
|
276
|
+
* wrote one. Matched on the WORDS, not on an exact string, because summarisers
|
|
277
|
+
* write "Next Steps", "Action Items" and "Follow-ups" interchangeably.
|
|
278
|
+
*/
|
|
279
|
+
const OUTCOME_HEADING = /\b(next\s*steps?|action\s*items?|follow[\s-]?ups?|to[\s-]?dos?)\b/i;
|
|
280
|
+
|
|
281
|
+
export function outcomeSections(text: string): TextSection[] {
|
|
282
|
+
return splitSections(text).filter((section) => OUTCOME_HEADING.test(section.heading));
|
|
283
|
+
}
|