@ganju/utils 0.0.9 → 0.0.11
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/dist/channelDebounce.d.ts +11 -0
- package/dist/channelDebounce.d.ts.map +1 -1
- package/dist/channelDebounce.js +12 -1
- package/dist/chunking.d.ts +17 -3
- package/dist/chunking.d.ts.map +1 -1
- package/dist/chunking.js +125 -19
- package/dist/constants.d.ts +30 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +136 -5
- package/dist/index.d.ts +207 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/resourceRerank.d.ts +20 -0
- package/dist/resourceRerank.d.ts.map +1 -0
- package/dist/resourceRerank.js +37 -0
- package/dist/schema.d.ts +106 -24
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +45 -4
- package/dist/syncableResource.d.ts +14 -0
- package/dist/syncableResource.d.ts.map +1 -0
- package/dist/syncableResource.js +43 -0
- package/dist/toolCatalog.d.ts +70 -1
- package/dist/toolCatalog.d.ts.map +1 -1
- package/dist/toolCatalog.js +151 -63
- package/dist/toolConfirmation.d.ts +47 -0
- package/dist/toolConfirmation.d.ts.map +1 -0
- package/dist/toolConfirmation.js +217 -0
- package/package.json +1 -1
- package/src/channelDebounce.ts +36 -0
- package/src/chunking.ts +166 -23
- package/src/constants.ts +149 -5
- package/src/index.ts +51 -1
- package/src/resourceRerank.ts +54 -0
- package/src/schema.ts +58 -6
- package/src/syncableResource.ts +68 -0
- package/src/toolCatalog.ts +170 -63
- package/src/toolConfirmation.ts +261 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// When an organization turns on "confirm sensitive actions", a channel bot that
|
|
3
|
+
// wants to run a sensitive tool asks first, and the call runs only if the
|
|
4
|
+
// participant's next message is a plain yes. These are the pieces of that rule
|
|
5
|
+
// that aren't the runner's own bookkeeping.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.formatConfirmationSummary = exports.isConfirmationReply = exports.parseConfirmationButton = exports.confirmationButtonId = exports.confirmationButtonLabels = exports.languageFromPhoneNumber = exports.annotationsForEffect = exports.TOOL_EFFECTS = void 0;
|
|
8
|
+
// What an owner says a tool they wrote does, in the words the dashboard asks
|
|
9
|
+
// it in, and the MCP annotations each answer stands for: `read` only looks
|
|
10
|
+
// things up, `write` changes things that can be undone, `sensitive` sends,
|
|
11
|
+
// deletes, charges or can't be undone — the kind a confirming channel asks
|
|
12
|
+
// about.
|
|
13
|
+
exports.TOOL_EFFECTS = ['read', 'write', 'sensitive'];
|
|
14
|
+
const annotationsForEffect = (effect) => effect === 'read'
|
|
15
|
+
? { readOnlyHint: true, destructiveHint: false }
|
|
16
|
+
: effect === 'write'
|
|
17
|
+
? { readOnlyHint: false, destructiveHint: false }
|
|
18
|
+
: { readOnlyHint: false, destructiveHint: true };
|
|
19
|
+
exports.annotationsForEffect = annotationsForEffect;
|
|
20
|
+
// Whole replies that mean yes, lowercased with accents and punctuation
|
|
21
|
+
// removed. English, Spanish and Portuguese, the languages bots here answer in.
|
|
22
|
+
const AFFIRMATIVE = new Set([
|
|
23
|
+
'y',
|
|
24
|
+
'yes',
|
|
25
|
+
'yeah',
|
|
26
|
+
'yep',
|
|
27
|
+
'yup',
|
|
28
|
+
'sure',
|
|
29
|
+
'ok',
|
|
30
|
+
'okay',
|
|
31
|
+
'confirm',
|
|
32
|
+
'confirmed',
|
|
33
|
+
'approve',
|
|
34
|
+
'approved',
|
|
35
|
+
'go',
|
|
36
|
+
'go ahead',
|
|
37
|
+
'do it',
|
|
38
|
+
'send',
|
|
39
|
+
'send it',
|
|
40
|
+
'proceed',
|
|
41
|
+
'yes please',
|
|
42
|
+
'yes send it',
|
|
43
|
+
'yes do it',
|
|
44
|
+
'yes go ahead',
|
|
45
|
+
'yes confirm',
|
|
46
|
+
'sure thing',
|
|
47
|
+
'go for it',
|
|
48
|
+
'sounds good',
|
|
49
|
+
'looks good',
|
|
50
|
+
'all good',
|
|
51
|
+
'correct',
|
|
52
|
+
'si hazlo',
|
|
53
|
+
'si adelante',
|
|
54
|
+
'si envialo ya',
|
|
55
|
+
'perfecto',
|
|
56
|
+
'correcto',
|
|
57
|
+
'esta bien',
|
|
58
|
+
'ok dale',
|
|
59
|
+
'ok envialo',
|
|
60
|
+
'si',
|
|
61
|
+
'sip',
|
|
62
|
+
'claro',
|
|
63
|
+
'dale',
|
|
64
|
+
'va',
|
|
65
|
+
'vale',
|
|
66
|
+
'listo',
|
|
67
|
+
'confirmo',
|
|
68
|
+
'confirmado',
|
|
69
|
+
'confirmar',
|
|
70
|
+
'adelante',
|
|
71
|
+
'hazlo',
|
|
72
|
+
'envialo',
|
|
73
|
+
'enviar',
|
|
74
|
+
'de acuerdo',
|
|
75
|
+
'si por favor',
|
|
76
|
+
'si claro',
|
|
77
|
+
'si dale',
|
|
78
|
+
'si envialo',
|
|
79
|
+
'si confirmo',
|
|
80
|
+
'sim',
|
|
81
|
+
'pode',
|
|
82
|
+
'pode enviar'
|
|
83
|
+
]);
|
|
84
|
+
const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
|
|
85
|
+
// The Yes/No buttons offered under a confirmation question, in the
|
|
86
|
+
// participant's language when the platform reports it. The labels are also
|
|
87
|
+
// what a tap sends where a button is just a canned reply (Telegram's reply
|
|
88
|
+
// keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
|
|
89
|
+
// in every language here.
|
|
90
|
+
const BUTTON_LABELS = {
|
|
91
|
+
en: { yes: '✅ Yes', no: '❌ No' },
|
|
92
|
+
es: { yes: '✅ Sí', no: '❌ No' },
|
|
93
|
+
pt: { yes: '✅ Sim', no: '❌ Não' }
|
|
94
|
+
};
|
|
95
|
+
// WhatsApp reports no locale, so a button's language comes from the calling
|
|
96
|
+
// code of the participant's number: the Spanish- and Portuguese-speaking
|
|
97
|
+
// countries, English otherwise. Longest prefix wins.
|
|
98
|
+
const CALLING_CODE_LANGUAGE = {
|
|
99
|
+
'34': 'es',
|
|
100
|
+
'51': 'es',
|
|
101
|
+
'52': 'es',
|
|
102
|
+
'53': 'es',
|
|
103
|
+
'54': 'es',
|
|
104
|
+
'56': 'es',
|
|
105
|
+
'57': 'es',
|
|
106
|
+
'58': 'es',
|
|
107
|
+
'240': 'es',
|
|
108
|
+
'502': 'es',
|
|
109
|
+
'503': 'es',
|
|
110
|
+
'504': 'es',
|
|
111
|
+
'505': 'es',
|
|
112
|
+
'506': 'es',
|
|
113
|
+
'507': 'es',
|
|
114
|
+
'591': 'es',
|
|
115
|
+
'593': 'es',
|
|
116
|
+
'595': 'es',
|
|
117
|
+
'598': 'es',
|
|
118
|
+
'1787': 'es',
|
|
119
|
+
'1809': 'es',
|
|
120
|
+
'1829': 'es',
|
|
121
|
+
'1849': 'es',
|
|
122
|
+
'55': 'pt',
|
|
123
|
+
'238': 'pt',
|
|
124
|
+
'244': 'pt',
|
|
125
|
+
'245': 'pt',
|
|
126
|
+
'258': 'pt',
|
|
127
|
+
'351': 'pt'
|
|
128
|
+
};
|
|
129
|
+
const languageFromPhoneNumber = (phone) => {
|
|
130
|
+
const digits = phone.replace(/\D/g, '');
|
|
131
|
+
for (let length = 4; length >= 2; length--) {
|
|
132
|
+
const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
|
|
133
|
+
if (language)
|
|
134
|
+
return language;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
};
|
|
138
|
+
exports.languageFromPhoneNumber = languageFromPhoneNumber;
|
|
139
|
+
const confirmationButtonLabels = (locale) => {
|
|
140
|
+
const language = (locale || '').toLowerCase().split(/[-_]/)[0];
|
|
141
|
+
return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
|
|
142
|
+
};
|
|
143
|
+
exports.confirmationButtonLabels = confirmationButtonLabels;
|
|
144
|
+
// Where the platform sends back a button's id rather than its label (Discord,
|
|
145
|
+
// WhatsApp), the id says which answer it is and who was asked: in a shared
|
|
146
|
+
// channel everyone sees the buttons, and only that participant's tap counts.
|
|
147
|
+
// The runner never sees the id — a tap reaches it as the text "yes" or "no".
|
|
148
|
+
const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
|
|
149
|
+
const confirmationButtonId = (answer, externalParticipantId) => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
|
|
150
|
+
exports.confirmationButtonId = confirmationButtonId;
|
|
151
|
+
const parseConfirmationButton = (id) => {
|
|
152
|
+
const [prefix, answer, ...rest] = id.split(':');
|
|
153
|
+
const externalParticipantId = rest.join(':');
|
|
154
|
+
if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
if (answer !== 'yes' && answer !== 'no')
|
|
158
|
+
return null;
|
|
159
|
+
return { text: answer, externalParticipantId };
|
|
160
|
+
};
|
|
161
|
+
exports.parseConfirmationButton = parseConfirmationButton;
|
|
162
|
+
const normalize = (text) => text
|
|
163
|
+
.toLowerCase()
|
|
164
|
+
.normalize('NFKD')
|
|
165
|
+
.replace(/[̀-ͯ]/g, '')
|
|
166
|
+
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
167
|
+
.replace(/\s+/g, ' ')
|
|
168
|
+
.trim();
|
|
169
|
+
/**
|
|
170
|
+
* Whether a reply confirms the pending action. Deliberately narrow: the whole
|
|
171
|
+
* message has to be a yes. "Yes, but send it to Ana instead" is not one — it
|
|
172
|
+
* asks for a different action, and running the original would do exactly
|
|
173
|
+
* what the person just said not to. Anything that isn't a clear yes cancels,
|
|
174
|
+
* so the worst a misread costs is asking again.
|
|
175
|
+
*/
|
|
176
|
+
const isConfirmationReply = (text) => {
|
|
177
|
+
const trimmed = text.trim();
|
|
178
|
+
if (AFFIRMATIVE_EMOJI.has(trimmed))
|
|
179
|
+
return true;
|
|
180
|
+
return AFFIRMATIVE.has(normalize(trimmed));
|
|
181
|
+
};
|
|
182
|
+
exports.isConfirmationReply = isConfirmationReply;
|
|
183
|
+
// How much of each argument the summary shows, and how many arguments.
|
|
184
|
+
const SUMMARY_VALUE_MAX = 200;
|
|
185
|
+
const SUMMARY_ARGUMENTS_MAX = 10;
|
|
186
|
+
const summaryValue = (value) => {
|
|
187
|
+
const text = typeof value === 'string'
|
|
188
|
+
? value
|
|
189
|
+
: (JSON.stringify(value) ?? String(value));
|
|
190
|
+
// One line, and no backticks, so a value can neither fake another line of
|
|
191
|
+
// the summary nor break out of its code span into formatting or links.
|
|
192
|
+
const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
|
|
193
|
+
return flat.length > SUMMARY_VALUE_MAX
|
|
194
|
+
? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
|
|
195
|
+
: flat;
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* What the held calls will do, written from the calls themselves and appended
|
|
199
|
+
* under the model's question. The model words the question, but a yes runs the
|
|
200
|
+
* stored calls — so the participant has to see those, not only the model's
|
|
201
|
+
* account of them, which a prompt injection earlier in the turn could have
|
|
202
|
+
* bent.
|
|
203
|
+
*/
|
|
204
|
+
const formatConfirmationSummary = (calls, titleOf) => calls
|
|
205
|
+
.map(call => {
|
|
206
|
+
const entries = Object.entries(call.arguments ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
|
207
|
+
const lines = entries
|
|
208
|
+
.slice(0, SUMMARY_ARGUMENTS_MAX)
|
|
209
|
+
.map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
|
|
210
|
+
if (entries.length > SUMMARY_ARGUMENTS_MAX)
|
|
211
|
+
lines.push('- …');
|
|
212
|
+
// The hourglass says "not done yet" in every language, whatever the
|
|
213
|
+
// model's own words above it claim.
|
|
214
|
+
return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
|
|
215
|
+
})
|
|
216
|
+
.join('\n\n');
|
|
217
|
+
exports.formatConfirmationSummary = formatConfirmationSummary;
|
package/package.json
CHANGED
package/src/channelDebounce.ts
CHANGED
|
@@ -12,8 +12,41 @@ export interface BufferedChannelMessage {
|
|
|
12
12
|
text: string;
|
|
13
13
|
externalMessageId: string | null;
|
|
14
14
|
receivedAt: number;
|
|
15
|
+
// Answer without waiting for the burst to settle: an explicit command, or a
|
|
16
|
+
// message on a channel with buffering turned off. It still goes through the
|
|
17
|
+
// buffer — that is what runs the turn under a caller patient enough for a
|
|
18
|
+
// long answer — it just flushes at once, taking any pending text with it.
|
|
19
|
+
immediate?: boolean;
|
|
20
|
+
// The prompt this message invoked, when it was a slash command.
|
|
21
|
+
command?: BufferedChannelCommand | null;
|
|
22
|
+
// Where this message must be answered, when that isn't the envelope's
|
|
23
|
+
// `delivery` — a Discord interaction waiting on its deferred response. Kept
|
|
24
|
+
// on the message because the envelope is replaced by every newer push, and a
|
|
25
|
+
// plain message typed a moment later must not orphan the interaction.
|
|
26
|
+
reply?: Record<string, unknown> | null;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
// A resolved slash-command prompt, carried with the message that invoked it so
|
|
30
|
+
// the flush can run it exactly as the webhook would have.
|
|
31
|
+
export interface BufferedChannelCommand {
|
|
32
|
+
promptId: string;
|
|
33
|
+
artifactPromptId: string | null;
|
|
34
|
+
promptTitle: string;
|
|
35
|
+
args: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The prompt a batch runs: the newest command in it, since that is the one the
|
|
39
|
+
// participant asked for last. Null for a batch of plain text.
|
|
40
|
+
export const batchCommand = (
|
|
41
|
+
messages: BufferedChannelMessage[]
|
|
42
|
+
): BufferedChannelCommand | null => {
|
|
43
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
44
|
+
const command = messages[i].command;
|
|
45
|
+
if (command) return command;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
|
|
17
50
|
// Everything the flush needs to run the turn and deliver the reply, minus the
|
|
18
51
|
// message texts themselves. The newest push wins: a display name or thread id
|
|
19
52
|
// can change mid-burst, and the freshest is the one to answer against.
|
|
@@ -35,6 +68,9 @@ export interface ChannelBufferEnvelope {
|
|
|
35
68
|
export interface ChannelBufferFlush {
|
|
36
69
|
envelope: ChannelBufferEnvelope;
|
|
37
70
|
messages: BufferedChannelMessage[];
|
|
71
|
+
// Stable across every hand-off of the same batch, so the worker can tell a
|
|
72
|
+
// retry of a turn it already answered from a new one.
|
|
73
|
+
batchId: string;
|
|
38
74
|
}
|
|
39
75
|
|
|
40
76
|
// Read the channel's debounce window off its `config`, clamped to the supported
|
package/src/chunking.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { constants } from './constants';
|
|
2
|
+
import { decodeEntities } from './sanitize';
|
|
2
3
|
|
|
3
4
|
import type {
|
|
4
5
|
ExtractedDocument,
|
|
@@ -50,24 +51,140 @@ export const splitRecursive = (
|
|
|
50
51
|
return chunks;
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
// Each piece keeps its `raw` text — the slice of the input it covers, without
|
|
55
|
+
// the overlap borrowed from the piece before — so a caller can walk the input
|
|
56
|
+
// in order without reading any of it twice.
|
|
57
|
+
const chunkPieces = (
|
|
54
58
|
text: string,
|
|
55
59
|
options?: { targetChars?: number; overlapChars?: number }
|
|
56
|
-
): string
|
|
60
|
+
): Array<{ raw: string; content: string }> => {
|
|
57
61
|
const target = options?.targetChars ?? constants.CHUNK_TARGET_CHARS;
|
|
58
62
|
const overlap = options?.overlapChars ?? constants.CHUNK_OVERLAP_CHARS;
|
|
59
63
|
|
|
60
64
|
const trimmed = text.trim();
|
|
61
65
|
if (!trimmed) return [];
|
|
62
|
-
if (trimmed.length <= target) return [trimmed];
|
|
66
|
+
if (trimmed.length <= target) return [{ raw: trimmed, content: trimmed }];
|
|
63
67
|
|
|
64
68
|
const rawChunks = splitRecursive(trimmed, target);
|
|
65
|
-
const
|
|
69
|
+
const pieces: Array<{ raw: string; content: string }> = [];
|
|
66
70
|
for (let i = 0; i < rawChunks.length; i++) {
|
|
67
71
|
const prev = i > 0 ? rawChunks[i - 1].slice(-overlap) : '';
|
|
68
|
-
|
|
72
|
+
pieces.push({
|
|
73
|
+
raw: rawChunks[i],
|
|
74
|
+
content: (prev ? prev + '\n' : '') + rawChunks[i]
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return pieces.filter(p => p.content.trim().length > 0);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const chunkText = (
|
|
81
|
+
text: string,
|
|
82
|
+
options?: { targetChars?: number; overlapChars?: number }
|
|
83
|
+
): string[] => chunkPieces(text, options).map(p => p.content);
|
|
84
|
+
|
|
85
|
+
export type HeadingSyntax = 'markdown' | 'html';
|
|
86
|
+
|
|
87
|
+
interface HeadingEvent {
|
|
88
|
+
offset: number;
|
|
89
|
+
level: number;
|
|
90
|
+
text: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const MARKDOWN_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/;
|
|
94
|
+
const MARKDOWN_FENCE = /^ {0,3}(```|~~~)/;
|
|
95
|
+
const HTML_HEADING = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi;
|
|
96
|
+
const MAX_HEADING_CHARS = 120;
|
|
97
|
+
|
|
98
|
+
const MARKDOWN_FILE_NAME = /\.(md|markdown|mdx)$/i;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which heading syntax a document is written in, or null when it has none we
|
|
102
|
+
* can trust. Plain text is left out on purpose: a line that happens to start
|
|
103
|
+
* with "# " there is as likely a comment or a list as a title.
|
|
104
|
+
*/
|
|
105
|
+
export const headingSyntaxFor = (input: {
|
|
106
|
+
mimeType?: string | null;
|
|
107
|
+
fileName?: string | null;
|
|
108
|
+
isWebPage?: boolean;
|
|
109
|
+
}): HeadingSyntax | null => {
|
|
110
|
+
// Crawled pages are flattened to text with their headings written back as
|
|
111
|
+
// Markdown lines, whatever the page's own content type was.
|
|
112
|
+
if (input.isWebPage) return 'markdown';
|
|
113
|
+
if (input.mimeType === constants.MIMETYPE_TEXT_MARKDOWN) return 'markdown';
|
|
114
|
+
if (input.mimeType === constants.MIMETYPE_TEXT_HTML) return 'html';
|
|
115
|
+
if (input.fileName && MARKDOWN_FILE_NAME.test(input.fileName)) {
|
|
116
|
+
return 'markdown';
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const cleanHeading = (text: string): string =>
|
|
122
|
+
text.replace(/\s+/g, ' ').trim().slice(0, MAX_HEADING_CHARS);
|
|
123
|
+
|
|
124
|
+
const scanMarkdownHeadings = (
|
|
125
|
+
text: string,
|
|
126
|
+
state: { inFence: boolean }
|
|
127
|
+
): HeadingEvent[] => {
|
|
128
|
+
const events: HeadingEvent[] = [];
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const line of text.split('\n')) {
|
|
131
|
+
// A `#` inside a fenced block is a shell comment or a preprocessor line,
|
|
132
|
+
// not a title — and a fence can open in one chunk and close in the next.
|
|
133
|
+
if (MARKDOWN_FENCE.test(line)) {
|
|
134
|
+
state.inFence = !state.inFence;
|
|
135
|
+
} else if (!state.inFence) {
|
|
136
|
+
const match = MARKDOWN_HEADING.exec(line);
|
|
137
|
+
const heading = match ? cleanHeading(match[2]) : '';
|
|
138
|
+
if (match && heading) {
|
|
139
|
+
events.push({ offset, level: match[1].length, text: heading });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
offset += line.length + 1;
|
|
69
143
|
}
|
|
70
|
-
return
|
|
144
|
+
return events;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const scanHtmlHeadings = (text: string): HeadingEvent[] =>
|
|
148
|
+
[...text.matchAll(HTML_HEADING)]
|
|
149
|
+
.map(match => ({
|
|
150
|
+
offset: match.index ?? 0,
|
|
151
|
+
level: Number(match[1]),
|
|
152
|
+
text: cleanHeading(decodeEntities(match[2].replace(/<[^>]*>/g, ' ')))
|
|
153
|
+
}))
|
|
154
|
+
.filter(event => event.text);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Follows the heading hierarchy across a document's chunks, fed in order.
|
|
158
|
+
* Returns, for each chunk, the path of headings it sits under — the section a
|
|
159
|
+
* reader would be pointed to. A chunk that opens with a heading belongs to that
|
|
160
|
+
* heading; one that starts mid-section belongs to the section it continues,
|
|
161
|
+
* even when a later heading begins inside it.
|
|
162
|
+
*/
|
|
163
|
+
const createHeadingTracker = (syntax: HeadingSyntax) => {
|
|
164
|
+
let stack: Array<{ level: number; text: string }> = [];
|
|
165
|
+
const fence = { inFence: false };
|
|
166
|
+
const apply = (event: HeadingEvent) => {
|
|
167
|
+
stack = stack.filter(h => h.level < event.level);
|
|
168
|
+
stack.push({ level: event.level, text: event.text });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return (raw: string): string[] => {
|
|
172
|
+
const events =
|
|
173
|
+
syntax === 'markdown'
|
|
174
|
+
? scanMarkdownHeadings(raw, fence)
|
|
175
|
+
: scanHtmlHeadings(raw);
|
|
176
|
+
const [first, ...rest] = events;
|
|
177
|
+
// Before a document's first heading there is no section to continue — only
|
|
178
|
+
// the metadata header, or the markup around an HTML body — so a chunk that
|
|
179
|
+
// holds the first heading belongs to it wherever in the chunk it falls.
|
|
180
|
+
const opensWithHeading =
|
|
181
|
+
first !== undefined &&
|
|
182
|
+
(stack.length === 0 || raw.slice(0, first.offset).trim() === '');
|
|
183
|
+
if (opensWithHeading) apply(first);
|
|
184
|
+
const path = stack.map(h => h.text);
|
|
185
|
+
for (const event of opensWithHeading ? rest : events) apply(event);
|
|
186
|
+
return path;
|
|
187
|
+
};
|
|
71
188
|
};
|
|
72
189
|
|
|
73
190
|
export const buildHeader = (input: {
|
|
@@ -87,9 +204,14 @@ export const buildHeader = (input: {
|
|
|
87
204
|
.filter(Boolean)
|
|
88
205
|
.join('\n');
|
|
89
206
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
207
|
+
// Loose on purpose: a chunk of inline text has no extracted document behind it,
|
|
208
|
+
// so only `headingPath` can be known about it.
|
|
209
|
+
export interface ChunkMetadata extends Partial<ExtractedDocumentMetadata> {
|
|
210
|
+
chunkIndexInPage?: number;
|
|
211
|
+
chunksInPage?: number;
|
|
212
|
+
// The headings this chunk sits under, outermost first. Present only for
|
|
213
|
+
// Markdown and HTML, and only when the chunk is under at least one heading.
|
|
214
|
+
headingPath?: string[];
|
|
93
215
|
}
|
|
94
216
|
|
|
95
217
|
export interface PreparedChunk {
|
|
@@ -97,28 +219,43 @@ export interface PreparedChunk {
|
|
|
97
219
|
metadata: ChunkMetadata | null;
|
|
98
220
|
}
|
|
99
221
|
|
|
222
|
+
const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
|
|
223
|
+
|
|
100
224
|
export const prepareChunks = (
|
|
101
225
|
header: string,
|
|
102
226
|
documents: ExtractedDocument[] | null,
|
|
103
227
|
fallbackContent: string | null,
|
|
104
|
-
options?: {
|
|
228
|
+
options?: {
|
|
229
|
+
targetChars?: number;
|
|
230
|
+
overlapChars?: number;
|
|
231
|
+
// The resource's own type and file name, which decide the heading syntax
|
|
232
|
+
// for inline content and for documents that don't carry their own.
|
|
233
|
+
mimeType?: string | null;
|
|
234
|
+
fileName?: string | null;
|
|
235
|
+
}
|
|
105
236
|
): PreparedChunk[] => {
|
|
106
237
|
if (documents && documents.length > 0) {
|
|
107
238
|
const out: PreparedChunk[] = [];
|
|
108
239
|
documents.forEach((doc, docIndex) => {
|
|
109
|
-
const body = (doc.pageContent || '')
|
|
110
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
111
|
-
.trim();
|
|
240
|
+
const body = (doc.pageContent || '').replace(CONTROL_CHARS, '').trim();
|
|
112
241
|
if (!body) return;
|
|
113
242
|
const text = docIndex === 0 && header ? `${header}\n\n${body}` : body;
|
|
114
|
-
const
|
|
243
|
+
const syntax = headingSyntaxFor({
|
|
244
|
+
mimeType: doc.metadata?.source?.mimeType ?? options?.mimeType,
|
|
245
|
+
fileName: doc.metadata?.source?.fileName ?? options?.fileName,
|
|
246
|
+
isWebPage: !!doc.metadata?.web
|
|
247
|
+
});
|
|
248
|
+
const headingPathOf = syntax ? createHeadingTracker(syntax) : null;
|
|
249
|
+
const pieces = chunkPieces(text, options);
|
|
115
250
|
pieces.forEach((piece, pieceIndex) => {
|
|
251
|
+
const headingPath = headingPathOf?.(piece.raw) ?? [];
|
|
116
252
|
out.push({
|
|
117
|
-
content: piece,
|
|
253
|
+
content: piece.content,
|
|
118
254
|
metadata: {
|
|
119
255
|
...doc.metadata,
|
|
120
256
|
chunkIndexInPage: pieceIndex,
|
|
121
|
-
chunksInPage: pieces.length
|
|
257
|
+
chunksInPage: pieces.length,
|
|
258
|
+
...(headingPath.length > 0 ? { headingPath } : {})
|
|
122
259
|
}
|
|
123
260
|
});
|
|
124
261
|
});
|
|
@@ -126,12 +263,18 @@ export const prepareChunks = (
|
|
|
126
263
|
return out;
|
|
127
264
|
}
|
|
128
265
|
|
|
129
|
-
const body = (fallbackContent || '')
|
|
130
|
-
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
|
|
131
|
-
.trim();
|
|
266
|
+
const body = (fallbackContent || '').replace(CONTROL_CHARS, '').trim();
|
|
132
267
|
const fullText = [header, body].filter(Boolean).join('\n\n');
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
})
|
|
268
|
+
const syntax = headingSyntaxFor({
|
|
269
|
+
mimeType: options?.mimeType,
|
|
270
|
+
fileName: options?.fileName
|
|
271
|
+
});
|
|
272
|
+
const headingPathOf = syntax ? createHeadingTracker(syntax) : null;
|
|
273
|
+
return chunkPieces(fullText, options).map(piece => {
|
|
274
|
+
const headingPath = headingPathOf?.(piece.raw) ?? [];
|
|
275
|
+
return {
|
|
276
|
+
content: piece.content,
|
|
277
|
+
metadata: headingPath.length > 0 ? { headingPath } : null
|
|
278
|
+
};
|
|
279
|
+
});
|
|
137
280
|
};
|