@startsimpli/integrations 0.2.14 → 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__/corpus-field-gate.test.ts +246 -0
- package/src/__tests__/corpus-pages.test.tsx +47 -0
- package/src/components/pages/AskPage.tsx +11 -0
- package/src/components/pages/LibraryPage.tsx +12 -0
- package/src/corpus/ask.ts +21 -0
- package/src/corpus/entities.ts +38 -1
- package/src/corpus/types.ts +28 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"react-dom": "^19.0.0",
|
|
49
49
|
"typescript": "^6.0.0",
|
|
50
50
|
"vitest": "^3.0.0",
|
|
51
|
-
"@startsimpli/llm": "0.4.
|
|
51
|
+
"@startsimpli/llm": "0.4.16"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"type-check": "tsc --noEmit",
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE FIELD READ GATE, from the client's side (bd startsim-768w.38.33).
|
|
3
|
+
*
|
|
4
|
+
* `corpus-scope.test.ts` next door asks "can the pipeline be made to read
|
|
5
|
+
* another WORKSPACE's records". This asks the narrower and, on the tandem
|
|
6
|
+
* tenant, more likely question: can it be made to read a part of its OWN
|
|
7
|
+
* workspace's record that the server withheld from this reader.
|
|
8
|
+
*
|
|
9
|
+
* SAME ADVERSARIAL POSTURE, DELIBERATELY. Every case runs against a tenant that
|
|
10
|
+
* is LEAKING — it names `body` in `redacted_fields` and then sends `body`
|
|
11
|
+
* anyway, which is what a half-rolled deploy, a response cached from an admin's
|
|
12
|
+
* session, or a serializer edited without its gate looks like from out here. If
|
|
13
|
+
* the pipeline is only safe because the server is, these fail. That is the
|
|
14
|
+
* point; a test against a correctly-behaving server proves the server, not the
|
|
15
|
+
* client.
|
|
16
|
+
*
|
|
17
|
+
* THE BAIT is `doc_checkin`: a document whose SUMMARY is anodyne and whose
|
|
18
|
+
* withheld `body` contains the verbatim sentence that answers the question best.
|
|
19
|
+
* A mapper that reads `data.body` without checking `redacted_fields` puts that
|
|
20
|
+
* sentence into the prompt, the model quotes it, and the leak arrives in the
|
|
21
|
+
* most convincing possible format — cited, fluent, and attributed to the user's
|
|
22
|
+
* own corpus.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, expect, it } from 'vitest';
|
|
25
|
+
|
|
26
|
+
import { askCorpus } from '../corpus/ask';
|
|
27
|
+
import { createCorpusApi } from '../corpus/client';
|
|
28
|
+
import { toSourceDocument, withheldFieldsOf } from '../corpus/entities';
|
|
29
|
+
import type { TenantEntity } from '../corpus/entities';
|
|
30
|
+
import { createFixtureCorpusHttp, createFixtureLlm, FIXTURE_ORG } from '../corpus/fixtures';
|
|
31
|
+
import type { CorpusHttp, CorpusLlm, CorpusScope } from '../corpus/types';
|
|
32
|
+
|
|
33
|
+
const SCOPE: CorpusScope = { orgId: FIXTURE_ORG };
|
|
34
|
+
|
|
35
|
+
/** The sentence nobody consented to re-host in a third system. */
|
|
36
|
+
const VERBATIM = 'Dana: I relapsed in March and I have been in therapy weekly since.';
|
|
37
|
+
const ANODYNE = 'Personal check-in. Agreed to revisit the lease in Q3.';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A tenant row as the LEAKING server sends it: the gate ran (it named what it
|
|
41
|
+
* withheld) and the payload came anyway.
|
|
42
|
+
*/
|
|
43
|
+
function leakingEntity(overrides: Partial<TenantEntity> = {}): TenantEntity {
|
|
44
|
+
return {
|
|
45
|
+
id: 'doc_checkin',
|
|
46
|
+
entity_type: 'source_document',
|
|
47
|
+
external_id: 'granola:not_checkin',
|
|
48
|
+
name: 'Personal check-in — therapy, substance use, and relationship progress',
|
|
49
|
+
org_id: FIXTURE_ORG,
|
|
50
|
+
created_at: '2026-08-01T10:00:00Z',
|
|
51
|
+
data: {
|
|
52
|
+
source: 'granola',
|
|
53
|
+
subtype: 'meeting',
|
|
54
|
+
title: 'Personal check-in',
|
|
55
|
+
summary: ANODYNE,
|
|
56
|
+
body: VERBATIM,
|
|
57
|
+
occurred_at: '2026-08-01T09:00:00Z',
|
|
58
|
+
},
|
|
59
|
+
redacted_fields: ['body'],
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The same row as a CORRECT server sends it: stripped, and saying so. */
|
|
65
|
+
function gatedEntity(): TenantEntity {
|
|
66
|
+
const row = leakingEntity();
|
|
67
|
+
const { body: _dropped, ...rest } = row.data as Record<string, unknown>;
|
|
68
|
+
return { ...row, data: rest };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function httpFor(entities: TenantEntity[]): CorpusHttp {
|
|
72
|
+
return createFixtureCorpusHttp({ entities, relationships: [] }).http as CorpusHttp;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function apiFor(entities: TenantEntity[]) {
|
|
76
|
+
return createCorpusApi({ http: httpFor(entities), scope: SCOPE, supportedFilters: [] });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('the mapper honours the server’s own declaration', () => {
|
|
80
|
+
it('drops a withheld key even when the server sent it anyway', () => {
|
|
81
|
+
const document = toSourceDocument(leakingEntity(), SCOPE);
|
|
82
|
+
expect(document.body).not.toContain('relapsed');
|
|
83
|
+
expect(JSON.stringify(document)).not.toContain('relapsed');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to the summary rather than rendering an empty record', () => {
|
|
87
|
+
// A blank document would be the other failure: safe, and useless. The
|
|
88
|
+
// meeting is still a meeting, and its summary was never restricted.
|
|
89
|
+
const document = toSourceDocument(leakingEntity(), SCOPE);
|
|
90
|
+
expect(document.body).toBe(ANODYNE);
|
|
91
|
+
expect(document.summary).toBe(ANODYNE);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('says WHICH field was withheld, so absence is not mistaken for emptiness', () => {
|
|
95
|
+
// The distinction the whole gate turns on: "this meeting has no transcript"
|
|
96
|
+
// and "you were not shown this meeting's transcript" are different facts,
|
|
97
|
+
// and only one of them is an answer.
|
|
98
|
+
expect(toSourceDocument(leakingEntity(), SCOPE).withheldFields).toEqual(['body']);
|
|
99
|
+
expect(withheldFieldsOf(leakingEntity())).toEqual(['body']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('accepts the camelCase spelling the api client produces', () => {
|
|
103
|
+
const row = leakingEntity();
|
|
104
|
+
const camel: TenantEntity = { ...row, redacted_fields: undefined, redactedFields: ['body'] };
|
|
105
|
+
expect(toSourceDocument(camel, SCOPE).body).not.toContain('relapsed');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('leaves a record with nothing withheld exactly as it was', () => {
|
|
109
|
+
// The migration property, from this side: a corpus with no policy anywhere
|
|
110
|
+
// must map identically to how it mapped before this existed.
|
|
111
|
+
const open = leakingEntity({ redacted_fields: undefined });
|
|
112
|
+
const document = toSourceDocument(open, SCOPE);
|
|
113
|
+
expect(document.body).toBe(VERBATIM);
|
|
114
|
+
expect(document.withheldFields).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('withholds a section as readily as a top-level field', () => {
|
|
118
|
+
const row = leakingEntity({
|
|
119
|
+
data: {
|
|
120
|
+
source: 'granola',
|
|
121
|
+
summary: ANODYNE,
|
|
122
|
+
transcript: VERBATIM,
|
|
123
|
+
},
|
|
124
|
+
redacted_fields: ['transcript'],
|
|
125
|
+
});
|
|
126
|
+
const document = toSourceDocument(row, SCOPE);
|
|
127
|
+
expect(JSON.stringify(document.sections)).not.toContain('relapsed');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('the corpus client never surfaces a withheld field', () => {
|
|
132
|
+
it('keeps it out of a listed document', async () => {
|
|
133
|
+
const page = await apiFor([leakingEntity()]).listDocuments({ page: 1, pageSize: 50 });
|
|
134
|
+
expect(page.results).toHaveLength(1);
|
|
135
|
+
expect(JSON.stringify(page.results)).not.toContain('relapsed');
|
|
136
|
+
expect(page.results[0]?.withheldFields).toEqual(['body']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('keeps it out of a fetched document', async () => {
|
|
140
|
+
const document = await apiFor([leakingEntity()]).getDocument('doc_checkin');
|
|
141
|
+
expect(JSON.stringify(document)).not.toContain('relapsed');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('askCorpus — the withheld text never reaches the model', () => {
|
|
146
|
+
/** A model that records exactly what it was shown. */
|
|
147
|
+
function spyLlm(): { llm: CorpusLlm; seen: string[] } {
|
|
148
|
+
const seen: string[] = [];
|
|
149
|
+
const inner = createFixtureLlm();
|
|
150
|
+
return {
|
|
151
|
+
seen,
|
|
152
|
+
llm: {
|
|
153
|
+
describe: inner.describe,
|
|
154
|
+
async answer(input) {
|
|
155
|
+
seen.push(JSON.stringify(input.documents));
|
|
156
|
+
return inner.answer(input);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A question answerable from the SUMMARY, so the document really is retrieved
|
|
164
|
+
* and really does reach the prompt. Asking something only the withheld text
|
|
165
|
+
* could answer would prove nothing: an empty prompt keeps every secret.
|
|
166
|
+
*/
|
|
167
|
+
const question = 'What did we agree about the lease?';
|
|
168
|
+
|
|
169
|
+
it('puts the document in the prompt WITHOUT the withheld text', async () => {
|
|
170
|
+
const { llm, seen } = spyLlm();
|
|
171
|
+
await askCorpus({ question, mode: 'ask', scope: SCOPE }, { corpus: apiFor([leakingEntity()]), llm });
|
|
172
|
+
|
|
173
|
+
expect(seen).toHaveLength(1); // ONE call, not one per document
|
|
174
|
+
expect(seen[0]).toContain('lease'); // the record DID reach the model...
|
|
175
|
+
expect(seen[0]).not.toContain('relapsed'); // ...without the part it may not read
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('never quotes it back in the answer or a citation', async () => {
|
|
179
|
+
const result = await askCorpus(
|
|
180
|
+
{ question, mode: 'ask', scope: SCOPE },
|
|
181
|
+
{ corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(result.status).toBe('answered');
|
|
185
|
+
expect(result.answer).not.toContain('relapsed');
|
|
186
|
+
expect(JSON.stringify(result.citations)).not.toContain('relapsed');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('reports that something was withheld rather than absorbing it', async () => {
|
|
190
|
+
// Same contract as `droppedOutOfScope`: an answer built from a partial
|
|
191
|
+
// record has to say so. Otherwise "your corpus has nothing about that" and
|
|
192
|
+
// "you were not shown the part that does" are the same sentence.
|
|
193
|
+
const result = await askCorpus(
|
|
194
|
+
{ question, mode: 'ask', scope: SCOPE },
|
|
195
|
+
{ corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
|
|
196
|
+
);
|
|
197
|
+
expect(result.withheldFields).toEqual(['body']);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('tells the MODEL the record is partly withheld, so it cannot answer a confident no', async () => {
|
|
201
|
+
const { llm, seen } = spyLlm();
|
|
202
|
+
await askCorpus({ question, mode: 'ask', scope: SCOPE }, { corpus: apiFor([leakingEntity()]), llm });
|
|
203
|
+
expect(seen[0]).toContain('withheldFields');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('does not retrieve on a term that exists ONLY in the withheld text', async () => {
|
|
207
|
+
// The client-side half of the search oracle the tenant API closes. If the
|
|
208
|
+
// withheld words were still rankable here, a reader could confirm what a
|
|
209
|
+
// transcript contains by watching which records come back — an answer about
|
|
210
|
+
// somebody's private speech, delivered without ever printing it.
|
|
211
|
+
const result = await askCorpus(
|
|
212
|
+
{ question: 'relapsed', mode: 'ask', scope: SCOPE },
|
|
213
|
+
{ corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
|
|
214
|
+
);
|
|
215
|
+
expect(result.status).toBe('no_evidence');
|
|
216
|
+
expect(result.citations).toHaveLength(0);
|
|
217
|
+
expect(result.answer).not.toContain('relapsed');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('answers identically whether the server leaked or behaved', async () => {
|
|
221
|
+
// The falsifiable version of "the client does not depend on the server
|
|
222
|
+
// getting it right": same question, one API stripping the field and one
|
|
223
|
+
// not, and the two answers must not differ.
|
|
224
|
+
const ask = (entity: TenantEntity) =>
|
|
225
|
+
askCorpus(
|
|
226
|
+
{ question, mode: 'ask', scope: SCOPE },
|
|
227
|
+
{ corpus: apiFor([entity]), llm: createFixtureLlm() },
|
|
228
|
+
);
|
|
229
|
+
const [leaked, gated] = await Promise.all([ask(leakingEntity()), ask(gatedEntity())]);
|
|
230
|
+
expect(leaked.answer).toBe(gated.answer);
|
|
231
|
+
expect(leaked.withheldFields).toEqual(gated.withheldFields);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('still answers from the parts the reader IS allowed to see', async () => {
|
|
235
|
+
// Over-blocking would be the other way to fail: the summary was never
|
|
236
|
+
// restricted, and a corpus that goes silent because one field is gated has
|
|
237
|
+
// taken the product away in order to protect it.
|
|
238
|
+
const result = await askCorpus(
|
|
239
|
+
{ question, mode: 'ask', scope: SCOPE },
|
|
240
|
+
{ corpus: apiFor([leakingEntity()]), llm: createFixtureLlm() },
|
|
241
|
+
);
|
|
242
|
+
expect(result.status).toBe('answered');
|
|
243
|
+
expect(result.citations.length).toBeGreaterThan(0);
|
|
244
|
+
expect(result.answer).toContain('lease');
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -311,6 +311,53 @@ function askApiOver(api: CorpusApi, extra: Partial<CorpusAskApi> = {}): CorpusAs
|
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
|
|
314
|
+
describe('a withheld field is SHOWN as withheld, not as empty', () => {
|
|
315
|
+
// bd startsim-768w.38.33. The tenant strips a restricted attribute and names
|
|
316
|
+
// it back; if the screen renders the gap silently, the reader concludes the
|
|
317
|
+
// meeting had no transcript — which is a stronger, and wrong, claim than the
|
|
318
|
+
// gate ever made.
|
|
319
|
+
|
|
320
|
+
it('LibraryPage names what was held back on the open record', async () => {
|
|
321
|
+
const withheld: SourceDocument = {
|
|
322
|
+
...ownDocuments()[0],
|
|
323
|
+
id: 'doc_withheld',
|
|
324
|
+
title: 'Personal check-in',
|
|
325
|
+
withheldFields: ['body'],
|
|
326
|
+
};
|
|
327
|
+
const api = createFixtureCorpusApi({
|
|
328
|
+
scope: SCOPE, documents: [withheld], moneyEvents: [],
|
|
329
|
+
});
|
|
330
|
+
render(<LibraryPage api={api} scope={SCOPE} initialDocumentId="doc_withheld" />);
|
|
331
|
+
|
|
332
|
+
expect(await screen.findByText(/not shared with you/i)).toBeInTheDocument();
|
|
333
|
+
expect(screen.getByText(/body/)).toBeInTheDocument();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('AskPage says the answer may be incomplete', async () => {
|
|
337
|
+
const user = userEvent.setup();
|
|
338
|
+
const api: CorpusAskApi = {
|
|
339
|
+
async ask() {
|
|
340
|
+
return {
|
|
341
|
+
status: 'answered',
|
|
342
|
+
answer: 'They agreed to revisit the lease in Q3.',
|
|
343
|
+
citations: [],
|
|
344
|
+
considered: [],
|
|
345
|
+
unresolvedCitations: [],
|
|
346
|
+
droppedOutOfScope: 0,
|
|
347
|
+
withheldFields: ['body'],
|
|
348
|
+
provider: { provider: 'fixture', model: 'fixture-echo', live: false },
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
render(<AskPage api={api} scope={SCOPE} />);
|
|
353
|
+
await user.type(screen.getByRole('textbox'), 'lease');
|
|
354
|
+
await user.click(screen.getByRole('button', { name: 'Ask' }));
|
|
355
|
+
|
|
356
|
+
const answer = await screen.findByRole('region', { name: 'Answer' });
|
|
357
|
+
expect(within(answer).getByText(/only partly shared with you/i)).toBeInTheDocument();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
314
361
|
describe('AskPage — an answer is only an answer when it is traceable', () => {
|
|
315
362
|
it('renders the answer with a citation per record, clickable through to it', async () => {
|
|
316
363
|
const user = userEvent.setup();
|
|
@@ -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. */}
|
|
@@ -639,6 +639,18 @@ function DocumentDetail({
|
|
|
639
639
|
</details>
|
|
640
640
|
) : null}
|
|
641
641
|
|
|
642
|
+
{/* WITHHELD IS NOT EMPTY (bd startsim-768w.38.33). The tenant's per-field
|
|
643
|
+
read gate strips a restricted attribute and names it in
|
|
644
|
+
`redacted_fields`; without this line the reader sees a meeting with no
|
|
645
|
+
transcript and concludes there wasn't one. Named rather than counted,
|
|
646
|
+
because "the transcript" and "the salary" are different absences. */}
|
|
647
|
+
{document.withheldFields?.length ? (
|
|
648
|
+
<p className={`mt-3 ${cn.warning}`} role="status">
|
|
649
|
+
Part of this record is not shared with you:{' '}
|
|
650
|
+
{document.withheldFields.join(', ')}. Ask an admin if you need it.
|
|
651
|
+
</p>
|
|
652
|
+
) : null}
|
|
653
|
+
|
|
642
654
|
{/* Long-form under the SOURCE's own name for it — "Transcript" for a
|
|
643
655
|
meeting, "Extracted text" for a PDF. This component does not know
|
|
644
656
|
which is which, and must not. */}
|
package/src/corpus/ask.ts
CHANGED
|
@@ -75,6 +75,7 @@ function systemPrompt(mode: AskRequest['mode']): string {
|
|
|
75
75
|
'You answer questions about a company using only that company\'s own records.',
|
|
76
76
|
...shared,
|
|
77
77
|
'Be specific and short. Prefer naming the record and the date over hedging.',
|
|
78
|
+
'A document may list "withheld_from_this_reader". Those parts were deliberately not shown to you: never claim something was not said or did not happen on the basis of a document that lists them — say the record is partly withheld instead.',
|
|
78
79
|
].join('\n');
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -94,6 +95,7 @@ export function buildAnswerRequest(
|
|
|
94
95
|
occurredAt: document.occurredAt,
|
|
95
96
|
participants: document.participants.map((party) => party.name),
|
|
96
97
|
text: documentText(document, maxChars),
|
|
98
|
+
...(document.withheldFields?.length ? { withheldFields: document.withheldFields } : {}),
|
|
97
99
|
}));
|
|
98
100
|
return {
|
|
99
101
|
question: request.question,
|
|
@@ -126,6 +128,13 @@ export function renderAnswerPrompt(input: AnswerRequest): string {
|
|
|
126
128
|
`source: ${document.source}`,
|
|
127
129
|
`occurred_at: ${document.occurredAt ?? 'unknown'}`,
|
|
128
130
|
document.participants.length ? `participants: ${document.participants.join(', ')}` : '',
|
|
131
|
+
// The model is told what it was NOT given. Otherwise a summary-only view of
|
|
132
|
+
// a meeting whose transcript was withheld is indistinguishable from a
|
|
133
|
+
// meeting where the thing was never said, and the answer comes back as a
|
|
134
|
+
// confident negative about text nobody was allowed to show it.
|
|
135
|
+
document.withheldFields?.length
|
|
136
|
+
? `withheld_from_this_reader: ${document.withheldFields.join(', ')}`
|
|
137
|
+
: '',
|
|
129
138
|
'',
|
|
130
139
|
document.text,
|
|
131
140
|
'</document>',
|
|
@@ -279,6 +288,8 @@ export async function askCorpus(
|
|
|
279
288
|
};
|
|
280
289
|
}
|
|
281
290
|
|
|
291
|
+
const withheldFields = collectWithheld(retrieved);
|
|
292
|
+
|
|
282
293
|
const draft = await llm.answer(
|
|
283
294
|
buildAnswerRequest(request, retrieved, maxCharsPerDocument),
|
|
284
295
|
);
|
|
@@ -294,11 +305,21 @@ export async function askCorpus(
|
|
|
294
305
|
considered: retrieved,
|
|
295
306
|
unresolvedCitations: unresolved,
|
|
296
307
|
droppedOutOfScope,
|
|
308
|
+
...(withheldFields.length ? { withheldFields } : {}),
|
|
297
309
|
provider: llm.describe(),
|
|
298
310
|
freshness,
|
|
299
311
|
};
|
|
300
312
|
}
|
|
301
313
|
|
|
314
|
+
/** Every field withheld on any record that reached the prompt, deduped. */
|
|
315
|
+
function collectWithheld(retrieved: RetrievedDocument[]): string[] {
|
|
316
|
+
const names = new Set<string>();
|
|
317
|
+
for (const item of retrieved) {
|
|
318
|
+
for (const name of item.document.withheldFields ?? []) names.add(name);
|
|
319
|
+
}
|
|
320
|
+
return [...names].sort();
|
|
321
|
+
}
|
|
322
|
+
|
|
302
323
|
/**
|
|
303
324
|
* `uncited` is a real outcome, not a failure to report. The model produced text
|
|
304
325
|
* and nothing traceable; the surface renders that as a warning over the text
|
package/src/corpus/entities.ts
CHANGED
|
@@ -85,6 +85,14 @@ export interface TenantEntity {
|
|
|
85
85
|
orgId?: string;
|
|
86
86
|
owner_sub?: string;
|
|
87
87
|
ownerSub?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Attribute names the tenant's per-field read gate withheld from this caller
|
|
90
|
+
* (bd startsim-768w.38.33). The server has ALREADY removed them from `data`;
|
|
91
|
+
* this names them so the absence is legible rather than mistaken for a record
|
|
92
|
+
* that never had them.
|
|
93
|
+
*/
|
|
94
|
+
redacted_fields?: string[];
|
|
95
|
+
redactedFields?: string[];
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/**
|
|
@@ -111,9 +119,35 @@ export const DEFAULT_TYPE_KEYS: CorpusTypeKeys = {
|
|
|
111
119
|
// tolerant accessors
|
|
112
120
|
// ---------------------------------------------------------------------------
|
|
113
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Field names the server says it withheld from this caller
|
|
124
|
+
* (bd startsim-768w.38.33). Both spellings, for the same reason every other
|
|
125
|
+
* accessor here takes both: the `@startsimpli/api` client transforms
|
|
126
|
+
* snake_case↔camelCase and either client can fetch a row.
|
|
127
|
+
*/
|
|
128
|
+
export function withheldFieldsOf(entity: TenantEntity): string[] {
|
|
129
|
+
const raw = entity.redacted_fields ?? entity.redactedFields;
|
|
130
|
+
return Array.isArray(raw) ? raw.map((name) => String(name)).filter(Boolean) : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
function bag(entity: TenantEntity): Record<string, unknown> {
|
|
115
134
|
const data = entity.data;
|
|
116
|
-
|
|
135
|
+
const payload = data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
|
136
|
+
// BELT AND BRACES, ON PURPOSE. The gate is enforced server-side and `data`
|
|
137
|
+
// arrives already stripped — so in the normal case this loop removes nothing.
|
|
138
|
+
// It runs anyway because `redacted_fields` is the server telling us what it
|
|
139
|
+
// meant to withhold, and a build where the two disagree (a half-rolled deploy,
|
|
140
|
+
// a response cached from an admin's session, a serializer edited without its
|
|
141
|
+
// gate) is exactly the case where a client that trusts `data` alone quotes a
|
|
142
|
+
// transcript into an answer. Honouring the server's own declaration costs one
|
|
143
|
+
// pass over a small object and turns that class of bug into a no-op.
|
|
144
|
+
const withheld = withheldFieldsOf(entity);
|
|
145
|
+
if (!withheld.length) return payload;
|
|
146
|
+
const out: Record<string, unknown> = {};
|
|
147
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
148
|
+
if (!withheld.includes(key)) out[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
117
151
|
}
|
|
118
152
|
|
|
119
153
|
/**
|
|
@@ -449,6 +483,9 @@ export function toSourceDocument(
|
|
|
449
483
|
),
|
|
450
484
|
scope: scopeOf(entity, fallbackScope),
|
|
451
485
|
ingestedAt: asIso(entity.created_at ?? entity.createdAt),
|
|
486
|
+
...(withheldFieldsOf(entity).length
|
|
487
|
+
? { withheldFields: withheldFieldsOf(entity) }
|
|
488
|
+
: {}),
|
|
452
489
|
};
|
|
453
490
|
}
|
|
454
491
|
|
package/src/corpus/types.ts
CHANGED
|
@@ -127,6 +127,19 @@ export interface SourceDocument {
|
|
|
127
127
|
scope: CorpusScope;
|
|
128
128
|
/** When the connector last wrote this row. Drives the freshness claim. */
|
|
129
129
|
ingestedAt?: string | null;
|
|
130
|
+
/**
|
|
131
|
+
* Attribute names the SERVER withheld from this reader
|
|
132
|
+
* (bd startsim-768w.38.33). The tenant's per-field read gate strips them from
|
|
133
|
+
* `data` and names them back in `redacted_fields`; this carries that fact all
|
|
134
|
+
* the way to the screen and into the prompt.
|
|
135
|
+
*
|
|
136
|
+
* IT HAS TO TRAVEL, and that is the whole reason it is on the document rather
|
|
137
|
+
* than swallowed at the mapper. A record whose transcript was withheld is NOT
|
|
138
|
+
* a record with no transcript. Anything that cannot tell the two apart — a
|
|
139
|
+
* reader, or a model asked "was this discussed?" — answers "no" with total
|
|
140
|
+
* confidence about text it was simply never shown.
|
|
141
|
+
*/
|
|
142
|
+
withheldFields?: string[];
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
/**
|
|
@@ -299,6 +312,14 @@ export interface AskResult {
|
|
|
299
312
|
* returned something it should not have, and is worth seeing.
|
|
300
313
|
*/
|
|
301
314
|
droppedOutOfScope: number;
|
|
315
|
+
/**
|
|
316
|
+
* Attribute names the server withheld from this reader on at least one record
|
|
317
|
+
* that was considered (bd startsim-768w.38.33). Reported for exactly the
|
|
318
|
+
* reason `droppedOutOfScope` is: an answer built from a partial record has to
|
|
319
|
+
* say so, or "your corpus has nothing about that" and "you were not shown the
|
|
320
|
+
* part that does" become the same sentence.
|
|
321
|
+
*/
|
|
322
|
+
withheldFields?: string[];
|
|
302
323
|
/** Which provider/model actually answered, and whether it was a real one. */
|
|
303
324
|
provider: LlmDescription;
|
|
304
325
|
/** Freshness at answer time — an answer over a stale corpus says so. */
|
|
@@ -323,6 +344,13 @@ export interface PromptDocument {
|
|
|
323
344
|
occurredAt: string | null;
|
|
324
345
|
participants: string[];
|
|
325
346
|
text: string;
|
|
347
|
+
/**
|
|
348
|
+
* Fields the reader was not allowed to see (bd startsim-768w.38.33). Rendered
|
|
349
|
+
* into the document block so the model states a limit instead of inventing a
|
|
350
|
+
* certainty: without it, a summary-only view of a meeting whose transcript was
|
|
351
|
+
* withheld reads as proof the thing was never discussed.
|
|
352
|
+
*/
|
|
353
|
+
withheldFields?: string[];
|
|
326
354
|
}
|
|
327
355
|
|
|
328
356
|
export interface AnswerRequest {
|