@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
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
ConnectionTarget,
|
|
49
49
|
ConnectorAction,
|
|
50
50
|
ConnectorCatalogEntry,
|
|
51
|
+
ConnectorFieldSpec,
|
|
51
52
|
ConnectionsApi,
|
|
52
53
|
} from './types';
|
|
53
54
|
|
|
@@ -267,7 +268,7 @@ function CatalogCard({
|
|
|
267
268
|
onConnected: () => void;
|
|
268
269
|
}) {
|
|
269
270
|
const [open, setOpen] = React.useState(false);
|
|
270
|
-
const [values, setValues] = React.useState<Record<string,
|
|
271
|
+
const [values, setValues] = React.useState<Record<string, unknown>>({});
|
|
271
272
|
const [busy, setBusy] = React.useState(false);
|
|
272
273
|
const [error, setError] = React.useState('');
|
|
273
274
|
// Every declared capability starts ON. Not because "on" is a safe default in
|
|
@@ -296,7 +297,11 @@ function CatalogCard({
|
|
|
296
297
|
}
|
|
297
298
|
for (const spec of configFields) {
|
|
298
299
|
const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
|
|
299
|
-
if (v)
|
|
300
|
+
// `if (v)` DROPPED A FALSE, which for a checkbox makes "off" and
|
|
301
|
+
// "untouched" the same request. Harmless while every config field was a
|
|
302
|
+
// string; not harmless once one of them decides whether verbatim
|
|
303
|
+
// recordings are stored.
|
|
304
|
+
if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
|
|
300
305
|
}
|
|
301
306
|
await api.connect(foundrySlug, {
|
|
302
307
|
connector_key: entry.key, name: entry.name, credentials, config,
|
|
@@ -428,7 +433,7 @@ function CatalogCard({
|
|
|
428
433
|
* a secret never means re-typing the settings around it.
|
|
429
434
|
*
|
|
430
435
|
* Only the CREDENTIAL fields render. `config` is not a credential and is edited
|
|
431
|
-
* through
|
|
436
|
+
* through `ConnectionSettingsForm` below; mixing them here would make a
|
|
432
437
|
* rotation quietly a config change.
|
|
433
438
|
*/
|
|
434
439
|
function ReplaceKeyForm({
|
|
@@ -441,7 +446,7 @@ function ReplaceKeyForm({
|
|
|
441
446
|
classNames: typeof DEFAULTS;
|
|
442
447
|
onDone: () => void;
|
|
443
448
|
}) {
|
|
444
|
-
const [values, setValues] = React.useState<Record<string,
|
|
449
|
+
const [values, setValues] = React.useState<Record<string, unknown>>({});
|
|
445
450
|
const [busy, setBusy] = React.useState(false);
|
|
446
451
|
const [error, setError] = React.useState('');
|
|
447
452
|
|
|
@@ -514,6 +519,101 @@ function ReplaceKeyForm({
|
|
|
514
519
|
// a connected account: discover -> wire -> confirm -> fire
|
|
515
520
|
// --------------------------------------------------------------------------
|
|
516
521
|
|
|
522
|
+
/**
|
|
523
|
+
* The connection's own settings — its connector's `config_schema`, for an
|
|
524
|
+
* account that already exists.
|
|
525
|
+
*
|
|
526
|
+
* WHY IT IS A SEPARATE FORM FROM THE CONNECT CARD. Connecting is "here are my
|
|
527
|
+
* credentials, does this work"; this is "change how it behaves". Rotating a key
|
|
528
|
+
* must not quietly change a setting, and changing a setting must not ask for a
|
|
529
|
+
* key. `ReplaceKeyForm` has said as much in its own docstring since it shipped,
|
|
530
|
+
* pointing at a surface that did not exist.
|
|
531
|
+
*
|
|
532
|
+
* IT SENDS ONLY WHAT CHANGED, and the API merges. Two reasons, and the second is
|
|
533
|
+
* the one that bites: resending untouched fields turns "I ticked one box" into
|
|
534
|
+
* "I reassert every setting", which silently overwrites an edit made in another
|
|
535
|
+
* tab — and it is exactly how the retention bound gets reset to whatever this
|
|
536
|
+
* form happened to be showing.
|
|
537
|
+
*
|
|
538
|
+
* A FALSE IS SENT, not omitted. The connect card drops falsy values, which for a
|
|
539
|
+
* checkbox makes "off" indistinguishable from "untouched" — and against a
|
|
540
|
+
* merging API that would make the off position literally unreachable. For
|
|
541
|
+
* `ingest_transcripts` the off position is the whole privacy posture.
|
|
542
|
+
*/
|
|
543
|
+
function ConnectionSettingsForm({
|
|
544
|
+
api, foundrySlug, connection, fields, classNames: cn, onDone,
|
|
545
|
+
}: {
|
|
546
|
+
api: ConnectionsApi;
|
|
547
|
+
foundrySlug: string;
|
|
548
|
+
connection: Connection;
|
|
549
|
+
fields: ConnectorFieldSpec[];
|
|
550
|
+
classNames: typeof DEFAULTS;
|
|
551
|
+
onDone: () => void;
|
|
552
|
+
}) {
|
|
553
|
+
const stored = React.useMemo(
|
|
554
|
+
() => (connection.config ?? {}) as Record<string, unknown>,
|
|
555
|
+
[connection.config],
|
|
556
|
+
);
|
|
557
|
+
const [edits, setEdits] = React.useState<Record<string, string | boolean>>({});
|
|
558
|
+
const [busy, setBusy] = React.useState(false);
|
|
559
|
+
const [error, setError] = React.useState('');
|
|
560
|
+
|
|
561
|
+
const values = React.useMemo(() => ({ ...stored, ...edits }), [stored, edits]);
|
|
562
|
+
const warnings = patternWarnings(fields, values);
|
|
563
|
+
const changed = Object.keys(edits);
|
|
564
|
+
|
|
565
|
+
async function save() {
|
|
566
|
+
setBusy(true);
|
|
567
|
+
setError('');
|
|
568
|
+
try {
|
|
569
|
+
// Numbers go back as numbers. A `number` input hands back a string, and a
|
|
570
|
+
// retention bound stored as "0" rather than 0 is a comparison that quietly
|
|
571
|
+
// stops being true.
|
|
572
|
+
const config: Record<string, unknown> = {};
|
|
573
|
+
for (const key of changed) {
|
|
574
|
+
const spec = fields.find((field) => field.key === key);
|
|
575
|
+
const value = edits[key];
|
|
576
|
+
config[key] =
|
|
577
|
+
spec?.type === 'number' && typeof value === 'string' && value.trim() !== ''
|
|
578
|
+
? Number(value)
|
|
579
|
+
: value;
|
|
580
|
+
}
|
|
581
|
+
await api.updateConnection(foundrySlug, connection.id, { config });
|
|
582
|
+
onDone();
|
|
583
|
+
} catch (err) {
|
|
584
|
+
setError((err as { message?: string })?.message || 'Those settings were not saved.');
|
|
585
|
+
} finally {
|
|
586
|
+
setBusy(false);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return (
|
|
591
|
+
<div className="mt-3 space-y-3 border-t pt-3" role="group" aria-label="Connection settings">
|
|
592
|
+
<CredentialSchemaForm
|
|
593
|
+
fields={fields}
|
|
594
|
+
values={values}
|
|
595
|
+
onChange={(key, value) => setEdits((current) => ({ ...current, [key]: value }))}
|
|
596
|
+
warnings={warnings}
|
|
597
|
+
idPrefix={`settings-${connection.id}`}
|
|
598
|
+
/>
|
|
599
|
+
{error ? <p className={cn.error} role="alert">{error}</p> : null}
|
|
600
|
+
<div className="flex items-center gap-2">
|
|
601
|
+
<button
|
|
602
|
+
type="button"
|
|
603
|
+
className={cn.button}
|
|
604
|
+
disabled={busy || changed.length === 0}
|
|
605
|
+
onClick={() => void save()}
|
|
606
|
+
>
|
|
607
|
+
{busy ? 'Saving…' : 'Save settings'}
|
|
608
|
+
</button>
|
|
609
|
+
{changed.length === 0 ? (
|
|
610
|
+
<span className={cn.muted}>Nothing changed yet.</span>
|
|
611
|
+
) : null}
|
|
612
|
+
</div>
|
|
613
|
+
</div>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
517
617
|
function ConnectionCard({
|
|
518
618
|
api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
|
|
519
619
|
}: {
|
|
@@ -533,6 +633,7 @@ function ConnectionCard({
|
|
|
533
633
|
const [error, setError] = React.useState('');
|
|
534
634
|
const [notice, setNotice] = React.useState('');
|
|
535
635
|
const [replacing, setReplacing] = React.useState(false);
|
|
636
|
+
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
|
536
637
|
const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
|
|
537
638
|
|
|
538
639
|
const declared = entry?.capabilities;
|
|
@@ -568,8 +669,10 @@ function ConnectionCard({
|
|
|
568
669
|
}
|
|
569
670
|
}
|
|
570
671
|
|
|
672
|
+
const configFields = schemaFields(entry?.config_schema);
|
|
673
|
+
|
|
571
674
|
return (
|
|
572
|
-
<div className={cn.card}>
|
|
675
|
+
<div className={cn.card} data-connection={connection.id}>
|
|
573
676
|
<div className="flex items-start justify-between gap-4">
|
|
574
677
|
<div>
|
|
575
678
|
<p className="font-medium text-gray-900">{connection.name}</p>
|
|
@@ -624,12 +727,35 @@ function ConnectionCard({
|
|
|
624
727
|
>
|
|
625
728
|
{busy === 'discover' ? 'Loading…' : 'Workflows'}
|
|
626
729
|
</button>
|
|
730
|
+
{/* SETTINGS, which this card has never had. A connector's
|
|
731
|
+
`config_schema` ships on every catalog row and was rendered
|
|
732
|
+
exactly once — on the connect form, before the account existed.
|
|
733
|
+
After that a setting could not be changed from the product at
|
|
734
|
+
all, which for Granola means the switch deciding whether verbatim
|
|
735
|
+
recordings of people's conversations are stored was reachable
|
|
736
|
+
only by editing the database. `ReplaceKeyForm` already pointed at
|
|
737
|
+
"the connection's own settings" as if this existed. */}
|
|
738
|
+
{configFields.length ? (
|
|
739
|
+
<button
|
|
740
|
+
type="button"
|
|
741
|
+
className={cn.button}
|
|
742
|
+
disabled={Boolean(busy) || confirmingDisconnect}
|
|
743
|
+
onClick={() => {
|
|
744
|
+
setReplacing(false);
|
|
745
|
+
setError('');
|
|
746
|
+
setSettingsOpen((v) => !v);
|
|
747
|
+
}}
|
|
748
|
+
>
|
|
749
|
+
{settingsOpen ? 'Close settings' : 'Settings'}
|
|
750
|
+
</button>
|
|
751
|
+
) : null}
|
|
627
752
|
<button
|
|
628
753
|
type="button"
|
|
629
754
|
className={cn.button}
|
|
630
755
|
disabled={Boolean(busy) || confirmingDisconnect}
|
|
631
756
|
onClick={() => {
|
|
632
757
|
setReplacing(false);
|
|
758
|
+
setSettingsOpen(false);
|
|
633
759
|
setError('');
|
|
634
760
|
setConfirmingDisconnect(true);
|
|
635
761
|
}}
|
|
@@ -640,6 +766,17 @@ function ConnectionCard({
|
|
|
640
766
|
) : null}
|
|
641
767
|
</div>
|
|
642
768
|
|
|
769
|
+
{settingsOpen ? (
|
|
770
|
+
<ConnectionSettingsForm
|
|
771
|
+
api={api}
|
|
772
|
+
foundrySlug={foundrySlug}
|
|
773
|
+
connection={connection}
|
|
774
|
+
fields={configFields}
|
|
775
|
+
classNames={cn}
|
|
776
|
+
onDone={() => { setSettingsOpen(false); setNotice('Settings saved.'); onChanged(); }}
|
|
777
|
+
/>
|
|
778
|
+
) : null}
|
|
779
|
+
|
|
643
780
|
{replacing ? (
|
|
644
781
|
<ReplaceKeyForm
|
|
645
782
|
api={api}
|
|
@@ -114,11 +114,31 @@ function relative(value: string | null | undefined, now: number): string {
|
|
|
114
114
|
* and the row is a summary, not the document.
|
|
115
115
|
*/
|
|
116
116
|
function preview(document: SourceDocument, length = 180): string {
|
|
117
|
-
const text = stripMarkdown(
|
|
117
|
+
const text = stripMarkdown(
|
|
118
|
+
summaryOf(document) || document.body || document.sections[0]?.text || '',
|
|
119
|
+
);
|
|
118
120
|
if (!text) return '';
|
|
119
121
|
return text.length > length ? `${text.slice(0, length).trim()}…` : text;
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The short form, when the source wrote one.
|
|
126
|
+
*
|
|
127
|
+
* `body` falls back to the summary on a corpus that has never delivered a full
|
|
128
|
+
* text, so on today's tenant these are the same string and this returns it. When
|
|
129
|
+
* both exist they are different, and only this one belongs under the heading
|
|
130
|
+
* "Summary".
|
|
131
|
+
*/
|
|
132
|
+
function summaryOf(document: SourceDocument): string {
|
|
133
|
+
return document.summary || (document.body ?? '');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The verbatim text, when it is genuinely different from the summary. */
|
|
137
|
+
function fullTextOf(document: SourceDocument): string {
|
|
138
|
+
const body = document.body ?? '';
|
|
139
|
+
return body && body !== summaryOf(document) ? body : '';
|
|
140
|
+
}
|
|
141
|
+
|
|
122
142
|
// ---------------------------------------------------------------------------
|
|
123
143
|
|
|
124
144
|
export function LibraryPage({
|
|
@@ -589,15 +609,48 @@ function DocumentDetail({
|
|
|
589
609
|
</div>
|
|
590
610
|
) : null}
|
|
591
611
|
|
|
592
|
-
{document.body
|
|
612
|
+
{/* THE SUMMARY, UNDER THE WORD SUMMARY. This used to render `document.body`
|
|
613
|
+
here — which was the summary only for as long as nothing had ever
|
|
614
|
+
delivered a full text. The moment `ingest_transcripts` lands, `body` is
|
|
615
|
+
forty minutes of verbatim speech, and it would have appeared on this
|
|
616
|
+
screen labelled "Summary" while the actual summary appeared nowhere. */}
|
|
617
|
+
{summaryOf(document) ? (
|
|
593
618
|
<div className="mt-3">
|
|
594
619
|
<p className="text-sm font-medium text-gray-900">Summary</p>
|
|
595
620
|
{/* Sources write Markdown — a literal "## Decisions" on the screen
|
|
596
621
|
reads as a broken product, not as the source's punctuation. */}
|
|
597
|
-
<Markdown text={document
|
|
622
|
+
<Markdown text={summaryOf(document)} className="mt-1 text-sm text-gray-700" />
|
|
598
623
|
</div>
|
|
599
624
|
) : null}
|
|
600
625
|
|
|
626
|
+
{/* The FULL text, collapsed, next to the source's other long-form. Behind
|
|
627
|
+
a disclosure and not open by default, for the same reason a transcript
|
|
628
|
+
is opt-in at all: verbatim speech is a different data class from a
|
|
629
|
+
summary, and a screen that puts it in front of whoever opened the
|
|
630
|
+
record has quietly decided that on the reader's behalf. */}
|
|
631
|
+
{fullTextOf(document) ? (
|
|
632
|
+
<details className="mt-3">
|
|
633
|
+
<summary className="cursor-pointer text-sm font-medium text-gray-900">
|
|
634
|
+
Full text
|
|
635
|
+
</summary>
|
|
636
|
+
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-700">
|
|
637
|
+
{fullTextOf(document)}
|
|
638
|
+
</pre>
|
|
639
|
+
</details>
|
|
640
|
+
) : null}
|
|
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
|
+
|
|
601
654
|
{/* Long-form under the SOURCE's own name for it — "Transcript" for a
|
|
602
655
|
meeting, "Extracted text" for a PDF. This component does not know
|
|
603
656
|
which is which, and must not. */}
|
|
@@ -407,6 +407,23 @@ function RunSummary({
|
|
|
407
407
|
out of the notes by {result.provider.live ? `${result.provider.provider} · ${result.provider.model}` : 'no live model'}.
|
|
408
408
|
</p>
|
|
409
409
|
|
|
410
|
+
{/* WHAT THE CHECK THREW AWAY, on the screen.
|
|
411
|
+
Measured over the sixteen real meetings: the model proposes about
|
|
412
|
+
ninety items and roughly two thirds of them do not survive. That is not
|
|
413
|
+
a bug to hide — it is the number that keeps the list believable, and
|
|
414
|
+
hiding it would leave "the model read 30 out of the notes" looking like
|
|
415
|
+
the model is simply better than it is. If `verified` is ever false the
|
|
416
|
+
run says so rather than implying a check that did not happen. */}
|
|
417
|
+
{result.fromModel || result.ungrounded || result.unconfirmed ? (
|
|
418
|
+
<p className={cn.muted}>
|
|
419
|
+
{result.verified
|
|
420
|
+
? `Checked against the meetings themselves: ${result.unconfirmed + result.ungrounded} more were proposed and dropped — ` +
|
|
421
|
+
`${result.ungrounded} quoted something the record does not say, ` +
|
|
422
|
+
`${result.unconfirmed} could not be confirmed as something anyone actually took on.`
|
|
423
|
+
: 'These were NOT checked against the meetings — the verification step did not run, so treat them as suggestions.'}
|
|
424
|
+
</p>
|
|
425
|
+
) : null}
|
|
426
|
+
|
|
410
427
|
{result.needsReview.length ? (
|
|
411
428
|
// Weak resolution matches are shown, never applied. A to-do list that
|
|
412
429
|
// quietly empties itself is the one failure with no symptom.
|
package/src/corpus/ask.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { moneyEventAsDocument } from './entities';
|
|
22
22
|
import { retrieveForQuestion } from './retrieval';
|
|
23
|
+
import { documentText } from './text';
|
|
23
24
|
import type {
|
|
24
25
|
AnswerRequest,
|
|
25
26
|
AskRequest,
|
|
@@ -74,6 +75,7 @@ function systemPrompt(mode: AskRequest['mode']): string {
|
|
|
74
75
|
'You answer questions about a company using only that company\'s own records.',
|
|
75
76
|
...shared,
|
|
76
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.',
|
|
77
79
|
].join('\n');
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -93,6 +95,7 @@ export function buildAnswerRequest(
|
|
|
93
95
|
occurredAt: document.occurredAt,
|
|
94
96
|
participants: document.participants.map((party) => party.name),
|
|
95
97
|
text: documentText(document, maxChars),
|
|
98
|
+
...(document.withheldFields?.length ? { withheldFields: document.withheldFields } : {}),
|
|
96
99
|
}));
|
|
97
100
|
return {
|
|
98
101
|
question: request.question,
|
|
@@ -103,15 +106,18 @@ export function buildAnswerRequest(
|
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
/**
|
|
106
|
-
* A document's text for the prompt:
|
|
107
|
-
* a budget. Sections are labelled with the source's
|
|
108
|
-
* "Transcript" or "Extracted text" without this
|
|
109
|
+
* A document's text for the prompt: the summary first, then the full text, then
|
|
110
|
+
* each named section, cut at a budget. Sections are labelled with the source's
|
|
111
|
+
* own words, so the model sees "Transcript" or "Extracted text" without this
|
|
112
|
+
* package knowing what either is.
|
|
113
|
+
*
|
|
114
|
+
* ONE IMPLEMENTATION, in `./text`, shared with extraction. It used to be two
|
|
115
|
+
* near-identical private copies with different budgets, which is how the Ask
|
|
116
|
+
* surface came within one config flag of spending its entire 6,000-character
|
|
117
|
+
* per-document budget on the opening minutes of a transcript and never showing
|
|
118
|
+
* the model the summary at all.
|
|
109
119
|
*/
|
|
110
|
-
export
|
|
111
|
-
const parts = [document.body, ...document.sections.map((s) => `## ${s.label}\n${s.text}`)];
|
|
112
|
-
const joined = parts.filter(Boolean).join('\n\n');
|
|
113
|
-
return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n…[truncated]` : joined;
|
|
114
|
-
}
|
|
120
|
+
export { documentText };
|
|
115
121
|
|
|
116
122
|
/** Render the batched request as the single user message. */
|
|
117
123
|
export function renderAnswerPrompt(input: AnswerRequest): string {
|
|
@@ -122,6 +128,13 @@ export function renderAnswerPrompt(input: AnswerRequest): string {
|
|
|
122
128
|
`source: ${document.source}`,
|
|
123
129
|
`occurred_at: ${document.occurredAt ?? 'unknown'}`,
|
|
124
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
|
+
: '',
|
|
125
138
|
'',
|
|
126
139
|
document.text,
|
|
127
140
|
'</document>',
|
|
@@ -275,6 +288,8 @@ export async function askCorpus(
|
|
|
275
288
|
};
|
|
276
289
|
}
|
|
277
290
|
|
|
291
|
+
const withheldFields = collectWithheld(retrieved);
|
|
292
|
+
|
|
278
293
|
const draft = await llm.answer(
|
|
279
294
|
buildAnswerRequest(request, retrieved, maxCharsPerDocument),
|
|
280
295
|
);
|
|
@@ -290,11 +305,21 @@ export async function askCorpus(
|
|
|
290
305
|
considered: retrieved,
|
|
291
306
|
unresolvedCitations: unresolved,
|
|
292
307
|
droppedOutOfScope,
|
|
308
|
+
...(withheldFields.length ? { withheldFields } : {}),
|
|
293
309
|
provider: llm.describe(),
|
|
294
310
|
freshness,
|
|
295
311
|
};
|
|
296
312
|
}
|
|
297
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
|
+
|
|
298
323
|
/**
|
|
299
324
|
* `uncited` is a real outcome, not a failure to report. The model produced text
|
|
300
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
|
/**
|
|
@@ -391,6 +425,17 @@ export function toSourceDocument(
|
|
|
391
425
|
const data = bag(entity);
|
|
392
426
|
const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
|
|
393
427
|
|
|
428
|
+
// TWO TEXT FIELDS, NOT ONE, because the canonical schema declares two and says
|
|
429
|
+
// why: "Collapsing them means the transcript clobbers the summary the first
|
|
430
|
+
// time both arrive". This mapper collapsed them anyway —
|
|
431
|
+
// `pick(data,'body',…,'summary')` returns the first present key — so a
|
|
432
|
+
// document carrying both would have surfaced ONLY the transcript, and the
|
|
433
|
+
// `## Next Steps` block that the entire deterministic rules pass reads lives
|
|
434
|
+
// in the summary. Switching `ingest_transcripts` on would have taken a
|
|
435
|
+
// 95.8%-precision, 100%-owner-accuracy pass to zero, silently, on deploy.
|
|
436
|
+
const summary = asText(pick(data, 'summary', 'description'));
|
|
437
|
+
const fullText = asText(pick(data, 'body', 'text', 'content'));
|
|
438
|
+
|
|
394
439
|
const sections = toSections(pick(data, 'sections'));
|
|
395
440
|
// A source that stored its long-form under a bare attribute rather than in
|
|
396
441
|
// `sections` still contributes one. Listed keys are storage spellings, not
|
|
@@ -407,7 +452,13 @@ export function toSourceDocument(
|
|
|
407
452
|
asText(pick(data, 'title', 'subject', 'headline')) ||
|
|
408
453
|
entity.name ||
|
|
409
454
|
`Untitled ${source} record`,
|
|
410
|
-
body
|
|
455
|
+
// `body` still ANSWERS for the summary when the source has never delivered a
|
|
456
|
+
// full text, which is every row on the live tenant today. Every existing
|
|
457
|
+
// consumer of `.body` — retrieval, the Ask prompt, the Library preview —
|
|
458
|
+
// keeps working unchanged on a corpus of summaries; only a corpus that has
|
|
459
|
+
// both sees a difference, and that is the corpus this distinction is for.
|
|
460
|
+
body: fullText || summary,
|
|
461
|
+
...(summary ? { summary } : {}),
|
|
411
462
|
sections,
|
|
412
463
|
occurredAt: asIso(pick(data, 'occurred_at', 'occurredAt', 'happened_at', 'date')),
|
|
413
464
|
source,
|
|
@@ -432,6 +483,9 @@ export function toSourceDocument(
|
|
|
432
483
|
),
|
|
433
484
|
scope: scopeOf(entity, fallbackScope),
|
|
434
485
|
ingestedAt: asIso(entity.created_at ?? entity.createdAt),
|
|
486
|
+
...(withheldFieldsOf(entity).length
|
|
487
|
+
? { withheldFields: withheldFieldsOf(entity) }
|
|
488
|
+
: {}),
|
|
435
489
|
};
|
|
436
490
|
}
|
|
437
491
|
|