@hubfluencer/mcp 0.5.0 → 0.7.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.
- package/README.md +190 -32
- package/dist/index.js +2835 -144
- package/package.json +9 -1
- package/src/campaign.ts +797 -0
- package/src/client.ts +195 -36
- package/src/core.ts +633 -24
- package/src/index.ts +4809 -1596
- package/src/output-schemas.ts +360 -0
- package/src/perf-review-assets.ts +470 -0
- package/src/series-calendar.ts +351 -0
- package/src/uploads.ts +335 -42
- package/src/version.ts +18 -0
package/src/campaign.ts
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-light helpers for the campaign-engine tools (index.ts).
|
|
3
|
+
*
|
|
4
|
+
* Like core.ts / uploads.ts, this lives outside index.ts on purpose: index.ts
|
|
5
|
+
* registers every tool and connects an stdio transport at module load, so a unit
|
|
6
|
+
* test can't import it. The campaign tools' request-shape builders, response
|
|
7
|
+
* shaping, and — most importantly — the `create_campaign` orchestrator's
|
|
8
|
+
* step sequencing + partial-failure handling are extracted here so they are
|
|
9
|
+
* testable against a fake client.
|
|
10
|
+
*
|
|
11
|
+
* GROUND TRUTH (verified against the API controllers, 2026-07-02):
|
|
12
|
+
* - product_source_controller.ex, product_profile_controller.ex,
|
|
13
|
+
* brand_profile_controller.ex, campaign_pack_controller.ex.
|
|
14
|
+
* - NONE of those four controllers mount `Plugs.Idempotency`, so the campaign
|
|
15
|
+
* tools send NO Idempotency-Key (unlike the editor/tracking/slider tools).
|
|
16
|
+
* A plain transport retry of a create is therefore NOT server-deduplicated;
|
|
17
|
+
* the tool descriptions + `idempotentHint` reflect that honestly.
|
|
18
|
+
* - plan + hook_variations meter the FREE daily AiAssistQuota (429
|
|
19
|
+
* `ai_assist_quota_exceeded`, identical shape to the editor assists) and
|
|
20
|
+
* spend no video credits. materialize / add-variations create 0-credit
|
|
21
|
+
* DRAFTS. Nothing here ever starts paid generation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { asRecord } from "./core.js";
|
|
25
|
+
|
|
26
|
+
/** The narrow client surface the orchestrator needs — a subset of HubfluencerClient. */
|
|
27
|
+
export interface CampaignClient {
|
|
28
|
+
get<T = unknown>(
|
|
29
|
+
path: string,
|
|
30
|
+
query?: Record<string, string | number | undefined>,
|
|
31
|
+
): Promise<T>;
|
|
32
|
+
post<T = unknown>(
|
|
33
|
+
path: string,
|
|
34
|
+
body?: unknown,
|
|
35
|
+
idempotencyKey?: string,
|
|
36
|
+
): Promise<T>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A thrown API error carries an HTTP status (mirrors client.ts HubfluencerError). */
|
|
40
|
+
function statusOf(e: unknown): number | undefined {
|
|
41
|
+
const s = (e as { status?: unknown })?.status;
|
|
42
|
+
return typeof s === "number" ? s : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Product-profile field mapping ────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
// The product-profile create/update body accepts exactly these textual fields
|
|
48
|
+
// (product_profile_controller.ex @product_fields). primary_image_s3_key /
|
|
49
|
+
// logo_s3_key are set through the import-image flow, never passed here.
|
|
50
|
+
export const PRODUCT_PROFILE_FIELDS = [
|
|
51
|
+
"name",
|
|
52
|
+
"source_url",
|
|
53
|
+
"description",
|
|
54
|
+
"benefits",
|
|
55
|
+
"offer",
|
|
56
|
+
"proof_points",
|
|
57
|
+
"audience",
|
|
58
|
+
"cta",
|
|
59
|
+
"banned_claims",
|
|
60
|
+
"brand_tone",
|
|
61
|
+
"metadata",
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
/** The shape of the `product` block extract returns (ExtractedProduct schema). */
|
|
65
|
+
export interface ExtractedProduct {
|
|
66
|
+
name?: string | null;
|
|
67
|
+
description?: string | null;
|
|
68
|
+
images?: string[];
|
|
69
|
+
logo?: string | null;
|
|
70
|
+
price?: string | null;
|
|
71
|
+
currency?: string | null;
|
|
72
|
+
brand?: string | null;
|
|
73
|
+
availability?: string | null;
|
|
74
|
+
benefits?: string[];
|
|
75
|
+
offer?: string | null;
|
|
76
|
+
proof_points?: string[];
|
|
77
|
+
audience?: string | null;
|
|
78
|
+
cta?: string | null;
|
|
79
|
+
banned_claims?: string[];
|
|
80
|
+
brand_tone?: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Maps the facts `extract` returns into a product-profile create/update body,
|
|
85
|
+
* keeping only the textual fields the profile controller accepts and dropping
|
|
86
|
+
* empties (so a partial extraction doesn't overwrite good saved data with null).
|
|
87
|
+
* `sourceUrl` is threaded in from the extraction envelope (the product block
|
|
88
|
+
* itself carries no URL). Image/logo URLs are handled by the import-image flow,
|
|
89
|
+
* not here, so they are intentionally excluded.
|
|
90
|
+
*/
|
|
91
|
+
export function extractedProductToProfileAttrs(
|
|
92
|
+
product: ExtractedProduct | undefined,
|
|
93
|
+
sourceUrl?: string,
|
|
94
|
+
): Record<string, unknown> {
|
|
95
|
+
const p = product ?? {};
|
|
96
|
+
const attrs: Record<string, unknown> = {};
|
|
97
|
+
const str = (v: unknown): string | undefined => {
|
|
98
|
+
if (typeof v !== "string") return undefined;
|
|
99
|
+
const t = v.trim();
|
|
100
|
+
return t === "" ? undefined : t;
|
|
101
|
+
};
|
|
102
|
+
const list = (v: unknown): string[] | undefined => {
|
|
103
|
+
if (!Array.isArray(v)) return undefined;
|
|
104
|
+
const arr = v.filter(
|
|
105
|
+
(x): x is string => typeof x === "string" && x.trim() !== "",
|
|
106
|
+
);
|
|
107
|
+
return arr.length > 0 ? arr : undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const name = str(p.name);
|
|
111
|
+
if (name !== undefined) attrs.name = name;
|
|
112
|
+
const description = str(p.description);
|
|
113
|
+
if (description !== undefined) attrs.description = description;
|
|
114
|
+
const benefits = list(p.benefits);
|
|
115
|
+
if (benefits !== undefined) attrs.benefits = benefits;
|
|
116
|
+
const offer = str(p.offer);
|
|
117
|
+
if (offer !== undefined) attrs.offer = offer;
|
|
118
|
+
const proof = list(p.proof_points);
|
|
119
|
+
if (proof !== undefined) attrs.proof_points = proof;
|
|
120
|
+
const audience = str(p.audience);
|
|
121
|
+
if (audience !== undefined) attrs.audience = audience;
|
|
122
|
+
const cta = str(p.cta);
|
|
123
|
+
if (cta !== undefined) attrs.cta = cta;
|
|
124
|
+
const banned = list(p.banned_claims);
|
|
125
|
+
if (banned !== undefined) attrs.banned_claims = banned;
|
|
126
|
+
const tone = str(p.brand_tone);
|
|
127
|
+
if (tone !== undefined) attrs.brand_tone = tone;
|
|
128
|
+
const src = str(sourceUrl);
|
|
129
|
+
if (src !== undefined) attrs.source_url = src;
|
|
130
|
+
|
|
131
|
+
return attrs;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Picks only the accepted product-profile fields from a raw args object. */
|
|
135
|
+
export function pickProductProfileFields(
|
|
136
|
+
args: Record<string, unknown>,
|
|
137
|
+
): Record<string, unknown> {
|
|
138
|
+
const out: Record<string, unknown> = {};
|
|
139
|
+
for (const k of PRODUCT_PROFILE_FIELDS) {
|
|
140
|
+
if (args[k] !== undefined) out[k] = args[k];
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The brand-profile create/update body accepts exactly these fields
|
|
146
|
+
// (brand_profile_controller.ex @brand_fields). Asset links are managed through
|
|
147
|
+
// the separate attach/detach endpoints, not this body.
|
|
148
|
+
export const BRAND_PROFILE_FIELDS = [
|
|
149
|
+
"name",
|
|
150
|
+
"logo_s3_key",
|
|
151
|
+
"primary_color",
|
|
152
|
+
"secondary_color",
|
|
153
|
+
"font_family",
|
|
154
|
+
"tone_words",
|
|
155
|
+
"default_cta",
|
|
156
|
+
"default_audience",
|
|
157
|
+
"banned_claims",
|
|
158
|
+
"preferred_visual_language",
|
|
159
|
+
"preferred_creative_format",
|
|
160
|
+
"metadata",
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
/** Picks only the accepted brand-profile fields from a raw args object. */
|
|
164
|
+
export function pickBrandProfileFields(
|
|
165
|
+
args: Record<string, unknown>,
|
|
166
|
+
): Record<string, unknown> {
|
|
167
|
+
const out: Record<string, unknown> = {};
|
|
168
|
+
for (const k of BRAND_PROFILE_FIELDS) {
|
|
169
|
+
if (args[k] !== undefined) out[k] = args[k];
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Plan / hook source validation ────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* plan + hook_variations require EITHER a product_profile_id OR an inline
|
|
178
|
+
* `product` object with a non-blank name (campaign_pack_controller
|
|
179
|
+
* parse_plan_params / parse_hook_params). Returns an error string, or null when
|
|
180
|
+
* the pair is valid. Kept in code (not Zod) so the flat schemas stay TS2589-safe
|
|
181
|
+
* and the message names the exact remedy.
|
|
182
|
+
*/
|
|
183
|
+
export function validatePlanSource(
|
|
184
|
+
productProfileId: number | undefined,
|
|
185
|
+
product: { name?: unknown } | undefined,
|
|
186
|
+
): string | null {
|
|
187
|
+
const hasProfile =
|
|
188
|
+
typeof productProfileId === "number" &&
|
|
189
|
+
Number.isInteger(productProfileId) &&
|
|
190
|
+
productProfileId > 0;
|
|
191
|
+
const hasProduct =
|
|
192
|
+
product != null &&
|
|
193
|
+
typeof product === "object" &&
|
|
194
|
+
typeof product.name === "string" &&
|
|
195
|
+
product.name.trim() !== "";
|
|
196
|
+
if (hasProfile || hasProduct) return null;
|
|
197
|
+
return "Provide either product_profile_id (a saved profile) or a product object with a non-empty name.";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Builds the plan / hook_variations request body from the tool args, dropping
|
|
202
|
+
* undefined so the server sees a clean shape. `extras` carries the hook-only
|
|
203
|
+
* fields (brand_profile_id, count). Blank strings are dropped (the controller
|
|
204
|
+
* treats them as absent anyway).
|
|
205
|
+
*/
|
|
206
|
+
export function buildPlanBody(args: {
|
|
207
|
+
product_profile_id?: number;
|
|
208
|
+
product?: Record<string, unknown>;
|
|
209
|
+
goal?: string;
|
|
210
|
+
platform?: string;
|
|
211
|
+
language?: string;
|
|
212
|
+
brand_profile_id?: number;
|
|
213
|
+
count?: number;
|
|
214
|
+
}): Record<string, unknown> {
|
|
215
|
+
const body: Record<string, unknown> = {};
|
|
216
|
+
if (args.product_profile_id !== undefined)
|
|
217
|
+
body.product_profile_id = args.product_profile_id;
|
|
218
|
+
if (args.product !== undefined) body.product = args.product;
|
|
219
|
+
const str = (v: string | undefined) =>
|
|
220
|
+
typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
221
|
+
const goal = str(args.goal);
|
|
222
|
+
if (goal !== undefined) body.goal = goal;
|
|
223
|
+
const platform = str(args.platform);
|
|
224
|
+
if (platform !== undefined) body.platform = platform;
|
|
225
|
+
const language = str(args.language);
|
|
226
|
+
if (language !== undefined) body.language = language;
|
|
227
|
+
if (args.brand_profile_id !== undefined)
|
|
228
|
+
body.brand_profile_id = args.brand_profile_id;
|
|
229
|
+
if (args.count !== undefined) body.count = args.count;
|
|
230
|
+
return body;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Variations validation ────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** The server cap on one add-variations batch (@max_variations_batch). */
|
|
236
|
+
export const MAX_VARIATIONS_BATCH = 12;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Validates the add_pack_variations / create_campaign variations list against
|
|
240
|
+
* the server's 1..12 batch cap (campaign_pack_controller parse_variations).
|
|
241
|
+
* Returns an error string, or null when valid.
|
|
242
|
+
*/
|
|
243
|
+
export function validateVariationsBatch(variations: unknown): string | null {
|
|
244
|
+
if (!Array.isArray(variations) || variations.length === 0) {
|
|
245
|
+
return "Provide at least one variation.";
|
|
246
|
+
}
|
|
247
|
+
if (variations.length > MAX_VARIATIONS_BATCH) {
|
|
248
|
+
return `Provide at most ${MAX_VARIATIONS_BATCH} variations per request (got ${variations.length}).`;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Pack / item summarization for get_campaign_pack + list ────────────────────
|
|
254
|
+
|
|
255
|
+
/** One item, trimmed to the fields an agent needs to decide the next step. */
|
|
256
|
+
export interface PackItemSummary {
|
|
257
|
+
id: unknown;
|
|
258
|
+
kind: unknown;
|
|
259
|
+
status: unknown;
|
|
260
|
+
hook: unknown;
|
|
261
|
+
video_factory_slug: string | null;
|
|
262
|
+
slider_slug: string | null;
|
|
263
|
+
draft_slug: string | null;
|
|
264
|
+
draft_route: string | null;
|
|
265
|
+
credit_estimate: unknown;
|
|
266
|
+
preview_url: unknown;
|
|
267
|
+
video_result_id: unknown;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A materialized item deep-links by kind: editor_ad/short → the video factory
|
|
271
|
+
// slug, carousel → the slider slug. Surfacing the route saves the agent from
|
|
272
|
+
// re-deriving it and mirrors the serializer's own deep-link note.
|
|
273
|
+
function draftRouteFor(kind: unknown, slug: string | null): string | null {
|
|
274
|
+
if (slug === null) return null;
|
|
275
|
+
if (kind === "carousel") return `/slider/${slug}`;
|
|
276
|
+
if (kind === "short") return `/short/${slug}`;
|
|
277
|
+
if (kind === "editor_ad") return `/editor/${slug}`;
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Summarizes one raw campaign-pack item (as the CampaignPackItem serializer
|
|
283
|
+
* emits it) into the compact shape get_campaign_pack / list_campaign_packs
|
|
284
|
+
* return. Surfaces the materialized draft slug + its deep-link route and the
|
|
285
|
+
* per-item credit estimate (stored on metadata by the variations flow), so an
|
|
286
|
+
* agent can see exactly which items are draftable, which are drafted, and what
|
|
287
|
+
* generating each would cost.
|
|
288
|
+
*/
|
|
289
|
+
export function summarizePackItem(item: unknown): PackItemSummary {
|
|
290
|
+
const it = asRecord(item);
|
|
291
|
+
const meta = asRecord(it.metadata);
|
|
292
|
+
const factorySlug =
|
|
293
|
+
typeof it.video_factory_slug === "string" ? it.video_factory_slug : null;
|
|
294
|
+
const sliderSlug = typeof it.slider_slug === "string" ? it.slider_slug : null;
|
|
295
|
+
const draftSlug = factorySlug ?? sliderSlug;
|
|
296
|
+
return {
|
|
297
|
+
id: it.id,
|
|
298
|
+
kind: it.kind,
|
|
299
|
+
status: it.status,
|
|
300
|
+
hook: it.hook ?? null,
|
|
301
|
+
video_factory_slug: factorySlug,
|
|
302
|
+
slider_slug: sliderSlug,
|
|
303
|
+
draft_slug: draftSlug,
|
|
304
|
+
draft_route: draftRouteFor(it.kind, draftSlug),
|
|
305
|
+
// The per-item credit ESTIMATE (from the hook board), surfaced so the
|
|
306
|
+
// agent can price the still-separate paid generation step.
|
|
307
|
+
credit_estimate: meta.credit_estimate ?? null,
|
|
308
|
+
preview_url: it.preview_url ?? null,
|
|
309
|
+
video_result_id: it.video_result_id ?? null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Summarizes a raw campaign pack (CampaignPack serializer) with its items. */
|
|
314
|
+
export function summarizePack(pack: unknown): Record<string, unknown> {
|
|
315
|
+
const p = asRecord(pack);
|
|
316
|
+
const items = Array.isArray(p.items) ? p.items.map(summarizePackItem) : [];
|
|
317
|
+
return {
|
|
318
|
+
id: p.id,
|
|
319
|
+
title: p.title ?? null,
|
|
320
|
+
status: p.status,
|
|
321
|
+
source_type: p.source_type ?? null,
|
|
322
|
+
source_url: p.source_url ?? null,
|
|
323
|
+
brief: p.brief ?? null,
|
|
324
|
+
product_profile_id: p.product_profile_id ?? null,
|
|
325
|
+
brand_profile_id: p.brand_profile_id ?? null,
|
|
326
|
+
item_count: items.length,
|
|
327
|
+
items,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Summarizes a raw campaign pack for the LIST endpoint, which — unlike the show
|
|
333
|
+
* endpoint — does NOT preload `:items` (commerce.ex list_campaign_packs; the
|
|
334
|
+
* controller's serialize_items maps the NotLoaded association to []). Emitting
|
|
335
|
+
* `items: []` / `item_count: 0` here would read as "this pack is empty" and make
|
|
336
|
+
* agents mis-plan, so this variant OMITS both fields entirely: per-item detail is
|
|
337
|
+
* only honest via get_campaign_pack. Keeps the same pack-level fields as
|
|
338
|
+
* summarizePack minus items/item_count.
|
|
339
|
+
*/
|
|
340
|
+
export function summarizePackListEntry(pack: unknown): Record<string, unknown> {
|
|
341
|
+
const p = asRecord(pack);
|
|
342
|
+
return {
|
|
343
|
+
id: p.id,
|
|
344
|
+
title: p.title ?? null,
|
|
345
|
+
status: p.status,
|
|
346
|
+
source_type: p.source_type ?? null,
|
|
347
|
+
source_url: p.source_url ?? null,
|
|
348
|
+
brief: p.brief ?? null,
|
|
349
|
+
product_profile_id: p.product_profile_id ?? null,
|
|
350
|
+
brand_profile_id: p.brand_profile_id ?? null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── create_campaign orchestrator ─────────────────────────────────────────────
|
|
355
|
+
//
|
|
356
|
+
// The high-level intent tool mirrors make_video's altitude: one call takes a
|
|
357
|
+
// product URL (or a saved product_profile_id) all the way to a persisted pack
|
|
358
|
+
// full of editable DRAFTS. It is $0 end-to-end — it only ever touches the free
|
|
359
|
+
// AI-assist quota (plan + hook_variations) and creates 0-credit drafts; it NEVER
|
|
360
|
+
// triggers autopilot/generate/render (that stays a separate, explicit, paid
|
|
361
|
+
// step). Every stage is best-effort past the point a pack exists: a partial
|
|
362
|
+
// failure returns everything achieved so far plus precise resume guidance naming
|
|
363
|
+
// the granular tool to continue with.
|
|
364
|
+
|
|
365
|
+
export interface CreateCampaignArgs {
|
|
366
|
+
url?: string;
|
|
367
|
+
product_profile_id?: number;
|
|
368
|
+
brand_profile_id?: number;
|
|
369
|
+
item_count?: number;
|
|
370
|
+
formats?: string[];
|
|
371
|
+
language?: string;
|
|
372
|
+
goal?: string;
|
|
373
|
+
platform?: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A structured step outcome recorded on the orchestrator result's `steps` log. */
|
|
377
|
+
export interface CampaignStep {
|
|
378
|
+
step: string;
|
|
379
|
+
ok: boolean;
|
|
380
|
+
detail?: string;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface CreateCampaignResult {
|
|
384
|
+
product_profile_id: number | null;
|
|
385
|
+
campaign_pack_id: number | null;
|
|
386
|
+
brand_profile_id: number | null;
|
|
387
|
+
extracted: {
|
|
388
|
+
source_url: string | null;
|
|
389
|
+
confidence: unknown;
|
|
390
|
+
name: unknown;
|
|
391
|
+
} | null;
|
|
392
|
+
imported: { primary_image: boolean; logo: boolean };
|
|
393
|
+
items: PackItemSummary[];
|
|
394
|
+
summary: { created: number; materialized: number; planned: number };
|
|
395
|
+
steps: CampaignStep[];
|
|
396
|
+
next: string;
|
|
397
|
+
note: string;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const HOOK_MIN = 3;
|
|
401
|
+
const HOOK_MAX = 6;
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Runs the create_campaign pipeline against a client. Pure of MCP concerns
|
|
405
|
+
* (returns a plain result object; index.ts wraps it with ok()). Throws only on a
|
|
406
|
+
* FATAL error that leaves nothing worth returning (no product source at all, or
|
|
407
|
+
* the pack create itself failing) — every later stage is caught and folded into
|
|
408
|
+
* the result with resume guidance, so one flaky sub-step never discards the pack.
|
|
409
|
+
*
|
|
410
|
+
* `log` receives one line per stage (stderr-only in index.ts). The clamp on the
|
|
411
|
+
* hook count mirrors the server's 3..6 board size.
|
|
412
|
+
*/
|
|
413
|
+
export async function runCreateCampaign(
|
|
414
|
+
client: CampaignClient,
|
|
415
|
+
args: CreateCampaignArgs,
|
|
416
|
+
log: (msg: string) => void = () => {},
|
|
417
|
+
): Promise<CreateCampaignResult> {
|
|
418
|
+
const steps: CampaignStep[] = [];
|
|
419
|
+
const record = (step: string, ok: boolean, detail?: string) => {
|
|
420
|
+
steps.push({ step, ok, ...(detail ? { detail } : {}) });
|
|
421
|
+
log(`${ok ? "ok" : "skip"}: ${step}${detail ? ` — ${detail}` : ""}`);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
let productProfileId: number | null = args.product_profile_id ?? null;
|
|
425
|
+
let extracted: CreateCampaignResult["extracted"] = null;
|
|
426
|
+
const imported = { primary_image: false, logo: false };
|
|
427
|
+
let extractedProduct: ExtractedProduct | undefined;
|
|
428
|
+
|
|
429
|
+
// 1) INGEST — extract from the URL (0 credits, stateless) when no profile id
|
|
430
|
+
// was supplied. A URL is required when there's no product_profile_id.
|
|
431
|
+
if (productProfileId === null) {
|
|
432
|
+
if (!args.url) {
|
|
433
|
+
throw new Error("Provide either url or product_profile_id.");
|
|
434
|
+
}
|
|
435
|
+
const res = await client.post<{ data: unknown }>(
|
|
436
|
+
"/product-sources/extract",
|
|
437
|
+
{
|
|
438
|
+
url: args.url,
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
const data = asRecord(asRecord(res).data);
|
|
442
|
+
extractedProduct = asRecord(data.product) as ExtractedProduct;
|
|
443
|
+
extracted = {
|
|
444
|
+
source_url: (data.source_url as string) ?? args.url,
|
|
445
|
+
confidence: data.confidence ?? null,
|
|
446
|
+
name: extractedProduct.name ?? null,
|
|
447
|
+
};
|
|
448
|
+
record(
|
|
449
|
+
"extract_product_page",
|
|
450
|
+
true,
|
|
451
|
+
`confidence=${data.confidence ?? "?"}`,
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// 2) PERSIST identity — create a product profile from the extracted facts so
|
|
455
|
+
// the pack (and its drafts) inherit a durable source of truth. A profile
|
|
456
|
+
// needs a name; if extraction found none, fall back to the URL host.
|
|
457
|
+
const attrs = extractedProductToProfileAttrs(
|
|
458
|
+
extractedProduct,
|
|
459
|
+
extracted.source_url ?? args.url,
|
|
460
|
+
);
|
|
461
|
+
if (
|
|
462
|
+
typeof attrs.name !== "string" ||
|
|
463
|
+
(attrs.name as string).trim() === ""
|
|
464
|
+
) {
|
|
465
|
+
attrs.name = fallbackNameFromUrl(args.url);
|
|
466
|
+
}
|
|
467
|
+
const created = await client.post<{ data: { id: number } }>(
|
|
468
|
+
"/product-profiles",
|
|
469
|
+
attrs,
|
|
470
|
+
);
|
|
471
|
+
productProfileId = asRecord(asRecord(created).data).id as number;
|
|
472
|
+
record("create_product_profile", true, `id=${productProfileId}`);
|
|
473
|
+
|
|
474
|
+
// 3) IMPORT imagery — best-effort. A failed image import must NOT sink the
|
|
475
|
+
// campaign, so each is caught and recorded. primary image first, then logo.
|
|
476
|
+
const primaryUrl = firstString(extractedProduct.images);
|
|
477
|
+
if (primaryUrl) {
|
|
478
|
+
try {
|
|
479
|
+
await client.post("/product-sources/import-image", {
|
|
480
|
+
image_url: primaryUrl,
|
|
481
|
+
product_profile_id: productProfileId,
|
|
482
|
+
link_as: "primary_image",
|
|
483
|
+
});
|
|
484
|
+
imported.primary_image = true;
|
|
485
|
+
record("import_primary_image", true);
|
|
486
|
+
} catch (e) {
|
|
487
|
+
record("import_primary_image", false, describeErr(e));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const logoUrl =
|
|
491
|
+
typeof extractedProduct.logo === "string"
|
|
492
|
+
? extractedProduct.logo
|
|
493
|
+
: undefined;
|
|
494
|
+
if (logoUrl && logoUrl.trim() !== "") {
|
|
495
|
+
try {
|
|
496
|
+
await client.post("/product-sources/import-image", {
|
|
497
|
+
image_url: logoUrl,
|
|
498
|
+
product_profile_id: productProfileId,
|
|
499
|
+
link_as: "logo",
|
|
500
|
+
});
|
|
501
|
+
imported.logo = true;
|
|
502
|
+
record("import_logo", true);
|
|
503
|
+
} catch (e) {
|
|
504
|
+
record("import_logo", false, describeErr(e));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
record("use_product_profile", true, `id=${productProfileId}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// 4) PLAN — meters the free AI-assist quota (429 on exhaustion). Best-effort:
|
|
512
|
+
// the plan's positioning/estimate is nice-to-have context; the pack can
|
|
513
|
+
// still be built from hook variations if planning is quota-blocked. A 429
|
|
514
|
+
// here is surfaced (with the unlock hint) but does not abort.
|
|
515
|
+
let positioning: string | null = null;
|
|
516
|
+
try {
|
|
517
|
+
const planRes = await client.post<{ data: unknown }>(
|
|
518
|
+
"/campaign-packs/plan",
|
|
519
|
+
buildPlanBody({
|
|
520
|
+
product_profile_id: productProfileId,
|
|
521
|
+
goal: args.goal,
|
|
522
|
+
platform: args.platform,
|
|
523
|
+
language: args.language,
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
const planData = asRecord(asRecord(planRes).data);
|
|
527
|
+
positioning =
|
|
528
|
+
typeof planData.positioning === "string" ? planData.positioning : null;
|
|
529
|
+
record("plan_campaign", true);
|
|
530
|
+
} catch (e) {
|
|
531
|
+
if (statusOf(e) === 429) {
|
|
532
|
+
record(
|
|
533
|
+
"plan_campaign",
|
|
534
|
+
false,
|
|
535
|
+
"ai_assist_quota_exceeded (unlock_ai_assists to add +10, or continue)",
|
|
536
|
+
);
|
|
537
|
+
} else {
|
|
538
|
+
record("plan_campaign", false, describeErr(e));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// 5) HOOK VARIATIONS — also meters the free quota. This IS load-bearing: the
|
|
543
|
+
// variations become the pack's items. If it's quota-blocked or errors, we
|
|
544
|
+
// can't build a populated pack, so return early with resume guidance (the
|
|
545
|
+
// profile is already saved, so the agent resumes cheaply once unblocked).
|
|
546
|
+
const count = clampCount(args.item_count);
|
|
547
|
+
let variations: unknown[] = [];
|
|
548
|
+
try {
|
|
549
|
+
const hookRes = await client.post<{ data: unknown }>(
|
|
550
|
+
"/campaign-packs/hook-variations",
|
|
551
|
+
buildPlanBody({
|
|
552
|
+
product_profile_id: productProfileId,
|
|
553
|
+
brand_profile_id: args.brand_profile_id,
|
|
554
|
+
count,
|
|
555
|
+
goal: args.goal,
|
|
556
|
+
platform: args.platform,
|
|
557
|
+
language: args.language,
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
const hookData = asRecord(asRecord(hookRes).data);
|
|
561
|
+
variations = Array.isArray(hookData.variations) ? hookData.variations : [];
|
|
562
|
+
record("generate_hook_variations", true, `${variations.length} variations`);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
const quota = statusOf(e) === 429;
|
|
565
|
+
record(
|
|
566
|
+
"generate_hook_variations",
|
|
567
|
+
false,
|
|
568
|
+
quota ? "ai_assist_quota_exceeded" : describeErr(e),
|
|
569
|
+
);
|
|
570
|
+
return partialResult({
|
|
571
|
+
productProfileId,
|
|
572
|
+
campaignPackId: null,
|
|
573
|
+
brandProfileId: args.brand_profile_id ?? null,
|
|
574
|
+
extracted,
|
|
575
|
+
imported,
|
|
576
|
+
items: [],
|
|
577
|
+
positioning,
|
|
578
|
+
steps,
|
|
579
|
+
next: quota
|
|
580
|
+
? `generate_hook_variations({ product_profile_id: ${productProfileId} }) once the daily AI-assist quota resets, or unlock_ai_assists first, then create_campaign_pack + add_pack_variations.`
|
|
581
|
+
: `generate_hook_variations({ product_profile_id: ${productProfileId} }), then create_campaign_pack + add_pack_variations.`,
|
|
582
|
+
note: quota
|
|
583
|
+
? "Stopped at hook variations: the free daily AI-assist quota is exhausted. The product profile is saved — resume once it resets (or unlock more)."
|
|
584
|
+
: "Stopped at hook variations. The product profile is saved — resume with the granular tools.",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// If the board came back empty (LLM returned nothing usable), there's nothing
|
|
589
|
+
// to populate a pack with — save the caller a committed-but-empty pack.
|
|
590
|
+
if (variations.length === 0) {
|
|
591
|
+
return partialResult({
|
|
592
|
+
productProfileId,
|
|
593
|
+
campaignPackId: null,
|
|
594
|
+
brandProfileId: args.brand_profile_id ?? null,
|
|
595
|
+
extracted,
|
|
596
|
+
imported,
|
|
597
|
+
items: [],
|
|
598
|
+
positioning,
|
|
599
|
+
steps,
|
|
600
|
+
next: `generate_hook_variations({ product_profile_id: ${productProfileId} }) to retry, then create_campaign_pack + add_pack_variations.`,
|
|
601
|
+
note: "Hook variations came back empty — retry generate_hook_variations, then build the pack.",
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Respect the requested format filter: keep only variations whose
|
|
606
|
+
// suggested_format is in `formats` (when given). If the filter removes
|
|
607
|
+
// everything, fall back to the full board rather than a pack with no items.
|
|
608
|
+
const filtered = filterVariationsByFormat(variations, args.formats);
|
|
609
|
+
const chosen = (filtered.length > 0 ? filtered : variations).slice(
|
|
610
|
+
0,
|
|
611
|
+
MAX_VARIATIONS_BATCH,
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
// 6) CREATE the pack (0 credits, no persisted generation). This is the point
|
|
615
|
+
// of no return for "return the pack even if later steps wobble". A failure
|
|
616
|
+
// HERE is fatal (nothing durable to hand back), so it propagates.
|
|
617
|
+
const packBody: Record<string, unknown> = {
|
|
618
|
+
product_profile_id: productProfileId,
|
|
619
|
+
};
|
|
620
|
+
if (args.brand_profile_id !== undefined)
|
|
621
|
+
packBody.brand_profile_id = args.brand_profile_id;
|
|
622
|
+
if (args.language !== undefined && args.language.trim() !== "")
|
|
623
|
+
packBody.language = args.language;
|
|
624
|
+
if (extracted?.source_url) {
|
|
625
|
+
packBody.source_type = "url";
|
|
626
|
+
packBody.source_url = extracted.source_url;
|
|
627
|
+
}
|
|
628
|
+
if (typeof extractedProduct?.name === "string")
|
|
629
|
+
packBody.title = extractedProduct.name;
|
|
630
|
+
const packRes = await client.post<{ data: { id: number } }>(
|
|
631
|
+
"/campaign-packs",
|
|
632
|
+
packBody,
|
|
633
|
+
);
|
|
634
|
+
const campaignPackId = asRecord(asRecord(packRes).data).id as number;
|
|
635
|
+
record("create_campaign_pack", true, `id=${campaignPackId}`);
|
|
636
|
+
|
|
637
|
+
// 7) ADD + MATERIALIZE the chosen variations as DRAFTS (0 credits, best-effort
|
|
638
|
+
// per item server-side). One call handles the whole batch; the server
|
|
639
|
+
// keeps any variation it can't materialize as a "planned" item with a
|
|
640
|
+
// reason, so the response is authoritative for what became a draft.
|
|
641
|
+
let items: PackItemSummary[] = [];
|
|
642
|
+
let summary = { created: 0, materialized: 0, planned: 0 };
|
|
643
|
+
try {
|
|
644
|
+
const varRes = await client.post<{ data: unknown }>(
|
|
645
|
+
`/campaign-packs/${campaignPackId}/variations`,
|
|
646
|
+
{ variations: chosen, materialize: true },
|
|
647
|
+
);
|
|
648
|
+
const varData = asRecord(asRecord(varRes).data);
|
|
649
|
+
items = Array.isArray(varData.items)
|
|
650
|
+
? varData.items.map(summarizePackItem)
|
|
651
|
+
: [];
|
|
652
|
+
const s = asRecord(varData.summary);
|
|
653
|
+
summary = {
|
|
654
|
+
created: numberOr(s.created, items.length),
|
|
655
|
+
materialized: numberOr(s.materialized, 0),
|
|
656
|
+
planned: numberOr(s.planned, 0),
|
|
657
|
+
};
|
|
658
|
+
record(
|
|
659
|
+
"add_pack_variations",
|
|
660
|
+
true,
|
|
661
|
+
`${summary.materialized}/${summary.created} materialized`,
|
|
662
|
+
);
|
|
663
|
+
} catch (e) {
|
|
664
|
+
record("add_pack_variations", false, describeErr(e));
|
|
665
|
+
return partialResult({
|
|
666
|
+
productProfileId,
|
|
667
|
+
campaignPackId,
|
|
668
|
+
brandProfileId: args.brand_profile_id ?? null,
|
|
669
|
+
extracted,
|
|
670
|
+
imported,
|
|
671
|
+
items: [],
|
|
672
|
+
positioning,
|
|
673
|
+
steps,
|
|
674
|
+
next: `add_pack_variations({ pack_id: ${campaignPackId}, variations: [...], materialize: true }) to populate the pack (up to ${MAX_VARIATIONS_BATCH} per call), or get_campaign_pack({ id: ${campaignPackId} }) to inspect it.`,
|
|
675
|
+
note: "The pack was created but adding its variations failed — resume with add_pack_variations.",
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const draftable = summary.materialized;
|
|
680
|
+
return {
|
|
681
|
+
product_profile_id: productProfileId,
|
|
682
|
+
campaign_pack_id: campaignPackId,
|
|
683
|
+
brand_profile_id: args.brand_profile_id ?? null,
|
|
684
|
+
extracted,
|
|
685
|
+
imported,
|
|
686
|
+
items,
|
|
687
|
+
summary,
|
|
688
|
+
steps,
|
|
689
|
+
next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then, for EACH draft you want to publish, generate it EXPLICITLY (a SEPARATE, PAID step): editor_ad/short → start_autopilot({ slug }) or the granular generate/render tools; carousel → generate_slider({ slug }).`,
|
|
690
|
+
note:
|
|
691
|
+
`Campaign pack ${campaignPackId} created with ${summary.created} item(s), ${draftable} materialized into editable DRAFTS. ` +
|
|
692
|
+
(positioning ? `Positioning: ${positioning}. ` : "") +
|
|
693
|
+
"$0 so far (free AI-assist quota + 0-credit drafts). Generation/render is a separate, explicit, PAID step per draft — nothing was generated.",
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── orchestrator helpers ─────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
function partialResult(p: {
|
|
700
|
+
productProfileId: number | null;
|
|
701
|
+
campaignPackId: number | null;
|
|
702
|
+
brandProfileId: number | null;
|
|
703
|
+
extracted: CreateCampaignResult["extracted"];
|
|
704
|
+
imported: { primary_image: boolean; logo: boolean };
|
|
705
|
+
items: PackItemSummary[];
|
|
706
|
+
positioning: string | null;
|
|
707
|
+
steps: CampaignStep[];
|
|
708
|
+
next: string;
|
|
709
|
+
note: string;
|
|
710
|
+
}): CreateCampaignResult {
|
|
711
|
+
const materialized = p.items.filter((i) => i.draft_slug !== null).length;
|
|
712
|
+
return {
|
|
713
|
+
product_profile_id: p.productProfileId,
|
|
714
|
+
campaign_pack_id: p.campaignPackId,
|
|
715
|
+
brand_profile_id: p.brandProfileId,
|
|
716
|
+
extracted: p.extracted,
|
|
717
|
+
imported: p.imported,
|
|
718
|
+
items: p.items,
|
|
719
|
+
summary: {
|
|
720
|
+
created: p.items.length,
|
|
721
|
+
materialized,
|
|
722
|
+
planned: p.items.length - materialized,
|
|
723
|
+
},
|
|
724
|
+
steps: p.steps,
|
|
725
|
+
next: p.next,
|
|
726
|
+
note: p.note,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function clampCount(n: number | undefined): number {
|
|
731
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return HOOK_MAX;
|
|
732
|
+
return Math.min(HOOK_MAX, Math.max(HOOK_MIN, Math.round(n)));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Keeps only variations whose suggested_format is in `formats` (normalized to
|
|
737
|
+
* the server's editor_ad/short/carousel spelling). An empty/absent filter keeps
|
|
738
|
+
* everything. Returned so create_campaign can honor a `formats` request without
|
|
739
|
+
* a second server round-trip.
|
|
740
|
+
*/
|
|
741
|
+
export function filterVariationsByFormat(
|
|
742
|
+
variations: unknown[],
|
|
743
|
+
formats: string[] | undefined,
|
|
744
|
+
): unknown[] {
|
|
745
|
+
if (!Array.isArray(formats) || formats.length === 0) return variations;
|
|
746
|
+
const wanted = new Set(formats.map(normalizeFormat));
|
|
747
|
+
return variations.filter((v) =>
|
|
748
|
+
wanted.has(normalizeFormat(asRecord(v).suggested_format)),
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function normalizeFormat(v: unknown): string {
|
|
753
|
+
if (typeof v !== "string") return "";
|
|
754
|
+
return v
|
|
755
|
+
.trim()
|
|
756
|
+
.toLowerCase()
|
|
757
|
+
.replace(/[\s-]+/g, "_");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function firstString(arr: unknown): string | undefined {
|
|
761
|
+
if (!Array.isArray(arr)) return undefined;
|
|
762
|
+
for (const v of arr) {
|
|
763
|
+
if (typeof v === "string" && v.trim() !== "") return v;
|
|
764
|
+
}
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function numberOr(v: unknown, fallback: number): number {
|
|
769
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// A concise, human error line for the steps log — the API code when present
|
|
773
|
+
// (machine-stable), else the message, never a raw stack.
|
|
774
|
+
function describeErr(e: unknown): string {
|
|
775
|
+
const code = (e as { code?: unknown })?.code;
|
|
776
|
+
if (typeof code === "string" && code !== "") return code;
|
|
777
|
+
const status = statusOf(e);
|
|
778
|
+
if (e instanceof Error && e.message)
|
|
779
|
+
return status ? `HTTP ${status}: ${e.message}` : e.message;
|
|
780
|
+
return status ? `HTTP ${status}` : "failed";
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Derives a product-profile name from a URL when extraction found none — the
|
|
785
|
+
* host with a leading www. dropped (e.g. "https://shop.acme.com/x" → "shop.acme.com").
|
|
786
|
+
* Falls back to a generic label for an unparseable URL so create never fails the
|
|
787
|
+
* profile's required-name changeset.
|
|
788
|
+
*/
|
|
789
|
+
export function fallbackNameFromUrl(url: string | undefined): string {
|
|
790
|
+
if (typeof url !== "string" || url.trim() === "") return "Imported product";
|
|
791
|
+
try {
|
|
792
|
+
const host = new URL(url).hostname.replace(/^www\./, "");
|
|
793
|
+
return host || "Imported product";
|
|
794
|
+
} catch {
|
|
795
|
+
return "Imported product";
|
|
796
|
+
}
|
|
797
|
+
}
|