flashrev-ai-enrich 1.0.0

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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * customer_api is a special capability that does NOT call the FlashRev backend.
3
+ * The CLI fetches the user-provided URL locally and returns the parsed response.
4
+ *
5
+ * Input fields (provided via --input or mapped from CSV columns via inputMapping):
6
+ * url string required, target URL
7
+ * method string optional, default GET
8
+ * headers object optional, HTTP headers
9
+ * body any optional, request body (object → JSON.stringify, string passed as-is)
10
+ * params object optional, query string params
11
+ * timeout number optional, milliseconds, default 30000
12
+ *
13
+ * Returns: parsed response JSON (or { text } wrapper for non-JSON text).
14
+ */
15
+ export async function callCustomerApi(input = {}) {
16
+ const url = input.url || input.endpoint;
17
+ if (!url) {
18
+ const error = new Error("customer_api requires `url` in --input");
19
+ error.status = 412;
20
+ throw error;
21
+ }
22
+
23
+ const method = String(input.method || "GET").toUpperCase();
24
+ const headers = { ...(input.headers || {}) };
25
+ const params = input.params;
26
+ const body = input.body;
27
+ const timeoutMs = Number(input.timeout) || 30_000;
28
+
29
+ // append query string
30
+ const finalUrl = params && typeof params === "object" && Object.keys(params).length
31
+ ? appendQuery(url, params)
32
+ : url;
33
+
34
+ // serialize body
35
+ let serializedBody;
36
+ if (body !== undefined && body !== null && !isBodylessMethod(method)) {
37
+ if (typeof body === "string") {
38
+ serializedBody = body;
39
+ } else {
40
+ serializedBody = JSON.stringify(body);
41
+ if (!hasContentType(headers)) headers["Content-Type"] = "application/json";
42
+ }
43
+ }
44
+
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
47
+
48
+ try {
49
+ const response = await fetch(finalUrl, {
50
+ method,
51
+ headers,
52
+ body: serializedBody,
53
+ signal: controller.signal
54
+ });
55
+ const text = await response.text();
56
+ const data = parseMaybeJson(text);
57
+ if (!response.ok) {
58
+ const error = new Error(`customer_api HTTP ${response.status}`);
59
+ error.status = response.status;
60
+ error.response = data;
61
+ throw error;
62
+ }
63
+ return data;
64
+ } catch (e) {
65
+ if (e.name === "AbortError") {
66
+ const error = new Error(`customer_api timeout after ${timeoutMs}ms`);
67
+ error.status = 504;
68
+ throw error;
69
+ }
70
+ throw e;
71
+ } finally {
72
+ clearTimeout(timer);
73
+ }
74
+ }
75
+
76
+ function appendQuery(url, params) {
77
+ const search = new URLSearchParams();
78
+ for (const [key, value] of Object.entries(params)) {
79
+ if (value === undefined || value === null) continue;
80
+ search.set(key, String(value));
81
+ }
82
+ const sep = url.includes("?") ? "&" : "?";
83
+ return `${url}${sep}${search.toString()}`;
84
+ }
85
+
86
+ function isBodylessMethod(method) {
87
+ return method === "GET" || method === "HEAD";
88
+ }
89
+
90
+ function hasContentType(headers) {
91
+ return Object.keys(headers).some((key) => key.toLowerCase() === "content-type");
92
+ }
93
+
94
+ function parseMaybeJson(text) {
95
+ if (!text) return {};
96
+ try {
97
+ return JSON.parse(text);
98
+ } catch {
99
+ return { text };
100
+ }
101
+ }
@@ -0,0 +1,64 @@
1
+ import { billingForCapability, unitPriceFor } from "./billing.js";
2
+
3
+ /**
4
+ * Estimation shown by dry-run.
5
+ *
6
+ * unitPriceToken prefers the real price synced from /configs onto the capability;
7
+ * falls back to billing.js when remote fetch failed.
8
+ *
9
+ * unlock_contact capabilities may charge less due to backend dedup (same person_id
10
+ * unlocked again is free). The estimate does not predict this — it returns the
11
+ * theoretical maximum cost.
12
+ */
13
+ export function estimateJob(config, job, rows) {
14
+ const rowCount = rows.length;
15
+ const billing = billingForCapability(job.capability);
16
+ const unitCost = unitPriceFor(job.capability);
17
+ const effectiveConcurrency = effectiveConcurrencyOf(config, job);
18
+ const avgLatency = avgLatencyOf(job.capability);
19
+ const estimatedSeconds = Math.ceil((rowCount / Math.max(effectiveConcurrency, 1)) * avgLatency);
20
+
21
+ return {
22
+ capability: job.capability.id,
23
+ funcName: job.capability.funcName || job.capability.id,
24
+ displayName: job.capability.displayName,
25
+ rowCount,
26
+ unitCost,
27
+ unit: billing.unit,
28
+ billingAction: billing.action,
29
+ estimatedApiCalls: rowCount,
30
+ estimatedTokens: rowCount * unitCost,
31
+ effectiveConcurrency,
32
+ estimatedSeconds,
33
+ estimateMethod: billing.source === "remote" ? "remote_unit_price" : "local_fallback",
34
+ note: noteFor(job.capability)
35
+ };
36
+ }
37
+
38
+ function effectiveConcurrencyOf(config, job) {
39
+ const cliMax = Number(config?.defaults?.concurrency || 3);
40
+ const capMax = Number(job?.capability?.concurrency || cliMax);
41
+ return Math.max(1, Math.min(cliMax, capMax));
42
+ }
43
+
44
+ /**
45
+ * Approximate per-row latency in seconds. Used to display estimatedSeconds in dry-run.
46
+ * Bucketed by featId.
47
+ */
48
+ function avgLatencyOf(capability) {
49
+ const featId = capability?.featId || "";
50
+ if (featId.startsWith("run_llm")) return 30; // LLM ~30s
51
+ if (featId === "enrich_flashagent") return 8; // scrape/search ~8s
52
+ if (featId === "verify_phone_number") return 2;
53
+ if (featId === "verify_email_address") return 2;
54
+ if (featId === "unlock_contact") return 3;
55
+ if (capability?.concurrency === 1) return 10; // LinkedIn realtime scrape (serial)
56
+ return 2;
57
+ }
58
+
59
+ function noteFor(capability) {
60
+ if (capability?.featId === "unlock_contact") {
61
+ return "Rows already unlocked may be served from cache (cost=0). Actual cost ≤ estimate.";
62
+ }
63
+ return undefined;
64
+ }
@@ -0,0 +1,338 @@
1
+ import { callCustomerApi } from "./customer-api.js";
2
+ import { firstDefined, getPath, redactSecrets } from "./utils.js";
3
+
4
+ /**
5
+ * FlashRev open-api client.
6
+ *
7
+ * Wire path:
8
+ * CLI → X-API-Key header → FlashRev API gateway (open-ai-api.flashlabs.ai)
9
+ *
10
+ * Four endpoints:
11
+ * /flashrev/api/v2/oauth/me token balance
12
+ * /flashrev/api/v2/commodity/token/transaction/list consumption history
13
+ * /flashrev/api/v1/enrich/configs capability registry (incl. unitPriceToken)
14
+ * /flashrev/api/v1/enrich/run per-row enrichment (sync)
15
+ */
16
+ export class FlashRevEnrichClient {
17
+ constructor(config) {
18
+ this.config = config;
19
+ }
20
+
21
+ /** Query token balance. */
22
+ async tokens() {
23
+ return normalizeTokens(await this.requestEndpoint("tokens", {}));
24
+ }
25
+
26
+ /**
27
+ * Query token consumption history.
28
+ *
29
+ * The backend MVP does not support startTime/endTime — the CLI filters locally by date.
30
+ * When --from/--to are given we may need to paginate to cover the desired window.
31
+ * Paging stops on any of:
32
+ * - reached the requested limit
33
+ * - current page already entirely older than --from (no point paging further)
34
+ * - hit MAX_PAGES (safety bound)
35
+ * - backend returned an empty page
36
+ */
37
+ async tokenHistory({ from, to, limit, featIds } = {}) {
38
+ const MAX_PAGES = 20; // at most 20 × 100 = 2000 records
39
+ const pageSize = 100;
40
+ const fromTs = from ? Date.parse(`${from}T00:00:00`) : null;
41
+ const limitNum = limit ? Number(limit) : Infinity;
42
+
43
+ const collected = [];
44
+ for (let page = 1; page <= MAX_PAGES; page++) {
45
+ const payload = { page, pageSize, transactionType: 2 };
46
+ if (Array.isArray(featIds) && featIds.length) payload.featIds = featIds;
47
+ const raw = await this.requestEndpoint("tokenHistory", payload);
48
+ const batch = normalizeTokenHistory(raw);
49
+ if (!batch.length) break;
50
+
51
+ const filtered = filterByDateRange(batch, from, to);
52
+ collected.push(...filtered);
53
+
54
+ if (collected.length >= limitNum) break;
55
+
56
+ // Early stop: if the oldest item on this page is already older than --from, paging further is wasted.
57
+ if (fromTs != null) {
58
+ const oldest = batch[batch.length - 1];
59
+ const oldestTs = oldest?.date ? Date.parse(String(oldest.date).replace(" ", "T")) : null;
60
+ if (oldestTs != null && oldestTs < fromTs) break;
61
+ }
62
+ // No filter at all → keep original behavior: first page only.
63
+ if (!from && !to && !limit) break;
64
+ if (batch.length < pageSize) break; // last page
65
+ }
66
+
67
+ return limit ? collected.slice(0, limitNum) : collected;
68
+ }
69
+
70
+ /** Fetch the capability registry (~34 entries). */
71
+ async listConfigs() {
72
+ return normalizeConfigs(await this.requestEndpoint("configs"));
73
+ }
74
+
75
+ /**
76
+ * Per-row enrichment. When capability.funcName === "customer_api" the CLI handles it locally.
77
+ * Everything else goes through POST /api/v1/enrich/run with { func_name, input }.
78
+ */
79
+ async enrichRow({ capability, input, outputs = [], rowIndex } = {}) {
80
+ const funcName = capability.funcName || capability.id;
81
+
82
+ if (funcName === "customer_api") {
83
+ const data = await callCustomerApi(input);
84
+ return normalizeEnrichResponse({ data }, outputs, { cost: { tokens: 0, cached: false } });
85
+ }
86
+
87
+ const body = {
88
+ func_name: funcName,
89
+ input: input || {},
90
+ row_id: rowIndex == null ? undefined : `row-${rowIndex}`
91
+ };
92
+ const raw = await this.requestEndpoint("enrich", body);
93
+ return normalizeEnrichResponse(raw, outputs);
94
+ }
95
+
96
+ // ──────────────────────────────────────────────────────────
97
+ // Generic HTTP layer
98
+ // ──────────────────────────────────────────────────────────
99
+
100
+ async requestEndpoint(name, payload = undefined) {
101
+ const endpoint = this.config.flashrev.endpoints[name];
102
+ if (!endpoint) throw new Error(`Missing FlashRev endpoint config for ${name}.`);
103
+ return this.request(endpoint, payload, name);
104
+ }
105
+
106
+ async request(endpoint, payload = undefined, label = "request") {
107
+ const apiKey = this.config.__apiKey;
108
+ if (!apiKey) {
109
+ const envName = this.config.flashrev.apiKeyEnv || "FLASHREV_API_KEY";
110
+ const error = new Error(`Missing FlashRev API key. Set ${envName} or flashrev.apiKey in config.`);
111
+ error.hint = "Users can get an API key from FlashRev, then export it before running this CLI.";
112
+ throw error;
113
+ }
114
+
115
+ const method = String(endpoint.method || "POST").toUpperCase();
116
+ const isBodyless = method === "GET" || method === "HEAD";
117
+ const url = new URL(endpoint.path, this.config.flashrev.baseUrl);
118
+ if (isBodyless && payload && typeof payload === "object") {
119
+ for (const [key, value] of Object.entries(payload)) {
120
+ if (value === undefined || value === null) continue;
121
+ url.searchParams.set(key, String(value));
122
+ }
123
+ }
124
+
125
+ const headers = { Accept: "application/json", "Content-Type": "application/json" };
126
+ applyAuth(headers, this.config.flashrev.auth, apiKey);
127
+
128
+ const response = await fetch(url, {
129
+ method,
130
+ headers,
131
+ body: isBodyless ? undefined : JSON.stringify(payload || {})
132
+ });
133
+
134
+ const text = await response.text();
135
+ const parsed = parseMaybeJson(text);
136
+
137
+ if (!response.ok) {
138
+ const detail = parsed?.msg || parsed?.message || parsed?.error || "";
139
+ const error = new Error(`FlashRev ${label} failed: HTTP ${response.status}${detail ? ` - ${detail}` : ""}`);
140
+ error.status = response.status;
141
+ error.response = redactSecrets(parsed);
142
+ if (response.status === 401 || response.status === 403) {
143
+ error.hint = "Check the FlashRev API key and account permissions.";
144
+ }
145
+ if (response.status === 402) {
146
+ error.hint = "The FlashRev account does not have enough tokens for this operation.";
147
+ }
148
+ if (response.status === 429) {
149
+ error.hint = "Rate limited. Retry later or lower --concurrency.";
150
+ }
151
+ throw error;
152
+ }
153
+
154
+ // Business-level error: HTTP 200 but inner code != 200.
155
+ if (parsed && typeof parsed === "object" && parsed.code != null && parsed.code !== 200) {
156
+ const error = new Error(`FlashRev ${label} failed: code=${parsed.code} ${parsed.msg || ""}`);
157
+ error.status = Number(parsed.code) || 500;
158
+ error.response = redactSecrets(parsed);
159
+ throw error;
160
+ }
161
+
162
+ return parsed;
163
+ }
164
+ }
165
+
166
+ // ──────────────────────────────────────────────────────────
167
+ // Response normalization
168
+ // ──────────────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Token balance returned by /api/v2/oauth/me. The token fields live under data.limit:
172
+ * tokenTotal total quota
173
+ * tokenCost consumed amount
174
+ * tokenCategoryRemain remaining by bucket (SUBSCRIPTION / GIFT / ADDON)
175
+ * newCreditFlag = "Y" account is on the token-credit model
176
+ * remaining = tokenTotal - tokenCost.
177
+ */
178
+ export function normalizeTokens(raw = {}) {
179
+ const data = raw?.data ?? raw;
180
+ const limit = data?.limit || {};
181
+ const total = Number(limit.tokenTotal ?? 0);
182
+ const used = Number(limit.tokenCost ?? 0);
183
+ const remaining = Math.max(0, total - used);
184
+ return {
185
+ remaining,
186
+ used,
187
+ total,
188
+ plan: data?.vip?.packageName || firstByPaths(data, ["packageName", "plan"]) || "",
189
+ tokenCategoryRemain: limit.tokenCategoryRemain || null,
190
+ newCreditFlag: data?.newCreditFlag || null,
191
+ raw
192
+ };
193
+ }
194
+
195
+ /** Consumption history returned by /commodity/token/transaction/list. */
196
+ export function normalizeTokenHistory(raw = {}) {
197
+ const entries =
198
+ firstByPaths(raw, [
199
+ "data.list",
200
+ "data.records",
201
+ "list",
202
+ "records",
203
+ "items",
204
+ "data.items",
205
+ "data.history",
206
+ "history"
207
+ ]) || [];
208
+ return entries.map((entry) => ({
209
+ date: firstDefined(
210
+ entry.createdTime,
211
+ entry.createdAt,
212
+ entry.created_at,
213
+ entry.date,
214
+ entry.time,
215
+ ""
216
+ ),
217
+ action: firstDefined(entry.featName, entry.action, entry.featId, entry.feat_id, entry.name, ""),
218
+ featId: firstDefined(entry.featId, entry.feat_id, ""),
219
+ tokens: Number(firstDefined(entry.tokenAmount, entry.tokens, entry.amount, entry.cost, 0)),
220
+ unit: firstDefined(entry.unit, ""),
221
+ status: firstDefined(entry.status, ""),
222
+ raw: entry
223
+ }));
224
+ }
225
+
226
+ /**
227
+ * Capability registry returned by /enrich/configs. Public fields:
228
+ * funcName, displayName, featId, unitPriceToken, concurrency,
229
+ * inputColumn[], outputColumn[], rules[][]
230
+ */
231
+ export function normalizeConfigs(raw = {}) {
232
+ const list = firstByPaths(raw, ["data.list", "data", "list"]) || [];
233
+ if (!Array.isArray(list)) return [];
234
+ return list;
235
+ }
236
+
237
+ /**
238
+ * Per-row result returned by /enrich/run.
239
+ * Backend EnrichRunResult: { code, msg, data, cost: { tokens, cached } }
240
+ */
241
+ export function normalizeEnrichResponse(raw = {}, outputs = [], overrides = {}) {
242
+ const data = extractEnrichData(raw);
243
+ const cost = overrides.cost ?? raw.cost ?? raw.data?.cost ?? { tokens: 0, cached: false };
244
+ const values = {};
245
+ if (!outputs.length && data && typeof data === "object" && !Array.isArray(data)) {
246
+ return { values: flattenDynamicData(data), cost, raw };
247
+ }
248
+ for (const output of outputs) {
249
+ values[output.outputColumn] = firstByPaths(data, [
250
+ output.responseField,
251
+ `data.${output.responseField}`,
252
+ `result.${output.responseField}`
253
+ ]);
254
+ }
255
+ return { values, cost, raw };
256
+ }
257
+
258
+ export function buildEnrichPayload(args) {
259
+ // Backward-compat helper for tests: emit the new request body shape.
260
+ return {
261
+ func_name: args?.capability?.funcName || args?.capability?.id,
262
+ input: args?.input || {},
263
+ row_id: args?.rowIndex == null ? undefined : `row-${args.rowIndex}`
264
+ };
265
+ }
266
+
267
+ // ──────────────────────────────────────────────────────────
268
+ // Utilities
269
+ // ──────────────────────────────────────────────────────────
270
+
271
+ function extractEnrichData(raw = {}) {
272
+ return firstDefined(raw.values, raw.data?.values, raw.data, raw.result, raw.results, raw.output, raw);
273
+ }
274
+
275
+ function flattenDynamicData(data) {
276
+ const blocked = new Set(["raw", "metadata", "usage", "debug", "sources", "cost"]);
277
+ return Object.fromEntries(
278
+ Object.entries(data).filter(([key, value]) => {
279
+ if (blocked.has(key)) return false;
280
+ return value === null || ["string", "number", "boolean"].includes(typeof value) || Array.isArray(value);
281
+ })
282
+ );
283
+ }
284
+
285
+ function firstByPaths(raw, paths = []) {
286
+ for (const path of paths) {
287
+ const value = getPath(raw, path);
288
+ if (value !== undefined && value !== null && value !== "") return value;
289
+ }
290
+ return undefined;
291
+ }
292
+
293
+ function applyAuth(headers, auth, apiKey) {
294
+ const type = auth?.type || "bearer";
295
+ const header = auth?.header || "Authorization";
296
+ if (type === "header") {
297
+ headers[header] = apiKey;
298
+ return;
299
+ }
300
+ headers[header] = `${auth?.prefix ?? "Bearer "}${apiKey}`;
301
+ }
302
+
303
+ function parseMaybeJson(text) {
304
+ if (!text) return {};
305
+ try {
306
+ return JSON.parse(text);
307
+ } catch {
308
+ return { text };
309
+ }
310
+ }
311
+
312
+ function filterByDateRange(history, from, to) {
313
+ // --from / --to are interpreted as local-timezone dates (YYYY-MM-DD).
314
+ // Backend createdTime like "2026-05-29 06:59:56" is also local time.
315
+ // Use local Date constructors to avoid UTC offset filtering out same-day records.
316
+ const fromTs = from ? localDayStart(from) : null;
317
+ const toTs = to ? localDayEnd(to) : null;
318
+ return history.filter((item) => {
319
+ if (!item.date) return false;
320
+ const ts = Date.parse(String(item.date).replace(" ", "T"));
321
+ if (Number.isNaN(ts)) return false;
322
+ if (fromTs != null && ts < fromTs) return false;
323
+ if (toTs != null && ts > toTs) return false;
324
+ return true;
325
+ });
326
+ }
327
+
328
+ function localDayStart(ymd) {
329
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ymd);
330
+ if (!m) return Date.parse(ymd);
331
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0).getTime();
332
+ }
333
+
334
+ function localDayEnd(ymd) {
335
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(ymd);
336
+ if (!m) return Date.parse(ymd);
337
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 23, 59, 59, 999).getTime();
338
+ }
package/src/job.js ADDED
@@ -0,0 +1,126 @@
1
+ import { getCapability } from "./capabilities.js";
2
+ import { routePromptToJob } from "./prompt-router.js";
3
+ import { readJson, parseKeyValueList } from "./utils.js";
4
+
5
+ export async function resolveJob(flags, cliMaps, cliOutputs, headers = [], { client, capabilities } = {}) {
6
+ const jobPath = flags.job;
7
+ const fileJob = jobPath ? await readJson(jobPath) : {};
8
+
9
+ const explicitCapabilityId = flags.capability || fileJob.capability;
10
+
11
+ // Explicit --capability wins. If --prompt is also set, ignore it with a stderr warning.
12
+ if (explicitCapabilityId && flags.prompt) {
13
+ process.stderr.write(
14
+ `warning: --prompt ignored because --capability=${explicitCapabilityId} is set explicitly.\n`
15
+ );
16
+ }
17
+
18
+ // Prompt-only path: ask run_llm to route the natural-language request to a funcName.
19
+ if (!explicitCapabilityId && flags.prompt) {
20
+ if (!client || !Array.isArray(capabilities)) {
21
+ throw new Error("Internal: --prompt routing requires the FlashRev client and capability registry.");
22
+ }
23
+ process.stderr.write("Routing prompt via run_llm (1 token)...\n");
24
+ const routed = await routePromptToJob({ client, prompt: flags.prompt, csvHeaders: headers, capabilities });
25
+ const capability = getCapability(routed.funcName);
26
+ const inputMapping = { ...routed.inputMapping, ...cliMaps };
27
+ const outputMapping = { ...routed.outputMapping, ...cliOutputs };
28
+ let outputs;
29
+ try {
30
+ outputs = resolveOutputs(capability, outputMapping, flags);
31
+ } catch (e) {
32
+ // Surface the LLM's routing reasoning so the user knows why they ended up here.
33
+ const reasoningHint = routed.reasoning ? ` LLM reasoning: ${routed.reasoning}` : "";
34
+ throw new Error(
35
+ `Prompt routing produced an unusable job for ${routed.funcName}: ${e.message}${reasoningHint} Retry with --capability.`
36
+ );
37
+ }
38
+ return {
39
+ capability,
40
+ prompt: flags.prompt,
41
+ inputMapping,
42
+ outputMapping,
43
+ outputs,
44
+ routedFromPrompt: true,
45
+ routerReasoning: routed.reasoning,
46
+ routerTokensCharged: routed.tokensCharged
47
+ };
48
+ }
49
+
50
+ if (!explicitCapabilityId) {
51
+ throw new Error("Missing enrichment intent. Provide --capability, --prompt, or --job.");
52
+ }
53
+
54
+ const capability = getCapability(explicitCapabilityId);
55
+ const prompt = fileJob.prompt || "";
56
+ const inputMapping = {
57
+ ...(fileJob.inputMapping || fileJob.inputs || {}),
58
+ ...cliMaps
59
+ };
60
+ const outputMapping = {
61
+ ...(fileJob.outputMapping || fileJob.outputs || {}),
62
+ ...cliOutputs
63
+ };
64
+ const outputs = resolveOutputs(capability, outputMapping, flags);
65
+ return { capability, prompt, inputMapping, outputMapping, outputs };
66
+ }
67
+
68
+ export function mappingsFromFlags(mapValues, outputValues) {
69
+ return {
70
+ inputMapping: parseKeyValueList(mapValues, "--map"),
71
+ outputMapping: parseKeyValueList(outputValues, "--output")
72
+ };
73
+ }
74
+
75
+ export function resolveOutputs(capability, outputMapping, flags = {}) {
76
+ const entries = Object.entries(outputMapping || {});
77
+ if (entries.length) {
78
+ return entries.map(([outputColumn, responseField]) => ({ outputColumn, responseField }));
79
+ }
80
+
81
+ const requested = flags.outputFields
82
+ ? String(flags.outputFields)
83
+ .split(",")
84
+ .map((field) => field.trim())
85
+ .filter(Boolean)
86
+ : [];
87
+
88
+ if (requested.length) {
89
+ return requested.map((field) => ({ outputColumn: field, responseField: field }));
90
+ }
91
+
92
+ if (capability.dynamicOutput) {
93
+ throw new Error(
94
+ `${capability.id} has dynamic outputs. Provide --output field=field or --output-fields field1,field2.`
95
+ );
96
+ }
97
+
98
+ return capability.outputFields.map((field) => ({
99
+ outputColumn: field.key,
100
+ responseField: field.key
101
+ }));
102
+ }
103
+
104
+ export function validateJobForHeaders(job, headers) {
105
+ const headerSet = new Set(headers);
106
+ const missingColumns = Object.entries(job.inputMapping)
107
+ .filter(([, column]) => !headerSet.has(column))
108
+ .map(([input, column]) => `${input}=${column}`);
109
+ if (missingColumns.length) {
110
+ throw new Error(`Input mapping references missing CSV columns: ${missingColumns.join(", ")}`);
111
+ }
112
+
113
+ const satisfiable =
114
+ job.capability.rules.length === 0 ||
115
+ job.capability.rules.some((rule) => rule.every((field) => job.inputMapping[field]));
116
+ if (!satisfiable) {
117
+ const rules = job.capability.rules.map((rule) => rule.join(" + ")).join(" OR ");
118
+ throw new Error(`Input mapping does not satisfy ${job.capability.id}. Need one of: ${rules}`);
119
+ }
120
+ }
121
+
122
+ export function buildInputForRow(row, inputMapping) {
123
+ const input = {};
124
+ for (const [inputField, column] of Object.entries(inputMapping)) input[inputField] = row[column] ?? "";
125
+ return input;
126
+ }