@hubfluencer/mcp 0.4.0 → 0.6.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,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
+ }