@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.
@@ -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 type { AnswerDraft, AnswerRequest, CorpusLlm, LlmDescription } from './types';
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 || '(provider default)',
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
+ }