@startsimpli/integrations 0.2.13 → 0.2.16
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__/connections.test.tsx +165 -0
- package/src/__tests__/corpus-ask.test.ts +19 -1
- package/src/__tests__/corpus-copy.test.tsx +4 -1
- package/src/__tests__/corpus-field-gate.test.ts +246 -0
- package/src/__tests__/corpus-pages.test.tsx +58 -2
- package/src/__tests__/corpus-tracker.test.tsx +43 -0
- package/src/__tests__/corpus-transcript.test.ts +476 -0
- package/src/components/CredentialSchemaForm.tsx +55 -8
- package/src/components/pages/AskPage.tsx +11 -0
- package/src/components/pages/ConnectionsPage.tsx +142 -5
- package/src/components/pages/LibraryPage.tsx +56 -3
- package/src/components/pages/TrackerPage.tsx +17 -0
- package/src/corpus/ask.ts +33 -8
- package/src/corpus/entities.ts +56 -2
- package/src/corpus/extract.ts +340 -15
- package/src/corpus/fixtures.ts +9 -1
- package/src/corpus/llm.ts +80 -1
- package/src/corpus/retrieval.ts +28 -4
- package/src/corpus/text.ts +76 -0
- package/src/corpus/types.ts +108 -2
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRANSCRIPT-BACKED EXTRACTION (bd startsim-768w.38.31).
|
|
3
|
+
*
|
|
4
|
+
* THE MEASUREMENT THAT PRODUCED THIS FILE. Extraction recall on the live tandem
|
|
5
|
+
* tenant is capped around 60%, and it is not the extractor's fault: every one of
|
|
6
|
+
* the sixteen real `source_document` rows carries a `summary` and NOTHING else —
|
|
7
|
+
* 54,534 characters of Granola's own AI summary, zero characters of body. The
|
|
8
|
+
* commitments the evaluation scores as "missed" were discarded by the summariser
|
|
9
|
+
* before this package ever saw the record. No prompt recovers text that was
|
|
10
|
+
* never fetched.
|
|
11
|
+
*
|
|
12
|
+
* The transcript is at the vendor and the connector already knows how to ask for
|
|
13
|
+
* it (`GET /v1/notes/{id}?include=transcript`, gated on
|
|
14
|
+
* `SourceInstance.config["ingest_transcripts"]`, default false by the Q4 privacy
|
|
15
|
+
* posture). This file is the DOWNSTREAM half: everything that has to be true
|
|
16
|
+
* before that flag is worth flipping.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT PINS, in the order the pipeline runs:
|
|
19
|
+
*
|
|
20
|
+
* 1. THE SUMMARY SURVIVES THE TRANSCRIPT. The canonical schema declares
|
|
21
|
+
* `summary` and `body` as two attributes precisely so "the transcript
|
|
22
|
+
* clobbers the summary the first time both arrive" cannot happen — and the
|
|
23
|
+
* mapper collapsed them anyway. That single `pick()` would have deleted the
|
|
24
|
+
* 95.8%-precision rules pass on the day transcripts were switched on.
|
|
25
|
+
* 2. THE MODEL PASS ACTUALLY READS THE VERBATIM TEXT, and is told what the
|
|
26
|
+
* rules pass already found so it reports only the remainder.
|
|
27
|
+
* 3. TRIMMING KEEPS THE END OF THE MEETING. Commitments are made in the last
|
|
28
|
+
* five minutes; a head-first truncation deletes exactly the half worth
|
|
29
|
+
* reading.
|
|
30
|
+
* 4. VERIFICATION. More text raises recall and drops precision — measured, 65.9%
|
|
31
|
+
* recall at 67.5% precision on summaries alone, and a transcript is ten times
|
|
32
|
+
* the text. Every model candidate has to be confirmed against the source with
|
|
33
|
+
* a supporting quote, in ONE batched call, and anything unconfirmed is
|
|
34
|
+
* dropped rather than shipped to someone's to-do list.
|
|
35
|
+
*/
|
|
36
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
37
|
+
|
|
38
|
+
import { toSourceDocument } from '../corpus/entities';
|
|
39
|
+
import type { TenantEntity } from '../corpus/entities';
|
|
40
|
+
import { documentText, extractCommitments, rulesExtract } from '../corpus/extract';
|
|
41
|
+
import type {
|
|
42
|
+
CommitmentDraft,
|
|
43
|
+
CorpusExtractorLlm,
|
|
44
|
+
CorpusScope,
|
|
45
|
+
ExtractionDraft,
|
|
46
|
+
SourceDocument,
|
|
47
|
+
VerificationRequest,
|
|
48
|
+
} from '../corpus/types';
|
|
49
|
+
|
|
50
|
+
const SCOPE: CorpusScope = { orgId: 'tandem' };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The summariser's own output, in the shape all sixteen real records have: `##`
|
|
54
|
+
* headings per topic, the last of them an outcome block of bolded imperatives
|
|
55
|
+
* with the owner in parentheses.
|
|
56
|
+
*/
|
|
57
|
+
const SUMMARY = [
|
|
58
|
+
'## Discussion',
|
|
59
|
+
'We went through the Rexford paperwork and the parking lot quote.',
|
|
60
|
+
'',
|
|
61
|
+
'## Next Steps',
|
|
62
|
+
'- **Send the signed Rexford lease to Michelle** (Kate)',
|
|
63
|
+
'- **Get a second quote for the Hadley lot** (Jonathan)',
|
|
64
|
+
].join('\n');
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The same meeting as the vendor's ASR heard it. Two commitments in here never
|
|
68
|
+
* reached the summary — which is the entire finding this file exists for.
|
|
69
|
+
*/
|
|
70
|
+
const TRANSCRIPT = [
|
|
71
|
+
'Jonathan: Alright, Rexford. Kate, the lease came back signed this morning.',
|
|
72
|
+
'Kate: I saw it. I will send it over to Michelle today so she can file it.',
|
|
73
|
+
'Jonathan: Good. On the Hadley lot, the number they gave us is high.',
|
|
74
|
+
'Kate: Get a second quote before you agree to anything.',
|
|
75
|
+
'Jonathan: I will. Separately — I still owe Rae the insurance certificate for the Lamar building.',
|
|
76
|
+
'Kate: Right, and somebody has to cancel the old Pigoso QuickBooks seat before we get billed again.',
|
|
77
|
+
'Jonathan: I will take the certificate. Put the QuickBooks seat on Megan.',
|
|
78
|
+
].join('\n');
|
|
79
|
+
|
|
80
|
+
/** The raw tenant row exactly as the corpus delivery writes it. */
|
|
81
|
+
function row(data: Record<string, unknown>): TenantEntity {
|
|
82
|
+
return {
|
|
83
|
+
id: 'doc_rexford',
|
|
84
|
+
entity_type: 'source_document',
|
|
85
|
+
external_id: 'granola:not_6IARYTobc9AwLI',
|
|
86
|
+
name: 'Rexford paperwork and Hadley parking lot',
|
|
87
|
+
data: {
|
|
88
|
+
subtype: 'meeting',
|
|
89
|
+
title: 'Rexford paperwork and Hadley parking lot',
|
|
90
|
+
source: 'granola',
|
|
91
|
+
source_ref: 'not_6IARYTobc9AwLI',
|
|
92
|
+
occurred_at: '2026-08-05T15:00:00Z',
|
|
93
|
+
...data,
|
|
94
|
+
},
|
|
95
|
+
created_at: '2026-08-05T16:00:00Z',
|
|
96
|
+
org_id: 'tandem',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const summaryOnly = () => toSourceDocument(row({ summary: SUMMARY }), SCOPE);
|
|
101
|
+
/**
|
|
102
|
+
* WHAT THE CONNECTOR REALLY WRITES once `ingest_transcripts` is on: the
|
|
103
|
+
* `meeting_transcript` record `merge_into`s the note's own document and fills the
|
|
104
|
+
* canonical `body` attribute with the verbatim text
|
|
105
|
+
* (`granola.declare_canonical()`), alongside the `summary` that was already
|
|
106
|
+
* there. Not a `transcript` key — the fixture corpus used one of those, which is
|
|
107
|
+
* exactly why no existing test caught the collapse below.
|
|
108
|
+
*/
|
|
109
|
+
const withTranscript = () => toSourceDocument(row({ summary: SUMMARY, body: TRANSCRIPT }), SCOPE);
|
|
110
|
+
|
|
111
|
+
describe('a transcript must not delete the summary', () => {
|
|
112
|
+
it('keeps the summary as its own field when the verbatim body arrives', () => {
|
|
113
|
+
const document = withTranscript();
|
|
114
|
+
expect(document.summary).toContain('## Next Steps');
|
|
115
|
+
expect(document.body).toContain('Jonathan: Alright, Rexford');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('still parses the vendor outcome block when a transcript is present', () => {
|
|
119
|
+
// THE REGRESSION THIS FILE WAS WRITTEN FOR. `pick(data,'body',...,'summary')`
|
|
120
|
+
// returns the FIRST present key, so a document carrying both returned only
|
|
121
|
+
// the transcript, `outcomeSections` found no `## Next Steps` in it, and the
|
|
122
|
+
// rules pass — 95.8% precision, 100% owner accuracy — produced zero.
|
|
123
|
+
const before = rulesExtract(summaryOnly());
|
|
124
|
+
const after = rulesExtract(withTranscript());
|
|
125
|
+
expect(before).toHaveLength(2);
|
|
126
|
+
expect(after.map((draft) => draft.title)).toEqual(before.map((draft) => draft.title));
|
|
127
|
+
expect(after.map((draft) => draft.ownerName)).toEqual(['Kate', 'Jonathan']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reads the summary on a corpus that has no body at all', () => {
|
|
131
|
+
// Every row on the live tenant today. `body` has to keep answering for the
|
|
132
|
+
// summary or every existing consumer of it goes blank on the same deploy.
|
|
133
|
+
const document = summaryOnly();
|
|
134
|
+
expect(document.body).toContain('## Next Steps');
|
|
135
|
+
expect(document.summary).toContain('## Next Steps');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('the text handed to the model', () => {
|
|
140
|
+
it('carries the verbatim transcript, not just the summary', () => {
|
|
141
|
+
const text = documentText(withTranscript(), 12_000);
|
|
142
|
+
expect(text).toContain('## Next Steps');
|
|
143
|
+
expect(text).toContain('insurance certificate for the Lamar building');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not repeat the summary twice when body fell back to it', () => {
|
|
147
|
+
const text = documentText(summaryOnly(), 12_000);
|
|
148
|
+
expect(text.match(/## Next Steps/g)).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('keeps the END of a long transcript, where the commitments are', () => {
|
|
152
|
+
// A head-first `slice(0, maxChars)` deletes the last five minutes of the
|
|
153
|
+
// meeting, which is the only part where anyone says who is doing what.
|
|
154
|
+
const filler = Array.from({ length: 400 }, (_, index) => `Speaker: filler line ${index}`).join('\n');
|
|
155
|
+
const long = toSourceDocument(row({ summary: SUMMARY, body: `${filler}\n${TRANSCRIPT}` }), SCOPE);
|
|
156
|
+
const text = documentText(long, 4_000);
|
|
157
|
+
expect(text.length).toBeLessThanOrEqual(6_000);
|
|
158
|
+
// The summary is small and structured: it is never the thing that gets cut.
|
|
159
|
+
expect(text).toContain('## Next Steps');
|
|
160
|
+
expect(text).toContain('Put the QuickBooks seat on Megan');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// verification
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
interface StubOptions {
|
|
169
|
+
items?: ExtractionDraft['items'];
|
|
170
|
+
verdicts?: Array<{ id: string; confirmed: boolean; quote?: string }>;
|
|
171
|
+
onVerify?: (input: VerificationRequest) => void;
|
|
172
|
+
verifyThrows?: boolean;
|
|
173
|
+
omitVerify?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stubExtractor(options: StubOptions = {}): CorpusExtractorLlm {
|
|
177
|
+
const verify = async (input: VerificationRequest) => {
|
|
178
|
+
options.onVerify?.(input);
|
|
179
|
+
if (options.verifyThrows) throw new Error('provider unreachable');
|
|
180
|
+
return {
|
|
181
|
+
verdicts: (options.verdicts ?? input.candidates.map((candidate) => ({
|
|
182
|
+
id: candidate.id,
|
|
183
|
+
confirmed: true,
|
|
184
|
+
quote: candidate.quote,
|
|
185
|
+
}))),
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
async extract() {
|
|
190
|
+
return { items: options.items ?? [], resolutions: [] };
|
|
191
|
+
},
|
|
192
|
+
...(options.omitVerify ? {} : { verify }),
|
|
193
|
+
describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A corpus of exactly one document, with a capture of what got written. */
|
|
198
|
+
function stubCorpus(document: SourceDocument, saved: CommitmentDraft[][] = []) {
|
|
199
|
+
return {
|
|
200
|
+
listDocuments: async () => ({ count: 1, next: null, previous: null, results: [document] }),
|
|
201
|
+
getDocument: async () => document,
|
|
202
|
+
listParties: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
203
|
+
listSources: async () => [],
|
|
204
|
+
listCommitments: async () => ({ count: 0, next: null, previous: null, results: [] }),
|
|
205
|
+
saveCommitments: async (commitments: unknown[]) => {
|
|
206
|
+
saved.push(commitments as CommitmentDraft[]);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const modelItem = (title: string, quote: string) => ({
|
|
212
|
+
documentId: 'doc_rexford',
|
|
213
|
+
subtype: 'action',
|
|
214
|
+
title,
|
|
215
|
+
detail: '',
|
|
216
|
+
status: 'open',
|
|
217
|
+
quote,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('the verification pass', () => {
|
|
221
|
+
it('drops a candidate the verifier refuses to confirm', async () => {
|
|
222
|
+
const document = withTranscript();
|
|
223
|
+
const saved: CommitmentDraft[][] = [];
|
|
224
|
+
const result = await extractCommitments(
|
|
225
|
+
{ scope: SCOPE },
|
|
226
|
+
{
|
|
227
|
+
corpus: stubCorpus(document, saved),
|
|
228
|
+
extractor: stubExtractor({
|
|
229
|
+
items: [
|
|
230
|
+
modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate'),
|
|
231
|
+
modelItem('Buy the Hadley lot outright this quarter', 'the number they gave us is high'),
|
|
232
|
+
],
|
|
233
|
+
verdicts: [
|
|
234
|
+
{ id: 'c1', confirmed: true, quote: 'I still owe Rae the insurance certificate' },
|
|
235
|
+
{ id: 'c2', confirmed: false },
|
|
236
|
+
],
|
|
237
|
+
}),
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
expect(result.fromModel).toBe(1);
|
|
241
|
+
expect(result.unconfirmed).toBe(1);
|
|
242
|
+
expect(result.verified).toBe(true);
|
|
243
|
+
const titles = saved.flat().map((item) => (item as unknown as { title: string }).title);
|
|
244
|
+
expect(titles).toContain('Send Rae the Lamar insurance certificate');
|
|
245
|
+
expect(titles).not.toContain('Buy the Hadley lot outright this quarter');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('drops a candidate whose quote is not in the source, with no model call at all', async () => {
|
|
249
|
+
// The free half of verification. A quote that does not appear in the record
|
|
250
|
+
// is the signature of an invented commitment, and catching it costs nothing.
|
|
251
|
+
const document = withTranscript();
|
|
252
|
+
const result = await extractCommitments(
|
|
253
|
+
{ scope: SCOPE },
|
|
254
|
+
{
|
|
255
|
+
corpus: stubCorpus(document),
|
|
256
|
+
extractor: stubExtractor({
|
|
257
|
+
items: [modelItem('Wire the deposit to the title company', 'Kate confirmed the wire went out')],
|
|
258
|
+
}),
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
expect(result.fromModel).toBe(0);
|
|
262
|
+
expect(result.ungrounded).toBe(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('verifies every candidate in ONE call, with echoed ids', async () => {
|
|
266
|
+
const document = withTranscript();
|
|
267
|
+
const calls: VerificationRequest[] = [];
|
|
268
|
+
await extractCommitments(
|
|
269
|
+
{ scope: SCOPE },
|
|
270
|
+
{
|
|
271
|
+
corpus: stubCorpus(document),
|
|
272
|
+
extractor: stubExtractor({
|
|
273
|
+
items: [
|
|
274
|
+
modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate'),
|
|
275
|
+
modelItem('Cancel the old Pigoso QuickBooks seat', 'cancel the old Pigoso QuickBooks seat'),
|
|
276
|
+
],
|
|
277
|
+
onVerify: (input) => calls.push(input),
|
|
278
|
+
}),
|
|
279
|
+
},
|
|
280
|
+
);
|
|
281
|
+
expect(calls).toHaveLength(1);
|
|
282
|
+
expect(calls[0].candidates).toHaveLength(2);
|
|
283
|
+
expect(new Set(calls[0].candidates.map((candidate) => candidate.id)).size).toBe(2);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('never spends a verification call on the rules pass', async () => {
|
|
287
|
+
// The rules pass quotes the vendor's own bullet verbatim. Paying a model to
|
|
288
|
+
// confirm a deterministic transcription of a line we can print is money for
|
|
289
|
+
// a chance to lose a correct item.
|
|
290
|
+
const document = withTranscript();
|
|
291
|
+
const calls: VerificationRequest[] = [];
|
|
292
|
+
await extractCommitments(
|
|
293
|
+
{ scope: SCOPE },
|
|
294
|
+
{ corpus: stubCorpus(document), extractor: stubExtractor({ onVerify: (input) => calls.push(input) }) },
|
|
295
|
+
);
|
|
296
|
+
expect(calls).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('drops the model half rather than shipping it unverified when the verifier fails', async () => {
|
|
300
|
+
const document = withTranscript();
|
|
301
|
+
const result = await extractCommitments(
|
|
302
|
+
{ scope: SCOPE },
|
|
303
|
+
{
|
|
304
|
+
corpus: stubCorpus(document),
|
|
305
|
+
extractor: stubExtractor({
|
|
306
|
+
items: [modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate')],
|
|
307
|
+
verifyThrows: true,
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
expect(result.fromModel).toBe(0);
|
|
312
|
+
expect(result.verified).toBe(false);
|
|
313
|
+
// The deterministic half is untouched by a provider outage, one level up
|
|
314
|
+
// from `extract` being wrapped for the same reason.
|
|
315
|
+
expect(result.fromRules).toBe(2);
|
|
316
|
+
expect(result.created).toBe(2);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('says so rather than claiming verification when the extractor cannot verify', async () => {
|
|
320
|
+
const document = withTranscript();
|
|
321
|
+
const result = await extractCommitments(
|
|
322
|
+
{ scope: SCOPE },
|
|
323
|
+
{
|
|
324
|
+
corpus: stubCorpus(document),
|
|
325
|
+
extractor: stubExtractor({
|
|
326
|
+
omitVerify: true,
|
|
327
|
+
items: [modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate')],
|
|
328
|
+
}),
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
expect(result.verified).toBe(false);
|
|
332
|
+
// Grounded, so it is not invented — but nothing confirmed it is a commitment.
|
|
333
|
+
expect(result.fromModel).toBe(1);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('the two passes have different jobs', () => {
|
|
338
|
+
it('tells the model what the rules pass already found, per document', async () => {
|
|
339
|
+
const document = withTranscript();
|
|
340
|
+
const seen: string[][] = [];
|
|
341
|
+
const extractor: CorpusExtractorLlm = {
|
|
342
|
+
async extract(input) {
|
|
343
|
+
seen.push(input.documents[0].alreadyFound);
|
|
344
|
+
return { items: [], resolutions: [] };
|
|
345
|
+
},
|
|
346
|
+
describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
|
|
347
|
+
};
|
|
348
|
+
await extractCommitments({ scope: SCOPE }, { corpus: stubCorpus(document), extractor });
|
|
349
|
+
expect(seen[0]).toEqual([
|
|
350
|
+
'Send the signed Rexford lease to Michelle',
|
|
351
|
+
'Get a second quote for the Hadley lot',
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('sends the transcript to the model, not the summary the rules pass already read', async () => {
|
|
356
|
+
const document = withTranscript();
|
|
357
|
+
const texts: string[] = [];
|
|
358
|
+
const extractor: CorpusExtractorLlm = {
|
|
359
|
+
async extract(input) {
|
|
360
|
+
texts.push(input.documents[0].text);
|
|
361
|
+
return { items: [], resolutions: [] };
|
|
362
|
+
},
|
|
363
|
+
describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
|
|
364
|
+
};
|
|
365
|
+
await extractCommitments({ scope: SCOPE }, { corpus: stubCorpus(document), extractor });
|
|
366
|
+
expect(texts[0]).toContain('Put the QuickBooks seat on Megan');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('does not regress the rules pass when there is no transcript', async () => {
|
|
370
|
+
const document = summaryOnly();
|
|
371
|
+
const result = await extractCommitments(
|
|
372
|
+
{ scope: SCOPE },
|
|
373
|
+
{ corpus: stubCorpus(document), extractor: stubExtractor() },
|
|
374
|
+
);
|
|
375
|
+
expect(result.fromRules).toBe(2);
|
|
376
|
+
expect(result.created).toBe(2);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('one run stays inside a request', () => {
|
|
381
|
+
it('processes fewer documents per call when they carry transcripts', async () => {
|
|
382
|
+
// bd startsim-768w.38.28: the tenant gateway closes a request at 60 seconds
|
|
383
|
+
// and a transcript is roughly ten times the text of a summary. A fixed
|
|
384
|
+
// document count per call is the wrong axis; a TEXT budget is the right one.
|
|
385
|
+
const long = (id: string) =>
|
|
386
|
+
toSourceDocument(
|
|
387
|
+
{ ...row({ summary: SUMMARY, body: TRANSCRIPT.repeat(60) }), id },
|
|
388
|
+
SCOPE,
|
|
389
|
+
);
|
|
390
|
+
const documents = ['d1', 'd2', 'd3', 'd4'].map(long);
|
|
391
|
+
const seen: number[] = [];
|
|
392
|
+
const extractor: CorpusExtractorLlm = {
|
|
393
|
+
async extract(input) {
|
|
394
|
+
seen.push(input.documents.length);
|
|
395
|
+
return { items: [], resolutions: [] };
|
|
396
|
+
},
|
|
397
|
+
describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
|
|
398
|
+
};
|
|
399
|
+
const result = await extractCommitments(
|
|
400
|
+
{ scope: SCOPE },
|
|
401
|
+
{
|
|
402
|
+
corpus: {
|
|
403
|
+
...stubCorpus(documents[0]),
|
|
404
|
+
listDocuments: async () => ({ count: 4, next: null, previous: null, results: documents }),
|
|
405
|
+
},
|
|
406
|
+
extractor,
|
|
407
|
+
},
|
|
408
|
+
{ maxCharsPerRun: 20_000 },
|
|
409
|
+
);
|
|
410
|
+
expect(seen).toHaveLength(1);
|
|
411
|
+
expect(seen[0]).toBeLessThan(4);
|
|
412
|
+
// And it SAYS it did not finish, so the caller resumes rather than believing
|
|
413
|
+
// a partial sweep was the whole corpus.
|
|
414
|
+
expect(result.nextOffset).not.toBeNull();
|
|
415
|
+
expect(result.totalDocuments).toBe(4);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('still does the whole corpus in one call when the documents are summaries', async () => {
|
|
419
|
+
const documents = ['d1', 'd2', 'd3', 'd4'].map((id) =>
|
|
420
|
+
toSourceDocument({ ...row({ summary: SUMMARY }), id }, SCOPE),
|
|
421
|
+
);
|
|
422
|
+
const seen: number[] = [];
|
|
423
|
+
const extractor: CorpusExtractorLlm = {
|
|
424
|
+
async extract(input) {
|
|
425
|
+
seen.push(input.documents.length);
|
|
426
|
+
return { items: [], resolutions: [] };
|
|
427
|
+
},
|
|
428
|
+
describe: () => ({ provider: 'anthropic', model: 'claude-sonnet-5', live: true }),
|
|
429
|
+
};
|
|
430
|
+
const result = await extractCommitments(
|
|
431
|
+
{ scope: SCOPE },
|
|
432
|
+
{
|
|
433
|
+
corpus: {
|
|
434
|
+
...stubCorpus(documents[0]),
|
|
435
|
+
listDocuments: async () => ({ count: 4, next: null, previous: null, results: documents }),
|
|
436
|
+
},
|
|
437
|
+
extractor,
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
expect(seen).toEqual([4]);
|
|
441
|
+
expect(result.nextOffset).toBeNull();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe('the extraction result reports the shape of the run', () => {
|
|
446
|
+
it('counts what each stage kept and dropped', async () => {
|
|
447
|
+
const document = withTranscript();
|
|
448
|
+
const result = await extractCommitments(
|
|
449
|
+
{ scope: SCOPE },
|
|
450
|
+
{
|
|
451
|
+
corpus: stubCorpus(document),
|
|
452
|
+
extractor: stubExtractor({
|
|
453
|
+
items: [
|
|
454
|
+
modelItem('Send Rae the Lamar insurance certificate', 'I still owe Rae the insurance certificate'),
|
|
455
|
+
modelItem('Wire the deposit to the title company', 'a quote that is not in the record'),
|
|
456
|
+
modelItem('Cancel the old Pigoso QuickBooks seat', 'cancel the old Pigoso QuickBooks seat'),
|
|
457
|
+
],
|
|
458
|
+
verdicts: [
|
|
459
|
+
{ id: 'c1', confirmed: true },
|
|
460
|
+
{ id: 'c3', confirmed: false },
|
|
461
|
+
],
|
|
462
|
+
}),
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
expect(result.fromRules).toBe(2);
|
|
466
|
+
expect(result.fromModel).toBe(1);
|
|
467
|
+
expect(result.ungrounded).toBe(1);
|
|
468
|
+
expect(result.unconfirmed).toBe(1);
|
|
469
|
+
expect(result.verified).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
/** Kept honest: nothing in this file may reach a real provider. */
|
|
474
|
+
it('never constructs a provider client', () => {
|
|
475
|
+
expect(vi.isMockFunction(globalThis.fetch)).toBe(false);
|
|
476
|
+
});
|
|
@@ -23,9 +23,17 @@ import type { ConnectorFieldSpec, ConnectorSchemaLike } from './pages/types';
|
|
|
23
23
|
export interface CredentialSchemaFormProps {
|
|
24
24
|
/** The catalog's own schema, rendered in DECLARED ORDER. */
|
|
25
25
|
fields: ConnectorFieldSpec[];
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Current values, keyed by field key. Secrets are never read back from here.
|
|
28
|
+
*
|
|
29
|
+
* `unknown` and not `string`, because a config schema has booleans and numbers
|
|
30
|
+
* in it. This was `Record<string, string>` with a `string` `onChange`, which
|
|
31
|
+
* made it structurally impossible for the component to emit a JSON boolean —
|
|
32
|
+
* so a checkbox could only ever have produced the string "true"/"false", and
|
|
33
|
+
* `bool("false")` is `true`.
|
|
34
|
+
*/
|
|
35
|
+
values: Record<string, unknown>;
|
|
36
|
+
onChange: (key: string, value: string | boolean) => void;
|
|
29
37
|
/** Per-field messages. A `pattern` miss WARNS; only `required` blocks submit. */
|
|
30
38
|
errors?: Record<string, string>;
|
|
31
39
|
warnings?: Record<string, string>;
|
|
@@ -43,6 +51,32 @@ export interface CredentialSchemaFormProps {
|
|
|
43
51
|
/** Field types this build renders natively. Anything else becomes text. */
|
|
44
52
|
const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
|
|
45
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A boolean is a CHECKBOX, and this is not a cosmetic preference.
|
|
56
|
+
*
|
|
57
|
+
* `boolean` has been in the server's `FIELD_TYPES` since the field grammar was
|
|
58
|
+
* written, and this component has never rendered one — it fell through to a text
|
|
59
|
+
* input. The most consequential setting the platform has is Granola's
|
|
60
|
+
* `ingest_transcripts`, which decides whether verbatim recordings of people's
|
|
61
|
+
* conversations are stored, and in a text box the way you turn it off is to type
|
|
62
|
+
* the word "false" — which `bool()` reads as ON.
|
|
63
|
+
*/
|
|
64
|
+
const BOOLEAN_TYPES = new Set(['boolean', 'checkbox', 'bool']);
|
|
65
|
+
|
|
66
|
+
export function isBoolean(spec: ConnectorFieldSpec): boolean {
|
|
67
|
+
return !isSecret(spec) && BOOLEAN_TYPES.has(String(spec.type || '').toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The spellings of "off" that can arrive from a stored config or a form. */
|
|
71
|
+
export function asBoolean(value: unknown): boolean {
|
|
72
|
+
if (typeof value === 'string') {
|
|
73
|
+
return !['', '0', 'false', 'no', 'off', 'none', 'null', 'undefined'].includes(
|
|
74
|
+
value.trim().toLowerCase(),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return Boolean(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
46
80
|
/**
|
|
47
81
|
* Is this field a secret? `type: "secret"` is the server's spelling and
|
|
48
82
|
* `write_only` is the flag it enforces alongside it; `secret: true` is accepted
|
|
@@ -91,9 +125,14 @@ export function CredentialSchemaForm({
|
|
|
91
125
|
// a value for it somehow reached this component. A masked input holding
|
|
92
126
|
// the real key is one devtools panel away from being the leak the whole
|
|
93
127
|
// credential surface is built to prevent.
|
|
128
|
+
const stored = values[spec.key];
|
|
129
|
+
// The DECLARED DEFAULT fills an unset field, so an untouched form shows
|
|
130
|
+
// what will actually happen rather than an empty box the admin has to
|
|
131
|
+
// guess at. `??` and not `||`: a stored `false` or `0` is a value.
|
|
94
132
|
const value = isSecret(spec)
|
|
95
|
-
? (values[`${spec.key}__draft`] ?? '')
|
|
96
|
-
: (
|
|
133
|
+
? String(values[`${spec.key}__draft`] ?? '')
|
|
134
|
+
: String(stored ?? spec.default ?? '');
|
|
135
|
+
const checked = asBoolean(stored ?? spec.default ?? false);
|
|
97
136
|
const error = errors[spec.key];
|
|
98
137
|
const warning = warnings[spec.key];
|
|
99
138
|
|
|
@@ -104,7 +143,15 @@ export function CredentialSchemaForm({
|
|
|
104
143
|
{spec.required ? <span aria-hidden="true"> *</span> : null}
|
|
105
144
|
</label>
|
|
106
145
|
|
|
107
|
-
{
|
|
146
|
+
{isBoolean(spec) ? (
|
|
147
|
+
<input
|
|
148
|
+
id={id}
|
|
149
|
+
type="checkbox"
|
|
150
|
+
checked={checked}
|
|
151
|
+
onChange={(e) => onChange(spec.key, e.target.checked)}
|
|
152
|
+
className={classNames.input ?? 'mt-1 h-4 w-4 rounded border-gray-300'}
|
|
153
|
+
/>
|
|
154
|
+
) : type === 'select' ? (
|
|
108
155
|
<select
|
|
109
156
|
id={id}
|
|
110
157
|
value={value}
|
|
@@ -164,7 +211,7 @@ export function CredentialSchemaForm({
|
|
|
164
211
|
*/
|
|
165
212
|
export function missingRequired(
|
|
166
213
|
fields: ConnectorFieldSpec[],
|
|
167
|
-
values: Record<string,
|
|
214
|
+
values: Record<string, unknown>,
|
|
168
215
|
): string[] {
|
|
169
216
|
return fields
|
|
170
217
|
.filter((spec) => spec.required)
|
|
@@ -175,7 +222,7 @@ export function missingRequired(
|
|
|
175
222
|
/** Fields whose value does not match a declared `pattern`. WARNS, never blocks. */
|
|
176
223
|
export function patternWarnings(
|
|
177
224
|
fields: ConnectorFieldSpec[],
|
|
178
|
-
values: Record<string,
|
|
225
|
+
values: Record<string, unknown>,
|
|
179
226
|
): Record<string, string> {
|
|
180
227
|
const out: Record<string, string> = {};
|
|
181
228
|
for (const spec of fields) {
|
|
@@ -260,6 +260,17 @@ function AnswerPanel({
|
|
|
260
260
|
</p>
|
|
261
261
|
) : null}
|
|
262
262
|
|
|
263
|
+
{/* An answer built from a partly-withheld record has to say so
|
|
264
|
+
(bd startsim-768w.38.33) — otherwise "your corpus has nothing about
|
|
265
|
+
that" and "you were not shown the part that does" are the same
|
|
266
|
+
sentence, and the second one is the dangerous one to believe. */}
|
|
267
|
+
{result.withheldFields?.length ? (
|
|
268
|
+
<p className={cn.warning} role="status">
|
|
269
|
+
Some records were only partly shared with you ({result.withheldFields.join(', ')}),
|
|
270
|
+
so this answer may be incomplete.
|
|
271
|
+
</p>
|
|
272
|
+
) : null}
|
|
273
|
+
|
|
263
274
|
{/* A model asked for prose answers in Markdown. Rendering it as literal
|
|
264
275
|
text puts "**$4,800**" on the screen, which reads as the product
|
|
265
276
|
failing rather than as the model emphasising the number. */}
|