@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.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +275 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/conformance.suite.json +21 -0
  6. package/firedrill/overloaded.scenario.json +11 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/run-response-lost.scenario.json +11 -0
  9. package/firedrill/small-responses.scenario.json +21 -0
  10. package/firedrill/tight-limits.scenario.json +21 -0
  11. package/firedrill/tools/unstructured/behavior.mjs +69 -0
  12. package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
  13. package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
  14. package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
  15. package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
  16. package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
  17. package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
  18. package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
  19. package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
  20. package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
  21. package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
  22. package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
  23. package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
  24. package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
  25. package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
  26. package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
  27. package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
  28. package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
  29. package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
  30. package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
  31. package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
  32. package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
  33. package/firedrill/tools/unstructured/lib/store.mjs +33 -0
  34. package/firedrill/tools/unstructured/lib/util.mjs +149 -0
  35. package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
  36. package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
  37. package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
  38. package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
  39. package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
  40. package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
  41. package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
  42. package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
  43. package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
  44. package/firedrill/unstructured-archivist.drill.json +68 -0
  45. package/firedrill/unstructured-chunking.drill.json +67 -0
  46. package/firedrill/unstructured-connectors.drill.json +121 -0
  47. package/firedrill/unstructured-denied.drill.json +58 -0
  48. package/firedrill/unstructured-fresh-actor.drill.json +68 -0
  49. package/firedrill/unstructured-overloaded.drill.json +51 -0
  50. package/firedrill/unstructured-partition-errors.drill.json +66 -0
  51. package/firedrill/unstructured-partition.drill.json +95 -0
  52. package/firedrill/unstructured-rate-limited.drill.json +66 -0
  53. package/firedrill/unstructured-revoked-key.drill.json +773 -0
  54. package/firedrill/unstructured-run-lost.drill.json +51 -0
  55. package/firedrill/unstructured-small-responses.drill.json +173 -0
  56. package/firedrill/unstructured-tight-limits.drill.json +203 -0
  57. package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
  58. package/firedrill/world.json +1556 -0
  59. package/firedrill.json +5 -0
  60. package/package.json +52 -0
  61. package/starter.json +1114 -0
  62. package/test/conformance.mjs +37 -0
  63. package/test/flows/access.mjs +54 -0
  64. package/test/flows/chunking.mjs +95 -0
  65. package/test/flows/connectors.mjs +76 -0
  66. package/test/flows/errors.mjs +115 -0
  67. package/test/flows/faults.mjs +73 -0
  68. package/test/flows/partition.mjs +225 -0
  69. package/test/flows/workflows.mjs +123 -0
  70. package/test/hostile-gen.mjs +0 -0
  71. package/test/hostile.mjs +155 -0
  72. package/test/lib.mjs +113 -0
@@ -0,0 +1,154 @@
1
+ // Pure HTTP codecs. `decode` folds path, query and JSON body into snake_case arguments; a request that cannot be
2
+ // mapped travels as `__request_error` ("400:…" or "422:…") so the handler answers the FastAPI `{ detail }` envelope.
3
+ // `encode` renders success bodies and every failure as `{ "detail": "…" }` or `{ "detail": [ { loc, msg, type } ] }`.
4
+ import { STATUS } from "./errors.mjs";
5
+ import { clip } from "./util.mjs";
6
+
7
+ const MAX_DEPTH = 512;
8
+ const MAX_REPEATS = 50;
9
+ const MAX_MEMBERS = 20000;
10
+ const BANNED = new Set(["__proto__", "constructor", "prototype"]);
11
+ export const RESERVED = "__request_error";
12
+
13
+ /** Iterative shape check (explicit stack): nesting depth, member count and prototype-poisoning keys. */
14
+ export function shapeProblem(value) {
15
+ const stack = [[value, 1]];
16
+ let visited = 0;
17
+ while (stack.length > 0) {
18
+ const [node, depth] = stack.pop();
19
+ if (depth > MAX_DEPTH) return "Request body is nested too deeply";
20
+ if (node === null || typeof node !== "object") continue;
21
+ visited += 1;
22
+ if (visited > MAX_MEMBERS) return "Request body has too many members";
23
+ if (Array.isArray(node)) {
24
+ if (node.length > MAX_MEMBERS) return "Request body has too many array entries";
25
+ for (const child of node) if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
26
+ } else {
27
+ for (const key of Object.keys(node)) {
28
+ if (BANNED.has(key)) return `Invalid key "${clip(key, 60)}"`;
29
+ const child = node[key];
30
+ if (child !== null && typeof child === "object") stack.push([child, depth + 1]);
31
+ }
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ export const Q = {
38
+ str: (value) => value,
39
+ int: (value) => (typeof value === "string" && /^-?[0-9]{1,15}$/.test(value) ? Number(value) : value),
40
+ };
41
+
42
+ export function headerOf(request, name) {
43
+ const values = request.headers[name];
44
+ return Array.isArray(values) && values.length > 0 ? values[values.length - 1] : null;
45
+ }
46
+
47
+ /**
48
+ * Builds a decoder: `path` lists path parameters copied verbatim; `query` maps names to converters; `body` is "json"
49
+ * (object required; known `fields` copied, unknown keys reported as 422 extra inputs) or "none".
50
+ */
51
+ export function decoder({ path = [], query = {}, body = "none", fields = [], idem = true }) {
52
+ const known = new Set(fields);
53
+ return (request) => {
54
+ const args = {};
55
+ for (const name of path) args[name] = typeof request.path[name] === "string" ? request.path[name] : "";
56
+ const refuse = (message) => ({ arguments: { ...args, [RESERVED]: clip(message, 300) } });
57
+ for (const [name, convert] of Object.entries(query)) {
58
+ const values = request.query[name];
59
+ if (!Array.isArray(values) || values.length === 0) continue;
60
+ if (values.length > MAX_REPEATS) return refuse(`422:loc=query.${name}: Parameter is repeated more than ${MAX_REPEATS} times`);
61
+ args[name] = convert(values[values.length - 1]);
62
+ }
63
+ if (body === "json") {
64
+ if (request.body.kind === "json") {
65
+ const value = request.body.value;
66
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return refuse("422:loc=body: Input should be a valid dictionary or object");
67
+ const problem = shapeProblem(value);
68
+ if (problem !== null) return refuse(`422:loc=body: ${problem}`);
69
+ const extra = [];
70
+ for (const key of Object.keys(value)) {
71
+ if (key === RESERVED || Object.hasOwn(args, key)) continue;
72
+ if (known.has(key)) args[key] = value[key];
73
+ else extra.push(key);
74
+ }
75
+ if (extra.length > 0) return refuse(`422:${extra.slice(0, 20).map((key) => `loc=body.${clip(key, 100)}: Extra inputs are not permitted`).join(" | ")}`);
76
+ } else if (request.body.kind === "form" || (request.body.kind === "text" && request.body.value.trim().length > 0)) {
77
+ return refuse("422:loc=body: Input should be a valid dictionary or object");
78
+ } else if (request.body.kind === "none" || request.body.kind === "text") {
79
+ if (request.method === "POST" || request.method === "PUT") {
80
+ if (fields.length > 0) return refuse("422:loc=body: Field required");
81
+ }
82
+ }
83
+ }
84
+ const out = { arguments: args };
85
+ if (idem) {
86
+ const key = headerOf(request, "idempotency-key");
87
+ if (typeof key === "string" && key.length > 0 && key.length <= 255) out.idempotencyKey = key;
88
+ }
89
+ return out;
90
+ };
91
+ }
92
+
93
+ const TYPE_BY_TEXT = [
94
+ ["Field required", "missing"],
95
+ ["Input should be a valid integer", "int_parsing"],
96
+ ["Input should be a valid boolean", "bool_parsing"],
97
+ ["Input should be a valid dictionary", "dict_type"],
98
+ ["Input should be a valid list", "list_type"],
99
+ ["Input should be a valid string", "string_type"],
100
+ ["Extra inputs are not permitted", "extra_forbidden"],
101
+ ["Input should be less than max_characters", "value_error"],
102
+ ["Input should be greater than or equal to", "greater_than_equal"],
103
+ ["Input should be greater than", "greater_than"],
104
+ ["Input should be less than or equal to", "less_than_equal"],
105
+ ["Input should be less than", "less_than"],
106
+ ["String should have at least", "string_too_short"],
107
+ ["String should have at most", "string_too_long"],
108
+ ["String should match pattern", "string_pattern_mismatch"],
109
+ ["List should have at most", "too_long"],
110
+ ["Input should be ", "enum"],
111
+ ];
112
+
113
+ /** "loc=body.files: Field required | loc=query.page: …" -> FastAPI detail array. */
114
+ export function detailArray(message) {
115
+ const out = [];
116
+ for (const piece of message.split(" | ")) {
117
+ const match = /^loc=([^:]*): ?(.*)$/s.exec(piece);
118
+ const loc = match ? match[1].split(".").filter((s) => s.length > 0) : ["body"];
119
+ const msg = clip(match ? match[2] : piece, 300);
120
+ let type = "value_error";
121
+ for (const [prefix, name] of TYPE_BY_TEXT) if (msg.startsWith(prefix)) {
122
+ type = name;
123
+ break;
124
+ }
125
+ out.push({ loc, msg, type });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ export function encodeError({ invocation, outcome }) {
131
+ const error = outcome.error ?? {};
132
+ const refusal = invocation?.arguments?.[RESERVED];
133
+ const json = (value, headers) => (headers ? { headers, body: { kind: "json", value } } : { body: { kind: "json", value } });
134
+ if (outcome.status === "invalid") {
135
+ if (typeof refusal === "string") return json({ detail: detailArray(refusal.replace(/^\d{3}:/, "")) });
136
+ const issues = Array.isArray(error.issues) ? error.issues.slice(0, 20) : [];
137
+ const detail = issues.map((issue) => ({
138
+ loc: ["body", ...(Array.isArray(issue.path) ? issue.path.slice(0, 8).map((p) => clip(String(p), 100)) : [])],
139
+ msg: clip(String(issue.message ?? "Input is invalid"), 300),
140
+ type: "value_error",
141
+ }));
142
+ return json({ detail: detail.length > 0 ? detail : [{ loc: ["body"], msg: "Input is invalid", type: "value_error" }] });
143
+ }
144
+ if (outcome.status === "denied") return json({ detail: "This API key is not granted this operation in the Firedrill world." });
145
+ if (outcome.status === "unsupported") return json({ detail: "Not Found" });
146
+ const code = String(error.code ?? "").replace(/^tool\./, "");
147
+ const message = typeof error.message === "string" && error.message.length > 0 ? error.message : "Internal Server Error";
148
+ if (code === "VALIDATION_ERROR" && message.startsWith("loc=")) return json({ detail: detailArray(message) });
149
+ if (code === "RATE_LIMITED") return json({ detail: message }, { "retry-after": "1" });
150
+ return json({ detail: STATUS.has(code) ? message : "Internal Server Error" });
151
+ }
152
+
153
+ /** Success -> the canonical value verbatim; failure -> the envelope. */
154
+ export const encoder = (result) => (result.outcome.status === "ok" ? { body: { kind: "json", value: result.outcome.value } } : encodeError(result));
@@ -0,0 +1,129 @@
1
+ // Source and destination connectors: list, create, get, update, delete, plus source connection checks.
2
+ import { MASK, connectorView, isSecretKey, normaliseConfig, requiredKeys } from "../lib/connectors.mjs";
3
+ import { Issues, notFound, requestProblem } from "../lib/errors.mjs";
4
+ import { limits, nextSeq, usableId, uuid } from "../lib/ids.mjs";
5
+ import { workspaceOf } from "../lib/identity.mjs";
6
+ import { isRunning, jobPhase, nowIso } from "../lib/jobs-derive.mjs";
7
+ import { scanAll, scanPrefix } from "../lib/store.mjs";
8
+ import { withinBudget } from "../lib/pages.mjs";
9
+ import { mangled, parseEnum, parseString } from "../lib/validate.mjs";
10
+
11
+ const KEY_RE = /^[a-z0-9](-?[a-z0-9])*$/;
12
+
13
+ /** The connector row of `namespace` with this id in the caller's workspace, or NOT_FOUND. */
14
+ export function findConnector(context, namespace, id, workspace, label) {
15
+ if (!usableId(id) || mangled(id)) notFound(context, `${label} not found`);
16
+ const row = context.state.get(namespace, id);
17
+ if (row === null || row.workspace_id !== workspace) notFound(context, `${label} not found`);
18
+ return row;
19
+ }
20
+
21
+ /** Validates `config` for `type`; masked secrets keep the stored value. Adds issues; returns the config or null. */
22
+ function validateConfig(value, type, loc, issues, stored) {
23
+ const result = normaliseConfig(value, loc);
24
+ if (result.issues !== undefined) {
25
+ for (const [where, message] of result.issues) issues.add(where, message);
26
+ return null;
27
+ }
28
+ const config = result.config;
29
+ if (stored !== null) for (const key of Object.keys(config)) if (config[key] === MASK && isSecretKey(key) && Object.hasOwn(stored, key)) config[key] = stored[key];
30
+ for (const key of requiredKeys(type)) if (typeof config[key] !== "string" || config[key].length === 0) issues.add(`${loc}.${key}`, "Field required");
31
+ return config;
32
+ }
33
+
34
+ /** True when a SCHEDULED or IN_PROGRESS job of the workspace runs a workflow that references this connector. */
35
+ function inUseByRunningJob(context, workspace, idField, id) {
36
+ const bound = limits(context).jobs;
37
+ const now = context.clock.nowUs();
38
+ const workflows = new Map();
39
+ for (const job of scanAll(context, "jobs", bound)) {
40
+ if (job.workspace_id !== workspace || !isRunning(jobPhase(job, now))) continue;
41
+ if (!workflows.has(job.workflow_id)) workflows.set(job.workflow_id, context.state.get("workflows", job.workflow_id));
42
+ const workflow = workflows.get(job.workflow_id);
43
+ if (workflow !== null && workflow[idField] === id) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ export function connectorOps({ namespace, idField, types, typeFilter, label }) {
49
+ const find = (context, id, workspace) => findConnector(context, namespace, id, workspace, label);
50
+ const all = (context, workspace) => scanAll(context, namespace, limits(context).connectors).filter((row) => row.workspace_id === workspace);
51
+
52
+ const list = (input, context) => {
53
+ const workspace = workspaceOf(context);
54
+ requestProblem(input, context);
55
+ const issues = new Issues();
56
+ const type = parseEnum(input[typeFilter], `query.${typeFilter}`, issues, types, null);
57
+ issues.raise(context);
58
+ const rows = all(context, workspace)
59
+ .filter((row) => type === null || row.type === type)
60
+ .sort((a, b) => a.seq - b.seq)
61
+ .map(connectorView);
62
+ return withinBudget(context, rows, `The list of ${rows.length} ${label.toLowerCase()}s`, `filter by ${typeFilter} or delete unused ${label.toLowerCase()}s`);
63
+ };
64
+
65
+ const create = (input, context) => {
66
+ const workspace = workspaceOf(context);
67
+ requestProblem(input, context);
68
+ const issues = new Issues();
69
+ const name = parseString(input.name, "body.name", issues, { required: true, min: 1, max: 200 });
70
+ const type = input.type === undefined || input.type === null ? (issues.add("body.type", "Field required"), null) : parseEnum(input.type, "body.type", issues, types, null);
71
+ const key = parseString(input.key, "body.key", issues, { max: 63 });
72
+ if (key !== null && !KEY_RE.test(key)) issues.add("body.key", "String should match pattern '^[a-z0-9](-?[a-z0-9])*$'");
73
+ if (input.config === undefined || input.config === null) issues.add("body.config", "Field required");
74
+ const config = type !== null && input.config !== undefined && input.config !== null ? validateConfig(input.config, type, "body.config", issues, null) : null;
75
+ issues.raise(context);
76
+ if (key !== null && all(context, workspace).some((row) => row.key === key)) issues.add("body.key", "Connector key already exists in this workspace").raise(context);
77
+ const row = { id: uuid(context), workspace_id: workspace, seq: nextSeq(context), name, type, config, key, created_at: nowIso(context), updated_at: null };
78
+ context.state.put(namespace, row.id, row);
79
+ return connectorView(row);
80
+ };
81
+
82
+ const get = (input, context) => connectorView(find(context, input[idField], workspaceOf(context)));
83
+
84
+ const update = (input, context) => {
85
+ const workspace = workspaceOf(context);
86
+ requestProblem(input, context);
87
+ const row = find(context, input[idField], workspace);
88
+ const issues = new Issues();
89
+ const name = parseString(input.name, "body.name", issues, { min: 1, max: 200 });
90
+ if (input.type !== undefined && input.type !== null) {
91
+ const type = parseEnum(input.type, "body.type", issues, types, null);
92
+ if (type !== null && type !== row.type) issues.add("body.type", "Connector type cannot be changed");
93
+ }
94
+ const config = input.config !== undefined && input.config !== null ? validateConfig(input.config, row.type, "body.config", issues, row.config) : null;
95
+ issues.raise(context);
96
+ const next = { ...row, name: name ?? row.name, config: config ?? row.config, updated_at: nowIso(context) };
97
+ context.state.put(namespace, row.id, next);
98
+ return connectorView(next);
99
+ };
100
+
101
+ const remove = (input, context) => {
102
+ const workspace = workspaceOf(context);
103
+ const row = find(context, input[idField], workspace);
104
+ if (inUseByRunningJob(context, workspace, idField, row.id)) new Issues().add("body", `${label} is in use by a running job`).raise(context);
105
+ if (namespace === "sources") {
106
+ for (const file of scanPrefix(context, "source-files", `${row.id}:`, limits(context).files_per_source)) context.state.delete("source-files", `${row.id}:${file.file_id}`);
107
+ context.state.delete("connection-checks", row.id);
108
+ }
109
+ context.state.delete(namespace, row.id);
110
+ return {};
111
+ };
112
+
113
+ const checkConnection = (input, context) => {
114
+ const row = find(context, input[idField], workspaceOf(context));
115
+ const missing = requiredKeys(row.type).find((key) => typeof row.config[key] !== "string" || row.config[key].length === 0);
116
+ const check = { source_id: row.id, status: missing === undefined ? "SUCCESS" : "FAILURE", reason: missing === undefined ? null : `Missing required configuration key: ${missing}`, created_at: nowIso(context) };
117
+ context.state.put("connection-checks", row.id, check);
118
+ return { status: check.status, reason: check.reason, created_at: check.created_at };
119
+ };
120
+
121
+ const getConnectionCheck = (input, context) => {
122
+ const row = find(context, input[idField], workspaceOf(context));
123
+ const check = context.state.get("connection-checks", row.id);
124
+ if (check === null) notFound(context, "No connection check found");
125
+ return { status: check.status, reason: check.reason, created_at: check.created_at };
126
+ };
127
+
128
+ return { list, create, get, update, remove, checkConnection, getConnectionCheck };
129
+ }
@@ -0,0 +1,83 @@
1
+ // Jobs: list, get, cancel, details, failed files and output download (recomputed from the stored node snapshot).
2
+ import { Issues, fail, notFound, requestProblem } from "../lib/errors.mjs";
3
+ import { limits, usableId } from "../lib/ids.mjs";
4
+ import { workspaceOf } from "../lib/identity.mjs";
5
+ import { isRunning, jobPhase, jobStatus, jobView, nodeStats, nowIso, outputNodeOf } from "../lib/jobs-derive.mjs";
6
+ import { pageOf, responseBudget, withinBudget } from "../lib/pages.mjs";
7
+ import { partitionFile } from "../lib/partition/index.mjs";
8
+ import { scanAll } from "../lib/store.mjs";
9
+ import { mangled, parseEnum, parseInteger } from "../lib/validate.mjs";
10
+ import { nodeOptions } from "./nodes.mjs";
11
+
12
+ const JOB_STATUSES = ["SCHEDULED", "IN_PROGRESS", "COMPLETED", "STOPPED", "FAILED"];
13
+
14
+ export function findJob(context, id, workspace) {
15
+ if (!usableId(id) || mangled(id)) notFound(context, "Job not found");
16
+ const row = context.state.get("jobs", id);
17
+ if (row === null || row.workspace_id !== workspace) notFound(context, "Job not found");
18
+ return row;
19
+ }
20
+
21
+ export const list = (input, context) => {
22
+ const workspace = workspaceOf(context);
23
+ requestProblem(input, context);
24
+ const issues = new Issues();
25
+ const status = parseEnum(input.status, "query.status", issues, JOB_STATUSES, null);
26
+ const page = parseInteger(input.page, "query.page", issues, 1, { min: 1, max: 1000000 });
27
+ const pageSize = parseInteger(input.page_size, "query.page_size", issues, 20, { min: 1, max: 100 });
28
+ if (input.workflow_id !== undefined && (typeof input.workflow_id !== "string" || mangled(input.workflow_id))) issues.add("query.workflow_id", "Input should be a valid string");
29
+ issues.raise(context);
30
+ const now = context.clock.nowUs();
31
+ const rows = scanAll(context, "jobs", limits(context).jobs)
32
+ .filter((job) => job.workspace_id === workspace && (input.workflow_id === undefined || job.workflow_id === input.workflow_id))
33
+ .map((job) => ({ job, view: jobView(job, now) }))
34
+ .filter((entry) => status === null || entry.view.status === status)
35
+ .sort((a, b) => (a.job.created_at < b.job.created_at ? 1 : a.job.created_at > b.job.created_at ? -1 : b.job.seq - a.job.seq));
36
+ return pageOf(context, rows.map((entry) => entry.view), (page - 1) * pageSize, pageSize);
37
+ };
38
+
39
+ export const get = (input, context) => jobView(findJob(context, input.job_id, workspaceOf(context)), context.clock.nowUs());
40
+
41
+ export const cancel = (input, context) => {
42
+ const job = findJob(context, input.job_id, workspaceOf(context));
43
+ const phase = jobPhase(job, context.clock.nowUs());
44
+ if (!isRunning(phase)) new Issues().add("body", phase === "STOPPED" ? "Job is already stopped" : "Job is already finished").raise(context);
45
+ context.state.put("jobs", job.id, { ...job, cancelled_at: nowIso(context), stop_requested: true });
46
+ context.events.emit("job.cancelled", { job_id: job.id, workflow_id: job.workflow_id });
47
+ return { id: job.id, status: "STOPPED", message: "Job cancellation requested" };
48
+ };
49
+
50
+ export const details = (input, context) => {
51
+ const job = findJob(context, input.job_id, workspaceOf(context));
52
+ const derived = jobStatus(job, context.clock.nowUs());
53
+ const failed = job.files.filter((file) => file.error !== null).length;
54
+ const message = derived.phase === "FINISHED" ? `${job.files.length - failed} of ${job.files.length} files processed successfully` : derived.reason ?? `Job is ${derived.status.toLowerCase().replace("_", " ")}`;
55
+ return { id: job.id, processing_status: derived.processing_status, node_stats: nodeStats(job, derived.phase), message };
56
+ };
57
+
58
+ export const failedFiles = (input, context) => {
59
+ const job = findJob(context, input.job_id, workspaceOf(context));
60
+ return { failed_files: job.files.filter((file) => file.error !== null).map((file) => ({ document: file.path, error: file.error })) };
61
+ };
62
+
63
+ export const downloadOutput = (input, context) => {
64
+ const workspace = workspaceOf(context);
65
+ requestProblem(input, context);
66
+ const job = findJob(context, input.job_id, workspace);
67
+ const issues = new Issues();
68
+ if (typeof input.file_id !== "string" || input.file_id.length === 0) issues.add("query.file_id", "Field required");
69
+ if (typeof input.node_id !== "string" || input.node_id.length === 0) issues.add("query.node_id", "Field required");
70
+ issues.raise(context);
71
+ const phase = jobPhase(job, context.clock.nowUs());
72
+ if (phase !== "FINISHED") issues.add("body", "Job output is not available yet").raise(context);
73
+ const node = outputNodeOf(job);
74
+ const file = job.files.find((entry) => entry.file_id === input.file_id && entry.error === null);
75
+ if (node === null || node.id !== input.node_id || file === undefined) notFound(context, "Job output file not found");
76
+ const content = file.source_row !== null ? context.state.get("source-files", file.source_row) : context.state.get("job-files", `${job.id}:${file.file_id}`);
77
+ if (content === null) notFound(context, "Job output file not found");
78
+ const options = { ...nodeOptions(job.nodes, content.last_modified ?? job.created_at), responseBudget: responseBudget(context) };
79
+ const result = partitionFile({ filename: content.filename, content: content.content, content_type: content.content_type, last_modified: content.last_modified }, options, context);
80
+ if (!result.ok && result.code === "RESPONSE_TOO_LARGE") fail(context, "RESPONSE_TOO_LARGE", result.message.replace("Partition output", "Job output"));
81
+ if (!result.ok) notFound(context, "Job output file not found");
82
+ return withinBudget(context, result.elements, "Job output");
83
+ };
@@ -0,0 +1,107 @@
1
+ // Workflow node validation and the mapping from a node snapshot to partitioner options.
2
+ import { normaliseConfig } from "../lib/connectors.mjs";
3
+ import { uuid } from "../lib/ids.mjs";
4
+ import { parseBool, parseInteger, parseLanguages, parseString } from "../lib/validate.mjs";
5
+ import { Issues } from "../lib/errors.mjs";
6
+ import { MAX_TEXT } from "../lib/partition/elements.mjs";
7
+
8
+ export const NODE_TYPES = ["partition", "chunk", "embed", "prompter"];
9
+ const PARTITION_SUBTYPES = ["auto", "fast", "hi_res", "vlm"];
10
+ const CHUNK_SUBTYPES = ["chunk_by_title", "chunk_by_character", "chunk_by_page", "chunk_by_similarity"];
11
+ const CHUNK_STRATEGY = new Map([["chunk_by_title", "by_title"], ["chunk_by_character", "basic"], ["chunk_by_page", "by_page"]]);
12
+
13
+ /** Default nodes of an `auto` workflow. */
14
+ export const defaultNodes = (context) => [
15
+ { id: uuid(context), name: "Partitioner", type: "partition", subtype: "auto", settings: {} },
16
+ { id: uuid(context), name: "Chunker", type: "chunk", subtype: "chunk_by_title", settings: { max_characters: 1500, new_after_n_chars: 1500, overlap: 0 } },
17
+ ];
18
+
19
+ /** Validates `workflow_nodes` of a custom workflow; returns the normalised node array or null (issues added). */
20
+ export function validateNodes(value, loc, issues, context) {
21
+ if (!Array.isArray(value) || value.length === 0) {
22
+ issues.add(loc, value === undefined || value === null ? "Field required" : "Input should be a valid list");
23
+ return null;
24
+ }
25
+ if (value.length > 16) {
26
+ issues.add(loc, "List should have at most 16 items");
27
+ return null;
28
+ }
29
+ const nodes = [];
30
+ let partitions = 0;
31
+ let chunks = 0;
32
+ for (const [index, node] of value.entries()) {
33
+ const at = `${loc}.${index}`;
34
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
35
+ issues.add(at, "Input should be a valid dictionary or object");
36
+ continue;
37
+ }
38
+ const name = parseString(node.name, `${at}.name`, issues, { required: true, min: 1, max: 200 });
39
+ const type = typeof node.type === "string" && NODE_TYPES.includes(node.type) ? node.type : (issues.add(`${at}.type`, "Input should be 'partition', 'chunk', 'embed' or 'prompter'"), null);
40
+ const subtype = parseString(node.subtype, `${at}.subtype`, issues, { required: true, min: 1, max: 100 });
41
+ const id = node.id === undefined || node.id === null ? uuid(context) : parseString(node.id, `${at}.id`, issues, { min: 1, max: 64 });
42
+ let settings = {};
43
+ if (node.settings !== undefined && node.settings !== null) {
44
+ const result = normaliseConfig(node.settings, `${at}.settings`);
45
+ if (result.issues !== undefined) for (const [where, message] of result.issues) issues.add(where, message);
46
+ else settings = result.config;
47
+ }
48
+ if (type === "partition") {
49
+ partitions += 1;
50
+ if (index !== 0) issues.add(`${at}.type`, "The partition node must be the first node");
51
+ if (subtype !== null && !PARTITION_SUBTYPES.includes(subtype)) issues.add(`${at}.subtype`, "Input should be 'auto', 'fast', 'hi_res' or 'vlm'");
52
+ parseLanguages(settings.languages, `${at}.settings.languages`, issues);
53
+ } else if (type === "chunk") {
54
+ chunks += 1;
55
+ if (subtype !== null && !CHUNK_SUBTYPES.includes(subtype)) issues.add(`${at}.subtype`, "Input should be 'chunk_by_title', 'chunk_by_character', 'chunk_by_page' or 'chunk_by_similarity'");
56
+ if (subtype === "chunk_by_similarity") issues.add(`${at}.subtype`, "Chunking strategy by_similarity is not supported by this Tool");
57
+ const check = new Issues();
58
+ const max = parseInteger(settings.max_characters, `${at}.settings.max_characters`, check, 500, { min: 1, max: MAX_TEXT });
59
+ parseInteger(settings.new_after_n_chars, `${at}.settings.new_after_n_chars`, check, max, { min: 0, max: MAX_TEXT });
60
+ const overlap = parseInteger(settings.overlap, `${at}.settings.overlap`, check, 0, { min: 0, max: MAX_TEXT });
61
+ parseInteger(settings.combine_under_n_chars, `${at}.settings.combine_under_n_chars`, check, max, { min: 0, max: MAX_TEXT });
62
+ for (const flag of ["overlap_all", "multipage_sections", "include_orig_elements"]) parseBool(settings[flag], `${at}.settings.${flag}`, check, false);
63
+ if (check.empty && overlap >= max) check.add(`${at}.settings.overlap`, `Input should be less than max_characters (${max})`);
64
+ for (const item of check.items) issues.add(item.slice(4, item.indexOf(": ")), item.slice(item.indexOf(": ") + 2));
65
+ }
66
+ if (name !== null && type !== null && subtype !== null && id !== null) nodes.push({ id, name, type, subtype, settings });
67
+ }
68
+ if (partitions !== 1) issues.add(loc, "A custom workflow needs exactly one partition node");
69
+ if (chunks > 1) issues.add(loc, "A custom workflow may have at most one chunk node");
70
+ return issues.empty ? nodes : null;
71
+ }
72
+
73
+ /** Partitioner options for a node snapshot (validated on create/update, so parsing cannot fail here). */
74
+ export function nodeOptions(nodes, nowIso) {
75
+ const quiet = new Issues();
76
+ const chunkNode = nodes.find((node) => node.type === "chunk") ?? null;
77
+ let chunk = null;
78
+ if (chunkNode !== null && CHUNK_STRATEGY.has(chunkNode.subtype)) {
79
+ const s = chunkNode.settings;
80
+ // Clamped to MAX_TEXT so a chunk never exceeds the declared element text bound, whatever a stored node says.
81
+ const maxCharacters = Math.min(parseInteger(s.max_characters, "x", quiet, 500, { min: 1 }), MAX_TEXT);
82
+ const newAfter = parseInteger(s.new_after_n_chars, "x", quiet, maxCharacters, { min: 0 });
83
+ chunk = {
84
+ strategy: CHUNK_STRATEGY.get(chunkNode.subtype),
85
+ maxCharacters,
86
+ newAfterNChars: Math.min(newAfter, maxCharacters),
87
+ overlap: Math.min(parseInteger(s.overlap, "x", quiet, 0, { min: 0 }), maxCharacters - 1),
88
+ overlapAll: parseBool(s.overlap_all, "x", quiet, false),
89
+ combineUnderNChars: chunkNode.subtype === "chunk_by_title" ? parseInteger(s.combine_under_n_chars, "x", quiet, maxCharacters, { min: 0 }) : 0,
90
+ multipageSections: parseBool(s.multipage_sections, "x", quiet, true),
91
+ includeOrigElements: parseBool(s.include_orig_elements, "x", quiet, true),
92
+ };
93
+ }
94
+ const partitionNode = nodes.find((node) => node.type === "partition") ?? null;
95
+ const settings = partitionNode === null ? {} : partitionNode.settings;
96
+ return {
97
+ contentType: null,
98
+ // Validated on create/update (at most 20 entries of at most 20 characters), so the list is used whole.
99
+ languages: typeof settings.languages === "string" && settings.languages.length > 0 ? settings.languages.split(",").map((l) => l.trim()).filter((l) => l.length > 0) : ["eng"],
100
+ includePageBreaks: parseBool(settings.include_page_breaks, "x", quiet, false),
101
+ startingPage: 1,
102
+ xmlKeepTags: parseBool(settings.xml_keep_tags, "x", quiet, false),
103
+ uniqueIds: false,
104
+ nowIso,
105
+ chunk,
106
+ };
107
+ }
@@ -0,0 +1,112 @@
1
+ // `general.partition`: the legacy Partition Endpoint over the deterministic text partitioner.
2
+ import { Issues, fail, requestProblem } from "../lib/errors.mjs";
3
+ import { nextSeq } from "../lib/ids.mjs";
4
+ import { workspaceOf } from "../lib/identity.mjs";
5
+ import { assertWithinBudget, jsonBytes, responseBudget } from "../lib/pages.mjs";
6
+ import { elementsToCsv } from "../lib/partition/csvout.mjs";
7
+ import { MAX_TEXT } from "../lib/partition/elements.mjs";
8
+ import { partitionFile } from "../lib/partition/index.mjs";
9
+ import { clip, isoFromUs, utf8Length } from "../lib/util.mjs";
10
+ import { parseBool, parseEnum, parseInteger, parseLanguages, parseString } from "../lib/validate.mjs";
11
+
12
+ const STRATEGIES = ["fast", "hi_res", "auto", "ocr_only", "vlm"];
13
+ const CHUNKING = ["basic", "by_title", "by_page", "by_similarity"];
14
+ const OUTPUTS = ["application/json", "text/csv"];
15
+ const ENCODINGS = new Set(["utf-8", "utf8", "utf_8", "ascii", "us-ascii"]);
16
+ const MAX_FILES = 32;
17
+ const MAX_CONTENT = 1048576;
18
+
19
+
20
+ /** Validates every partition/chunk parameter. Returns { options, strategy, chunkingStrategy, outputFormat }. */
21
+ export function partitionOptions(input, context) {
22
+ const invalid = (message) => fail(context, "INVALID_REQUEST", message);
23
+ const strategyRaw = input.strategy === undefined || input.strategy === null || input.strategy === "" ? "auto" : input.strategy;
24
+ const strategy = typeof strategyRaw === "string" ? strategyRaw.toLowerCase() : "";
25
+ if (!STRATEGIES.includes(strategy)) invalid(`Invalid strategy: ${clip(strategyRaw)}. Must be one of ['fast', 'hi_res', 'auto', 'ocr_only', 'vlm']`);
26
+ if (typeof input.hi_res_model_name === "string" && input.hi_res_model_name.length > 0) invalid(`Unknown model type: ${clip(input.hi_res_model_name)}`);
27
+ const outputFormat = input.output_format === undefined || input.output_format === null || input.output_format === "" ? "application/json" : input.output_format;
28
+ if (!OUTPUTS.includes(outputFormat)) invalid(`Invalid output format: ${clip(outputFormat)}. Must be one of ['application/json', 'text/csv']`);
29
+ let chunkingStrategy = null;
30
+ if (input.chunking_strategy !== undefined && input.chunking_strategy !== null && input.chunking_strategy !== "") {
31
+ if (!CHUNKING.includes(input.chunking_strategy)) invalid(`Invalid chunking strategy: ${clip(input.chunking_strategy)}. Must be one of ['basic', 'by_title', 'by_page', 'by_similarity']`);
32
+ if (input.chunking_strategy === "by_similarity") invalid("Chunking strategy by_similarity is not supported by this Tool");
33
+ chunkingStrategy = input.chunking_strategy;
34
+ }
35
+ if (input.encoding !== undefined && input.encoding !== null && input.encoding !== "") {
36
+ if (typeof input.encoding !== "string" || !ENCODINGS.has(input.encoding.toLowerCase())) invalid(`Unsupported encoding: ${clip(input.encoding)}`);
37
+ }
38
+ const issues = new Issues();
39
+ const contentType = parseString(input.content_type, "body.content_type", issues, { max: 200 });
40
+ const languages = parseLanguages(input.languages, "body.languages", issues) ?? [];
41
+ const includePageBreaks = parseBool(input.include_page_breaks, "body.include_page_breaks", issues, false);
42
+ const startingPage = parseInteger(input.starting_page_number, "body.starting_page_number", issues, 1, { min: 0, max: 1000000 });
43
+ const uniqueIds = parseBool(input.unique_element_ids, "body.unique_element_ids", issues, false);
44
+ const xmlKeepTags = parseBool(input.xml_keep_tags, "body.xml_keep_tags", issues, false);
45
+ parseBool(input.coordinates, "body.coordinates", issues, false);
46
+ const maxCharacters = parseInteger(input.max_characters, "body.max_characters", issues, 500, { min: 1, max: MAX_TEXT });
47
+ let newAfterNChars = parseInteger(input.new_after_n_chars, "body.new_after_n_chars", issues, 1500, { min: 0, max: MAX_TEXT });
48
+ const overlap = parseInteger(input.overlap, "body.overlap", issues, 0, { min: 0, max: MAX_TEXT });
49
+ const overlapAll = parseBool(input.overlap_all, "body.overlap_all", issues, false);
50
+ const combineUnderNChars = parseInteger(input.combine_under_n_chars, "body.combine_under_n_chars", issues, maxCharacters, { min: 0, max: MAX_TEXT });
51
+ const multipageSections = parseBool(input.multipage_sections, "body.multipage_sections", issues, true);
52
+ const includeOrigElements = parseBool(input.include_orig_elements, "body.include_orig_elements", issues, true);
53
+ for (const name of ["split_pdf_page", "split_pdf_allow_failed", "include_slide_notes", "pdf_infer_table_structure"]) parseBool(input[name], `body.${name}`, issues, false);
54
+ parseInteger(input.split_pdf_concurrency_level, "body.split_pdf_concurrency_level", issues, 5, { min: 1, max: 50 });
55
+ parseInteger(input.split_pdf_page_range, "body.split_pdf_page_range", issues, 0, { min: 0 });
56
+ if (strategy === "vlm") {
57
+ if (typeof input.vlm_model !== "string" || input.vlm_model.length === 0) issues.add("body.vlm_model", "Field required");
58
+ if (typeof input.vlm_model_provider !== "string" || input.vlm_model_provider.length === 0) issues.add("body.vlm_model_provider", "Field required");
59
+ }
60
+ if (chunkingStrategy !== null && overlap >= maxCharacters) issues.add("body.overlap", `Input should be less than max_characters (${maxCharacters})`);
61
+ if (newAfterNChars > maxCharacters) newAfterNChars = maxCharacters;
62
+ issues.raise(context);
63
+ const chunk = chunkingStrategy === null ? null : { strategy: chunkingStrategy, maxCharacters, newAfterNChars, overlap, overlapAll, combineUnderNChars: chunkingStrategy === "by_title" ? combineUnderNChars : 0, multipageSections, includeOrigElements };
64
+ const options = { contentType, languages: languages.length > 0 ? languages : ["eng"], includePageBreaks, startingPage, xmlKeepTags, uniqueIds, nowIso: isoFromUs(context.clock.nowUs()), chunk, responseBudget: responseBudget(context), chunkSizing: { chars: 0, count: 0 }, elementSizing: { bytes: 0 } };
65
+ return { options, strategy, chunkingStrategy, outputFormat };
66
+ }
67
+
68
+ /** Validates the `files` array shape (a 422 in the FastAPI envelope). */
69
+ export function checkFiles(files, context, loc = "body.files") {
70
+ const issues = new Issues();
71
+ if (!Array.isArray(files) || files.length === 0) issues.add(loc, "Field required");
72
+ else if (files.length > MAX_FILES) issues.add(loc, `At most ${MAX_FILES} files per request`);
73
+ else
74
+ for (const [index, file] of files.entries()) {
75
+ if (file === null || typeof file !== "object" || typeof file.content !== "string" || typeof file.filename !== "string") issues.add(`${loc}.${index}`, "Input should be a valid file upload");
76
+ else if (file.content.length > MAX_CONTENT) issues.add(`${loc}.${index}`, `File content exceeds ${MAX_CONTENT} characters`);
77
+ else if (file.filename.length > 500) issues.add(`${loc}.${index}.filename`, "String should have at most 500 characters");
78
+ }
79
+ issues.raise(context);
80
+ }
81
+
82
+ export function partition(input, context) {
83
+ workspaceOf(context);
84
+ requestProblem(input, context);
85
+ const { options, strategy, chunkingStrategy, outputFormat } = partitionOptions(input, context);
86
+ checkFiles(input.files, context);
87
+ const elements = [];
88
+ const filenames = [];
89
+ const filetypes = [];
90
+ let inputBytes = 0;
91
+ let pageCount = 0;
92
+ for (const file of input.files) {
93
+ const result = partitionFile(file, options, context);
94
+ if (!result.ok) fail(context, result.code, result.message);
95
+ inputBytes += utf8Length(file.content);
96
+ filenames.push(clip(file.filename, 200));
97
+ filetypes.push(result.filetype);
98
+ const pages = new Set();
99
+ for (const element of result.elements) pages.add(element.metadata.page_number);
100
+ pageCount += pages.size;
101
+ for (const element of result.elements) elements.push(element);
102
+ }
103
+ const body = outputFormat === "text/csv" ? elementsToCsv(elements) : null;
104
+ const bytes = body === null ? jsonBytes(elements) : utf8Length(body);
105
+ assertWithinBudget(context, bytes, "Partition output", "upload fewer or smaller files");
106
+ const seq = nextSeq(context);
107
+ const log = { seq, actor_id: clip(context.actor.id, 200), filenames, filetypes, strategy, chunking_strategy: chunkingStrategy, output_format: outputFormat, element_count: elements.length, page_count: pageCount, input_bytes: inputBytes, created_at: options.nowIso };
108
+ const logId = `p_${String(seq).padStart(10, "0")}`;
109
+ context.state.put("partition-log", logId, log);
110
+ context.events.emit("partition.completed", { log_id: logId, filenames, filetypes, strategy, chunking_strategy: chunkingStrategy, element_count: elements.length, page_count: pageCount, input_bytes: inputBytes });
111
+ return body === null ? elements : { csv: body };
112
+ }