@firedrill-tools/unstructured 0.1.4
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/LICENSE +201 -0
- package/README.md +275 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +5 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/overloaded.scenario.json +11 -0
- package/firedrill/rate-limited.scenario.json +11 -0
- package/firedrill/run-response-lost.scenario.json +11 -0
- package/firedrill/small-responses.scenario.json +21 -0
- package/firedrill/tight-limits.scenario.json +21 -0
- package/firedrill/tools/unstructured/behavior.mjs +69 -0
- package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
- package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
- package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
- package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
- package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
- package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
- package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
- package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
- package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
- package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
- package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
- package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
- package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
- package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
- package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
- package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
- package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
- package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
- package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
- package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
- package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
- package/firedrill/tools/unstructured/lib/store.mjs +33 -0
- package/firedrill/tools/unstructured/lib/util.mjs +149 -0
- package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
- package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
- package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
- package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
- package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
- package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
- package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
- package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
- package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
- package/firedrill/unstructured-archivist.drill.json +68 -0
- package/firedrill/unstructured-chunking.drill.json +67 -0
- package/firedrill/unstructured-connectors.drill.json +121 -0
- package/firedrill/unstructured-denied.drill.json +58 -0
- package/firedrill/unstructured-fresh-actor.drill.json +68 -0
- package/firedrill/unstructured-overloaded.drill.json +51 -0
- package/firedrill/unstructured-partition-errors.drill.json +66 -0
- package/firedrill/unstructured-partition.drill.json +95 -0
- package/firedrill/unstructured-rate-limited.drill.json +66 -0
- package/firedrill/unstructured-revoked-key.drill.json +773 -0
- package/firedrill/unstructured-run-lost.drill.json +51 -0
- package/firedrill/unstructured-small-responses.drill.json +173 -0
- package/firedrill/unstructured-tight-limits.drill.json +203 -0
- package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
- package/firedrill/world.json +1556 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +1114 -0
- package/test/conformance.mjs +37 -0
- package/test/flows/access.mjs +54 -0
- package/test/flows/chunking.mjs +95 -0
- package/test/flows/connectors.mjs +76 -0
- package/test/flows/errors.mjs +115 -0
- package/test/flows/faults.mjs +73 -0
- package/test/flows/partition.mjs +225 -0
- package/test/flows/workflows.mjs +123 -0
- package/test/hostile-gen.mjs +0 -0
- package/test/hostile.mjs +155 -0
- package/test/lib.mjs +113 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// HTML partitioning over the streamed token sequence: headings, paragraphs, lists, tables, code, images, header/footer,
|
|
2
|
+
// links. Linear by construction: an explicit element stack with per-name open counts (an unmatched close tag costs
|
|
3
|
+
// nothing, a matched one pops what was pushed), counters instead of stack scans for head/title/header/footer, text
|
|
4
|
+
// accumulated in arrays and joined once, one open anchor at a time (as HTML parsers do) and one open mark per emphasis
|
|
5
|
+
// tag. Every bound answers a declared error: nesting depth, table rows and cells per row.
|
|
6
|
+
import { classifyBlock } from "./elements.mjs";
|
|
7
|
+
import { tokenize } from "./html-tokens.mjs";
|
|
8
|
+
import { collapseSpace, escapeHtml, hasClassToken } from "./html-util.mjs";
|
|
9
|
+
|
|
10
|
+
export const MAX_DEPTH = 256;
|
|
11
|
+
export const MAX_TABLE_ROWS = 10000;
|
|
12
|
+
export const MAX_TABLE_COLUMNS = 200;
|
|
13
|
+
|
|
14
|
+
const HEADING_DEPTH = new Map([["h1", 0], ["h2", 1], ["h3", 2], ["h4", 3], ["h5", 4], ["h6", 5]]);
|
|
15
|
+
const TYPED = new Map([["li", "ListItem"], ["pre", "CodeSnippet"], ["address", "Address"], ["figcaption", "FigureCaption"], ["header", "Header"], ["footer", "Footer"]]);
|
|
16
|
+
const BLOCKS = new Set(["p", "div", "section", "article", "main", "aside", "nav", "blockquote", "ul", "ol", "dl", "dt", "dd", "form", "figure", "body", "html", "tr", "td", "th", "thead", "tbody", "caption"]);
|
|
17
|
+
const VOID = new Set(["br", "hr", "img", "input", "meta", "link", "source", "wbr", "col"]);
|
|
18
|
+
const EMPHASIS = new Map([["b", "b"], ["strong", "b"], ["i", "i"], ["em", "i"]]);
|
|
19
|
+
const CHROME = new Set(["head", "title", "header", "footer"]);
|
|
20
|
+
const OVERFLOW = "File has too many text blocks";
|
|
21
|
+
|
|
22
|
+
const hasPageBreak = (attrs, which) => typeof attrs.style === "string" && attrs.style.toLowerCase().includes(`page-break-${which}`);
|
|
23
|
+
const stripLeadingNewlines = (text) => {
|
|
24
|
+
let k = 0;
|
|
25
|
+
while (k < text.length && text[k] === "\n") k += 1;
|
|
26
|
+
return text.slice(k);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function partitionHtml(builder, html) {
|
|
30
|
+
const stack = [];
|
|
31
|
+
const openCount = new Map();
|
|
32
|
+
const inside = { head: 0, title: 0, header: 0, footer: 0 };
|
|
33
|
+
let buffer = null;
|
|
34
|
+
let table = null;
|
|
35
|
+
|
|
36
|
+
const push = (name) => {
|
|
37
|
+
stack.push(name);
|
|
38
|
+
openCount.set(name, (openCount.get(name) ?? 0) + 1);
|
|
39
|
+
if (CHROME.has(name)) inside[name] += 1;
|
|
40
|
+
return stack.length <= MAX_DEPTH;
|
|
41
|
+
};
|
|
42
|
+
const popTo = (at) => {
|
|
43
|
+
while (stack.length > at) {
|
|
44
|
+
const name = stack.pop();
|
|
45
|
+
const count = openCount.get(name) - 1;
|
|
46
|
+
if (count === 0) openCount.delete(name);
|
|
47
|
+
else openCount.set(name, count);
|
|
48
|
+
if (CHROME.has(name)) inside[name] -= 1;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const closeTag = (name) => {
|
|
52
|
+
if (openCount.has(name)) popTo(stack.lastIndexOf(name));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const openBuffer = (type, depth) => {
|
|
56
|
+
buffer = { type, depth, parts: [], linkTexts: [], linkUrls: [], emphasis: [], tags: [], anchor: null, marks: [] };
|
|
57
|
+
};
|
|
58
|
+
const ensureBuffer = () => {
|
|
59
|
+
if (buffer === null) openBuffer(null, null);
|
|
60
|
+
};
|
|
61
|
+
const since = (index) => collapseSpace(buffer.parts.slice(index).join(""));
|
|
62
|
+
const closeAnchor = () => {
|
|
63
|
+
const anchor = buffer.anchor;
|
|
64
|
+
buffer.anchor = null;
|
|
65
|
+
const label = since(anchor.start);
|
|
66
|
+
if (label.length > 0) {
|
|
67
|
+
buffer.linkTexts.push(label);
|
|
68
|
+
buffer.linkUrls.push(anchor.href);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const closeMark = (tag) => {
|
|
72
|
+
let at = buffer.marks.length - 1;
|
|
73
|
+
while (at >= 0 && buffer.marks[at].tag !== tag) at -= 1;
|
|
74
|
+
if (at < 0) return;
|
|
75
|
+
const [mark] = buffer.marks.splice(at, 1);
|
|
76
|
+
const content = since(mark.start);
|
|
77
|
+
if (content.length > 0) {
|
|
78
|
+
buffer.emphasis.push(content);
|
|
79
|
+
buffer.tags.push(mark.tag);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const flush = () => {
|
|
83
|
+
if (buffer === null) return;
|
|
84
|
+
const current = buffer;
|
|
85
|
+
if (current.anchor !== null) closeAnchor();
|
|
86
|
+
while (current.marks.length > 0) closeMark(current.marks[current.marks.length - 1].tag);
|
|
87
|
+
buffer = null;
|
|
88
|
+
const raw = current.parts.join("");
|
|
89
|
+
const text = current.type === "CodeSnippet" ? stripLeadingNewlines(raw).trimEnd() : collapseSpace(raw);
|
|
90
|
+
if (text.length === 0) return;
|
|
91
|
+
let type = current.type ?? classifyBlock(text);
|
|
92
|
+
const chrome = inside.header > 0 ? "Header" : inside.footer > 0 ? "Footer" : null;
|
|
93
|
+
if (chrome !== null && type !== "Title") type = chrome;
|
|
94
|
+
const meta = {};
|
|
95
|
+
if (type === "Title") meta.category_depth = current.depth ?? 0;
|
|
96
|
+
if (current.linkTexts.length > 0) {
|
|
97
|
+
meta.link_texts = current.linkTexts;
|
|
98
|
+
meta.link_urls = current.linkUrls;
|
|
99
|
+
}
|
|
100
|
+
if (current.emphasis.length > 0) {
|
|
101
|
+
meta.emphasized_text_contents = current.emphasis;
|
|
102
|
+
meta.emphasized_text_tags = current.tags;
|
|
103
|
+
}
|
|
104
|
+
builder.add(type, text, meta);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const closeCell = () => {
|
|
108
|
+
table.row.push(collapseSpace(table.cell.join("")));
|
|
109
|
+
table.cell = null;
|
|
110
|
+
return table.row.length <= MAX_TABLE_COLUMNS ? undefined : `File has a table row with more than ${MAX_TABLE_COLUMNS} cells`;
|
|
111
|
+
};
|
|
112
|
+
const closeRow = () => {
|
|
113
|
+
const problem = table.cell !== null ? closeCell() : undefined;
|
|
114
|
+
if (problem !== undefined) return problem;
|
|
115
|
+
table.rows.push(table.row);
|
|
116
|
+
table.row = null;
|
|
117
|
+
return table.rows.length <= MAX_TABLE_ROWS ? undefined : `File has a table with more than ${MAX_TABLE_ROWS} rows`;
|
|
118
|
+
};
|
|
119
|
+
const closeTable = () => {
|
|
120
|
+
const problem = table.row !== null ? closeRow() : undefined;
|
|
121
|
+
if (problem !== undefined) return problem;
|
|
122
|
+
if (table.rows.length > 0) {
|
|
123
|
+
const text = table.rows.map((row) => row.join(" ")).join("\n");
|
|
124
|
+
const markup = `<table>${table.rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`).join("")}</table>`;
|
|
125
|
+
builder.add("Table", text, { text_as_html: markup });
|
|
126
|
+
}
|
|
127
|
+
table = null;
|
|
128
|
+
return undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const visit = (token) => {
|
|
132
|
+
if (token.kind === "text") {
|
|
133
|
+
if (inside.head > 0 || inside.title > 0) return undefined;
|
|
134
|
+
if (table !== null) {
|
|
135
|
+
if (table.cell !== null) table.cell.push(token.text);
|
|
136
|
+
} else if (token.text.trim().length > 0 || buffer !== null) {
|
|
137
|
+
ensureBuffer();
|
|
138
|
+
buffer.parts.push(token.text);
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const name = token.name;
|
|
143
|
+
if (token.kind === "open") {
|
|
144
|
+
if (hasPageBreak(token.attrs, "before") || (name === "hr" && typeof token.attrs.class === "string" && hasClassToken(token.attrs.class, "page-break"))) {
|
|
145
|
+
flush();
|
|
146
|
+
builder.pageBreak();
|
|
147
|
+
}
|
|
148
|
+
if (table !== null) {
|
|
149
|
+
let problem;
|
|
150
|
+
if (name === "tr") {
|
|
151
|
+
if (table.row !== null) problem = closeRow();
|
|
152
|
+
table.row = [];
|
|
153
|
+
} else if (name === "td" || name === "th") {
|
|
154
|
+
if (table.cell !== null) problem = closeCell();
|
|
155
|
+
if (table.row === null) table.row = [];
|
|
156
|
+
table.cell = [];
|
|
157
|
+
} else if (name === "br" && table.cell !== null) table.cell.push(" ");
|
|
158
|
+
if (problem !== undefined) return problem;
|
|
159
|
+
} else if (name === "table") {
|
|
160
|
+
flush();
|
|
161
|
+
table = { rows: [], row: null, cell: null };
|
|
162
|
+
} else if (name === "br") {
|
|
163
|
+
if (buffer !== null) buffer.parts.push("\n");
|
|
164
|
+
} else if (name === "img") {
|
|
165
|
+
flush();
|
|
166
|
+
builder.add("Image", collapseSpace(typeof token.attrs.alt === "string" ? token.attrs.alt : ""), { image_url: typeof token.attrs.src === "string" && token.attrs.src.length > 0 ? token.attrs.src : undefined });
|
|
167
|
+
} else if (HEADING_DEPTH.has(name)) {
|
|
168
|
+
flush();
|
|
169
|
+
openBuffer("Title", HEADING_DEPTH.get(name));
|
|
170
|
+
} else if (TYPED.has(name)) {
|
|
171
|
+
flush();
|
|
172
|
+
if (name !== "header" && name !== "footer") openBuffer(TYPED.get(name), null);
|
|
173
|
+
} else if (BLOCKS.has(name) || name === "hr") flush();
|
|
174
|
+
else if (name === "a") {
|
|
175
|
+
ensureBuffer();
|
|
176
|
+
if (buffer.anchor !== null) closeAnchor();
|
|
177
|
+
buffer.anchor = { start: buffer.parts.length, href: typeof token.attrs.href === "string" ? token.attrs.href : "" };
|
|
178
|
+
} else if (EMPHASIS.has(name)) {
|
|
179
|
+
ensureBuffer();
|
|
180
|
+
const tag = EMPHASIS.get(name);
|
|
181
|
+
if (!buffer.marks.some((mark) => mark.tag === tag)) buffer.marks.push({ start: buffer.parts.length, tag });
|
|
182
|
+
}
|
|
183
|
+
if (!token.selfClosing && !VOID.has(name) && !push(name)) return `File is not valid html: elements nested deeper than ${MAX_DEPTH} levels`;
|
|
184
|
+
return builder.overflow ? OVERFLOW : undefined;
|
|
185
|
+
}
|
|
186
|
+
if (table !== null) {
|
|
187
|
+
let problem;
|
|
188
|
+
if ((name === "td" || name === "th") && table.cell !== null) problem = closeCell();
|
|
189
|
+
else if (name === "tr" && table.row !== null) problem = closeRow();
|
|
190
|
+
else if (name === "table") problem = closeTable();
|
|
191
|
+
closeTag(name);
|
|
192
|
+
return problem ?? (builder.overflow ? OVERFLOW : undefined);
|
|
193
|
+
}
|
|
194
|
+
if (name === "a") {
|
|
195
|
+
if (buffer !== null && buffer.anchor !== null) closeAnchor();
|
|
196
|
+
} else if (EMPHASIS.has(name)) {
|
|
197
|
+
if (buffer !== null) closeMark(EMPHASIS.get(name));
|
|
198
|
+
} else if (HEADING_DEPTH.has(name) || TYPED.has(name) || BLOCKS.has(name)) flush();
|
|
199
|
+
closeTag(name);
|
|
200
|
+
return builder.overflow ? OVERFLOW : undefined;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const result = tokenize(html, visit);
|
|
204
|
+
if (result.error !== undefined) return { ok: false, message: result.error };
|
|
205
|
+
if (table !== null) {
|
|
206
|
+
const problem = closeTable();
|
|
207
|
+
if (problem !== undefined) return { ok: false, message: problem };
|
|
208
|
+
}
|
|
209
|
+
flush();
|
|
210
|
+
return builder.overflow ? { ok: false, message: OVERFLOW } : { ok: true };
|
|
211
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// File-type detection and dispatch: one document in, { ok, elements } or { ok: false, code, message } out.
|
|
2
|
+
import { chunkElements } from "./chunk.mjs";
|
|
3
|
+
import { Builder } from "./elements.mjs";
|
|
4
|
+
import { partitionDelimited } from "./csv.mjs";
|
|
5
|
+
import { partitionHtml } from "./html.mjs";
|
|
6
|
+
import { partitionMarkdown } from "./markdown.mjs";
|
|
7
|
+
import { partitionEmail } from "./email.mjs";
|
|
8
|
+
import { partitionJson, partitionXml } from "./other.mjs";
|
|
9
|
+
import { partitionText } from "./text.mjs";
|
|
10
|
+
import { sha256Id } from "../sha256.mjs";
|
|
11
|
+
import { uuid } from "../ids.mjs";
|
|
12
|
+
import { utf8Length } from "../util.mjs";
|
|
13
|
+
|
|
14
|
+
const BY_EXTENSION = new Map([
|
|
15
|
+
["txt", "text/plain"], ["text", "text/plain"], ["log", "text/plain"], ["md", "text/markdown"], ["markdown", "text/markdown"], ["html", "text/html"],
|
|
16
|
+
["htm", "text/html"], ["csv", "text/csv"], ["tsv", "text/tsv"], ["json", "application/json"], ["eml", "message/rfc822"], ["xml", "application/xml"],
|
|
17
|
+
["rst", "text/x-rst"],
|
|
18
|
+
]);
|
|
19
|
+
const ALIASES = new Map([
|
|
20
|
+
["text/x-markdown", "text/markdown"], ["application/xhtml+xml", "text/html"], ["text/xml", "application/xml"], ["text/tab-separated-values", "text/tsv"],
|
|
21
|
+
["application/csv", "text/csv"], ["text/rst", "text/x-rst"], ["text/x-log", "text/plain"], ["text/json", "application/json"],
|
|
22
|
+
]);
|
|
23
|
+
export const SUPPORTED = new Set(["text/plain", "text/markdown", "text/html", "text/csv", "text/tsv", "application/json", "message/rfc822", "application/xml", "text/x-rst"]);
|
|
24
|
+
const BINARY_EXTENSIONS = new Map([
|
|
25
|
+
["pdf", "application/pdf"], ["docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"], ["doc", "application/msword"],
|
|
26
|
+
["pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"], ["xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
|
|
27
|
+
["png", "image/png"], ["jpg", "image/jpeg"], ["jpeg", "image/jpeg"], ["epub", "application/epub+zip"], ["zip", "application/zip"], ["odt", "application/vnd.oasis.opendocument.text"],
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** Detected media type: explicit content_type first, then the part's type, then the filename extension. Null when unknown. */
|
|
31
|
+
export function detectType(file, explicitType) {
|
|
32
|
+
const normalise = (raw) => {
|
|
33
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
34
|
+
const base = raw.split(";")[0].trim().toLowerCase();
|
|
35
|
+
return ALIASES.get(base) ?? base;
|
|
36
|
+
};
|
|
37
|
+
const explicit = normalise(explicitType);
|
|
38
|
+
if (explicit !== null) return explicit;
|
|
39
|
+
const partType = normalise(file.content_type);
|
|
40
|
+
if (partType !== null && partType !== "application/octet-stream") return partType;
|
|
41
|
+
const name = typeof file.filename === "string" ? file.filename : "";
|
|
42
|
+
const dot = name.lastIndexOf(".");
|
|
43
|
+
const extension = dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
44
|
+
if (BY_EXTENSION.has(extension)) return BY_EXTENSION.get(extension);
|
|
45
|
+
if (BINARY_EXTENSIONS.has(extension)) return BINARY_EXTENSIONS.get(extension);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Response budget assumed when the caller passes none (`meta/limits.response_bytes` default). */
|
|
50
|
+
const DEFAULT_BUDGET = 921600;
|
|
51
|
+
const budgetOf = (options) => (typeof options.responseBudget === "number" ? options.responseBudget : DEFAULT_BUDGET);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Byte bound for the running element-size estimate: the response budget when the elements themselves are the response
|
|
55
|
+
* (unchunked JSON or CSV with a known budget), three quarters of it when every element is serialised again into
|
|
56
|
+
* `orig_elements` (stored gzip never shrinks, base64 adds a third), and no bound when the chunks alone are returned.
|
|
57
|
+
*/
|
|
58
|
+
function elementBudget(options) {
|
|
59
|
+
if (options.chunk === null) return typeof options.responseBudget === "number" ? options.responseBudget : Infinity;
|
|
60
|
+
return options.chunk.includeOrigElements ? Math.floor((budgetOf(options) * 3) / 4) : Infinity;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Partitions one text document.
|
|
65
|
+
* @param file { filename, content, content_type?, last_modified? }
|
|
66
|
+
* @param options { elementSizing?: { bytes } shared across the files of one request, contentType, languages, includePageBreaks, startingPage, xmlKeepTags, uniqueIds, nowIso, chunk: null | chunk options }
|
|
67
|
+
* @param context Tool context (used only for UUID ids when uniqueIds)
|
|
68
|
+
*/
|
|
69
|
+
export function partitionFile(file, options, context) {
|
|
70
|
+
const filetype = detectType(file, options.contentType);
|
|
71
|
+
if (filetype === null || !SUPPORTED.has(filetype)) return { ok: false, code: "UNSUPPORTED_FILE_TYPE", message: `${filetype === null ? "None" : filetype} not currently supported` };
|
|
72
|
+
if (file.bad_transfer === true) return { ok: false, code: "INVALID_FILE", message: "File content is not valid base64 UTF-8 text" };
|
|
73
|
+
const builder = new Builder(
|
|
74
|
+
{
|
|
75
|
+
filename: file.filename,
|
|
76
|
+
filetype,
|
|
77
|
+
languages: options.languages,
|
|
78
|
+
startingPage: options.startingPage,
|
|
79
|
+
lastModified: typeof file.last_modified === "string" && file.last_modified.length > 0 ? file.last_modified : options.nowIso,
|
|
80
|
+
includePageBreaks: options.includePageBreaks,
|
|
81
|
+
sizing: options.elementSizing,
|
|
82
|
+
byteBudget: elementBudget(options),
|
|
83
|
+
},
|
|
84
|
+
options.uniqueIds ? context : null,
|
|
85
|
+
);
|
|
86
|
+
const text = file.content.startsWith("") ? file.content.slice(1) : file.content;
|
|
87
|
+
let result;
|
|
88
|
+
if (filetype === "text/plain" || filetype === "text/x-rst") result = partitionText(builder, text);
|
|
89
|
+
else if (filetype === "text/markdown") result = partitionMarkdown(builder, text);
|
|
90
|
+
else if (filetype === "text/html") result = partitionHtml(builder, text);
|
|
91
|
+
else if (filetype === "text/csv") result = partitionDelimited(builder, text, ",");
|
|
92
|
+
else if (filetype === "text/tsv") result = partitionDelimited(builder, text, "\t");
|
|
93
|
+
else if (filetype === "message/rfc822") result = partitionEmail(builder, text);
|
|
94
|
+
else if (filetype === "application/xml") result = partitionXml(builder, text, options.xmlKeepTags);
|
|
95
|
+
else result = partitionJson(builder, text);
|
|
96
|
+
if (builder.tooLarge) {
|
|
97
|
+
const orig = options.chunk !== null;
|
|
98
|
+
const floor = orig ? Math.ceil((builder.sizing.bytes * 4) / 3) : builder.sizing.bytes;
|
|
99
|
+
return { ok: false, code: "RESPONSE_TOO_LARGE", message: `Partition output${orig ? " with orig_elements" : ""} of at least ${floor} bytes exceeds the ${budgetOf(options)} byte response limit; upload fewer or smaller files${orig ? " or set include_orig_elements=false" : ""}` };
|
|
100
|
+
}
|
|
101
|
+
if (!result.ok) return { ok: false, code: result.code ?? "INVALID_FILE", message: result.message };
|
|
102
|
+
if (builder.overflow) return { ok: false, code: "INVALID_FILE", message: "File produces too many elements" };
|
|
103
|
+
let elements = builder.elements;
|
|
104
|
+
if (options.chunk !== null && options.chunk.includeOrigElements && typeof options.responseBudget === "number") {
|
|
105
|
+
// Every original element lands in some chunk's `orig_elements` (stored-block gzip never shrinks, base64 adds a third),
|
|
106
|
+
// so an element array whose JSON already passes three quarters of the budget cannot produce a response within it.
|
|
107
|
+
// Failing here skips compressing tens of megabytes that would be discarded by the response budget anyway.
|
|
108
|
+
const bytes = utf8Length(JSON.stringify(elements));
|
|
109
|
+
if (bytes * 4 > options.responseBudget * 3) return { ok: false, code: "RESPONSE_TOO_LARGE", message: `Partition output with orig_elements of at least ${Math.ceil((bytes * 4) / 3)} bytes exceeds the ${options.responseBudget} byte response limit; upload fewer or smaller files or set include_orig_elements=false` };
|
|
110
|
+
}
|
|
111
|
+
if (options.chunk !== null) {
|
|
112
|
+
let ordinal = 0;
|
|
113
|
+
const idOf = (chunkText) => {
|
|
114
|
+
ordinal += 1;
|
|
115
|
+
return options.uniqueIds ? uuid(context) : sha256Id(file.filename, "chunk", String(ordinal), chunkText);
|
|
116
|
+
};
|
|
117
|
+
const chunked = chunkElements(elements, { ...options.chunk, responseBudget: options.responseBudget, chunkSizing: options.chunkSizing }, idOf);
|
|
118
|
+
if (!chunked.ok) return chunked;
|
|
119
|
+
elements = chunked.elements;
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, elements, filetype };
|
|
122
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Markdown partitioning: headings, fenced code, pipe tables, lists, images, inline emphasis and links.
|
|
2
|
+
import { classifyBlock, stripListMarker } from "./elements.mjs";
|
|
3
|
+
import { tableHtml, tableText } from "./csv.mjs";
|
|
4
|
+
import { MAX_TABLE_COLUMNS, MAX_TABLE_ROWS } from "./html.mjs";
|
|
5
|
+
import { MAX_BLOCKS } from "./text.mjs";
|
|
6
|
+
|
|
7
|
+
const LIST_RE = /^(\s*)([-*+]|\d{1,4}[.)])\s+(.*)$/;
|
|
8
|
+
const CELL_RE = /^:?-+:?$/; // GFM: at least one dash per cell, optional alignment colons
|
|
9
|
+
|
|
10
|
+
/** ATX heading: `#`{1,6} + whitespace + text with optional closing hashes. Linear scan, returns { depth, text } or null. */
|
|
11
|
+
function parseHeading(line) {
|
|
12
|
+
let depth = 0;
|
|
13
|
+
while (depth < line.length && line[depth] === "#") depth += 1;
|
|
14
|
+
if (depth === 0 || depth > 6 || depth >= line.length || (line[depth] !== " " && line[depth] !== "\t")) return null;
|
|
15
|
+
let end = line.length;
|
|
16
|
+
while (end > depth && (line[end - 1] === " " || line[end - 1] === "\t")) end -= 1;
|
|
17
|
+
while (end > depth && line[end - 1] === "#") end -= 1;
|
|
18
|
+
while (end > depth && (line[end - 1] === " " || line[end - 1] === "\t")) end -= 1;
|
|
19
|
+
return { depth, text: line.slice(depth, end).trim() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `` on its own line. Linear scan via indexOf, returns { alt, url } or null. */
|
|
23
|
+
function parseImage(line) {
|
|
24
|
+
if (!line.startsWith(";
|
|
26
|
+
if (closeAlt < 0 || line.indexOf("]", 2) !== closeAlt) return null;
|
|
27
|
+
const closeUrl = line.indexOf(")", closeAlt + 2);
|
|
28
|
+
if (closeUrl < 0 || line.slice(closeUrl + 1).trim().length > 0) return null;
|
|
29
|
+
const inner = line.slice(closeAlt + 2, closeUrl);
|
|
30
|
+
return { alt: line.slice(2, closeAlt), url: inner.split(/\s/)[0] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SEPARATOR_CHARS_RE = /^[\s|:-]*$/; // a separator row holds only pipes, dashes, colons and whitespace
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pipe-table separator row (`| --- | :---: |`): the number of cells when every cell is `:?-+:?` after trimming, otherwise
|
|
37
|
+
* 0. Linear in the line at any width: ordinary text is rejected by one character-class scan before anything is split,
|
|
38
|
+
* and a real separator is never ignored for being long, so a table wider than the cell bound fails with the declared
|
|
39
|
+
* error instead of silently becoming text.
|
|
40
|
+
*/
|
|
41
|
+
function separatorCells(line) {
|
|
42
|
+
if (!line.includes("|") || !SEPARATOR_CHARS_RE.test(line)) return 0;
|
|
43
|
+
const cells = splitRow(line);
|
|
44
|
+
return cells.length > 0 && cells.every((cell) => CELL_RE.test(cell)) ? cells.length : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A forward `indexOf` memo: `text` is scanned once per needle no matter how often it is asked, because every query
|
|
49
|
+
* position is at or after the previous one (the caller's index only moves forward), so a cached hit at or beyond
|
|
50
|
+
* the query is still the first occurrence. Returns Infinity when the needle does not occur again.
|
|
51
|
+
*/
|
|
52
|
+
function finder(text, needle) {
|
|
53
|
+
let at = -1;
|
|
54
|
+
return (from) => {
|
|
55
|
+
if (at < from) {
|
|
56
|
+
const next = text.indexOf(needle, from);
|
|
57
|
+
at = next < 0 ? Infinity : next;
|
|
58
|
+
}
|
|
59
|
+
return at;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Extracts links and emphasis from inline markup and returns the plain text plus metadata. Linear in the text. */
|
|
64
|
+
export function inline(text) {
|
|
65
|
+
const meta = {};
|
|
66
|
+
const linkTexts = [];
|
|
67
|
+
const linkUrls = [];
|
|
68
|
+
const emphasis = [];
|
|
69
|
+
const tags = [];
|
|
70
|
+
const out = [];
|
|
71
|
+
const linkClose = finder(text, "](");
|
|
72
|
+
const linkEnd = finder(text, ")");
|
|
73
|
+
const tick = finder(text, "`");
|
|
74
|
+
const markers = new Map([["*", finder(text, "*")], ["**", finder(text, "**")], ["_", finder(text, "_")], ["__", finder(text, "__")]]);
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < text.length) {
|
|
77
|
+
if (text[i] === "[") {
|
|
78
|
+
const close = linkClose(i);
|
|
79
|
+
const end = close !== Infinity ? linkEnd(close + 2) : Infinity;
|
|
80
|
+
if (end !== Infinity && end - i <= 2000) {
|
|
81
|
+
const label = text.slice(i + 1, close);
|
|
82
|
+
linkTexts.push(label);
|
|
83
|
+
linkUrls.push(text.slice(close + 2, end).split(/\s/)[0]);
|
|
84
|
+
out.push(label);
|
|
85
|
+
i = end + 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (text[i] === "*" || text[i] === "_") {
|
|
90
|
+
const marker = text[i];
|
|
91
|
+
const double = text[i + 1] === marker;
|
|
92
|
+
const open = double ? marker + marker : marker;
|
|
93
|
+
const close = markers.get(open)(i + open.length);
|
|
94
|
+
if (close !== Infinity && close > i + open.length && close - i <= 2000) {
|
|
95
|
+
const content = text.slice(i + open.length, close);
|
|
96
|
+
if (!content.includes("\n") && content.trim().length > 0 && content[0] !== " ") {
|
|
97
|
+
emphasis.push(content);
|
|
98
|
+
tags.push(double ? "b" : "i");
|
|
99
|
+
out.push(content);
|
|
100
|
+
i = close + open.length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (text[i] === "`") {
|
|
106
|
+
const close = tick(i + 1);
|
|
107
|
+
if (close !== Infinity && close - i <= 2000) {
|
|
108
|
+
out.push(text.slice(i + 1, close));
|
|
109
|
+
i = close + 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
out.push(text[i]);
|
|
114
|
+
i += 1;
|
|
115
|
+
}
|
|
116
|
+
if (linkTexts.length > 0) {
|
|
117
|
+
meta.link_texts = linkTexts;
|
|
118
|
+
meta.link_urls = linkUrls;
|
|
119
|
+
}
|
|
120
|
+
if (emphasis.length > 0) {
|
|
121
|
+
meta.emphasized_text_contents = emphasis;
|
|
122
|
+
meta.emphasized_text_tags = tags;
|
|
123
|
+
}
|
|
124
|
+
return { text: out.join(""), meta };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flushParagraph(builder, lines) {
|
|
128
|
+
if (lines.length === 0) return;
|
|
129
|
+
const block = lines.join("\n");
|
|
130
|
+
const { text, meta } = inline(block);
|
|
131
|
+
const type = classifyBlock(text);
|
|
132
|
+
builder.add(type === "ListItem" ? "UncategorizedText" : type, text.split("\n").map((l) => l.trim()).join("\n"), type === "Title" ? { category_depth: 0, ...meta } : meta);
|
|
133
|
+
lines.length = 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const splitRow = (line) => {
|
|
137
|
+
let row = line.trim();
|
|
138
|
+
if (row.startsWith("|")) row = row.slice(1);
|
|
139
|
+
if (row.endsWith("|")) row = row.slice(0, -1);
|
|
140
|
+
return row.split("|").map((cell) => cell.trim());
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function partitionMarkdown(builder, text) {
|
|
144
|
+
const lines = text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
145
|
+
const paragraph = [];
|
|
146
|
+
let blocks = 0;
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < lines.length) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
// Blank lines and lines read while a paragraph is open never count against the bound (a line that closes the paragraph
|
|
151
|
+
// emits at most one more element, and elements have their own bound), so only real content can reach it.
|
|
152
|
+
if (paragraph.length === 0 && line.trim().length > 0 && (blocks += 1) > MAX_BLOCKS) return { ok: false, message: "File has too many text blocks" };
|
|
153
|
+
if (line.includes("\f")) {
|
|
154
|
+
flushParagraph(builder, paragraph);
|
|
155
|
+
builder.pageBreak();
|
|
156
|
+
i += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (line.trim().length === 0) {
|
|
160
|
+
flushParagraph(builder, paragraph);
|
|
161
|
+
i += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (line.startsWith("```") || line.startsWith("~~~")) {
|
|
165
|
+
flushParagraph(builder, paragraph);
|
|
166
|
+
const fence = line.slice(0, 3);
|
|
167
|
+
const code = [];
|
|
168
|
+
i += 1;
|
|
169
|
+
while (i < lines.length && !lines[i].startsWith(fence)) code.push(lines[i++]);
|
|
170
|
+
builder.add("CodeSnippet", code.join("\n"));
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const heading = parseHeading(line);
|
|
175
|
+
if (heading !== null) {
|
|
176
|
+
flushParagraph(builder, paragraph);
|
|
177
|
+
const { text: title, meta } = inline(heading.text);
|
|
178
|
+
builder.add("Title", title, { category_depth: heading.depth - 1, ...meta });
|
|
179
|
+
i += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const width = line.includes("|") && i + 1 < lines.length ? separatorCells(lines[i + 1]) : 0;
|
|
183
|
+
if (width > 0) {
|
|
184
|
+
flushParagraph(builder, paragraph);
|
|
185
|
+
// Same bounds as HTML tables: more rows or cells than the bound fail the file, nothing is dropped. The separator
|
|
186
|
+
// row counts too, so a header narrower than its separator cannot slip a wider table past the bound.
|
|
187
|
+
if (width > MAX_TABLE_COLUMNS) return { ok: false, message: `File has a table row with more than ${MAX_TABLE_COLUMNS} cells` };
|
|
188
|
+
const rows = [splitRow(line)];
|
|
189
|
+
if (rows[0].length > MAX_TABLE_COLUMNS) return { ok: false, message: `File has a table row with more than ${MAX_TABLE_COLUMNS} cells` };
|
|
190
|
+
i += 2;
|
|
191
|
+
while (i < lines.length && lines[i].includes("|") && lines[i].trim().length > 0) {
|
|
192
|
+
const row = splitRow(lines[i++]);
|
|
193
|
+
if (row.length > MAX_TABLE_COLUMNS) return { ok: false, message: `File has a table row with more than ${MAX_TABLE_COLUMNS} cells` };
|
|
194
|
+
rows.push(row);
|
|
195
|
+
if (rows.length > MAX_TABLE_ROWS) return { ok: false, message: `File has a table with more than ${MAX_TABLE_ROWS} rows` };
|
|
196
|
+
}
|
|
197
|
+
builder.add("Table", tableText(rows), { text_as_html: tableHtml(rows) });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const image = parseImage(line);
|
|
201
|
+
if (image !== null) {
|
|
202
|
+
flushParagraph(builder, paragraph);
|
|
203
|
+
builder.add("Image", image.alt, { image_url: image.url.length > 0 ? image.url : undefined });
|
|
204
|
+
i += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const item = LIST_RE.exec(line);
|
|
208
|
+
if (item !== null && paragraph.length === 0) {
|
|
209
|
+
const depth = Math.min(Math.floor(item[1].replace(/\t/g, " ").length / 2), 10);
|
|
210
|
+
const { text: itemText, meta } = inline(stripListMarker(line).trim());
|
|
211
|
+
builder.add("ListItem", itemText, { category_depth: depth, ...meta });
|
|
212
|
+
i += 1;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
paragraph.push(line);
|
|
216
|
+
i += 1;
|
|
217
|
+
}
|
|
218
|
+
flushParagraph(builder, paragraph);
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// XML and pre-partitioned JSON documents (e-mail lives in email.mjs).
|
|
2
|
+
import { ELEMENT_TYPES, MAX_TEXT, stringsBytes, valuesBytes } from "./elements.mjs";
|
|
3
|
+
import { MAX_DEPTH } from "./html.mjs";
|
|
4
|
+
import { tokenize } from "./html-tokens.mjs";
|
|
5
|
+
import { emitPlainBlocks } from "./text.mjs";
|
|
6
|
+
import { shapeProblem } from "../wire.mjs";
|
|
7
|
+
|
|
8
|
+
export function partitionXml(builder, text, keepTags) {
|
|
9
|
+
const stack = [];
|
|
10
|
+
let leafText = [];
|
|
11
|
+
let leafOpen = null;
|
|
12
|
+
const emitLeaf = () => {
|
|
13
|
+
const leaf = leafText.join("").trim();
|
|
14
|
+
leafText = [];
|
|
15
|
+
if (leaf.length === 0) return undefined;
|
|
16
|
+
if (keepTags) builder.add("UncategorizedText", `<${leafOpen}>${leaf}</${leafOpen}>`);
|
|
17
|
+
else if (!emitPlainBlocks(builder, leaf)) return "File has too many text blocks";
|
|
18
|
+
return builder.overflow ? "File has too many text blocks" : undefined;
|
|
19
|
+
};
|
|
20
|
+
const visit = (token) => {
|
|
21
|
+
if (token.kind === "open") {
|
|
22
|
+
if (leafOpen !== null && !keepTags) {
|
|
23
|
+
const problem = emitLeaf();
|
|
24
|
+
if (problem !== undefined) return problem;
|
|
25
|
+
}
|
|
26
|
+
leafText = [];
|
|
27
|
+
if (!token.selfClosing) {
|
|
28
|
+
stack.push(token.name);
|
|
29
|
+
if (stack.length > MAX_DEPTH) return `File is not a valid xml: elements nested deeper than ${MAX_DEPTH} levels`;
|
|
30
|
+
leafOpen = token.name;
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
if (token.kind === "close") {
|
|
35
|
+
if (stack.length === 0 || stack[stack.length - 1] !== token.name) return "File is not a valid xml";
|
|
36
|
+
stack.pop();
|
|
37
|
+
const problem = leafOpen === token.name ? emitLeaf() : undefined;
|
|
38
|
+
leafText = [];
|
|
39
|
+
leafOpen = null;
|
|
40
|
+
return problem;
|
|
41
|
+
}
|
|
42
|
+
leafText.push(token.text);
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
const result = tokenize(text, visit);
|
|
46
|
+
if (result.error !== undefined) return { ok: false, message: result.error === "File is not valid html" ? "File is not a valid xml" : result.error };
|
|
47
|
+
if (stack.length > 0) return { ok: false, message: "File is not a valid xml" };
|
|
48
|
+
return { ok: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SCHEMA_MESSAGE = "Json schema does not match the Unstructured schema";
|
|
52
|
+
|
|
53
|
+
/** Pre-partitioned Unstructured JSON: elements are returned as given (ids regenerated when absent). */
|
|
54
|
+
export function partitionJson(builder, text) {
|
|
55
|
+
let value;
|
|
56
|
+
try {
|
|
57
|
+
value = JSON.parse(text);
|
|
58
|
+
} catch {
|
|
59
|
+
return { ok: false, code: "INVALID_REQUEST", message: SCHEMA_MESSAGE };
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(value) || value.length > 100000 || shapeProblem(value) !== null) return { ok: false, code: "INVALID_REQUEST", message: SCHEMA_MESSAGE };
|
|
62
|
+
for (const item of value) {
|
|
63
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) return { ok: false, code: "INVALID_REQUEST", message: SCHEMA_MESSAGE };
|
|
64
|
+
if (typeof item.type !== "string" || !ELEMENT_TYPES.has(item.type) || typeof item.text !== "string" || item.text.length > MAX_TEXT) return { ok: false, code: "INVALID_REQUEST", message: SCHEMA_MESSAGE };
|
|
65
|
+
if (item.element_id !== undefined && (typeof item.element_id !== "string" || item.element_id.length === 0 || item.element_id.length > 128)) return { ok: false, code: "INVALID_REQUEST", message: SCHEMA_MESSAGE };
|
|
66
|
+
if (item.metadata !== undefined && (item.metadata === null || typeof item.metadata !== "object" || Array.isArray(item.metadata) || !metadataFits(item.metadata))) return { ok: false, code: "INVALID_REQUEST", message: SCHEMA_MESSAGE };
|
|
67
|
+
}
|
|
68
|
+
const fixed = 60 + String(builder.filename).length + String(builder.filetype).length + String(builder.lastModified).length + stringsBytes(builder.languages);
|
|
69
|
+
for (const item of value) {
|
|
70
|
+
if (!builder.account(fixed + item.type.length + item.text.length + 32 + (item.metadata === undefined ? 0 : valuesBytes(item.metadata)))) return { ok: true };
|
|
71
|
+
const metadata = { filename: builder.filename, filetype: builder.filetype, languages: builder.languages.slice(), page_number: builder.page, last_modified: builder.lastModified };
|
|
72
|
+
if (item.metadata !== undefined) copyMetadata(item.metadata, metadata);
|
|
73
|
+
builder.elements.push({ type: item.type, element_id: typeof item.element_id === "string" ? item.element_id : builder.id(item.text), text: item.text, metadata });
|
|
74
|
+
builder.ordinal += 1;
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const TYPED = new Map([["filename", "string"], ["filetype", "string"], ["page_number", "integer"], ["languages", "strings"], ["category_depth", "integer"], ["parent_id", "string"], ["last_modified", "string"], ["is_continuation", "boolean"], ["text_as_html", "string"]]);
|
|
80
|
+
const MAX_METADATA_KEYS = 100;
|
|
81
|
+
const MAX_KEY = 100;
|
|
82
|
+
const MAX_STRING = 10000;
|
|
83
|
+
const MAX_ARRAY = 200;
|
|
84
|
+
const scalar = (v) => v === null || typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)) || (typeof v === "string" && v.length <= MAX_STRING);
|
|
85
|
+
|
|
86
|
+
/** True when one caller metadata value fits the output schema for its key (typed known keys, else a scalar or a scalar array). */
|
|
87
|
+
function valueFits(key, value) {
|
|
88
|
+
const typed = TYPED.get(key);
|
|
89
|
+
if (typed === "string") return typeof value === "string" && value.length <= MAX_STRING;
|
|
90
|
+
if (typed === "integer") return Number.isInteger(value) && value >= 0;
|
|
91
|
+
if (typed === "boolean") return typeof value === "boolean";
|
|
92
|
+
if (typed === "strings") return Array.isArray(value) && value.length <= 50 && value.every((v) => typeof v === "string" && v.length <= 100);
|
|
93
|
+
return scalar(value) || (Array.isArray(value) && value.length <= MAX_ARRAY && value.every(scalar));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when every caller metadata entry can be returned as given: at most 100 keys of at most 100 characters, none named
|
|
98
|
+
* `__proto__`/`constructor`/`prototype`, typed known keys with their type, other values scalars (strings ≤ 10,000 characters)
|
|
99
|
+
* or arrays of at most 200 scalars. A metadata object that does not fit fails the whole upload (400 schema mismatch);
|
|
100
|
+
* nothing is dropped from one that does.
|
|
101
|
+
*/
|
|
102
|
+
function metadataFits(source) {
|
|
103
|
+
const keys = Object.keys(source);
|
|
104
|
+
if (keys.length > MAX_METADATA_KEYS) return false;
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype" || key.length > MAX_KEY) return false;
|
|
107
|
+
if (!valueFits(key, source[key])) return false;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Copies caller metadata (already checked by `metadataFits`) onto the element metadata; arrays are copied, nothing is cut. */
|
|
113
|
+
function copyMetadata(source, target) {
|
|
114
|
+
for (const key of Object.keys(source)) {
|
|
115
|
+
const value = source[key];
|
|
116
|
+
target[key] = Array.isArray(value) ? value.slice() : value;
|
|
117
|
+
}
|
|
118
|
+
}
|