ponch-mcp-server 1.0.77 → 1.0.78

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/dist/index.js CHANGED
@@ -2229,11 +2229,10 @@ var AddonActivoSchema = import_zod16.z.object({
2229
2229
  estado: import_zod16.z.enum(["active", "cancelled"])
2230
2230
  }).strict();
2231
2231
  var TipoMemoriaEnum = import_zod17.z.enum([
2232
- "filtro",
2233
2232
  "preferencia",
2233
+ "regla",
2234
2234
  "patron",
2235
- "aprendido",
2236
- "delegacion"
2235
+ "aversion"
2237
2236
  ]);
2238
2237
  var StatusMemoriaEnum = import_zod17.z.enum([
2239
2238
  "explicito",
@@ -2761,35 +2760,46 @@ function registerCoreTools(server, session) {
2761
2760
  }
2762
2761
 
2763
2762
  // src/tools/marketing.ts
2764
- var import_zod38 = require("zod");
2763
+ var import_zod49 = require("zod");
2765
2764
 
2766
2765
  // ../packages/marketing-business-logic/dist/index.js
2767
2766
  var import_firebase_admin2 = require("firebase-admin");
2768
- var import_firebase_admin3 = require("firebase-admin");
2769
2767
  var import_zod27 = require("zod");
2770
2768
  var import_zod28 = require("zod");
2771
2769
  var import_zod29 = require("zod");
2772
2770
  var import_fs3 = require("fs");
2773
2771
  var import_path2 = require("path");
2774
2772
  var import_url = require("url");
2773
+ var import_firebase_admin3 = require("firebase-admin");
2774
+ var import_zod30 = require("zod");
2775
2775
  var import_firebase_admin4 = require("firebase-admin");
2776
+ var import_zod31 = require("zod");
2776
2777
  var import_firebase_admin5 = require("firebase-admin");
2778
+ var import_zod32 = require("zod");
2777
2779
  var import_firebase_admin6 = require("firebase-admin");
2778
- var import_zod30 = require("zod");
2780
+ var import_zod33 = require("zod");
2779
2781
  var import_firebase_admin7 = require("firebase-admin");
2780
- var import_zod31 = require("zod");
2781
- var import_zod32 = require("zod");
2782
+ var import_zod34 = require("zod");
2783
+ var import_zod35 = require("zod");
2782
2784
  var import_firebase_admin8 = require("firebase-admin");
2783
- var import_zod33 = require("zod");
2785
+ var import_zod36 = require("zod");
2786
+ var import_zod37 = require("zod");
2787
+ var import_zod38 = require("zod");
2784
2788
  var import_firebase_admin9 = require("firebase-admin");
2789
+ var import_zod39 = require("zod");
2785
2790
  var import_firebase_admin10 = require("firebase-admin");
2791
+ var import_zod40 = require("zod");
2792
+ var import_zod41 = require("zod");
2793
+ var import_zod42 = require("zod");
2794
+ var import_zod43 = require("zod");
2795
+ var import_zod44 = require("zod");
2786
2796
  var import_firestore3 = require("firebase-admin/firestore");
2787
2797
  var import_firestore4 = require("firebase-admin/firestore");
2788
2798
  var import_firestore5 = require("firebase-admin/firestore");
2789
2799
  var import_firestore6 = require("firebase-admin/firestore");
2790
- var import_zod34 = require("zod");
2800
+ var import_zod45 = require("zod");
2791
2801
  var import_firestore7 = require("firebase-admin/firestore");
2792
- var import_zod35 = require("zod");
2802
+ var import_zod46 = require("zod");
2793
2803
  var import_firestore8 = require("firebase-admin/firestore");
2794
2804
  var import_meta = {};
2795
2805
  var RULE_NEGATIVES = {
@@ -2934,6 +2944,7 @@ async function brandBriefWriter(input) {
2934
2944
  return {
2935
2945
  ok: false,
2936
2946
  error: "Brand Brief no cumple el schema esperado.",
2947
+ code: "BRAND_BRIEF_VALIDATION_FAILED",
2937
2948
  detalle: validation.error,
2938
2949
  path: validation.path,
2939
2950
  recibido: validation.received,
@@ -2944,7 +2955,7 @@ async function brandBriefWriter(input) {
2944
2955
  const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2945
2956
  const brandSnap = await brandRef.get();
2946
2957
  if (!brandSnap.exists) {
2947
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
2958
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
2948
2959
  }
2949
2960
  const brief = validation.parsed;
2950
2961
  const briefWithMeta = {
@@ -2968,90 +2979,6 @@ async function brandBriefWriter(input) {
2968
2979
  mensaje: `Brand Brief guardado para "${brandId}". El tenant puede validar los campos en Config > Mi Negocio.`
2969
2980
  };
2970
2981
  }
2971
- async function save(input) {
2972
- const { db, tenantId, brandId, plan } = input;
2973
- const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2974
- const brandSnap = await brandRef.get();
2975
- if (!brandSnap.exists) {
2976
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
2977
- }
2978
- const { blogStrategy, ...planSinBlogStrategy } = plan;
2979
- const planWithMeta = {
2980
- ...planSinBlogStrategy,
2981
- fechaCreacion: (/* @__PURE__ */ new Date()).toISOString(),
2982
- creadoPorId: "mcp-cowork"
2983
- };
2984
- await brandRef.set({
2985
- plan: planWithMeta,
2986
- ...blogStrategy ? { blogStrategy } : {},
2987
- id: brandId,
2988
- brandId,
2989
- tenantId,
2990
- schemaVersion: 1,
2991
- updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
2992
- }, { merge: true });
2993
- return {
2994
- ok: true,
2995
- mensaje: `Plan de marketing guardado para brand "${brandId}"` + (blogStrategy ? " (incluye blogStrategy)" : "")
2996
- };
2997
- }
2998
- async function updateField(input) {
2999
- const { db, tenantId, brandId, field, value } = input;
3000
- const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3001
- const brandSnap = await brandRef.get();
3002
- if (!brandSnap.exists) {
3003
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
3004
- }
3005
- const brandData = brandSnap.data();
3006
- let parsedValue = value;
3007
- if (typeof value === "string") {
3008
- const trimmed = value.trim();
3009
- if (trimmed.startsWith("[") && trimmed.endsWith("]") || trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith('"') && trimmed.endsWith('"')) {
3010
- try {
3011
- parsedValue = JSON.parse(trimmed);
3012
- } catch {
3013
- }
3014
- }
3015
- }
3016
- const validation = validatePlanField(field, parsedValue);
3017
- if (!validation.ok) {
3018
- return {
3019
- ok: false,
3020
- error: `Campo "${field}" no cumple el schema esperado.`,
3021
- detalle: validation.error,
3022
- path: validation.path,
3023
- recibido: validation.received,
3024
- ejemplo_correcto: PLAN_FIELD_EXAMPLES[field],
3025
- hint: "Manda el valor como objeto/array nativo, NO como string JSON. Usa el ejemplo arriba como referencia exacta del shape esperado."
3026
- };
3027
- }
3028
- const updatePayload = {
3029
- id: brandId,
3030
- brandId,
3031
- tenantId,
3032
- schemaVersion: 1,
3033
- updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
3034
- };
3035
- if (field === "blogStrategy") {
3036
- updatePayload.blogStrategy = validation.parsed;
3037
- } else {
3038
- const currentPlan = brandData.plan ?? {};
3039
- updatePayload.plan = {
3040
- ...currentPlan,
3041
- [field]: validation.parsed,
3042
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3043
- };
3044
- }
3045
- await brandRef.set(updatePayload, { merge: true });
3046
- return {
3047
- ok: true,
3048
- mensaje: `Campo "${field}" actualizado en el plan de "${brandId}"`
3049
- };
3050
- }
3051
- var planWriter = {
3052
- save,
3053
- updateField
3054
- };
3055
2982
  var _localeCache = {};
3056
2983
  function _resolveLocaleFile(locale) {
3057
2984
  let baseDir;
@@ -3062,7 +2989,9 @@ function _resolveLocaleFile(locale) {
3062
2989
  }
3063
2990
  const candidates = [
3064
2991
  (0, import_path2.join)(baseDir, "..", "..", "..", "..", "src", "i18n", "locales", `${locale}.json`),
3065
- (0, import_path2.join)(baseDir, "..", "..", "..", "src", "i18n", "locales", `${locale}.json`)
2992
+ (0, import_path2.join)(baseDir, "..", "..", "..", "src", "i18n", "locales", `${locale}.json`),
2993
+ (0, import_path2.join)(baseDir, "i18n", "locales", `${locale}.json`),
2994
+ (0, import_path2.join)(baseDir, "..", "i18n", "locales", `${locale}.json`)
3066
2995
  ];
3067
2996
  for (const candidate of candidates) {
3068
2997
  try {
@@ -3325,10 +3254,11 @@ var MartinContractSchema = import_zod28.z.object({
3325
3254
  }
3326
3255
  });
3327
3256
  var ParamsSchema = import_zod27.z.object({
3328
- tenantId: import_zod27.z.string().min(1),
3329
- brandId: import_zod27.z.string().min(1),
3330
- /** Plan completo generado por Claude. Puede incluir blogStrategy. */
3331
- plan: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.unknown())
3257
+ tenantId: import_zod27.z.string().min(1).describe("Tenant identifier (the business account)."),
3258
+ brandId: import_zod27.z.string().min(1).describe("Brand identifier within the tenant."),
3259
+ brandBrief: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.unknown()).describe(
3260
+ "Full Brand Brief object generated by the LLM. The helper runs full Zod validation against the canonical brandBrief schema before persisting."
3261
+ )
3332
3262
  });
3333
3263
  var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3334
3264
  import_zod27.z.object({
@@ -3338,6 +3268,7 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3338
3268
  import_zod27.z.object({
3339
3269
  ok: import_zod27.z.literal(false),
3340
3270
  error: import_zod27.z.string(),
3271
+ code: import_zod27.z.enum(["BRAND_BRIEF_VALIDATION_FAILED", "BRAND_NOT_FOUND"]).optional(),
3341
3272
  detalle: import_zod27.z.unknown().optional(),
3342
3273
  path: import_zod27.z.string().optional(),
3343
3274
  recibido: import_zod27.z.unknown().optional(),
@@ -3346,10 +3277,157 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3346
3277
  })
3347
3278
  ]);
3348
3279
  var rawContract = {
3349
- name: "save_marketing_plan",
3350
- description: "Save a marketing plan for a brand. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
3280
+ name: "save_brand_brief",
3281
+ description: "Persist the Brand Brief generated by Claude into the brand config doc. Applies full Zod validation against the canonical Brand Brief schema; returns code=BRAND_BRIEF_VALIDATION_FAILED with detalle/path/recibido/ejemplo_correcto/hint so the LLM can self-correct on retry.",
3351
3282
  paramsSchema: ParamsSchema,
3352
3283
  outputSchema: OutputSchema,
3284
+ // Sobrescribe brandBrief existente vía merge — reversible (regenerar el
3285
+ // brief es barato), no afecta publicación, no llama externo.
3286
+ requiresConfirmation: false,
3287
+ destructive: false,
3288
+ affectsPublication: false,
3289
+ affectsExternal: false,
3290
+ martinSummaryTemplate: (input, output, locale) => {
3291
+ if (!output.ok) {
3292
+ if (output.code) {
3293
+ return getMessage(`marketing.errors.${output.code}`, locale);
3294
+ }
3295
+ return getMessage("marketing.safeError.generic", locale);
3296
+ }
3297
+ if (locale === "en") {
3298
+ return `I saved the brand brief for "${input.brandId}". You can review it in Config > My Business.`;
3299
+ }
3300
+ return `Guard\xE9 el brand brief de "${input.brandId}". Lo puedes revisar en Config > Mi Negocio.`;
3301
+ },
3302
+ auditAction: "marketing.brand_brief.guardar",
3303
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3304
+ extractChanges: (input, output) => ({
3305
+ before: null,
3306
+ // El helper no lee el brief previo (set merge).
3307
+ after: output.ok ? { brandBrief: input.brandBrief } : null
3308
+ }),
3309
+ quotasConsumed: [],
3310
+ permissionScope: "module",
3311
+ permissionKey: "marketing",
3312
+ permissionAction: "editar",
3313
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3314
+ };
3315
+ var brandBriefWriterContract = MartinContractSchema.parse(
3316
+ rawContract
3317
+ );
3318
+ async function save(input) {
3319
+ const { db, tenantId, brandId, plan } = input;
3320
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3321
+ const brandSnap = await brandRef.get();
3322
+ if (!brandSnap.exists) {
3323
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
3324
+ }
3325
+ const { blogStrategy, ...planSinBlogStrategy } = plan;
3326
+ const planWithMeta = {
3327
+ ...planSinBlogStrategy,
3328
+ fechaCreacion: (/* @__PURE__ */ new Date()).toISOString(),
3329
+ creadoPorId: "mcp-cowork"
3330
+ };
3331
+ await brandRef.set({
3332
+ plan: planWithMeta,
3333
+ ...blogStrategy ? { blogStrategy } : {},
3334
+ id: brandId,
3335
+ brandId,
3336
+ tenantId,
3337
+ schemaVersion: 1,
3338
+ updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
3339
+ }, { merge: true });
3340
+ return {
3341
+ ok: true,
3342
+ mensaje: `Plan de marketing guardado para brand "${brandId}"` + (blogStrategy ? " (incluye blogStrategy)" : "")
3343
+ };
3344
+ }
3345
+ async function updateField(input) {
3346
+ const { db, tenantId, brandId, field, value } = input;
3347
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3348
+ const brandSnap = await brandRef.get();
3349
+ if (!brandSnap.exists) {
3350
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
3351
+ }
3352
+ const brandData = brandSnap.data();
3353
+ let parsedValue = value;
3354
+ if (typeof value === "string") {
3355
+ const trimmed = value.trim();
3356
+ if (trimmed.startsWith("[") && trimmed.endsWith("]") || trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith('"') && trimmed.endsWith('"')) {
3357
+ try {
3358
+ parsedValue = JSON.parse(trimmed);
3359
+ } catch {
3360
+ }
3361
+ }
3362
+ }
3363
+ const validation = validatePlanField(field, parsedValue);
3364
+ if (!validation.ok) {
3365
+ return {
3366
+ ok: false,
3367
+ error: `Campo "${field}" no cumple el schema esperado.`,
3368
+ code: "FIELD_VALIDATION_FAILED",
3369
+ detalle: validation.error,
3370
+ path: validation.path,
3371
+ recibido: validation.received,
3372
+ ejemplo_correcto: PLAN_FIELD_EXAMPLES[field],
3373
+ hint: "Manda el valor como objeto/array nativo, NO como string JSON. Usa el ejemplo arriba como referencia exacta del shape esperado."
3374
+ };
3375
+ }
3376
+ const updatePayload = {
3377
+ id: brandId,
3378
+ brandId,
3379
+ tenantId,
3380
+ schemaVersion: 1,
3381
+ updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
3382
+ };
3383
+ if (field === "blogStrategy") {
3384
+ updatePayload.blogStrategy = validation.parsed;
3385
+ } else {
3386
+ const currentPlan = brandData.plan ?? {};
3387
+ updatePayload.plan = {
3388
+ ...currentPlan,
3389
+ [field]: validation.parsed,
3390
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3391
+ };
3392
+ }
3393
+ await brandRef.set(updatePayload, { merge: true });
3394
+ return {
3395
+ ok: true,
3396
+ mensaje: `Campo "${field}" actualizado en el plan de "${brandId}"`
3397
+ };
3398
+ }
3399
+ var planWriter = {
3400
+ save,
3401
+ updateField
3402
+ };
3403
+ var ParamsSchema2 = import_zod30.z.object({
3404
+ tenantId: import_zod30.z.string().min(1).describe("Tenant identifier (the business account)."),
3405
+ brandId: import_zod30.z.string().min(1).describe("Brand identifier within the tenant."),
3406
+ plan: import_zod30.z.record(import_zod30.z.string(), import_zod30.z.unknown()).describe(
3407
+ "Full marketing plan object generated by the LLM. May include a blogStrategy field \u2014 if present, it is extracted and stored at brand level (not nested inside plan)."
3408
+ )
3409
+ });
3410
+ var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3411
+ import_zod30.z.object({
3412
+ ok: import_zod30.z.literal(true),
3413
+ mensaje: import_zod30.z.string()
3414
+ }),
3415
+ import_zod30.z.object({
3416
+ ok: import_zod30.z.literal(false),
3417
+ error: import_zod30.z.string(),
3418
+ code: import_zod30.z.enum(["BRAND_NOT_FOUND", "FIELD_VALIDATION_FAILED"]).optional(),
3419
+ detalle: import_zod30.z.unknown().optional(),
3420
+ path: import_zod30.z.string().optional(),
3421
+ recibido: import_zod30.z.unknown().optional(),
3422
+ ejemplo_correcto: import_zod30.z.unknown().optional(),
3423
+ hint: import_zod30.z.string().optional()
3424
+ })
3425
+ ]);
3426
+ var rawContract2 = {
3427
+ name: "save_marketing_plan",
3428
+ description: "Save a marketing plan for a brand. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
3429
+ paramsSchema: ParamsSchema2,
3430
+ outputSchema: OutputSchema2,
3353
3431
  // No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
3354
3432
  // no afecta publicación, no llama externo.
3355
3433
  requiresConfirmation: false,
@@ -3358,8 +3436,10 @@ var rawContract = {
3358
3436
  affectsExternal: false,
3359
3437
  martinSummaryTemplate: (input, output, locale) => {
3360
3438
  if (!output.ok) {
3361
- if (locale === "en") return `I couldn't save the plan: ${output.error}`;
3362
- return `No pude guardar el plan: ${output.error}`;
3439
+ if (output.code) {
3440
+ return getMessage(`marketing.errors.${output.code}`, locale);
3441
+ }
3442
+ return getMessage("marketing.safeError.generic", locale);
3363
3443
  }
3364
3444
  if (locale === "en") return `I saved the marketing plan for ${input.brandId}.`;
3365
3445
  return `Guard\xE9 el plan de marketing de ${input.brandId}.`;
@@ -3379,7 +3459,56 @@ var rawContract = {
3379
3459
  sideEffects: ["writes_firestore", "updates_brand_config"]
3380
3460
  };
3381
3461
  var planWriterSaveContract = MartinContractSchema.parse(
3382
- rawContract
3462
+ rawContract2
3463
+ );
3464
+ var UpdateFieldParamsSchema = import_zod30.z.object({
3465
+ tenantId: import_zod30.z.string().min(1).describe("Tenant identifier (the business account)."),
3466
+ brandId: import_zod30.z.string().min(1).describe("Brand identifier within the tenant."),
3467
+ field: import_zod30.z.string().min(1).describe(
3468
+ "Plan field name to update (e.g. 'colecciones', 'temporadas', 'quickWins', 'blogStrategy'). blogStrategy is stored at brand level, NOT inside plan."
3469
+ ),
3470
+ value: import_zod30.z.unknown().describe(
3471
+ "New value for the field. Send objects/arrays as native types (not JSON strings). If a JSON-shaped string arrives, the helper auto-parses it as fallback."
3472
+ )
3473
+ });
3474
+ var rawUpdateFieldContract = {
3475
+ name: "update_marketing_plan_field",
3476
+ description: "Update ONE field of an existing marketing plan via partial merge \u2014 does NOT replace the whole plan. Auto-parses JSON-shaped strings into objects/arrays. Validates via validatePlanField against the canonical plan schema; on FIELD_VALIDATION_FAILED returns detalle/path/recibido/ejemplo_correcto/hint for the LLM to self-correct on retry. Note: blogStrategy is stored at the brand level, not inside plan.",
3477
+ paramsSchema: UpdateFieldParamsSchema,
3478
+ outputSchema: OutputSchema2,
3479
+ // Merge parcial sobre el plan existente — reversible (regenerar/reescribir
3480
+ // un campo es trivial). No publica nada externo.
3481
+ requiresConfirmation: false,
3482
+ destructive: false,
3483
+ affectsPublication: false,
3484
+ affectsExternal: false,
3485
+ martinSummaryTemplate: (input, output, locale) => {
3486
+ if (!output.ok) {
3487
+ if (output.code) {
3488
+ return getMessage(`marketing.errors.${output.code}`, locale);
3489
+ }
3490
+ return getMessage("marketing.safeError.generic", locale);
3491
+ }
3492
+ if (locale === "en") {
3493
+ return `I updated the "${input.field}" field in the plan for "${input.brandId}".`;
3494
+ }
3495
+ return `Actualic\xE9 el campo "${input.field}" en el plan de "${input.brandId}".`;
3496
+ },
3497
+ auditAction: "marketing.plan.actualizar_campo",
3498
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3499
+ extractChanges: (input, output) => ({
3500
+ before: null,
3501
+ // El helper no captura el valor previo del campo.
3502
+ after: output.ok ? { field: input.field, value: input.value } : null
3503
+ }),
3504
+ quotasConsumed: [],
3505
+ permissionScope: "module",
3506
+ permissionKey: "marketing",
3507
+ permissionAction: "editar",
3508
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3509
+ };
3510
+ var planWriterUpdateFieldContract = MartinContractSchema.parse(
3511
+ rawUpdateFieldContract
3383
3512
  );
3384
3513
  async function collectionSuggestionsWriter(input) {
3385
3514
  const { db, tenantId, brandId, suggestions } = input;
@@ -3388,6 +3517,7 @@ async function collectionSuggestionsWriter(input) {
3388
3517
  return {
3389
3518
  ok: false,
3390
3519
  error: "Collection suggestions no cumplen el schema esperado.",
3520
+ code: "COLLECTION_SUGGESTIONS_VALIDATION_FAILED",
3391
3521
  detalle: validation.error,
3392
3522
  path: validation.path,
3393
3523
  recibido: validation.received,
@@ -3399,7 +3529,7 @@ async function collectionSuggestionsWriter(input) {
3399
3529
  const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3400
3530
  const brandSnap = await brandRef.get();
3401
3531
  if (!brandSnap.exists) {
3402
- return { ok: false, error: `Brand ${brandId} no encontrada` };
3532
+ return { ok: false, error: `Brand ${brandId} no encontrada`, code: "BRAND_NOT_FOUND" };
3403
3533
  }
3404
3534
  const brandData = brandSnap.data();
3405
3535
  const existing = brandData.collectionSuggestions ?? {};
@@ -3434,6 +3564,73 @@ async function collectionSuggestionsWriter(input) {
3434
3564
  message: `${validSuggestions.length} sugerencias guardadas. El tenant las ver\xE1 en Marketing > Mi Plan > Shopify.`
3435
3565
  };
3436
3566
  }
3567
+ var ParamsSchema3 = import_zod31.z.object({
3568
+ tenantId: import_zod31.z.string().min(1).describe("Tenant identifier (the business account)."),
3569
+ brandId: import_zod31.z.string().min(1).describe("Brand identifier within the tenant."),
3570
+ suggestions: import_zod31.z.array(import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown())).describe(
3571
+ "Array of SEO suggestions per collection. Each item must include collectionId and may include suggestedTitle, suggestedDescription, suggestedMetaTitle, suggestedMetaDescription, suggestedHandle, suggestedImageAlt, keyword, notas. Char limits: metaTitle <=60, metaDescription <=158, imageAlt <=125."
3572
+ )
3573
+ });
3574
+ var OutputSchema3 = import_zod31.z.discriminatedUnion("ok", [
3575
+ import_zod31.z.object({
3576
+ ok: import_zod31.z.literal(true),
3577
+ saved: import_zod31.z.number().int().nonnegative(),
3578
+ message: import_zod31.z.string()
3579
+ }),
3580
+ import_zod31.z.object({
3581
+ ok: import_zod31.z.literal(false),
3582
+ error: import_zod31.z.string(),
3583
+ code: import_zod31.z.enum(["COLLECTION_SUGGESTIONS_VALIDATION_FAILED", "BRAND_NOT_FOUND"]).optional(),
3584
+ detalle: import_zod31.z.unknown().optional(),
3585
+ path: import_zod31.z.string().optional(),
3586
+ recibido: import_zod31.z.unknown().optional(),
3587
+ ejemplo_correcto: import_zod31.z.unknown().optional(),
3588
+ hint: import_zod31.z.string().optional()
3589
+ })
3590
+ ]);
3591
+ var rawContract3 = {
3592
+ name: "save_collection_suggestions",
3593
+ description: "Save SEO suggestions for Shopify collections. Merges per-collection (preserves prior suggestions for other collectionIds). Validates max chars (metaTitle 60, metaDescription 158, imageAlt 125); on COLLECTION_SUGGESTIONS_VALIDATION_FAILED returns detalle/path/recibido/ejemplo_correcto/hint so the LLM can self-correct on retry. Tenant approves them in Marketing > My Plan > Shopify.",
3594
+ paramsSchema: ParamsSchema3,
3595
+ outputSchema: OutputSchema3,
3596
+ // Merge parcial sobre sugerencias previas — reversible (regenerar sugerencia
3597
+ // es trivial). El tenant las aprueba antes de aplicarlas a Shopify, así
3598
+ // que esta operación no publica ni afecta nada externo.
3599
+ requiresConfirmation: false,
3600
+ destructive: false,
3601
+ affectsPublication: false,
3602
+ affectsExternal: false,
3603
+ martinSummaryTemplate: (_input, output, locale) => {
3604
+ if (!output.ok) {
3605
+ if (output.code) {
3606
+ return getMessage(`marketing.errors.${output.code}`, locale);
3607
+ }
3608
+ return getMessage("marketing.safeError.generic", locale);
3609
+ }
3610
+ if (locale === "en") {
3611
+ return `I saved ${output.saved} collection suggestion${output.saved === 1 ? "" : "s"}. The tenant can review them in Marketing > My Plan > Shopify.`;
3612
+ }
3613
+ return `Guard\xE9 ${output.saved} sugerencia${output.saved === 1 ? "" : "s"} de colecci\xF3n. El tenant las puede revisar en Marketing > Mi Plan > Shopify.`;
3614
+ },
3615
+ auditAction: "marketing.collection_suggestions.guardar",
3616
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3617
+ extractChanges: (input, output) => ({
3618
+ before: null,
3619
+ // Helper hace merge sin leer estado previo.
3620
+ after: output.ok ? {
3621
+ saved: output.saved,
3622
+ collectionIds: input.suggestions.map((s) => s.collectionId).filter((id) => id !== void 0)
3623
+ } : null
3624
+ }),
3625
+ quotasConsumed: [],
3626
+ permissionScope: "module",
3627
+ permissionKey: "marketing",
3628
+ permissionAction: "editar",
3629
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3630
+ };
3631
+ var collectionSuggestionsWriterContract = MartinContractSchema.parse(
3632
+ rawContract3
3633
+ );
3437
3634
  var CAMPOS_PERMITIDOS = {
3438
3635
  gbp: {
3439
3636
  required: ["summary", "topicType"],
@@ -3537,11 +3734,15 @@ async function contenidoUpdater(input) {
3537
3734
  const docRef = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoId}`);
3538
3735
  const snap = await docRef.get();
3539
3736
  if (!snap.exists) {
3540
- return { ok: false, error: `Contenido ${contenidoId} no existe` };
3737
+ return { ok: false, error: `Contenido ${contenidoId} no existe`, code: "CONTENT_NOT_FOUND" };
3541
3738
  }
3542
3739
  const existing = snap.data();
3543
3740
  if (existing.tenantId !== tenantId) {
3544
- return { ok: false, error: "Contenido no pertenece a este tenant" };
3741
+ return {
3742
+ ok: false,
3743
+ error: "Contenido no pertenece a este tenant",
3744
+ code: "CONTENT_TENANT_MISMATCH"
3745
+ };
3545
3746
  }
3546
3747
  const plataforma = existing.plataforma;
3547
3748
  if (newDatos && Object.keys(newDatos).length > 0) {
@@ -3551,7 +3752,7 @@ async function contenidoUpdater(input) {
3551
3752
  };
3552
3753
  const schemaError = validateDatosSchema(plataforma, mergedDatos);
3553
3754
  if (schemaError) {
3554
- return { ok: false, error: schemaError };
3755
+ return { ok: false, error: schemaError, code: "CONTENT_DATA_VALIDATION_FAILED" };
3555
3756
  }
3556
3757
  const zodSchemas = {
3557
3758
  shopify_blog: DatosBlogSchema,
@@ -3593,11 +3794,98 @@ async function contenidoUpdater(input) {
3593
3794
  camposActualizados: Object.keys(update).filter((k) => k !== "updatedAt")
3594
3795
  };
3595
3796
  }
3797
+ var ParamsSchema4 = import_zod32.z.object({
3798
+ tenantId: import_zod32.z.string().min(1).describe("Tenant identifier (the business account)."),
3799
+ contenidoId: import_zod32.z.string().min(1).describe("Document ID in marketing_contenido to update."),
3800
+ datos: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown()).optional().describe(
3801
+ "Partial datos object to merge with existing datos via dot notation. Validated against the platform schema (Blog/GBP/IG/Review). OMIT if not updating datos."
3802
+ ),
3803
+ fotoId: import_zod32.z.string().nullable().optional().describe(
3804
+ "Linked photo ID. Pass null to clear the photo, OMIT to leave unchanged."
3805
+ ),
3806
+ keyword: import_zod32.z.string().nullable().optional().describe(
3807
+ "Primary keyword. Pass null to clear, OMIT to leave unchanged."
3808
+ ),
3809
+ languageCode: import_zod32.z.string().optional().describe(
3810
+ "Content language code (e.g. 'es', 'en'). OMIT to leave unchanged."
3811
+ ),
3812
+ estado: import_zod32.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe(
3813
+ "New content state. OMIT to leave unchanged. Use approve_content or reject_content tools for state transitions that require validation."
3814
+ ),
3815
+ calendarioItemRef: import_zod32.z.string().nullable().optional().describe(
3816
+ 'Link content to a calendar slot (format "semana:N:slot:M"). Pass null to unlink, OMIT to leave unchanged.'
3817
+ )
3818
+ });
3819
+ var OutputSchema4 = import_zod32.z.discriminatedUnion("ok", [
3820
+ import_zod32.z.object({
3821
+ ok: import_zod32.z.literal(true),
3822
+ contenidoId: import_zod32.z.string(),
3823
+ camposActualizados: import_zod32.z.array(import_zod32.z.string())
3824
+ }),
3825
+ import_zod32.z.object({
3826
+ ok: import_zod32.z.literal(false),
3827
+ error: import_zod32.z.string(),
3828
+ code: import_zod32.z.enum(["CONTENT_NOT_FOUND", "CONTENT_TENANT_MISMATCH", "CONTENT_DATA_VALIDATION_FAILED"]).optional()
3829
+ })
3830
+ ]);
3831
+ var rawContract4 = {
3832
+ name: "update_generated_content",
3833
+ description: "Update fields of an existing generated content (marketing_contenido) via partial merge. Cannot change tenantId/brandId/id (immutable). If `datos` is passed, it merges with the existing datos (does not replace whole object). Validates `datos` against the platform's schema (Blog/GBP/IG/Review) \u2014 returns CONTENT_DATA_VALIDATION_FAILED with details on mismatch.",
3834
+ paramsSchema: ParamsSchema4,
3835
+ outputSchema: OutputSchema4,
3836
+ // Editar borrador es reversible (volver a llamar con valores previos).
3837
+ // No publica nada externo — el contenido sigue siendo borrador hasta que
3838
+ // el tenant lo apruebe y la CF de publish lo empuje.
3839
+ requiresConfirmation: false,
3840
+ destructive: false,
3841
+ affectsPublication: false,
3842
+ affectsExternal: false,
3843
+ martinSummaryTemplate: (input, output, locale) => {
3844
+ if (!output.ok) {
3845
+ if (output.code) {
3846
+ return getMessage(`marketing.errors.${output.code}`, locale);
3847
+ }
3848
+ return getMessage("marketing.safeError.generic", locale);
3849
+ }
3850
+ const count = output.camposActualizados.length;
3851
+ if (locale === "en") {
3852
+ return `I updated the content (${count} field${count === 1 ? "" : "s"} changed).`;
3853
+ }
3854
+ return `Actualic\xE9 el contenido (${count} campo${count === 1 ? "" : "s"} modificado${count === 1 ? "" : "s"}).`;
3855
+ },
3856
+ auditAction: "marketing.contenido.actualizar",
3857
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_contenido/${input.contenidoId}`,
3858
+ extractChanges: (input, output) => ({
3859
+ before: null,
3860
+ // Helper no captura snapshot previo (merge parcial).
3861
+ after: output.ok ? {
3862
+ camposActualizados: output.camposActualizados,
3863
+ datosKeys: input.datos ? Object.keys(input.datos) : [],
3864
+ fotoId: input.fotoId ?? void 0,
3865
+ keyword: input.keyword ?? void 0,
3866
+ languageCode: input.languageCode,
3867
+ estado: input.estado,
3868
+ calendarioItemRef: input.calendarioItemRef ?? void 0
3869
+ } : null
3870
+ }),
3871
+ quotasConsumed: [],
3872
+ permissionScope: "module",
3873
+ permissionKey: "marketing",
3874
+ permissionAction: "editar",
3875
+ sideEffects: ["writes_firestore"]
3876
+ };
3877
+ var contenidoUpdaterContract = MartinContractSchema.parse(
3878
+ rawContract4
3879
+ );
3596
3880
  async function addCalendarSlot(input) {
3597
3881
  const { db, tenantId, brandId, mes, semana, slot } = input;
3598
3882
  const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
3599
3883
  if (calQuery.empty) {
3600
- return { ok: false, error: `Calendario ${mes} no encontrado para brand ${brandId}` };
3884
+ return {
3885
+ ok: false,
3886
+ error: `Calendario ${mes} no encontrado para brand ${brandId}`,
3887
+ code: "CALENDAR_NOT_FOUND"
3888
+ };
3601
3889
  }
3602
3890
  const calDocRef = calQuery.docs[0].ref;
3603
3891
  let resultSlotIndex = -1;
@@ -3627,40 +3915,47 @@ async function addCalendarSlot(input) {
3627
3915
  });
3628
3916
  return { ok: true, slotIndex: resultSlotIndex };
3629
3917
  }
3630
- var SlotSchema = import_zod30.z.object({
3631
- dia: import_zod30.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato YYYY-MM-DD"),
3632
- plataforma: import_zod30.z.enum(["gbp", "shopify_blog", "instagram", "review"]),
3633
- tipo: import_zod30.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]),
3634
- keyword: import_zod30.z.string().min(1),
3635
- tema: import_zod30.z.string().optional(),
3636
- productoId: import_zod30.z.string().optional(),
3637
- estado: import_zod30.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
3638
- locationId: import_zod30.z.string().optional(),
3639
- locationNombre: import_zod30.z.string().optional()
3918
+ var SlotSchema = import_zod33.z.object({
3919
+ dia: import_zod33.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Format YYYY-MM-DD").describe("Slot date in YYYY-MM-DD format. Must fall within the week range."),
3920
+ plataforma: import_zod33.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target platform for the content."),
3921
+ tipo: import_zod33.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type. Match it to the platform (e.g. blog \u2192 shopify_blog)."),
3922
+ keyword: import_zod33.z.string().min(1).describe("Primary keyword for the content."),
3923
+ tema: import_zod33.z.string().optional().describe("Content topic. OMIT the field if not applicable; do NOT send empty string or null."),
3924
+ productoId: import_zod33.z.string().optional().describe("Linked product ID. OMIT the field if not applicable."),
3925
+ estado: import_zod33.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("Initial state. OMIT to default to 'planificado'."),
3926
+ locationId: import_zod33.z.string().optional().describe("GBP location ID \u2014 only for plataforma=gbp with multi-location. OMIT otherwise."),
3927
+ locationNombre: import_zod33.z.string().optional().describe("GBP location display name. OMIT if not applicable.")
3640
3928
  });
3641
- var ParamsSchema2 = import_zod30.z.object({
3642
- tenantId: import_zod30.z.string().min(1),
3643
- brandId: import_zod30.z.string().min(1),
3644
- mes: import_zod30.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3645
- semana: import_zod30.z.number().int().min(1).max(5),
3646
- slot: SlotSchema
3929
+ var ParamsSchema5 = import_zod33.z.object({
3930
+ tenantId: import_zod33.z.string().min(1).describe("Tenant identifier (the business account)."),
3931
+ brandId: import_zod33.z.string().min(1).describe("Brand identifier within the tenant."),
3932
+ mes: import_zod33.z.string().regex(/^\d{4}-\d{2}$/, "Format YYYY-MM").describe("Calendar month in YYYY-MM format."),
3933
+ semana: import_zod33.z.number().int().min(1).max(5).describe("Week number within the month (1-5)."),
3934
+ slot: SlotSchema.describe("New slot data to add to the calendar.")
3647
3935
  });
3648
- var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3649
- import_zod30.z.object({ ok: import_zod30.z.literal(true), slotIndex: import_zod30.z.number().int() }),
3650
- import_zod30.z.object({ ok: import_zod30.z.literal(false), error: import_zod30.z.string() })
3936
+ var OutputSchema5 = import_zod33.z.discriminatedUnion("ok", [
3937
+ import_zod33.z.object({ ok: import_zod33.z.literal(true), slotIndex: import_zod33.z.number().int() }),
3938
+ import_zod33.z.object({
3939
+ ok: import_zod33.z.literal(false),
3940
+ error: import_zod33.z.string(),
3941
+ code: import_zod33.z.enum(["CALENDAR_NOT_FOUND"]).optional()
3942
+ })
3651
3943
  ]);
3652
- var rawContract2 = {
3944
+ var rawContract5 = {
3653
3945
  name: "add_calendar_slot",
3654
3946
  description: "Add a new slot to the editorial calendar. Does NOT modify existing slots \u2014 use update_calendar_slot for that.",
3655
- paramsSchema: ParamsSchema2,
3656
- outputSchema: OutputSchema2,
3947
+ paramsSchema: ParamsSchema5,
3948
+ outputSchema: OutputSchema5,
3657
3949
  requiresConfirmation: false,
3658
3950
  destructive: false,
3659
3951
  affectsPublication: false,
3660
3952
  affectsExternal: false,
3661
3953
  martinSummaryTemplate: (input, output, locale) => {
3662
3954
  if (!output.ok) {
3663
- return locale === "en" ? `I couldn't add the slot: ${output.error}` : `No pude agregar el slot: ${output.error}`;
3955
+ if (output.code) {
3956
+ return getMessage(`marketing.errors.${output.code}`, locale);
3957
+ }
3958
+ return getMessage("marketing.safeError.generic", locale);
3664
3959
  }
3665
3960
  return locale === "en" ? `Added a slot in week ${input.semana} of ${input.mes}.` : `Agregu\xE9 un slot en la semana ${input.semana} de ${input.mes}.`;
3666
3961
  },
@@ -3680,7 +3975,7 @@ var rawContract2 = {
3680
3975
  sideEffects: ["writes_firestore", "updates_calendar_slot"]
3681
3976
  };
3682
3977
  var addCalendarSlotContract = MartinContractSchema.parse(
3683
- rawContract2
3978
+ rawContract5
3684
3979
  );
3685
3980
  var CAMPOS_SEMANTICOS = ["keyword", "tema", "plataforma", "tipo"];
3686
3981
  function mapContenidoEstadoToSlotEstado(contenidoEstado) {
@@ -3943,35 +4238,39 @@ async function handleNuevoSlot(args) {
3943
4238
  descartados: 0
3944
4239
  };
3945
4240
  }
3946
- var ParamsSchema3 = import_zod31.z.object({
3947
- tenantId: import_zod31.z.string().min(1),
3948
- brandId: import_zod31.z.string().min(1),
3949
- mes: import_zod31.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3950
- semana: import_zod31.z.number().int().min(1).max(5),
3951
- slotIndex: import_zod31.z.number().int().min(0),
3952
- cambios: import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown()),
3953
- accionContenidoExistente: import_zod31.z.string().optional()
4241
+ var ParamsSchema6 = import_zod34.z.object({
4242
+ tenantId: import_zod34.z.string().min(1).describe("Tenant identifier (the business account)."),
4243
+ brandId: import_zod34.z.string().min(1).describe("Brand identifier within the tenant."),
4244
+ mes: import_zod34.z.string().regex(/^\d{4}-\d{2}$/, "Format YYYY-MM").describe("Calendar month in YYYY-MM format."),
4245
+ semana: import_zod34.z.number().int().min(1).max(5).describe("Week number within the month (1-5)."),
4246
+ slotIndex: import_zod34.z.number().int().min(0).describe("Zero-based index of the slot within the week.items[] array."),
4247
+ cambios: import_zod34.z.record(import_zod34.z.string(), import_zod34.z.unknown()).describe(
4248
+ "Fields to update on the slot. Accepts: dia, plataforma, tipo, keyword, tema, productoId, estado, locationId, locationNombre, contenidoRef, fotoIdAsignada, notas. Unknown fields are rejected by the helper."
4249
+ ),
4250
+ accionContenidoExistente: import_zod34.z.string().optional().describe(
4251
+ "Required when the slot already has a contenidoRef AND cambios touch semantic fields (keyword/tema/plataforma/tipo). One of: 'descartar' | 'mover:semana:N:slot:M' | 'nuevo_slot' | 'mantener'. OMIT if not applicable; do NOT send null."
4252
+ )
3954
4253
  });
3955
- var OutputSchema3 = import_zod31.z.discriminatedUnion("ok", [
3956
- import_zod31.z.object({
3957
- ok: import_zod31.z.literal(true),
3958
- action: import_zod31.z.enum(["updated", "nuevo_slot", "moved"]),
3959
- slotIndex: import_zod31.z.number().int(),
3960
- descartados: import_zod31.z.number().int(),
3961
- movedTo: import_zod31.z.string().optional()
4254
+ var OutputSchema6 = import_zod34.z.discriminatedUnion("ok", [
4255
+ import_zod34.z.object({
4256
+ ok: import_zod34.z.literal(true),
4257
+ action: import_zod34.z.enum(["updated", "nuevo_slot", "moved"]),
4258
+ slotIndex: import_zod34.z.number().int(),
4259
+ descartados: import_zod34.z.number().int(),
4260
+ movedTo: import_zod34.z.string().optional()
3962
4261
  }),
3963
- import_zod31.z.object({
3964
- ok: import_zod31.z.literal(false),
3965
- error: import_zod31.z.string(),
3966
- code: import_zod31.z.enum(["SLOT_NOT_FOUND", "ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
3967
- opciones: import_zod31.z.array(import_zod31.z.string()).optional()
4262
+ import_zod34.z.object({
4263
+ ok: import_zod34.z.literal(false),
4264
+ error: import_zod34.z.string(),
4265
+ code: import_zod34.z.enum(["SLOT_NOT_FOUND", "ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
4266
+ opciones: import_zod34.z.array(import_zod34.z.string()).optional()
3968
4267
  })
3969
4268
  ]);
3970
- var rawContract3 = {
4269
+ var rawContract6 = {
3971
4270
  name: "update_calendar_slot",
3972
4271
  description: "MODIFY an existing slot in the editorial calendar. Does NOT add new slots \u2014 use add_calendar_slot for that. If the slot already had a contenidoRef and the changes touch semantic fields (keyword/tema/plataforma/tipo), requires accionContenidoExistente with 4 options: descartar | mover:semana:N:slot:M | nuevo_slot | mantener.",
3973
- paramsSchema: ParamsSchema3,
3974
- outputSchema: OutputSchema3,
4272
+ paramsSchema: ParamsSchema6,
4273
+ outputSchema: OutputSchema6,
3975
4274
  // Por default, modificar un slot es reversible (cambias campos cosméticos).
3976
4275
  // PERO si accionContenidoExistente='descartar', el helper marca el
3977
4276
  // contenido vinculado como descartado — eso SÍ es destructivo. El predicado
@@ -4028,7 +4327,7 @@ var rawContract3 = {
4028
4327
  sideEffects: ["writes_firestore", "updates_calendar_slot"]
4029
4328
  };
4030
4329
  var calendarSlotUpdaterContract = MartinContractSchema.parse(
4031
- rawContract3
4330
+ rawContract6
4032
4331
  );
4033
4332
  async function getCalendar(input) {
4034
4333
  const { db, tenantId, brandId, mes } = input;
@@ -4050,23 +4349,23 @@ async function getCalendar(input) {
4050
4349
  calendario: { id: doc.id, ...doc.data() }
4051
4350
  };
4052
4351
  }
4053
- var ParamsSchema4 = import_zod32.z.object({
4054
- tenantId: import_zod32.z.string().min(1),
4055
- brandId: import_zod32.z.string().min(1),
4056
- mes: import_zod32.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM")
4352
+ var ParamsSchema7 = import_zod35.z.object({
4353
+ tenantId: import_zod35.z.string().min(1).describe("Tenant identifier (the business account)."),
4354
+ brandId: import_zod35.z.string().min(1).describe("Brand identifier within the tenant."),
4355
+ mes: import_zod35.z.string().regex(/^\d{4}-\d{2}$/, "Format YYYY-MM").describe("Calendar month to read in YYYY-MM format.")
4057
4356
  });
4058
- var OutputSchema4 = import_zod32.z.object({
4059
- ok: import_zod32.z.literal(true),
4060
- mes: import_zod32.z.string(),
4061
- brandId: import_zod32.z.string(),
4062
- calendario: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown()).nullable(),
4063
- mensaje: import_zod32.z.string().optional()
4357
+ var OutputSchema7 = import_zod35.z.object({
4358
+ ok: import_zod35.z.literal(true),
4359
+ mes: import_zod35.z.string(),
4360
+ brandId: import_zod35.z.string(),
4361
+ calendario: import_zod35.z.record(import_zod35.z.string(), import_zod35.z.unknown()).nullable(),
4362
+ mensaje: import_zod35.z.string().optional()
4064
4363
  });
4065
- var rawContract4 = {
4364
+ var rawContract7 = {
4066
4365
  name: "get_calendar",
4067
4366
  description: "Read the editorial calendar for a given brand and month. Returns weeks with planned items per platform.",
4068
- paramsSchema: ParamsSchema4,
4069
- outputSchema: OutputSchema4,
4367
+ paramsSchema: ParamsSchema7,
4368
+ outputSchema: OutputSchema7,
4070
4369
  requiresConfirmation: false,
4071
4370
  destructive: false,
4072
4371
  affectsPublication: false,
@@ -4088,7 +4387,7 @@ var rawContract4 = {
4088
4387
  sideEffects: ["reads_firestore"]
4089
4388
  };
4090
4389
  var getCalendarContract = MartinContractSchema.parse(
4091
- rawContract4
4390
+ rawContract7
4092
4391
  );
4093
4392
  var PLATAFORMA_A_FORMATO = {
4094
4393
  gbp: "gbp_4_3",
@@ -4101,21 +4400,26 @@ async function photoAssigner(input) {
4101
4400
  const contenidoRefDoc = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoRef}`);
4102
4401
  const contenidoSnap = await contenidoRefDoc.get();
4103
4402
  if (!contenidoSnap.exists) {
4104
- return { ok: false, error: `Contenido ${contenidoRef} no encontrado` };
4403
+ return { ok: false, error: `Contenido ${contenidoRef} no encontrado`, code: "CONTENT_NOT_FOUND" };
4105
4404
  }
4106
4405
  const contenido = contenidoSnap.data();
4107
4406
  if (contenido.tenantId !== tenantId) {
4108
- return { ok: false, error: "Contenido no pertenece a este tenant" };
4407
+ return {
4408
+ ok: false,
4409
+ error: "Contenido no pertenece a este tenant",
4410
+ code: "CONTENT_TENANT_MISMATCH"
4411
+ };
4109
4412
  }
4110
4413
  const fotoQuery = await db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("id", "==", fotoId).limit(1).get();
4111
4414
  if (fotoQuery.empty) {
4112
- return { ok: false, error: `Foto ${fotoId} no encontrada` };
4415
+ return { ok: false, error: `Foto ${fotoId} no encontrada`, code: "PHOTO_NOT_FOUND" };
4113
4416
  }
4114
4417
  const foto = fotoQuery.docs[0].data();
4115
4418
  if (foto.estado !== ESTADO_FOTO.EDITADA && foto.estado !== "usada") {
4116
4419
  return {
4117
4420
  ok: false,
4118
- error: `Foto en estado "${foto.estado}", debe ser "editada"`
4421
+ error: `Foto en estado "${foto.estado}", debe ser "editada"`,
4422
+ code: "PHOTO_NOT_READY"
4119
4423
  };
4120
4424
  }
4121
4425
  const formato = PLATAFORMA_A_FORMATO[contenido.plataforma] ?? "original";
@@ -4196,6 +4500,78 @@ async function photoAssigner(input) {
4196
4500
  formato
4197
4501
  };
4198
4502
  }
4503
+ var ParamsSchema8 = import_zod36.z.object({
4504
+ tenantId: import_zod36.z.string().min(1).describe("Tenant identifier (the business account)."),
4505
+ brandId: import_zod36.z.string().min(1).describe("Brand identifier within the tenant."),
4506
+ contenidoRef: import_zod36.z.string().min(1).describe("Document ID in marketing_contenido (the post receiving the photo)."),
4507
+ fotoId: import_zod36.z.string().min(1).describe("Photo ID in marketing_fotos. Photo must be in state='editada'."),
4508
+ calendarioItemRef: import_zod36.z.string().regex(/^semana:\d+:slot:\d+$/).optional().describe(
4509
+ 'Calendar slot reference in format "semana:N:slot:M". Optional \u2014 if provided, syncs the slot.fotoIdAsignada UI snapshot atomically with the content update. OMIT if there is no calendar slot to sync.'
4510
+ )
4511
+ });
4512
+ var OutputSchema8 = import_zod36.z.discriminatedUnion("ok", [
4513
+ import_zod36.z.object({
4514
+ ok: import_zod36.z.literal(true),
4515
+ fotoId: import_zod36.z.string(),
4516
+ contenidoRef: import_zod36.z.string(),
4517
+ plataforma: import_zod36.z.string(),
4518
+ varianteUrl: import_zod36.z.string().nullable(),
4519
+ formato: import_zod36.z.string()
4520
+ }),
4521
+ import_zod36.z.object({
4522
+ ok: import_zod36.z.literal(false),
4523
+ error: import_zod36.z.string(),
4524
+ code: import_zod36.z.enum(["CONTENT_NOT_FOUND", "CONTENT_TENANT_MISMATCH", "PHOTO_NOT_FOUND", "PHOTO_NOT_READY"]).optional()
4525
+ })
4526
+ ]);
4527
+ var rawContract8 = {
4528
+ name: "assign_photo_to_content",
4529
+ description: 'Assign an edited photo to an existing content. Writes atomically to BOTH marketing_contenido.fotoId+mediaVariante (canonical for publish) AND marketing_calendario slot.fotoIdAsignada (UI snapshot, only if calendarioItemRef provided). NEVER use update_calendar_slot for this \u2014 that one writes to the agenda, not the post. The photo must be in state="editada".',
4530
+ paramsSchema: ParamsSchema8,
4531
+ outputSchema: OutputSchema8,
4532
+ // Cambia la foto vinculada al contenido — reversible (volver a llamar con
4533
+ // otra fotoId), no publica nada externo. La foto editada queda intacta;
4534
+ // solo se actualiza la referencia.
4535
+ requiresConfirmation: false,
4536
+ destructive: false,
4537
+ affectsPublication: false,
4538
+ affectsExternal: false,
4539
+ martinSummaryTemplate: (input, output, locale) => {
4540
+ if (!output.ok) {
4541
+ if (output.code) {
4542
+ return getMessage(`marketing.errors.${output.code}`, locale);
4543
+ }
4544
+ return getMessage("marketing.safeError.generic", locale);
4545
+ }
4546
+ if (locale === "en") {
4547
+ return `I assigned the photo to the ${output.plataforma} content.`;
4548
+ }
4549
+ return `Asign\xE9 la foto al contenido de ${output.plataforma}.`;
4550
+ },
4551
+ auditAction: "marketing.contenido.foto_asignar",
4552
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_contenido/${input.contenidoRef}`,
4553
+ extractChanges: (input, output) => ({
4554
+ before: null,
4555
+ // El helper no captura fotoId previo del contenido.
4556
+ after: output.ok ? {
4557
+ fotoId: output.fotoId,
4558
+ plataforma: output.plataforma,
4559
+ formato: output.formato,
4560
+ varianteUrl: output.varianteUrl,
4561
+ slotSyncRef: input.calendarioItemRef ?? null
4562
+ } : null
4563
+ }),
4564
+ quotasConsumed: [],
4565
+ permissionScope: "module",
4566
+ permissionKey: "marketing",
4567
+ permissionAction: "editar",
4568
+ // updates_calendar_slot porque sincroniza el snapshot fotoIdAsignada
4569
+ // cuando viene calendarioItemRef.
4570
+ sideEffects: ["writes_firestore", "updates_calendar_slot"]
4571
+ };
4572
+ var photoAssignerContract = MartinContractSchema.parse(
4573
+ rawContract8
4574
+ );
4199
4575
  function findPageByHeuristic(pages, pattern) {
4200
4576
  return pages.find((p) => pattern.test(p.title || "")) || pages.find((p) => pattern.test(p.handle || "")) || null;
4201
4577
  }
@@ -4205,7 +4581,7 @@ async function brandBriefBuilder(input) {
4205
4581
  const { db, tenantId, brandId } = input;
4206
4582
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4207
4583
  if (!configSnap.exists) {
4208
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
4584
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
4209
4585
  }
4210
4586
  const brand = configSnap.data();
4211
4587
  const [snapshotMetaSnap, productsSnap, collectionsSnap, pagesSnap, siteMetaSnap, tenantSnap] = await Promise.all([
@@ -4383,26 +4759,27 @@ var BRAND_BRIEF_SCHEMA_HINT = {
4383
4759
  escenas: [{ id: string, nombre: string, promptHint: string }]
4384
4760
  }`
4385
4761
  };
4386
- var ParamsSchema5 = import_zod33.z.object({
4387
- tenantId: import_zod33.z.string().min(1),
4388
- brandId: import_zod33.z.string().min(1)
4762
+ var ParamsSchema9 = import_zod37.z.object({
4763
+ tenantId: import_zod37.z.string().min(1).describe("Tenant identifier (the business account)."),
4764
+ brandId: import_zod37.z.string().min(1).describe("Brand identifier within the tenant.")
4389
4765
  });
4390
- var OutputSchema5 = import_zod33.z.discriminatedUnion("ok", [
4391
- import_zod33.z.object({
4392
- ok: import_zod33.z.literal(true),
4766
+ var OutputSchema9 = import_zod37.z.discriminatedUnion("ok", [
4767
+ import_zod37.z.object({
4768
+ ok: import_zod37.z.literal(true),
4393
4769
  /** Payload con instrucción + datos del negocio para que Claude genere el brief. */
4394
- payload: import_zod33.z.record(import_zod33.z.string(), import_zod33.z.unknown())
4770
+ payload: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown())
4395
4771
  }),
4396
- import_zod33.z.object({
4397
- ok: import_zod33.z.literal(false),
4398
- error: import_zod33.z.string()
4772
+ import_zod37.z.object({
4773
+ ok: import_zod37.z.literal(false),
4774
+ error: import_zod37.z.string(),
4775
+ code: import_zod37.z.enum(["BRAND_NOT_FOUND"]).optional()
4399
4776
  })
4400
4777
  ]);
4401
- var rawContract5 = {
4778
+ var rawContract9 = {
4402
4779
  name: "generate_brand_brief",
4403
4780
  description: "Aggregate business data (Shopify, SEO, GBP, scraped site_content, brand config, tenant locations) into a payload to generate a pre-filled Brand Brief. Read-only \u2014 does NOT write the brief; the caller saves it via save_brand_brief.",
4404
- paramsSchema: ParamsSchema5,
4405
- outputSchema: OutputSchema5,
4781
+ paramsSchema: ParamsSchema9,
4782
+ outputSchema: OutputSchema9,
4406
4783
  // Lectura pura, sin side effects de escritura ni publicación.
4407
4784
  requiresConfirmation: false,
4408
4785
  destructive: false,
@@ -4410,8 +4787,10 @@ var rawContract5 = {
4410
4787
  affectsExternal: false,
4411
4788
  martinSummaryTemplate: (_input, output, locale) => {
4412
4789
  if (!output.ok) {
4413
- if (locale === "en") return `I couldn't gather the data: ${output.error}`;
4414
- return `No pude reunir los datos: ${output.error}`;
4790
+ if (output.code) {
4791
+ return getMessage(`marketing.errors.${output.code}`, locale);
4792
+ }
4793
+ return getMessage("marketing.safeError.generic", locale);
4415
4794
  }
4416
4795
  if (locale === "en") return `I gathered the data for the brief.`;
4417
4796
  return `Reun\xED los datos para el brief.`;
@@ -4425,7 +4804,7 @@ var rawContract5 = {
4425
4804
  sideEffects: ["reads_firestore"]
4426
4805
  };
4427
4806
  var brandBriefBuilderContract = MartinContractSchema.parse(
4428
- rawContract5
4807
+ rawContract9
4429
4808
  );
4430
4809
  async function resolveLastImportId(db, tenantId, brandId) {
4431
4810
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
@@ -4441,13 +4820,14 @@ async function marketingPlanBuilder(input) {
4441
4820
  const { db, tenantId, brandId } = input;
4442
4821
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4443
4822
  if (!configSnap.exists) {
4444
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
4823
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
4445
4824
  }
4446
4825
  const brand = configSnap.data();
4447
4826
  if (!brand.seoSnapshot) {
4448
4827
  return {
4449
4828
  ok: false,
4450
- error: "No hay snapshot SEO. Ejecuta marketingSeoSnapshot primero."
4829
+ error: "No hay snapshot SEO. Ejecuta marketingSeoSnapshot primero.",
4830
+ code: "SEO_SNAPSHOT_MISSING"
4451
4831
  };
4452
4832
  }
4453
4833
  const productosQ = await db.collection("productos").where("tenantId", "==", tenantId).limit(100).get();
@@ -4612,6 +4992,54 @@ var BLOG_STRATEGY_REGLAS = [
4612
4992
  "blogId, handle, title, ultimoPostFecha, ultimoPostKeyword, totalArticulos \u2014 copiar del shopifyBlogs (datos reales del import)",
4613
4993
  "defaultBlogId y defaultBlogHandle \u2014 usar el primer blog de la lista"
4614
4994
  ];
4995
+ var ParamsSchema10 = import_zod38.z.object({
4996
+ tenantId: import_zod38.z.string().min(1).describe("Tenant identifier (the business account)."),
4997
+ brandId: import_zod38.z.string().min(1).describe("Brand identifier within the tenant.")
4998
+ });
4999
+ var OutputSchema10 = import_zod38.z.discriminatedUnion("ok", [
5000
+ import_zod38.z.object({
5001
+ ok: import_zod38.z.literal(true),
5002
+ payload: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe(
5003
+ "Aggregated business data + instructions + schema hints for the LLM to generate a marketing plan. Includes seoSnapshot, productos, historialReciente, shopifyColecciones, shopifyBlogs, brandBrief, planActual, and schema/rules hints for coleccionesPriorizadas and blogStrategy."
5004
+ )
5005
+ }),
5006
+ import_zod38.z.object({
5007
+ ok: import_zod38.z.literal(false),
5008
+ error: import_zod38.z.string(),
5009
+ code: import_zod38.z.enum(["BRAND_NOT_FOUND", "SEO_SNAPSHOT_MISSING"]).optional()
5010
+ })
5011
+ ]);
5012
+ var rawContract10 = {
5013
+ name: "generate_marketing_plan",
5014
+ description: "Aggregate business data (seoSnapshot, productos, brand config, brandBrief, historial, Shopify collections + blogs) into a payload for the LLM to generate a marketing plan. Read-only \u2014 does NOT save the plan. The caller saves via save_marketing_plan or update_marketing_plan_field after generating. Requires an existing seoSnapshot on the brand (returns SEO_SNAPSHOT_MISSING otherwise).",
5015
+ paramsSchema: ParamsSchema10,
5016
+ outputSchema: OutputSchema10,
5017
+ // Lectura pura, no muta nada.
5018
+ requiresConfirmation: false,
5019
+ destructive: false,
5020
+ affectsPublication: false,
5021
+ affectsExternal: false,
5022
+ martinSummaryTemplate: (_input, output, locale) => {
5023
+ if (!output.ok) {
5024
+ if (output.code) {
5025
+ return getMessage(`marketing.errors.${output.code}`, locale);
5026
+ }
5027
+ return getMessage("marketing.safeError.generic", locale);
5028
+ }
5029
+ if (locale === "en") return `I gathered the data to generate the plan.`;
5030
+ return `Reun\xED los datos para generar el plan.`;
5031
+ },
5032
+ // AUDITA SIEMPRE — regla A5. Lectura no muta, changes son null/null.
5033
+ auditAction: "marketing.plan.preparar",
5034
+ quotasConsumed: [],
5035
+ permissionScope: "module",
5036
+ permissionKey: "marketing",
5037
+ permissionAction: "ver",
5038
+ sideEffects: ["reads_firestore"]
5039
+ };
5040
+ var marketingPlanBuilderContract = MartinContractSchema.parse(
5041
+ rawContract10
5042
+ );
4615
5043
  function buildGenId() {
4616
5044
  return `mkt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
4617
5045
  }
@@ -4656,6 +5084,7 @@ async function contenidoWriter(input) {
4656
5084
  return {
4657
5085
  ok: false,
4658
5086
  error: `Limite de frecuencia alcanzado: ${activosEstaSemana.length}/${limite} ${plataforma} esta semana`,
5087
+ code: "CONTENT_FREQUENCY_LIMIT",
4659
5088
  activosEstaSemana: activosEstaSemana.length,
4660
5089
  limite
4661
5090
  };
@@ -4681,7 +5110,7 @@ async function contenidoWriter(input) {
4681
5110
  }
4682
5111
  const schemaError = validateDatosSchema(plataforma, datos);
4683
5112
  if (schemaError) {
4684
- return { ok: false, error: schemaError };
5113
+ return { ok: false, error: schemaError, code: "CONTENT_DATA_VALIDATION_FAILED" };
4685
5114
  }
4686
5115
  const zodSchemas = {
4687
5116
  shopify_blog: DatosBlogSchema,
@@ -4850,6 +5279,100 @@ async function contenidoWriter(input) {
4850
5279
  pipelineLinked
4851
5280
  };
4852
5281
  }
5282
+ var ParamsSchema11 = import_zod39.z.object({
5283
+ tenantId: import_zod39.z.string().min(1).describe("Tenant identifier (the business account)."),
5284
+ brandId: import_zod39.z.string().min(1).describe("Brand identifier within the tenant."),
5285
+ plataforma: import_zod39.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target platform for the content."),
5286
+ tipo: import_zod39.z.string().optional().describe(
5287
+ "Content type ('post', 'blog', 'carousel', 'reel', 'story', 'review_response'). Match to the platform."
5288
+ ),
5289
+ keyword: import_zod39.z.string().optional().describe("Primary keyword for the content. OMIT if not applicable."),
5290
+ languageCode: import_zod39.z.string().optional().describe(
5291
+ "Content language code (e.g. 'es', 'en'). For shopify_blog auto-injects to datos.languageCode if not present."
5292
+ ),
5293
+ fotoId: import_zod39.z.string().optional().describe("Linked photo ID. OMIT if no photo associated yet (use assign_photo_to_content later)."),
5294
+ datos: import_zod39.z.record(import_zod39.z.string(), import_zod39.z.unknown()).describe(
5295
+ "Platform-specific content payload. Validated against Blog/GBP/IG/Review schemas. Use buildDatosBlog/GBP/IG/Review helpers to construct safely."
5296
+ ),
5297
+ calendarioItemRef: import_zod39.z.string().optional().describe(
5298
+ 'Calendar slot reference (format "semana:N:slot:M"). If provided, links content to that slot AND discards prior non-discarded content of the same slot.'
5299
+ )
5300
+ });
5301
+ var PipelineLinkResultSchema = import_zod39.z.object({
5302
+ linked: import_zod39.z.boolean(),
5303
+ paso: import_zod39.z.string().nullable(),
5304
+ motivo: import_zod39.z.string().optional(),
5305
+ code: import_zod39.z.string().optional(),
5306
+ _instrucciones: import_zod39.z.string().optional()
5307
+ });
5308
+ var OutputSchema11 = import_zod39.z.discriminatedUnion("ok", [
5309
+ import_zod39.z.object({
5310
+ ok: import_zod39.z.literal(true),
5311
+ contenidoId: import_zod39.z.string(),
5312
+ estado: import_zod39.z.string(),
5313
+ plataforma: import_zod39.z.enum(["gbp", "shopify_blog", "instagram", "review"]),
5314
+ descartados: import_zod39.z.number().int().nonnegative(),
5315
+ pipelineLinked: PipelineLinkResultSchema
5316
+ }),
5317
+ import_zod39.z.object({
5318
+ ok: import_zod39.z.literal(false),
5319
+ error: import_zod39.z.string(),
5320
+ code: import_zod39.z.enum(["CONTENT_FREQUENCY_LIMIT", "CONTENT_DATA_VALIDATION_FAILED"]).optional(),
5321
+ activosEstaSemana: import_zod39.z.number().int().optional(),
5322
+ limite: import_zod39.z.number().int().optional()
5323
+ })
5324
+ ]);
5325
+ var rawContract11 = {
5326
+ name: "save_generated_content",
5327
+ description: "Save newly generated marketing content to marketing_contenido. The content is saved as pendiente_aprobacion \u2014 it is NOT published until the tenant approves it. Enforces weekly frequency limits per platform. If calendarioItemRef is provided, links to the slot and discards any prior content of the same slot. Validates datos against the platform schema.",
5328
+ paramsSchema: ParamsSchema11,
5329
+ outputSchema: OutputSchema11,
5330
+ // Crea borrador + descarta borrador previo del mismo slot. Reversible
5331
+ // (volver a llamar con datos corregidos crea un nuevo borrador y descarta
5332
+ // el actual). NO publica nada externo — la CF de publish se encarga
5333
+ // cuando el tenant aprueba.
5334
+ requiresConfirmation: false,
5335
+ destructive: false,
5336
+ affectsPublication: false,
5337
+ affectsExternal: false,
5338
+ martinSummaryTemplate: (input, output, locale) => {
5339
+ if (!output.ok) {
5340
+ if (output.code) {
5341
+ return getMessage(`marketing.errors.${output.code}`, locale);
5342
+ }
5343
+ return getMessage("marketing.safeError.generic", locale);
5344
+ }
5345
+ if (locale === "en") {
5346
+ return `I saved a ${output.plataforma} draft. It needs your approval before publishing.`;
5347
+ }
5348
+ return `Guard\xE9 un borrador de ${output.plataforma}. Necesita tu aprobaci\xF3n antes de publicarse.`;
5349
+ },
5350
+ auditAction: "marketing.contenido.crear",
5351
+ extractTargetPath: (input, output) => output.ok ? `tenants/${input.tenantId}/marketing_contenido/${output.contenidoId}` : `tenants/${input.tenantId}/marketing_contenido/`,
5352
+ extractChanges: (input, output) => ({
5353
+ before: null,
5354
+ after: output.ok ? {
5355
+ contenidoId: output.contenidoId,
5356
+ plataforma: output.plataforma,
5357
+ tipo: input.tipo,
5358
+ keyword: input.keyword,
5359
+ fotoId: input.fotoId,
5360
+ calendarioItemRef: input.calendarioItemRef,
5361
+ descartados: output.descartados,
5362
+ pipelineLinked: output.pipelineLinked
5363
+ } : null
5364
+ }),
5365
+ quotasConsumed: [],
5366
+ permissionScope: "module",
5367
+ permissionKey: "marketing",
5368
+ permissionAction: "editar",
5369
+ // updates_calendar_slot porque sincroniza contenidoRef en el slot cuando
5370
+ // calendarioItemRef viene; sin él, solo writes_firestore.
5371
+ sideEffects: ["writes_firestore", "updates_calendar_slot"]
5372
+ };
5373
+ var contenidoWriterContract = MartinContractSchema.parse(
5374
+ rawContract11
5375
+ );
4853
5376
  function fmtDate(d) {
4854
5377
  const y = d.getFullYear();
4855
5378
  const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -4890,7 +5413,7 @@ async function weeklyContentBuilder(input) {
4890
5413
  const targetSemana = semana ?? Math.ceil(now.getDate() / 7);
4891
5414
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4892
5415
  if (!configSnap.exists) {
4893
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
5416
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
4894
5417
  }
4895
5418
  const brand = configSnap.data();
4896
5419
  const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", targetMes).limit(1).get();
@@ -4954,7 +5477,8 @@ async function weeklyContentBuilder(input) {
4954
5477
  if (slotsExistentes.length === 0) {
4955
5478
  return {
4956
5479
  ok: false,
4957
- error: `La semana ${targetSemana} no tiene slots. Primero usa generate_weekly_content con modo='planificar' para proponer la distribuci\xF3n.`
5480
+ error: `La semana ${targetSemana} no tiene slots. Primero usa generate_weekly_content con modo='planificar' para proponer la distribuci\xF3n.`,
5481
+ code: "WEEK_HAS_NO_SLOTS"
4958
5482
  };
4959
5483
  }
4960
5484
  const slotsParaGenerar = slotsExistentes.filter(
@@ -5122,33 +5646,107 @@ OBLIGATORIO: calendarioItemRef con formato EXACTO "semana:N:slot:M" (ej: "semana
5122
5646
  OBLIGATORIO: fotoId \u2014 SIEMPRE pasa el ID de la foto que elegiste con get_photos_for_slot.
5123
5647
  Si el slot tiene notas[], LEERLAS y usarlas como contexto. Si estado es revisar, regenerar adaptando a las notas.
5124
5648
  BLOG SLOTS: Usa _blogJIT para el contexto de cada slot shopify_blog \u2014 blogTarget, tono, author, articulosExistentes para interlinking.`;
5125
- var DEFAULT_TEXT_THRESHOLD = 0.35;
5126
- var DEFAULT_IMAGE_THRESHOLD = 0.08;
5127
- var DEFAULT_CONFLICT_THRESHOLD = 0.85;
5128
- function jaccardTitleSimilarity(a, b) {
5129
- const tokensA = new Set(
5130
- (a || "").toLowerCase().split(/\s+/).filter((t) => t.length > 2)
5131
- );
5132
- const tokensB = new Set(
5133
- (b || "").toLowerCase().split(/\s+/).filter((t) => t.length > 2)
5134
- );
5135
- if (tokensA.size === 0 && tokensB.size === 0) return 0;
5136
- const intersection = new Set([...tokensA].filter((x) => tokensB.has(x)));
5137
- const union = /* @__PURE__ */ new Set([...tokensA, ...tokensB]);
5138
- if (union.size === 0) return 0;
5139
- return intersection.size / union.size;
5140
- }
5141
- function diversify(items, jaccardThreshold = 0.6) {
5142
- const kept = [];
5143
- for (const item of items) {
5144
- const handle = item.handle;
5145
- if (handle && kept.some((k) => k.handle === handle)) {
5146
- continue;
5147
- }
5148
- const title = item.title || "";
5149
- const isDuplicate = kept.some((k) => {
5150
- const kTitle = k.title || "";
5151
- return jaccardTitleSimilarity(title, kTitle) > jaccardThreshold;
5649
+ var ParamsSchema12 = import_zod40.z.object({
5650
+ tenantId: import_zod40.z.string().min(1).describe("Tenant identifier (the business account)."),
5651
+ brandId: import_zod40.z.string().min(1).describe("Brand identifier within the tenant."),
5652
+ semana: import_zod40.z.number().int().min(1).max(5).optional().describe(
5653
+ "Week number within the current month (1-5). OMIT to default to the current week inferred from today."
5654
+ ),
5655
+ modo: import_zod40.z.enum(["planificar", "generar"]).optional().describe(
5656
+ "Operation mode. 'planificar' (default): propose distribution platform+keyword+tipo per day. 'generar': requires slots in pre_aprobado/revisar \u2014 generate actual content. OMIT to default to 'planificar'."
5657
+ )
5658
+ });
5659
+ var OutputSchema12 = import_zod40.z.discriminatedUnion("ok", [
5660
+ import_zod40.z.object({
5661
+ ok: import_zod40.z.literal(true),
5662
+ payload: import_zod40.z.record(import_zod40.z.string(), import_zod40.z.unknown()).describe(
5663
+ "Aggregated context for the LLM. Shape varies by modo: 'planificar' returns brand skeleton + slotsYaExistentes + historial; 'generar' returns slotsParaGenerar + fotosDisponibles + brand.blogStrategy + blogJIT (if blog slots present)."
5664
+ )
5665
+ }),
5666
+ import_zod40.z.object({
5667
+ ok: import_zod40.z.literal(false),
5668
+ error: import_zod40.z.string(),
5669
+ code: import_zod40.z.enum(["BRAND_NOT_FOUND", "WEEK_HAS_NO_SLOTS"]).optional()
5670
+ })
5671
+ ]);
5672
+ var rawContract12 = {
5673
+ name: "generate_weekly_content",
5674
+ description: "Build the week's content. Two modes: 'planificar' aggregates context for the LLM to propose distribution (LLM then uses add_calendar_slot/update_calendar_slot per day); 'generar' returns pre-approved slots + photos + blog JIT context for the LLM to generate actual content (LLM then calls save_generated_content per slot). AUTO-CREATES the monthly marketing_calendario if it does not exist. NEVER generates content directly \u2014 always returns a payload for the LLM consumer.",
5675
+ paramsSchema: ParamsSchema12,
5676
+ outputSchema: OutputSchema12,
5677
+ // Auto-create de calendario es bootstrap reversible (volver a llamar usa el
5678
+ // calendario existente). NO publica nada externo. modo='generar' tampoco
5679
+ // publica — solo prepara contexto.
5680
+ requiresConfirmation: false,
5681
+ destructive: false,
5682
+ affectsPublication: false,
5683
+ affectsExternal: false,
5684
+ martinSummaryTemplate: (input, output, locale) => {
5685
+ if (!output.ok) {
5686
+ if (output.code) {
5687
+ return getMessage(`marketing.errors.${output.code}`, locale);
5688
+ }
5689
+ return getMessage("marketing.safeError.generic", locale);
5690
+ }
5691
+ const modo = input.modo ?? "planificar";
5692
+ if (locale === "en") {
5693
+ return modo === "planificar" ? `I gathered the context to propose this week's distribution.` : `I gathered the context to generate this week's content.`;
5694
+ }
5695
+ return modo === "planificar" ? `Reun\xED el contexto para proponer la distribuci\xF3n de la semana.` : `Reun\xED el contexto para generar el contenido de la semana.`;
5696
+ },
5697
+ auditAction: "marketing.weekly_content.preparar",
5698
+ // El helper escribe el calendario solo si no existe (auto-create).
5699
+ // El path canónico que afecta es el calendario del mes.
5700
+ extractTargetPath: (input, _output) => {
5701
+ const mes = (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
5702
+ return `tenants/${input.tenantId}/marketing_calendario/${input.tenantId}_${input.brandId}_${mes}`;
5703
+ },
5704
+ extractChanges: (input, output) => ({
5705
+ before: null,
5706
+ after: output.ok ? {
5707
+ modo: input.modo ?? "planificar",
5708
+ semana: input.semana ?? null,
5709
+ payloadKeys: Object.keys(output.payload)
5710
+ } : null
5711
+ }),
5712
+ quotasConsumed: [],
5713
+ permissionScope: "module",
5714
+ permissionKey: "marketing",
5715
+ // 'editar' porque auto-crea calendario si no existe. Si solo fuera read,
5716
+ // sería 'ver' — pero el bootstrap del calendario requiere permiso de escritura.
5717
+ permissionAction: "editar",
5718
+ sideEffects: ["reads_firestore", "writes_firestore"]
5719
+ };
5720
+ var weeklyContentBuilderContract = MartinContractSchema.parse(
5721
+ rawContract12
5722
+ );
5723
+ var DEFAULT_TEXT_THRESHOLD = 0.35;
5724
+ var DEFAULT_IMAGE_THRESHOLD = 0.08;
5725
+ var DEFAULT_CONFLICT_THRESHOLD = 0.85;
5726
+ function jaccardTitleSimilarity(a, b) {
5727
+ const tokensA = new Set(
5728
+ (a || "").toLowerCase().split(/\s+/).filter((t) => t.length > 2)
5729
+ );
5730
+ const tokensB = new Set(
5731
+ (b || "").toLowerCase().split(/\s+/).filter((t) => t.length > 2)
5732
+ );
5733
+ if (tokensA.size === 0 && tokensB.size === 0) return 0;
5734
+ const intersection = new Set([...tokensA].filter((x) => tokensB.has(x)));
5735
+ const union = /* @__PURE__ */ new Set([...tokensA, ...tokensB]);
5736
+ if (union.size === 0) return 0;
5737
+ return intersection.size / union.size;
5738
+ }
5739
+ function diversify(items, jaccardThreshold = 0.6) {
5740
+ const kept = [];
5741
+ for (const item of items) {
5742
+ const handle = item.handle;
5743
+ if (handle && kept.some((k) => k.handle === handle)) {
5744
+ continue;
5745
+ }
5746
+ const title = item.title || "";
5747
+ const isDuplicate = kept.some((k) => {
5748
+ const kTitle = k.title || "";
5749
+ return jaccardTitleSimilarity(title, kTitle) > jaccardThreshold;
5152
5750
  });
5153
5751
  if (!isDuplicate) kept.push(item);
5154
5752
  }
@@ -5309,6 +5907,93 @@ async function contentFinder(input) {
5309
5907
  }
5310
5908
  return result;
5311
5909
  }
5910
+ var IncludeSchema = import_zod41.z.object({
5911
+ products: import_zod41.z.boolean(),
5912
+ collections: import_zod41.z.boolean(),
5913
+ articles: import_zod41.z.boolean(),
5914
+ pages: import_zod41.z.boolean()
5915
+ }).describe(
5916
+ "Toggles for which Shopify content types to search. Default truthy for products/collections/articles, false for pages. Set false to skip a category for performance."
5917
+ );
5918
+ var LimitSchema = import_zod41.z.object({
5919
+ products: import_zod41.z.number().int().min(0).max(20),
5920
+ collections: import_zod41.z.number().int().min(0).max(10),
5921
+ articles: import_zod41.z.number().int().min(0).max(20),
5922
+ pages: import_zod41.z.number().int().min(0).max(10)
5923
+ }).describe(
5924
+ "Per-category result count caps. Defaults: products 5, collections 3, articles 5, pages 2."
5925
+ );
5926
+ var ParamsSchema13 = import_zod41.z.object({
5927
+ tenantId: import_zod41.z.string().min(1).describe("Tenant identifier (the business account)."),
5928
+ brandId: import_zod41.z.string().min(1).describe("Brand identifier within the tenant."),
5929
+ contexto: import_zod41.z.string().min(1).describe(
5930
+ "Search context: a paragraph, keyword, or intent string. Embedded for vector search."
5931
+ ),
5932
+ fecha: import_zod41.z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(
5933
+ "Content date in YYYY-MM-DD. Used to detect active season and prioritize matching collections."
5934
+ ),
5935
+ include: IncludeSchema,
5936
+ limit: LimitSchema,
5937
+ diversidad: import_zod41.z.boolean().describe(
5938
+ "Whether to apply Jaccard title diversification + handle dedupe to results. Default true."
5939
+ ),
5940
+ mode: import_zod41.z.enum(["text", "hybrid"]).optional().describe(
5941
+ "Search mode. 'text' (default): single query per collection against embeddingText, fast. 'hybrid': parallel text + image queries with Reciprocal Rank Fusion, 2x queries \u2014 useful when text mode returns few results or to surface visually-similar items with weak SEO."
5942
+ )
5943
+ });
5944
+ var VectorResultSchema = import_zod41.z.object({
5945
+ id: import_zod41.z.string(),
5946
+ similarity: import_zod41.z.number().optional()
5947
+ }).passthrough();
5948
+ var TemporadaSchema2 = import_zod41.z.object({
5949
+ coleccion: import_zod41.z.string().nullable(),
5950
+ titulo: import_zod41.z.string().nullable(),
5951
+ razon: import_zod41.z.string().nullable(),
5952
+ fechaInicio: import_zod41.z.string().nullable(),
5953
+ fechaFin: import_zod41.z.string().nullable()
5954
+ });
5955
+ var SuggestedActionSchema2 = import_zod41.z.record(import_zod41.z.string(), import_zod41.z.unknown());
5956
+ var DetectedConflictSchema2 = import_zod41.z.record(import_zod41.z.string(), import_zod41.z.unknown());
5957
+ var OutputSchema13 = import_zod41.z.object({
5958
+ productos: import_zod41.z.array(VectorResultSchema),
5959
+ colecciones: import_zod41.z.array(VectorResultSchema),
5960
+ articles: import_zod41.z.array(VectorResultSchema),
5961
+ pages: import_zod41.z.array(VectorResultSchema),
5962
+ _instrucciones: import_zod41.z.string(),
5963
+ _negativePrompt: import_zod41.z.string(),
5964
+ _temporadaActiva: TemporadaSchema2.nullable(),
5965
+ _detectedConflict: DetectedConflictSchema2.optional(),
5966
+ _suggestedActions: import_zod41.z.array(SuggestedActionSchema2)
5967
+ });
5968
+ var rawContract13 = {
5969
+ name: "find_content_for_topic",
5970
+ description: "Find Shopify content (products, collections, articles, pages) semantically related to a context/keyword + date. Uses multimodal vector search (1408d Vertex). Mode 'text' (default) queries embeddingText (fast); 'hybrid' merges text + image results via Reciprocal Rank Fusion (rescues items with weak text SEO). Auto-injects JIT context: linking instructions, brand visual negatives, active season collection, and conflict detection (>85% similar article).",
5971
+ paramsSchema: ParamsSchema13,
5972
+ outputSchema: OutputSchema13,
5973
+ // Lectura pura (vector search). Failures internas en search degradan a
5974
+ // arrays vacíos — el helper NO falla.
5975
+ requiresConfirmation: false,
5976
+ destructive: false,
5977
+ affectsPublication: false,
5978
+ affectsExternal: false,
5979
+ martinSummaryTemplate: (input, output, locale) => {
5980
+ const totalResults = output.productos.length + output.colecciones.length + output.articles.length + output.pages.length;
5981
+ if (locale === "en") {
5982
+ return `I found ${totalResults} item${totalResults === 1 ? "" : "s"} matching "${input.contexto}".`;
5983
+ }
5984
+ return `Encontr\xE9 ${totalResults} resultado${totalResults === 1 ? "" : "s"} relacionado${totalResults === 1 ? "" : "s"} a "${input.contexto}".`;
5985
+ },
5986
+ // AUDITA SIEMPRE — regla A5. Lectura no muta, changes son null/null.
5987
+ auditAction: "marketing.content.buscar",
5988
+ quotasConsumed: [],
5989
+ permissionScope: "module",
5990
+ permissionKey: "marketing",
5991
+ permissionAction: "ver",
5992
+ sideEffects: ["reads_firestore"]
5993
+ };
5994
+ var contentFinderContract = MartinContractSchema.parse(
5995
+ rawContract13
5996
+ );
5312
5997
  var DEFAULT_PHOTO_THRESHOLD = 0.7;
5313
5998
  var DEFAULT_SHOPIFY_THRESHOLD = 0.65;
5314
5999
  function mapPlataformaAFormato(plataforma) {
@@ -5340,7 +6025,7 @@ async function slotAssetFinder(input) {
5340
6025
  const shopifyThreshold = deps.thresholds?.shopify ?? DEFAULT_SHOPIFY_THRESHOLD;
5341
6026
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
5342
6027
  if (!configSnap.exists) {
5343
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
6028
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
5344
6029
  }
5345
6030
  const brand = configSnap.data();
5346
6031
  const brandBrief = brand.brandBrief ?? null;
@@ -5439,6 +6124,7 @@ async function slotAssetFinder(input) {
5439
6124
  temporada ? `Hay temporada activa (${temporada.coleccion.title ?? temporada.coleccion.id}). Respeta bloqueoProducto si esta activo.` : null
5440
6125
  ].filter(Boolean).join(" ");
5441
6126
  return {
6127
+ ok: true,
5442
6128
  fotos,
5443
6129
  _instrucciones,
5444
6130
  _negativePrompt: negativePrompt,
@@ -5453,6 +6139,79 @@ async function slotAssetFinder(input) {
5453
6139
  _fuente: fuente
5454
6140
  };
5455
6141
  }
6142
+ var ParamsSchema14 = import_zod42.z.object({
6143
+ tenantId: import_zod42.z.string().min(1).describe("Tenant identifier (the business account)."),
6144
+ brandId: import_zod42.z.string().min(1).describe("Brand identifier within the tenant."),
6145
+ keyword: import_zod42.z.string().min(1).describe(
6146
+ "Slot keyword to search photos for. Used as embedding query for multimodal vector search."
6147
+ ),
6148
+ plataforma: import_zod42.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe(
6149
+ "Target platform \u2014 determines the variant format to resolve (gbp_4_3, blog_3_2, ig_4_5, ig_1_1)."
6150
+ ),
6151
+ fecha: import_zod42.z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(
6152
+ "Slot date in YYYY-MM-DD. Used to detect active season and apply seasonal catalog overrides."
6153
+ ),
6154
+ limit: import_zod42.z.number().int().min(1).max(10).optional().describe("Max number of photos to return. Default 5. Max 10.")
6155
+ });
6156
+ var TemporadaSchema22 = import_zod42.z.object({
6157
+ coleccion: import_zod42.z.string().nullable(),
6158
+ titulo: import_zod42.z.string().nullable(),
6159
+ razon: import_zod42.z.string().nullable(),
6160
+ fechaInicio: import_zod42.z.string().nullable(),
6161
+ fechaFin: import_zod42.z.string().nullable()
6162
+ });
6163
+ var OutputSchema14 = import_zod42.z.discriminatedUnion("ok", [
6164
+ // Note: success case does not include `ok: true` literal in helper return —
6165
+ // helper returns the success shape directly. Adapter to discriminated union
6166
+ // happens at wrapper level if needed; here we accept both shapes.
6167
+ import_zod42.z.object({
6168
+ ok: import_zod42.z.literal(true),
6169
+ fotos: import_zod42.z.array(import_zod42.z.record(import_zod42.z.string(), import_zod42.z.unknown())),
6170
+ _instrucciones: import_zod42.z.string(),
6171
+ _negativePrompt: import_zod42.z.string(),
6172
+ _temporadaActiva: TemporadaSchema22.nullable(),
6173
+ _bloqueoProducto: import_zod42.z.boolean(),
6174
+ _fuente: import_zod42.z.enum(["tenant", "shopify_product"])
6175
+ }),
6176
+ import_zod42.z.object({
6177
+ ok: import_zod42.z.literal(false),
6178
+ error: import_zod42.z.string(),
6179
+ code: import_zod42.z.enum(["BRAND_NOT_FOUND"]).optional()
6180
+ })
6181
+ ]);
6182
+ var rawContract14 = {
6183
+ name: "get_photos_for_slot",
6184
+ description: "Find tenant photos for a calendar slot (keyword + platform + date) via multimodal vector search (1408d Vertex). Resolves the platform-specific variant (gbp_4_3/blog_3_2/ig_4_5/ig_1_1). Auto-injects JIT context: how to choose, brand visual negatives, active season overrides, and source layer (tenant photos vs Shopify product fallback). If <3 photos returned, consider calling request_photo_shoot to alert the tenant.",
6185
+ paramsSchema: ParamsSchema14,
6186
+ outputSchema: OutputSchema14,
6187
+ requiresConfirmation: false,
6188
+ destructive: false,
6189
+ affectsPublication: false,
6190
+ affectsExternal: false,
6191
+ martinSummaryTemplate: (input, output, locale) => {
6192
+ if (!output.ok) {
6193
+ if (output.code) {
6194
+ return getMessage(`marketing.errors.${output.code}`, locale);
6195
+ }
6196
+ return getMessage("marketing.safeError.generic", locale);
6197
+ }
6198
+ const count = output.fotos.length;
6199
+ if (locale === "en") {
6200
+ return `I found ${count} photo${count === 1 ? "" : "s"} for "${input.keyword}".`;
6201
+ }
6202
+ return `Encontr\xE9 ${count} foto${count === 1 ? "" : "s"} para "${input.keyword}".`;
6203
+ },
6204
+ // AUDITA SIEMPRE — regla A5.
6205
+ auditAction: "marketing.fotos.buscar_para_slot",
6206
+ quotasConsumed: [],
6207
+ permissionScope: "module",
6208
+ permissionKey: "marketing",
6209
+ permissionAction: "ver",
6210
+ sideEffects: ["reads_firestore"]
6211
+ };
6212
+ var slotAssetFinderContract = MartinContractSchema.parse(
6213
+ rawContract14
6214
+ );
5456
6215
  var DEFAULT_SIMILARITY_THRESHOLD = 0.6;
5457
6216
  function cosineSimilarity(a, b) {
5458
6217
  let dot = 0;
@@ -5528,6 +6287,72 @@ async function canvaTemplateSelector(input) {
5528
6287
  _instrucciones: "Plantilla Canva seleccionada. Llama a marketingDesignWithCanva({contenidoId, plantillaId, fotoVariantePath, textos}) para renderizar la pieza final."
5529
6288
  };
5530
6289
  }
6290
+ var ParamsSchema15 = import_zod43.z.object({
6291
+ tenantId: import_zod43.z.string().min(1).describe("Tenant identifier (the business account)."),
6292
+ brandId: import_zod43.z.string().min(1).describe("Brand identifier within the tenant."),
6293
+ plataforma: import_zod43.z.string().min(1).describe(
6294
+ "Target platform (e.g. 'gbp', 'shopify_blog', 'instagram'). Filters templates that declare this plataforma."
6295
+ ),
6296
+ tipoContenido: import_zod43.z.string().min(1).describe(
6297
+ "Content type (e.g. 'post', 'carousel', 'story', 'blog'). Filters templates that declare this tipoContenido."
6298
+ ),
6299
+ keyword: import_zod43.z.string().min(1).describe("Slot keyword. Used as embedding query for cosine similarity match.")
6300
+ });
6301
+ var OutputSchema15 = import_zod43.z.union([
6302
+ import_zod43.z.object({
6303
+ plantillaId: import_zod43.z.string(),
6304
+ titulo: import_zod43.z.string().nullable(),
6305
+ thumbnailUrl: import_zod43.z.string().nullable(),
6306
+ similarity: import_zod43.z.number(),
6307
+ _instrucciones: import_zod43.z.string()
6308
+ }),
6309
+ import_zod43.z.object({
6310
+ plantillaId: import_zod43.z.null(),
6311
+ motivo: import_zod43.z.enum([
6312
+ "brand_no_encontrada",
6313
+ "no_canva",
6314
+ "no_match_plataforma",
6315
+ "modo_cliente_no_soportado",
6316
+ "similarity_baja"
6317
+ ]),
6318
+ similarity: import_zod43.z.number().optional(),
6319
+ _instrucciones: import_zod43.z.string().optional(),
6320
+ _todo: import_zod43.z.string().optional()
6321
+ })
6322
+ ]);
6323
+ var rawContract15 = {
6324
+ name: "select_canva_template",
6325
+ description: "Select the best Canva template from the tenant for a slot. Filters templates by plataforma+tipoContenido, then cosine-similarity matches embeddings vs the keyword query. Returns plantillaId on match (similarity >= 0.60). On miss returns plantillaId=null with motivo enum (no_canva, no_match_plataforma, modo_cliente_no_soportado, similarity_baja, brand_no_encontrada). The LLM should degrade gracefully (generate without Canva template) on any miss.",
6326
+ paramsSchema: ParamsSchema15,
6327
+ outputSchema: OutputSchema15,
6328
+ // Lectura pura. NO falla — todo "no encontré" es resultado válido del helper.
6329
+ requiresConfirmation: false,
6330
+ destructive: false,
6331
+ affectsPublication: false,
6332
+ affectsExternal: false,
6333
+ martinSummaryTemplate: (_input, output, locale) => {
6334
+ if (output.plantillaId !== null) {
6335
+ if (locale === "en") {
6336
+ return `I selected the Canva template "${output.titulo ?? output.plantillaId}".`;
6337
+ }
6338
+ return `Seleccion\xE9 la plantilla Canva "${output.titulo ?? output.plantillaId}".`;
6339
+ }
6340
+ if (locale === "en") {
6341
+ return `I couldn't pick a Canva template (${output.motivo}). Generating without one.`;
6342
+ }
6343
+ return `No pude elegir plantilla Canva (${output.motivo}). Genero sin plantilla.`;
6344
+ },
6345
+ // AUDITA SIEMPRE — regla A5.
6346
+ auditAction: "marketing.canva.seleccionar_plantilla",
6347
+ quotasConsumed: [],
6348
+ permissionScope: "module",
6349
+ permissionKey: "marketing",
6350
+ permissionAction: "ver",
6351
+ sideEffects: ["reads_firestore"]
6352
+ };
6353
+ var canvaTemplateSelectorContract = MartinContractSchema.parse(
6354
+ rawContract15
6355
+ );
5531
6356
  function buildDirectorPlanInstrucciones(catalogoVisual) {
5532
6357
  const etiquetas = catalogoVisual.etiquetas || {};
5533
6358
  return `Eres el director de arte de este negocio. Estas viendo la foto ORIGINAL sin editar que el tenant subio (comprimida para transporte, la original en calidad completa esta en archivoOriginal).
@@ -5750,6 +6575,159 @@ async function photoDirectorExecute(input) {
5750
6575
  _instrucciones: buildDirectorExecuteSuccessInstrucciones(result.balanceAfter)
5751
6576
  };
5752
6577
  }
6578
+ var PlanParamsSchema = import_zod44.z.object({
6579
+ tenantId: import_zod44.z.string().min(1).describe("Tenant identifier (the business account)."),
6580
+ fotoId: import_zod44.z.string().min(1).describe("Photo ID in marketing_fotos. The photo must have an archivoOriginal uploaded.")
6581
+ });
6582
+ var PlanContextSchema = import_zod44.z.object({
6583
+ fotoId: import_zod44.z.string(),
6584
+ archivoOriginal: import_zod44.z.string(),
6585
+ estrategia: import_zod44.z.string(),
6586
+ brandBrief: import_zod44.z.object({
6587
+ segmento: import_zod44.z.string().nullable(),
6588
+ personalidad: import_zod44.z.unknown().nullable()
6589
+ }),
6590
+ visualRules: import_zod44.z.record(import_zod44.z.string(), import_zod44.z.unknown()),
6591
+ catalogoVisual: import_zod44.z.record(import_zod44.z.string(), import_zod44.z.unknown()),
6592
+ estiloVisual: import_zod44.z.unknown().nullable(),
6593
+ notaTenant: import_zod44.z.unknown().nullable(),
6594
+ productoLinkeadoManual: import_zod44.z.unknown().nullable(),
6595
+ _instrucciones: import_zod44.z.string()
6596
+ });
6597
+ var PlanOutputSchema = import_zod44.z.discriminatedUnion("ok", [
6598
+ import_zod44.z.object({
6599
+ ok: import_zod44.z.literal(true),
6600
+ imageBase64: import_zod44.z.string().describe("Compressed photo as base64 for transport to the LLM."),
6601
+ context: PlanContextSchema
6602
+ }),
6603
+ import_zod44.z.object({
6604
+ ok: import_zod44.z.literal(false),
6605
+ code: import_zod44.z.string(),
6606
+ error: import_zod44.z.string(),
6607
+ _instrucciones: import_zod44.z.string().optional(),
6608
+ fix: import_zod44.z.string().optional(),
6609
+ fotoId: import_zod44.z.string().optional()
6610
+ })
6611
+ ]);
6612
+ var planRawContract = {
6613
+ name: "plan_photo_edit",
6614
+ description: "Load the original photo (compressed) + brand context (brandBrief, visualRules, catalogoVisual, notaTenant, productoLinkeadoManual) so the LLM can act as art director and decide tags + edit prompt. Read-only. Returns code=BRAND_CATALOG_NOT_DEFINED if the brand has no catalogoVisual yet \u2014 call generate_brand_brief first.",
6615
+ paramsSchema: PlanParamsSchema,
6616
+ outputSchema: PlanOutputSchema,
6617
+ requiresConfirmation: false,
6618
+ destructive: false,
6619
+ affectsPublication: false,
6620
+ affectsExternal: false,
6621
+ martinSummaryTemplate: (_input, output, locale) => {
6622
+ if (!output.ok) {
6623
+ return getMessage(`marketing.errors.${output.code}`, locale);
6624
+ }
6625
+ if (locale === "en") return `I loaded the photo and the brand context. Decide the edit.`;
6626
+ return `Cargu\xE9 la foto y el contexto de la brand. Decide la edici\xF3n.`;
6627
+ },
6628
+ // AUDITA SIEMPRE — regla A5.
6629
+ auditAction: "marketing.fotos.plan_edicion",
6630
+ quotasConsumed: [],
6631
+ permissionScope: "module",
6632
+ permissionKey: "marketing",
6633
+ permissionAction: "ver",
6634
+ sideEffects: ["reads_firestore"]
6635
+ };
6636
+ var photoDirectorPlanContract = MartinContractSchema.parse(
6637
+ planRawContract
6638
+ );
6639
+ var ExecuteParamsSchema = import_zod44.z.object({
6640
+ // tenantId is injected by the wrapper from ctx (A7 module-scope pattern,
6641
+ // even though the helper function itself derives tenantId via the foto's
6642
+ // brandId). Needed here for extractTargetPath canonical path.
6643
+ tenantId: import_zod44.z.string().min(1).describe("Tenant identifier (the business account)."),
6644
+ fotoId: import_zod44.z.string().min(1).describe("Photo ID to edit (must have been planned via plan_photo_edit)."),
6645
+ prompt: import_zod44.z.string().nullable().describe(
6646
+ "English prompt for Gemini Image Edit when acciones includes edit_background. Pass null when strategy is 'tal_cual' (no editing \u2014 just regenerate thumbnail + embedding with new tags)."
6647
+ ),
6648
+ acciones: import_zod44.z.array(import_zod44.z.enum(["edit_background", "none"])).describe(
6649
+ "Edit operations to perform. Use ['edit_background'] for AI background edit, ['none'] when strategy is 'tal_cual'."
6650
+ ),
6651
+ descripcion: import_zod44.z.string().describe("Semantic description in Spanish (saved as descripcionVision on the photo)."),
6652
+ tipo: import_zod44.z.string().nullable().describe("Photo type from the brand catalogoVisual. null only if catalog has no matching type."),
6653
+ tagsPrimarios: import_zod44.z.array(import_zod44.z.string()).describe("Primary tags from the catalogoVisual."),
6654
+ tagsSecundarios: import_zod44.z.array(import_zod44.z.string()).describe("Secondary tags from the catalogoVisual."),
6655
+ tagsContexto: import_zod44.z.array(import_zod44.z.string()).describe("Contextual tags (occasion, mood, style).")
6656
+ });
6657
+ var ExecuteOutputSchema = import_zod44.z.discriminatedUnion("ok", [
6658
+ import_zod44.z.object({
6659
+ ok: import_zod44.z.literal(true),
6660
+ fotoId: import_zod44.z.string(),
6661
+ archivoEditado: import_zod44.z.string().optional(),
6662
+ thumbnailUrl: import_zod44.z.string().optional(),
6663
+ iteracion: import_zod44.z.number().optional(),
6664
+ editCosto: import_zod44.z.number().optional(),
6665
+ creditsConsumed: import_zod44.z.number().optional(),
6666
+ balanceAfter: import_zod44.z.number().optional(),
6667
+ imageBase64: import_zod44.z.string().nullable(),
6668
+ _instrucciones: import_zod44.z.string()
6669
+ }),
6670
+ import_zod44.z.object({
6671
+ ok: import_zod44.z.literal(false),
6672
+ error: import_zod44.z.string(),
6673
+ code: import_zod44.z.string(),
6674
+ details: import_zod44.z.record(import_zod44.z.string(), import_zod44.z.unknown()).optional(),
6675
+ _instrucciones: import_zod44.z.string()
6676
+ })
6677
+ ]);
6678
+ var executeRawContract = {
6679
+ name: "execute_photo_edit",
6680
+ description: "Execute photo edit via Gemini Image Edit using the prompt and tags decided after viewing the photo in plan_photo_edit. Consumes credits per edit. Returns the edited photo for review; if not satisfactory, call again with adjusted prompt (max 3 iterations per photo). For 'tal_cual' strategy pass acciones=['none'] \u2014 no editing but thumbnail + embedding regenerate with the new tags.",
6681
+ paramsSchema: ExecuteParamsSchema,
6682
+ outputSchema: ExecuteOutputSchema,
6683
+ // CONSUME CRÉDITOS reales y muta la foto del tenant. requiresConfirmation
6684
+ // canónico para acciones costosas (regla decision tree del MARTIN_CONTRACT_PATTERN).
6685
+ requiresConfirmation: true,
6686
+ destructive: false,
6687
+ affectsPublication: false,
6688
+ affectsExternal: true,
6689
+ martinConfirmationTemplate: (_input, locale) => {
6690
+ if (locale === "en") {
6691
+ return `Edit this photo with AI? It consumes credits from your monthly balance.`;
6692
+ }
6693
+ return `\xBFEdito esta foto con IA? Consume cr\xE9ditos de tu saldo mensual.`;
6694
+ },
6695
+ martinSummaryTemplate: (_input, output, locale) => {
6696
+ if (!output.ok) {
6697
+ return getMessage(`marketing.errors.${output.code}`, locale);
6698
+ }
6699
+ if (locale === "en") {
6700
+ return `I edited the photo. Review the result; if it's not right, call again with an adjusted prompt.`;
6701
+ }
6702
+ return `Edit\xE9 la foto. Revisa el resultado; si no qued\xF3, llama de nuevo con prompt ajustado.`;
6703
+ },
6704
+ auditAction: "marketing.fotos.editar",
6705
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_fotos/${input.fotoId}`,
6706
+ extractChanges: (input, output) => ({
6707
+ before: null,
6708
+ after: output.ok ? {
6709
+ fotoId: output.fotoId,
6710
+ tipo: input.tipo,
6711
+ tagsPrimarios: input.tagsPrimarios,
6712
+ tagsSecundarios: input.tagsSecundarios,
6713
+ tagsContexto: input.tagsContexto,
6714
+ acciones: input.acciones,
6715
+ archivoEditado: output.archivoEditado,
6716
+ iteracion: output.iteracion,
6717
+ creditsConsumed: output.creditsConsumed
6718
+ } : null
6719
+ }),
6720
+ quotasConsumed: ["photoEditsPerMonth"],
6721
+ permissionScope: "module",
6722
+ permissionKey: "marketing",
6723
+ permissionAction: "editar",
6724
+ // affectsExternal=true porque llama a Gemini Image Edit + descarga thumbnail
6725
+ // + escribe doc actualizado.
6726
+ sideEffects: ["writes_firestore", "spends_credits", "consumes_ai_tokens"]
6727
+ };
6728
+ var photoDirectorExecuteContract = MartinContractSchema.parse(
6729
+ executeRawContract
6730
+ );
5753
6731
  var PARTE_1_IDENTIDAD = `
5754
6732
  Eres el asistente de marketing digital de {{brand_nombre}}.
5755
6733
  Tu trabajo es generar contenido de alta calidad para publicar en
@@ -6797,63 +7775,86 @@ function wrapWithContract(contract, helper, options = {}) {
6797
7775
  };
6798
7776
  };
6799
7777
  }
6800
- var RecordarMemoriaParamsSchema = import_zod34.z.object({
6801
- tipo: TipoMemoriaEnum,
6802
- categoria: CategoriaMemoriaEnum,
6803
- contenido: import_zod34.z.string().min(3).max(500)
7778
+ var RecordarMemoriaParamsSchema = import_zod45.z.object({
7779
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7780
+ tenantId: import_zod45.z.string().min(1).describe("Tenant identifier (the business account)."),
7781
+ uid: import_zod45.z.string().min(1).describe("User ID. Memories are private to this user within the tenant."),
7782
+ tipo: TipoMemoriaEnum.describe(
7783
+ "Memory type: 'preferencia' (personal preference), 'regla' (operational rule, e.g. 'do not alert for amounts <$500'), 'patron' (observed behavioral pattern), 'aversion' (explicit 'do not do X' rule)."
7784
+ ),
7785
+ categoria: CategoriaMemoriaEnum.describe(
7786
+ "Business area this memory applies to (compras, produccion, dispatch, ventas, marketing, operacion, personal, delegacion)."
7787
+ ),
7788
+ contenido: import_zod45.z.string().min(3).max(500).describe("Memory content in the user's own words (3-500 chars).")
6804
7789
  });
6805
- var RecordarMemoriaOutputSchema = import_zod34.z.object({
6806
- memoriaId: import_zod34.z.string(),
6807
- status: import_zod34.z.literal("creada")
7790
+ var RecordarMemoriaOutputSchema = import_zod45.z.object({
7791
+ memoriaId: import_zod45.z.string(),
7792
+ status: import_zod45.z.literal("creada")
6808
7793
  });
6809
- var OlvidarMemoriaParamsSchema = import_zod34.z.object({
6810
- memoriaId: import_zod34.z.string(),
6811
- motivo: import_zod34.z.string().optional()
7794
+ var OlvidarMemoriaParamsSchema = import_zod45.z.object({
7795
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7796
+ tenantId: import_zod45.z.string().min(1).describe("Tenant identifier (the business account)."),
7797
+ uid: import_zod45.z.string().min(1).describe("User ID. Memories are private to this user within the tenant."),
7798
+ memoriaId: import_zod45.z.string().describe("Memory document ID to archive."),
7799
+ motivo: import_zod45.z.string().optional().describe("Optional reason for archiving (logged for audit). OMIT if not applicable.")
6812
7800
  });
6813
- var OlvidarMemoriaOutputSchema = import_zod34.z.object({
6814
- status: import_zod34.z.literal("archivada")
7801
+ var OlvidarMemoriaOutputSchema = import_zod45.z.object({
7802
+ status: import_zod45.z.literal("archivada")
6815
7803
  });
6816
- var ConfigInputSchema = import_zod35.z.object({
6817
- diaSemana: import_zod35.z.number().int().min(0).max(6).nullable(),
6818
- diaMes: import_zod35.z.number().int().min(1).max(31).nullable(),
6819
- hora: import_zod35.z.string().regex(/^\d{2}:\d{2}$/),
6820
- // Timezone NO viene en input — hereda de tenants/{tid}.zonaHoraria (audit fix 2).
6821
- fechaPuntual: import_zod35.z.string().datetime({ offset: true }).nullable()
7804
+ var ConfigInputSchema = import_zod46.z.object({
7805
+ diaSemana: import_zod46.z.number().int().min(0).max(6).nullable().describe("Day of week (0=Sunday, 6=Saturday) for weekly routines. null for daily/monthly/puntual."),
7806
+ diaMes: import_zod46.z.number().int().min(1).max(31).nullable().describe("Day of month (1-31) for monthly routines. null for daily/weekly/puntual."),
7807
+ hora: import_zod46.z.string().regex(/^\d{2}:\d{2}$/).describe("Execution time in HH:MM format (24h, tenant timezone)."),
7808
+ // Timezone is NOT input — inherited from tenants/{tid}.zonaHoraria (audit fix 2).
7809
+ fechaPuntual: import_zod46.z.string().datetime({ offset: true }).nullable().describe("ISO datetime with offset for one-time routines (frecuencia=puntual). null otherwise.")
6822
7810
  }).strict();
6823
- var AccionInputSchema = import_zod35.z.object({
6824
- tool: import_zod35.z.string().min(1),
6825
- params: import_zod35.z.record(import_zod35.z.string(), import_zod35.z.unknown())
7811
+ var AccionInputSchema = import_zod46.z.object({
7812
+ tool: import_zod46.z.string().min(1).describe("MCP tool name to invoke when the routine fires (e.g. generate_weekly_content)."),
7813
+ params: import_zod46.z.record(import_zod46.z.string(), import_zod46.z.unknown()).describe("Parameters to pass to the tool. Schema depends on the target tool.")
6826
7814
  }).strict();
6827
- var ProgramarRutinaParamsSchema = import_zod35.z.object({
6828
- tipo: TipoRutinaEnum,
6829
- frecuencia: FrecuenciaRutinaEnum,
6830
- config: ConfigInputSchema,
6831
- accion: AccionInputSchema,
6832
- uidDestinatario: import_zod35.z.string()
7815
+ var ProgramarRutinaParamsSchema = import_zod46.z.object({
7816
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7817
+ tenantId: import_zod46.z.string().min(1).describe("Tenant identifier (the business account)."),
7818
+ uid: import_zod46.z.string().min(1).describe("User ID. Routines are private to this user within the tenant."),
7819
+ tipo: TipoRutinaEnum.describe("Routine type/category from the canonical enum."),
7820
+ frecuencia: FrecuenciaRutinaEnum.describe(
7821
+ "Execution cadence: 'diaria' | 'semanal' | 'mensual' | 'puntual'."
7822
+ ),
7823
+ config: ConfigInputSchema.describe("Schedule configuration. Match fields to the frecuencia."),
7824
+ accion: AccionInputSchema.describe("Action to execute on each fire (tool + params)."),
7825
+ uidDestinatario: import_zod46.z.string().describe("User ID who should receive the routine output (usually same as uid).")
6833
7826
  });
6834
- var ProgramarRutinaOutputSchema = import_zod35.z.object({
6835
- rutinaId: import_zod35.z.string(),
6836
- proximaEjecucionAt: import_zod35.z.string().datetime()
7827
+ var ProgramarRutinaOutputSchema = import_zod46.z.object({
7828
+ rutinaId: import_zod46.z.string(),
7829
+ proximaEjecucionAt: import_zod46.z.string().datetime()
6837
7830
  });
6838
- var PausarRutinaParamsSchema = import_zod35.z.object({
6839
- rutinaId: import_zod35.z.string(),
6840
- motivo: import_zod35.z.string().optional()
7831
+ var PausarRutinaParamsSchema = import_zod46.z.object({
7832
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7833
+ tenantId: import_zod46.z.string().min(1).describe("Tenant identifier (the business account)."),
7834
+ uid: import_zod46.z.string().min(1).describe("User ID. Routines are private to this user within the tenant."),
7835
+ rutinaId: import_zod46.z.string().describe("Routine document ID to pause."),
7836
+ motivo: import_zod46.z.string().optional().describe("Optional reason for pausing (logged for audit). OMIT if not applicable.")
6841
7837
  });
6842
- var PausarRutinaOutputSchema = import_zod35.z.object({
6843
- status: import_zod35.z.literal("pausada")
7838
+ var PausarRutinaOutputSchema = import_zod46.z.object({
7839
+ status: import_zod46.z.literal("pausada")
6844
7840
  });
6845
- var ArchivarRutinaParamsSchema = import_zod35.z.object({
6846
- rutinaId: import_zod35.z.string(),
6847
- motivo: import_zod35.z.string().optional()
7841
+ var ArchivarRutinaParamsSchema = import_zod46.z.object({
7842
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7843
+ tenantId: import_zod46.z.string().min(1).describe("Tenant identifier (the business account)."),
7844
+ uid: import_zod46.z.string().min(1).describe("User ID. Routines are private to this user within the tenant."),
7845
+ rutinaId: import_zod46.z.string().describe("Routine document ID to archive (will not execute again)."),
7846
+ motivo: import_zod46.z.string().optional().describe("Optional reason for archiving (logged for audit). OMIT if not applicable.")
6848
7847
  });
6849
- var ArchivarRutinaOutputSchema = import_zod35.z.object({
6850
- status: import_zod35.z.literal("archivada")
7848
+ var ArchivarRutinaOutputSchema = import_zod46.z.object({
7849
+ status: import_zod46.z.literal("archivada")
6851
7850
  });
6852
- var ListarRutinasParamsSchema = import_zod35.z.object({
6853
- uid: import_zod35.z.string().optional()
7851
+ var ListarRutinasParamsSchema = import_zod46.z.object({
7852
+ uid: import_zod46.z.string().optional().describe(
7853
+ "User ID to list routines for. OMIT to default to the calling user (the wrapper rejects requests for other uids unless caller is super_admin)."
7854
+ )
6854
7855
  });
6855
- var ListarRutinasOutputSchema = import_zod35.z.object({
6856
- rutinas: import_zod35.z.array(MartinRutinaSchema)
7856
+ var ListarRutinasOutputSchema = import_zod46.z.object({
7857
+ rutinas: import_zod46.z.array(MartinRutinaSchema)
6857
7858
  });
6858
7859
 
6859
7860
  // src/tools/buildContext.ts
@@ -7001,7 +8002,7 @@ function callPhotoDirectorExecute(input) {
7001
8002
  }
7002
8003
 
7003
8004
  // src/tools/marketing/photos.ts
7004
- var import_zod37 = require("zod");
8005
+ var import_zod47 = require("zod");
7005
8006
 
7006
8007
  // src/services/marketingEmbeddings.ts
7007
8008
  var import_google_auth_library = require("google-auth-library");
@@ -7268,259 +8269,70 @@ async function findNearestInCollectionWithOverride(params) {
7268
8269
  return findNearestInCollection(params);
7269
8270
  }
7270
8271
 
7271
- // src/tools/marketing/content.ts
7272
- var import_zod36 = require("zod");
7273
- var _logOverride = null;
7274
- async function logToMcpLogs(entry) {
7275
- if (_logOverride) return _logOverride(entry);
7276
- try {
7277
- const db = getAdminDb();
7278
- await db.collection("marketing_mcp_logs").add({
7279
- ...entry,
7280
- timestamp: import_firebase_admin.default.firestore.FieldValue.serverTimestamp()
7281
- });
7282
- } catch {
8272
+ // src/tools/marketing/photos.ts
8273
+ async function compressImageForTransport(imageUrl) {
8274
+ const sharp = (await import("sharp")).default;
8275
+ const res = await fetch(imageUrl);
8276
+ if (!res.ok) throw new Error(`download image for transport: HTTP ${res.status}`);
8277
+ const buffer = Buffer.from(await res.arrayBuffer());
8278
+ let quality = 80;
8279
+ let compressed = await sharp(buffer).resize(1568, 1568, { fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
8280
+ while (compressed.length > 9e5 && quality > 40) {
8281
+ quality -= 10;
8282
+ compressed = await sharp(buffer).resize(1568, 1568, { fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
7283
8283
  }
8284
+ return compressed.toString("base64");
7284
8285
  }
7285
- var IncludeSchema = import_zod36.z.object({
7286
- products: import_zod36.z.boolean().default(true),
7287
- collections: import_zod36.z.boolean().default(true),
7288
- articles: import_zod36.z.boolean().default(true),
7289
- pages: import_zod36.z.boolean().default(false)
7290
- }).default({
7291
- products: true,
7292
- collections: true,
7293
- articles: true,
7294
- pages: false
7295
- });
7296
- var LimitSchema = import_zod36.z.object({
7297
- products: import_zod36.z.number().int().min(0).max(20).default(5),
7298
- collections: import_zod36.z.number().int().min(0).max(10).default(3),
7299
- articles: import_zod36.z.number().int().min(0).max(20).default(5),
7300
- pages: import_zod36.z.number().int().min(0).max(10).default(2)
7301
- }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
7302
- async function findContentForTopicHandler(input, session) {
7303
- const tenantId = session.requireTenant();
7304
- const brandId = input.brandId;
7305
- const mode = getSdkMode();
7306
- const r = mode === "admin" ? await contentFinder({
7307
- db: getAdminDb(),
7308
- tenantId,
7309
- brandId,
7310
- contexto: input.contexto,
7311
- fecha: input.fecha,
7312
- include: input.include,
7313
- limit: input.limit,
7314
- diversidad: input.diversidad,
7315
- mode: input.mode,
7316
- deps: {
7317
- generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
7318
- findNearestInCollection: (params) => findNearestInCollectionWithOverride(
7319
- params
7320
- ),
7321
- rrfMerge,
7322
- thresholds: {
7323
- text: MARKETING_THRESHOLDS.content.text,
7324
- image: MARKETING_THRESHOLDS.content.image,
7325
- conflictSimilarity: MARKETING_THRESHOLDS.content.conflictSimilarity
7326
- }
7327
- }
7328
- }) : await callContentFinder({
7329
- tenantId,
7330
- brandId,
7331
- contexto: input.contexto,
7332
- fecha: input.fecha,
7333
- include: input.include,
7334
- limit: input.limit,
7335
- diversidad: input.diversidad,
7336
- mode: input.mode
7337
- });
7338
- logToMcpLogs({
7339
- toolName: "find_content_for_topic",
7340
- tenantId,
7341
- brandId,
7342
- input: {
7343
- contexto: input.contexto.slice(0, 500),
7344
- fecha: input.fecha,
7345
- include: input.include,
7346
- diversidad: input.diversidad
7347
- },
7348
- output: {
7349
- productsCount: r.productos.length,
7350
- collectionsCount: r.colecciones.length,
7351
- articlesCount: r.articles.length,
7352
- pagesCount: r.pages.length,
7353
- _suggestedActionsCount: r._suggestedActions.length,
7354
- _hadConflict: !!r._detectedConflict,
7355
- conflictType: r._detectedConflict?.type || null
7356
- }
7357
- }).catch((err) => console.error("[mcp_logs] write failed:", err));
7358
- return r;
7359
- }
7360
- function registerContentTools(server, session) {
8286
+ function registerPhotoTools(server, session) {
7361
8287
  server.tool(
7362
- "find_content_for_topic",
7363
- `Busca contenido semanticamente relevante del negocio (productos, colecciones, articles, pages) para un contexto/keyword dado, usando vector search multimodal 1408d. Retorna 4 listas en paralelo + JIT context del brand brief y temporada activa.
8288
+ "get_photos_for_slot",
8289
+ `Busca fotos editadas del tenant que matcheen semanticamente con el keyword de un slot del calendario, usando vector search multimodal 1408 dims (Vertex multimodalembedding@001).
7364
8290
 
7365
- RETORNA:
7366
- - productos, colecciones, articles, pages (segun include)
7367
- - _instrucciones: reglas para linkear contenido
7368
- - _negativePrompt: negativos visuales del brand brief
7369
- - _temporadaActiva: si hay coleccion priorizada para la fecha
7370
- - _detectedConflict / _suggestedActions: si detecta article muy similar (>85%)
7371
-
7372
- USAR:
7373
- (a) cuando escribas blogs, captions o contenido que linkee material del negocio (internal linking tier 1).
7374
- (b) cuando el tenant te pregunte que tiene sobre un tema ("tengo algo de flores moradas?", "que productos tengo para regalo de mama?") \u2014 exploracion ad-hoc del catalogo.
7375
- (c) antes de proponer un tema nuevo al calendario, para validar que el catalogo lo soporte.
7376
-
7377
- MODOS (parametro mode):
7378
- - 'text' (default): busca contra embeddingText. Rapido, preciso cuando las descripciones del catalogo estan optimizadas. Un solo query por coleccion.
7379
- - 'hybrid': combina embeddingText + embeddingImage con Reciprocal Rank Fusion. 2x queries por coleccion. Rescata productos que tienen buen visual pero SEO debil en texto \u2014 util para descubrir items que el modo text pierde. Recomendado cuando:
7380
- * El modo text devuelve pocos resultados
7381
- * Exploraras tu catalogo y quieres cobertura maxima
7382
- * Vas a hacer recomendaciones de SEO (items que solo aparecen en image = se\xF1al de texto debil)
7383
-
7384
- Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = el doc aparecio en ambos modos (alta confianza). 1 = aparecio solo en text (problema visual) o solo en image (problema SEO). Esos "solo image" son candidatos perfectos para recomendar optimizacion al tenant.`,
7385
- {
7386
- brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7387
- contexto: import_zod36.z.string().min(1).describe("Parrafo, keyword o intencion"),
7388
- fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7389
- include: IncludeSchema.optional(),
7390
- limit: LimitSchema.optional(),
7391
- diversidad: import_zod36.z.boolean().default(true),
7392
- mode: import_zod36.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
7393
- },
7394
- async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode }) => {
7395
- const brandId = inputBrandId ?? session.requireBrand();
7396
- const resolvedInclude = include || {
7397
- products: true,
7398
- collections: true,
7399
- articles: true,
7400
- pages: false
7401
- };
7402
- const resolvedLimit = limit || {
7403
- products: 5,
7404
- collections: 3,
7405
- articles: 5,
7406
- pages: 2
7407
- };
7408
- try {
7409
- const result = await findContentForTopicHandler(
7410
- {
7411
- brandId,
7412
- contexto,
7413
- fecha,
7414
- include: resolvedInclude,
7415
- limit: resolvedLimit,
7416
- diversidad,
7417
- mode
7418
- },
7419
- session
7420
- );
7421
- const slimResult = (item) => ({
7422
- id: item.id,
7423
- similarity: item.similarity,
7424
- title: item.title ?? item.nombre ?? null,
7425
- handle: item.handle ?? null,
7426
- description: typeof item.description === "string" ? item.description.slice(0, 200) : null,
7427
- image: item.image ? { src: item.image.src, alt: item.image.alt } : null,
7428
- collectionHandles: item.collectionHandles ?? null,
7429
- modesFound: item.modesFound ?? null,
7430
- rrfScore: item.rrfScore ?? null
7431
- });
7432
- const slimmed = {
7433
- productos: result.productos.map(slimResult),
7434
- colecciones: result.colecciones.map(slimResult),
7435
- articles: result.articles.map(slimResult),
7436
- pages: result.pages.map(slimResult),
7437
- _instrucciones: result._instrucciones,
7438
- _negativePrompt: result._negativePrompt,
7439
- _temporadaActiva: result._temporadaActiva,
7440
- _suggestedActions: result._suggestedActions,
7441
- ...result._detectedConflict ? { _detectedConflict: result._detectedConflict } : {}
7442
- };
7443
- return {
7444
- content: [
7445
- {
7446
- type: "text",
7447
- text: JSON.stringify(slimmed, null, 2)
7448
- }
7449
- ]
7450
- };
7451
- } catch (err) {
7452
- return {
7453
- content: [
7454
- {
7455
- type: "text",
7456
- text: JSON.stringify({
7457
- error: err.message || "Error desconocido"
7458
- })
7459
- }
7460
- ]
7461
- };
7462
- }
7463
- }
7464
- );
7465
- }
7466
-
7467
- // src/tools/marketing/photos.ts
7468
- async function compressImageForTransport(imageUrl) {
7469
- const sharp = (await import("sharp")).default;
7470
- const res = await fetch(imageUrl);
7471
- if (!res.ok) throw new Error(`download image for transport: HTTP ${res.status}`);
7472
- const buffer = Buffer.from(await res.arrayBuffer());
7473
- let quality = 80;
7474
- let compressed = await sharp(buffer).resize(1568, 1568, { fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
7475
- while (compressed.length > 9e5 && quality > 40) {
7476
- quality -= 10;
7477
- compressed = await sharp(buffer).resize(1568, 1568, { fit: "inside", withoutEnlargement: true }).jpeg({ quality }).toBuffer();
7478
- }
7479
- return compressed.toString("base64");
7480
- }
7481
- function registerPhotoTools(server, session) {
7482
- server.tool(
7483
- "get_photos_for_slot",
7484
- `Busca fotos editadas del tenant que matcheen semanticamente con el keyword de un slot del calendario, usando vector search multimodal 1408 dims (Vertex multimodalembedding@001).
7485
-
7486
- REGLAS:
7487
- - Solo retorna fotos estado='editada' del tenant + brand
7488
- - Aplica restricciones de catalogos de temporada activos (contextoTemporal)
7489
- - Filtra por similarity > 0.70 (capa 1 fotos) o > 0.65 (capa 2 fallback productos Shopify)
7490
- - Prioritiza override manual del tenant (productoLinkeadoManual matching keyword)
7491
- - Resuelve la variante correcta segun plataforma
7492
- - Inyecta negativos visuales del brand brief en el tool_result (JIT)
8291
+ REGLAS:
8292
+ - Solo retorna fotos estado='editada' del tenant + brand
8293
+ - Aplica restricciones de catalogos de temporada activos (contextoTemporal)
8294
+ - Filtra por similarity > 0.70 (capa 1 fotos) o > 0.65 (capa 2 fallback productos Shopify)
8295
+ - Prioritiza override manual del tenant (productoLinkeadoManual matching keyword)
8296
+ - Resuelve la variante correcta segun plataforma
8297
+ - Inyecta negativos visuales del brand brief en el tool_result (JIT)
7493
8298
 
7494
8299
  USAR: antes de generar contenido de cualquier slot del calendario.`,
7495
8300
  {
7496
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7497
- keyword: import_zod37.z.string().describe("Keyword del slot"),
7498
- plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
7499
- fecha: import_zod37.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
7500
- limit: import_zod37.z.number().int().min(1).max(10).default(5)
8301
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8302
+ keyword: import_zod47.z.string().describe("Keyword del slot"),
8303
+ plataforma: import_zod47.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
8304
+ fecha: import_zod47.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
8305
+ limit: import_zod47.z.number().int().min(1).max(10).default(5)
7501
8306
  },
7502
8307
  async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
7503
8308
  const tenantId = session.requireTenant();
7504
8309
  const brandId = inputBrandId ?? session.requireBrand();
7505
- const result = getSdkMode() === "admin" ? await slotAssetFinder({
7506
- db: getAdminDb(),
7507
- tenantId,
7508
- brandId,
7509
- keyword,
7510
- plataforma,
7511
- fecha,
7512
- limit,
7513
- deps: {
7514
- generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
7515
- findNearestPhotos: (params) => findNearestPhotosWithOverride(params),
7516
- findNearestInCollection: (params) => findNearestInCollectionWithOverride(params),
7517
- thresholds: {
7518
- photos: MARKETING_THRESHOLDS.fotos.capa1Tenant,
7519
- shopify: MARKETING_THRESHOLDS.fotos.capa2Shopify
8310
+ const ctx = await buildContext(session, brandId);
8311
+ const result = await dispatchWithContract({
8312
+ contract: slotAssetFinderContract,
8313
+ helper: (input) => slotAssetFinder({
8314
+ ...input,
8315
+ deps: {
8316
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8317
+ findNearestPhotos: (params) => findNearestPhotosWithOverride(params),
8318
+ findNearestInCollection: (params) => findNearestInCollectionWithOverride(params),
8319
+ thresholds: {
8320
+ photos: MARKETING_THRESHOLDS.fotos.capa1Tenant,
8321
+ shopify: MARKETING_THRESHOLDS.fotos.capa2Shopify
8322
+ }
7520
8323
  }
7521
- }
7522
- }) : await callSlotAssetFinder({ tenantId, brandId, keyword, plataforma, fecha, limit });
7523
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8324
+ }),
8325
+ callable: callSlotAssetFinder,
8326
+ input: { tenantId, brandId, keyword, plataforma, fecha, limit },
8327
+ ctx
8328
+ });
8329
+ const payload = result.state === "success" ? result.structuredOutput : {
8330
+ ok: false,
8331
+ state: result.state,
8332
+ mensaje: result.text,
8333
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
8334
+ };
8335
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7524
8336
  }
7525
8337
  );
7526
8338
  server.tool(
@@ -7538,18 +8350,33 @@ DESPUES de ver la foto, decide:
7538
8350
 
7539
8351
  Luego llama execute_photo_edit con tu analisis y prompt.`,
7540
8352
  {
7541
- fotoId: import_zod37.z.string().describe("ID de la foto")
8353
+ fotoId: import_zod47.z.string().describe("ID de la foto")
7542
8354
  },
7543
8355
  async ({ fotoId }) => {
7544
8356
  const tenantId = session.requireTenant();
7545
8357
  const lang = await resolveTenantIdioma(tenantId);
7546
- const result = getSdkMode() === "admin" ? await photoDirectorPlan({
7547
- db: getAdminDb(),
7548
- tenantId,
7549
- fotoId,
7550
- deps: { compressImageForTransport },
7551
- lang
7552
- }) : await callPhotoDirectorPlan({ tenantId, fotoId });
8358
+ const ctx = await buildContext(session, null);
8359
+ const dispatchResult = await dispatchWithContract({
8360
+ contract: photoDirectorPlanContract,
8361
+ helper: (input) => photoDirectorPlan({
8362
+ ...input,
8363
+ deps: { compressImageForTransport },
8364
+ lang
8365
+ }),
8366
+ callable: callPhotoDirectorPlan,
8367
+ input: { tenantId, fotoId },
8368
+ ctx
8369
+ });
8370
+ if (dispatchResult.state !== "success") {
8371
+ const errPayload = {
8372
+ ok: false,
8373
+ state: dispatchResult.state,
8374
+ mensaje: dispatchResult.text,
8375
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8376
+ };
8377
+ return { content: [{ type: "text", text: JSON.stringify(errPayload, null, 2) }] };
8378
+ }
8379
+ const result = dispatchResult.structuredOutput;
7553
8380
  if (!result.ok) {
7554
8381
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7555
8382
  }
@@ -7571,33 +8398,44 @@ Retorna la foto editada para que la revises. Si no te gusta:
7571
8398
 
7572
8399
  Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si se genera thumbnail y embedding con tus tags.`,
7573
8400
  {
7574
- fotoId: import_zod37.z.string(),
7575
- prompt: import_zod37.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
7576
- acciones: import_zod37.z.array(import_zod37.z.enum(["edit_background", "none"])),
7577
- descripcion: import_zod37.z.string().describe("Descripcion semantica en espanol"),
7578
- tipo: import_zod37.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
7579
- tagsPrimarios: import_zod37.z.array(import_zod37.z.string()),
7580
- tagsSecundarios: import_zod37.z.array(import_zod37.z.string()),
7581
- tagsContexto: import_zod37.z.array(import_zod37.z.string())
8401
+ fotoId: import_zod47.z.string(),
8402
+ prompt: import_zod47.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
8403
+ acciones: import_zod47.z.array(import_zod47.z.enum(["edit_background", "none"])),
8404
+ descripcion: import_zod47.z.string().describe("Descripcion semantica en espanol"),
8405
+ tipo: import_zod47.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
8406
+ tagsPrimarios: import_zod47.z.array(import_zod47.z.string()),
8407
+ tagsSecundarios: import_zod47.z.array(import_zod47.z.string()),
8408
+ tagsContexto: import_zod47.z.array(import_zod47.z.string())
7582
8409
  },
7583
8410
  async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
7584
8411
  const tenantId = session.requireTenant();
7585
8412
  const lang = await resolveTenantIdioma(tenantId);
7586
- if (getSdkMode() === "admin") {
7587
- const executePhotoEditAdapter = async (payload) => {
7588
- const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
7589
- const { GoogleAuth: GoogleAuth2 } = await import("google-auth-library");
7590
- const auth = new GoogleAuth2({ keyFile: session.serviceAccountPath });
7591
- const client = await auth.getIdTokenClient(cfUrl);
7592
- const response = await client.request({
7593
- url: cfUrl,
7594
- method: "POST",
7595
- data: payload,
7596
- headers: { "Content-Type": "application/json" }
7597
- });
7598
- return response.data ?? {};
7599
- };
7600
- const result2 = await photoDirectorExecute({
8413
+ const executePhotoEditAdapter = async (payload) => {
8414
+ const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
8415
+ const { GoogleAuth: GoogleAuth2 } = await import("google-auth-library");
8416
+ const auth = new GoogleAuth2({ keyFile: session.serviceAccountPath });
8417
+ const client = await auth.getIdTokenClient(cfUrl);
8418
+ const response = await client.request({
8419
+ url: cfUrl,
8420
+ method: "POST",
8421
+ data: payload,
8422
+ headers: { "Content-Type": "application/json" }
8423
+ });
8424
+ return response.data ?? {};
8425
+ };
8426
+ const ctx = await buildContext(session, null);
8427
+ const dispatchResult = await dispatchWithContract({
8428
+ contract: photoDirectorExecuteContract,
8429
+ helper: (input) => photoDirectorExecute({
8430
+ ...input,
8431
+ deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter },
8432
+ lang
8433
+ }),
8434
+ callable: callPhotoDirectorExecute,
8435
+ // Note: tenantId en el input para que extractTargetPath pueda armar
8436
+ // el path canónico (tenants/{tid}/marketing_fotos/{fotoId}).
8437
+ input: {
8438
+ tenantId,
7601
8439
  fotoId,
7602
8440
  prompt,
7603
8441
  acciones,
@@ -7605,32 +8443,20 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
7605
8443
  tipo,
7606
8444
  tagsPrimarios,
7607
8445
  tagsSecundarios,
7608
- tagsContexto,
7609
- deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter },
7610
- lang
7611
- });
7612
- if (!result2.ok) {
7613
- return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
7614
- }
7615
- const contentArray2 = [];
7616
- if (result2.imageBase64) {
7617
- contentArray2.push({ type: "image", data: result2.imageBase64, mimeType: "image/jpeg" });
7618
- }
7619
- const { imageBase64: _unused2, ...textFields2 } = result2;
7620
- contentArray2.push({ type: "text", text: JSON.stringify(textFields2, null, 2) });
7621
- return { content: contentArray2 };
7622
- }
7623
- const result = await callPhotoDirectorExecute({
7624
- tenantId,
7625
- fotoId,
7626
- prompt,
7627
- acciones,
7628
- descripcion,
7629
- tipo,
7630
- tagsPrimarios,
7631
- tagsSecundarios,
7632
- tagsContexto
8446
+ tagsContexto
8447
+ },
8448
+ ctx
7633
8449
  });
8450
+ if (dispatchResult.state !== "success") {
8451
+ const errPayload = {
8452
+ ok: false,
8453
+ state: dispatchResult.state,
8454
+ mensaje: dispatchResult.text,
8455
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8456
+ };
8457
+ return { content: [{ type: "text", text: JSON.stringify(errPayload, null, 2) }] };
8458
+ }
8459
+ const result = dispatchResult.structuredOutput;
7634
8460
  if (!result.ok) {
7635
8461
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7636
8462
  }
@@ -7655,7 +8481,7 @@ Retorna:
7655
8481
  - creditos: { balance, planId, periodEnd, costoPhotoEdit }
7656
8482
  - _instrucciones: que hacer segun el estado`,
7657
8483
  {
7658
- fotoId: import_zod37.z.string().describe("ID de la foto")
8484
+ fotoId: import_zod47.z.string().describe("ID de la foto")
7659
8485
  },
7660
8486
  async ({ fotoId }) => {
7661
8487
  const tenantId = session.requireTenant();
@@ -7755,19 +8581,34 @@ Retorna:
7755
8581
  "find_products_for_content",
7756
8582
  `[DEPRECATED 179.5] Wrapper interno de find_content_for_topic. Para nuevas integraciones, usa find_content_for_topic con include.products=true directamente. Este wrapper existe solo por compat hacia atras.`,
7757
8583
  {
7758
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7759
- contexto: import_zod37.z.string().describe("Parrafo, keyword o intencion del contenido"),
7760
- fecha: import_zod37.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7761
- limit: import_zod37.z.number().int().min(1).max(10).default(5),
7762
- diversidad: import_zod37.z.boolean().default(true)
8584
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8585
+ contexto: import_zod47.z.string().describe("Parrafo, keyword o intencion del contenido"),
8586
+ fecha: import_zod47.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
8587
+ limit: import_zod47.z.number().int().min(1).max(10).default(5),
8588
+ diversidad: import_zod47.z.boolean().default(true)
7763
8589
  },
7764
8590
  async ({ brandId: inputBrandId, contexto, fecha, limit, diversidad }) => {
7765
8591
  console.warn(
7766
8592
  "[deprecated] find_products_for_content \u2192 use find_content_for_topic with include.products"
7767
8593
  );
8594
+ const tenantId = session.requireTenant();
7768
8595
  const brandId = inputBrandId ?? session.requireBrand();
7769
- const newResult = await findContentForTopicHandler(
7770
- {
8596
+ const ctx = await buildContext(session, brandId);
8597
+ const dispatchResult = await dispatchWithContract({
8598
+ contract: contentFinderContract,
8599
+ helper: (input) => contentFinder({
8600
+ ...input,
8601
+ deps: {
8602
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8603
+ findNearestInCollection: (params) => findNearestInCollectionWithOverride(
8604
+ params
8605
+ ),
8606
+ rrfMerge
8607
+ }
8608
+ }),
8609
+ callable: callContentFinder,
8610
+ input: {
8611
+ tenantId,
7771
8612
  brandId,
7772
8613
  contexto,
7773
8614
  fecha,
@@ -7775,8 +8616,18 @@ Retorna:
7775
8616
  limit: { products: limit, collections: 0, articles: 0, pages: 0 },
7776
8617
  diversidad
7777
8618
  },
7778
- session
7779
- );
8619
+ ctx
8620
+ });
8621
+ if (dispatchResult.state !== "success") {
8622
+ const errPayload = {
8623
+ productos: [],
8624
+ error: dispatchResult.text,
8625
+ state: dispatchResult.state,
8626
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8627
+ };
8628
+ return { content: [{ type: "text", text: JSON.stringify(errPayload) }] };
8629
+ }
8630
+ const newResult = dispatchResult.structuredOutput;
7780
8631
  const productos = (newResult.productos || []).map((r) => ({
7781
8632
  productId: r.id,
7782
8633
  similarity: r.similarity,
@@ -7797,7 +8648,6 @@ Retorna:
7797
8648
  titulo: newResult._temporadaActiva.titulo,
7798
8649
  bloqueo: true
7799
8650
  } : null,
7800
- // Campo nuevo aditivo (no rompe consumers) — senaliza deprecation
7801
8651
  _deprecated: "Usa find_content_for_topic con include.products en su lugar"
7802
8652
  }, null, 2)
7803
8653
  }]
@@ -7812,27 +8662,42 @@ RETORNA { plantillaId, titulo, thumbnailUrl } o { plantillaId: null, motivo: 'no
7812
8662
 
7813
8663
  USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/{brandId}.canva.connected=true).`,
7814
8664
  {
7815
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7816
- plataforma: import_zod37.z.string().describe("gbp | instagram | shopify_blog"),
7817
- tipoContenido: import_zod37.z.string().describe("post | carousel | story | blog"),
7818
- keyword: import_zod37.z.string().describe("Keyword del slot")
8665
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8666
+ plataforma: import_zod47.z.string().describe("gbp | instagram | shopify_blog"),
8667
+ tipoContenido: import_zod47.z.string().describe("post | carousel | story | blog"),
8668
+ keyword: import_zod47.z.string().describe("Keyword del slot")
7819
8669
  },
7820
8670
  async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
7821
8671
  const tenantId = session.requireTenant();
7822
8672
  const brandId = inputBrandId ?? session.requireBrand();
7823
- const result = getSdkMode() === "admin" ? await canvaTemplateSelector({
7824
- db: getAdminDb(),
7825
- tenantId,
7826
- brandId,
7827
- plataforma,
7828
- tipoContenido,
7829
- keyword,
7830
- deps: {
7831
- generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
7832
- threshold: 0.6
7833
- }
7834
- }) : await callCanvaTemplateSelector({ tenantId, brandId, plataforma, tipoContenido, keyword });
7835
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8673
+ const ctx = await buildContext(session, brandId);
8674
+ const dispatchResult = await dispatchWithContract({
8675
+ contract: canvaTemplateSelectorContract,
8676
+ helper: (input) => canvaTemplateSelector({
8677
+ ...input,
8678
+ deps: {
8679
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8680
+ threshold: 0.6
8681
+ }
8682
+ }),
8683
+ callable: callCanvaTemplateSelector,
8684
+ input: { tenantId, brandId, plataforma, tipoContenido, keyword },
8685
+ ctx
8686
+ });
8687
+ if (dispatchResult.state !== "success") {
8688
+ const errPayload = {
8689
+ plantillaId: null,
8690
+ motivo: dispatchResult.state,
8691
+ mensaje: dispatchResult.text,
8692
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8693
+ };
8694
+ return { content: [{ type: "text", text: JSON.stringify(errPayload, null, 2) }] };
8695
+ }
8696
+ return {
8697
+ content: [
8698
+ { type: "text", text: JSON.stringify(dispatchResult.structuredOutput, null, 2) }
8699
+ ]
8700
+ };
7836
8701
  }
7837
8702
  );
7838
8703
  server.tool(
@@ -7845,15 +8710,15 @@ USAR: cuando get_photos_for_slot retorna pocas fotos y el slot aun no esta cubie
7845
8710
 
7846
8711
  IMPORTANTE \u2014 campo 'razon': Este texto lo ve el tenant en la app. Escribe en lenguaje amigable y claro, NO tecnico. Ejemplo MALO: "get_photos_for_slot retorno 0 fotos para shopify_blog". Ejemplo BUENO: "No hay fotos de alcatraz para el blog del 8 de abril". Siempre en espanol si el tenant habla espanol.`,
7847
8712
  {
7848
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7849
- semana: import_zod37.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
7850
- necesidades: import_zod37.z.array(
7851
- import_zod37.z.object({
7852
- tema: import_zod37.z.string(),
7853
- keyword: import_zod37.z.string(),
7854
- cantidadSugerida: import_zod37.z.number().int().positive(),
7855
- razon: import_zod37.z.string(),
7856
- slotsAfectados: import_zod37.z.array(import_zod37.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
8713
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8714
+ semana: import_zod47.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
8715
+ necesidades: import_zod47.z.array(
8716
+ import_zod47.z.object({
8717
+ tema: import_zod47.z.string(),
8718
+ keyword: import_zod47.z.string(),
8719
+ cantidadSugerida: import_zod47.z.number().int().positive(),
8720
+ razon: import_zod47.z.string(),
8721
+ slotsAfectados: import_zod47.z.array(import_zod47.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
7857
8722
  })
7858
8723
  ).min(1)
7859
8724
  },
@@ -7915,6 +8780,193 @@ IMPORTANTE \u2014 campo 'razon': Este texto lo ve el tenant en la app. Escribe e
7915
8780
  );
7916
8781
  }
7917
8782
 
8783
+ // src/tools/marketing/content.ts
8784
+ var import_zod48 = require("zod");
8785
+ var _logOverride = null;
8786
+ async function logToMcpLogs(entry) {
8787
+ if (_logOverride) return _logOverride(entry);
8788
+ try {
8789
+ const db = getAdminDb();
8790
+ await db.collection("marketing_mcp_logs").add({
8791
+ ...entry,
8792
+ timestamp: import_firebase_admin.default.firestore.FieldValue.serverTimestamp()
8793
+ });
8794
+ } catch {
8795
+ }
8796
+ }
8797
+ var IncludeSchema2 = import_zod48.z.object({
8798
+ products: import_zod48.z.boolean().default(true),
8799
+ collections: import_zod48.z.boolean().default(true),
8800
+ articles: import_zod48.z.boolean().default(true),
8801
+ pages: import_zod48.z.boolean().default(false)
8802
+ }).default({
8803
+ products: true,
8804
+ collections: true,
8805
+ articles: true,
8806
+ pages: false
8807
+ });
8808
+ var LimitSchema2 = import_zod48.z.object({
8809
+ products: import_zod48.z.number().int().min(0).max(20).default(5),
8810
+ collections: import_zod48.z.number().int().min(0).max(10).default(3),
8811
+ articles: import_zod48.z.number().int().min(0).max(20).default(5),
8812
+ pages: import_zod48.z.number().int().min(0).max(10).default(2)
8813
+ }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
8814
+ function registerContentTools(server, session) {
8815
+ server.tool(
8816
+ "find_content_for_topic",
8817
+ `Busca contenido semanticamente relevante del negocio (productos, colecciones, articles, pages) para un contexto/keyword dado, usando vector search multimodal 1408d. Retorna 4 listas en paralelo + JIT context del brand brief y temporada activa.
8818
+
8819
+ RETORNA:
8820
+ - productos, colecciones, articles, pages (segun include)
8821
+ - _instrucciones: reglas para linkear contenido
8822
+ - _negativePrompt: negativos visuales del brand brief
8823
+ - _temporadaActiva: si hay coleccion priorizada para la fecha
8824
+ - _detectedConflict / _suggestedActions: si detecta article muy similar (>85%)
8825
+
8826
+ USAR:
8827
+ (a) cuando escribas blogs, captions o contenido que linkee material del negocio (internal linking tier 1).
8828
+ (b) cuando el tenant te pregunte que tiene sobre un tema ("tengo algo de flores moradas?", "que productos tengo para regalo de mama?") \u2014 exploracion ad-hoc del catalogo.
8829
+ (c) antes de proponer un tema nuevo al calendario, para validar que el catalogo lo soporte.
8830
+
8831
+ MODOS (parametro mode):
8832
+ - 'text' (default): busca contra embeddingText. Rapido, preciso cuando las descripciones del catalogo estan optimizadas. Un solo query por coleccion.
8833
+ - 'hybrid': combina embeddingText + embeddingImage con Reciprocal Rank Fusion. 2x queries por coleccion. Rescata productos que tienen buen visual pero SEO debil en texto \u2014 util para descubrir items que el modo text pierde. Recomendado cuando:
8834
+ * El modo text devuelve pocos resultados
8835
+ * Exploraras tu catalogo y quieres cobertura maxima
8836
+ * Vas a hacer recomendaciones de SEO (items que solo aparecen en image = se\xF1al de texto debil)
8837
+
8838
+ Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = el doc aparecio en ambos modos (alta confianza). 1 = aparecio solo en text (problema visual) o solo en image (problema SEO). Esos "solo image" son candidatos perfectos para recomendar optimizacion al tenant.`,
8839
+ {
8840
+ brandId: import_zod48.z.string().optional().describe("ID de la brand"),
8841
+ contexto: import_zod48.z.string().min(1).describe("Parrafo, keyword o intencion"),
8842
+ fecha: import_zod48.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
8843
+ include: IncludeSchema2.optional(),
8844
+ limit: LimitSchema2.optional(),
8845
+ diversidad: import_zod48.z.boolean().default(true),
8846
+ mode: import_zod48.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
8847
+ },
8848
+ async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode }) => {
8849
+ const tenantId = session.requireTenant();
8850
+ const brandId = inputBrandId ?? session.requireBrand();
8851
+ const resolvedInclude = include || {
8852
+ products: true,
8853
+ collections: true,
8854
+ articles: true,
8855
+ pages: false
8856
+ };
8857
+ const resolvedLimit = limit || {
8858
+ products: 5,
8859
+ collections: 3,
8860
+ articles: 5,
8861
+ pages: 2
8862
+ };
8863
+ try {
8864
+ const ctx = await buildContext(session, brandId);
8865
+ const dispatchResult = await dispatchWithContract({
8866
+ contract: contentFinderContract,
8867
+ helper: (input) => contentFinder({
8868
+ ...input,
8869
+ deps: {
8870
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8871
+ findNearestInCollection: (params) => findNearestInCollectionWithOverride(
8872
+ params
8873
+ ),
8874
+ rrfMerge,
8875
+ thresholds: {
8876
+ text: MARKETING_THRESHOLDS.content.text,
8877
+ image: MARKETING_THRESHOLDS.content.image,
8878
+ conflictSimilarity: MARKETING_THRESHOLDS.content.conflictSimilarity
8879
+ }
8880
+ }
8881
+ }),
8882
+ callable: callContentFinder,
8883
+ input: {
8884
+ tenantId,
8885
+ brandId,
8886
+ contexto,
8887
+ fecha,
8888
+ include: resolvedInclude,
8889
+ limit: resolvedLimit,
8890
+ diversidad,
8891
+ mode
8892
+ },
8893
+ ctx
8894
+ });
8895
+ if (dispatchResult.state !== "success") {
8896
+ const errPayload = {
8897
+ error: dispatchResult.text,
8898
+ state: dispatchResult.state,
8899
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8900
+ };
8901
+ return { content: [{ type: "text", text: JSON.stringify(errPayload) }] };
8902
+ }
8903
+ const result = dispatchResult.structuredOutput;
8904
+ const slimResult = (item) => ({
8905
+ id: item.id,
8906
+ similarity: item.similarity,
8907
+ title: item.title ?? item.nombre ?? null,
8908
+ handle: item.handle ?? null,
8909
+ description: typeof item.description === "string" ? item.description.slice(0, 200) : null,
8910
+ image: item.image ? { src: item.image.src, alt: item.image.alt } : null,
8911
+ collectionHandles: item.collectionHandles ?? null,
8912
+ modesFound: item.modesFound ?? null,
8913
+ rrfScore: item.rrfScore ?? null
8914
+ });
8915
+ const slimmed = {
8916
+ productos: result.productos.map(slimResult),
8917
+ colecciones: result.colecciones.map(slimResult),
8918
+ articles: result.articles.map(slimResult),
8919
+ pages: result.pages.map(slimResult),
8920
+ _instrucciones: result._instrucciones,
8921
+ _negativePrompt: result._negativePrompt,
8922
+ _temporadaActiva: result._temporadaActiva,
8923
+ _suggestedActions: result._suggestedActions,
8924
+ ...result._detectedConflict ? { _detectedConflict: result._detectedConflict } : {}
8925
+ };
8926
+ logToMcpLogs({
8927
+ toolName: "find_content_for_topic",
8928
+ tenantId,
8929
+ brandId,
8930
+ input: {
8931
+ contexto: contexto.slice(0, 500),
8932
+ fecha,
8933
+ include: resolvedInclude,
8934
+ diversidad
8935
+ },
8936
+ output: {
8937
+ productsCount: result.productos.length,
8938
+ collectionsCount: result.colecciones.length,
8939
+ articlesCount: result.articles.length,
8940
+ pagesCount: result.pages.length,
8941
+ _suggestedActionsCount: result._suggestedActions.length,
8942
+ _hadConflict: !!result._detectedConflict,
8943
+ conflictType: result._detectedConflict?.type || null
8944
+ }
8945
+ }).catch((err) => console.error("[mcp_logs] write failed:", err));
8946
+ return {
8947
+ content: [
8948
+ {
8949
+ type: "text",
8950
+ text: JSON.stringify(slimmed, null, 2)
8951
+ }
8952
+ ]
8953
+ };
8954
+ } catch (err) {
8955
+ return {
8956
+ content: [
8957
+ {
8958
+ type: "text",
8959
+ text: JSON.stringify({
8960
+ error: err.message || "Error desconocido"
8961
+ })
8962
+ }
8963
+ ]
8964
+ };
8965
+ }
8966
+ }
8967
+ );
8968
+ }
8969
+
7918
8970
  // src/services/pipelineLink.ts
7919
8971
  var PLATAFORMA_A_PASO = {
7920
8972
  shopify_blog: "blog",
@@ -7999,8 +9051,8 @@ function registerMarketingTools(server, session) {
7999
9051
  "get_calendar",
8000
9052
  "Lee el calendario editorial del mes. Muestra semanas con items planificados por plataforma.",
8001
9053
  {
8002
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8003
- mes: import_zod38.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
9054
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9055
+ mes: import_zod49.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
8004
9056
  },
8005
9057
  async ({ brandId: inputBrandId, mes }) => {
8006
9058
  const tenantId = session.requireTenant();
@@ -8033,7 +9085,7 @@ function registerMarketingTools(server, session) {
8033
9085
  "get_seo_snapshot",
8034
9086
  "Lee el snapshot SEO de la brand: rank, keywords, oportunidades, competidores.",
8035
9087
  {
8036
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9088
+ brandId: import_zod49.z.string().optional().describe("ID de la brand")
8037
9089
  },
8038
9090
  async ({ brandId: inputBrandId }) => {
8039
9091
  const tenantId = session.requireTenant();
@@ -8052,8 +9104,8 @@ function registerMarketingTools(server, session) {
8052
9104
  "get_photo_gallery",
8053
9105
  "Fotos disponibles filtradas por estado. Muestra fotos con metadata y conteo.",
8054
9106
  {
8055
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8056
- estado: import_zod38.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
9107
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9108
+ estado: import_zod49.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
8057
9109
  },
8058
9110
  async ({ brandId: inputBrandId, estado }) => {
8059
9111
  session.requireTenant();
@@ -8093,7 +9145,7 @@ function registerMarketingTools(server, session) {
8093
9145
  "generate_marketing_plan",
8094
9146
  "Aggregate the data needed to generate a strategic marketing plan: SEO snapshot + products. The LLM uses the system prompt to generate the plan from this payload.",
8095
9147
  {
8096
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9148
+ brandId: import_zod49.z.string().optional().describe("ID de la brand")
8097
9149
  },
8098
9150
  async ({ brandId: inputBrandId }) => {
8099
9151
  const tenantId = session.requireTenant();
@@ -8107,8 +9159,8 @@ function registerMarketingTools(server, session) {
8107
9159
  "save_marketing_plan",
8108
9160
  "Save a marketing plan to the brand configuration. Writes to tenants/{tenantId}/marketing_config/{brandId}.plan. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
8109
9161
  {
8110
- brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
8111
- plan: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Full marketing plan object. Common fields: keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, coleccionesPriorizadas, blogStrategy.")
9162
+ brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
9163
+ plan: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).describe("Full marketing plan object. Common fields: keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, coleccionesPriorizadas, blogStrategy.")
8112
9164
  },
8113
9165
  async ({ brandId: inputBrandId, plan }) => {
8114
9166
  const tenantId = session.requireTenant();
@@ -8136,9 +9188,9 @@ function registerMarketingTools(server, session) {
8136
9188
  "update_marketing_plan_field",
8137
9189
  "Actualiza UN campo del plan de marketing sin sobreescribir el resto. Merge parcial. Ideal para agregar coleccionesPriorizadas, actualizar quickWins, etc.",
8138
9190
  {
8139
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8140
- field: import_zod38.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
8141
- value: import_zod38.z.unknown().describe("Valor del campo")
9191
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9192
+ field: import_zod49.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
9193
+ value: import_zod49.z.unknown().describe("Valor del campo")
8142
9194
  },
8143
9195
  async ({ brandId: inputBrandId, field, value }) => {
8144
9196
  const tenantId = session.requireTenant();
@@ -8151,7 +9203,7 @@ function registerMarketingTools(server, session) {
8151
9203
  "generate_brand_brief",
8152
9204
  "Aggregate all business data needed to generate a Brand Brief: Shopify (products, collections, orders, shop info), SEO snapshot, GBP profiles, scraped site_content, brand config, tenant locations. Read-only \u2014 does NOT write the brief. After receiving the payload, generate the brief content and save it via save_brand_brief.",
8153
9205
  {
8154
- brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context.")
9206
+ brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context.")
8155
9207
  },
8156
9208
  async ({ brandId: inputBrandId }) => {
8157
9209
  const tenantId = session.requireTenant();
@@ -8179,29 +9231,54 @@ function registerMarketingTools(server, session) {
8179
9231
  "save_brand_brief",
8180
9232
  "Save the Brand Brief at tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Partial merge \u2014 only the brandBrief field is overwritten.",
8181
9233
  {
8182
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8183
- brandBrief: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Full Brand Brief object.")
9234
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9235
+ brandBrief: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).describe("Full Brand Brief object.")
8184
9236
  },
8185
9237
  async ({ brandId: inputBrandId, brandBrief }) => {
8186
9238
  const tenantId = session.requireTenant();
8187
9239
  const brandId = inputBrandId ?? session.requireBrand();
8188
- const result = getSdkMode() === "admin" ? await brandBriefWriter({ db: getAdminDb(), tenantId, brandId, brandBrief }) : await callBrandBriefWriter({ tenantId, brandId, brandBrief });
8189
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9240
+ const ctx = await buildContext(session, brandId);
9241
+ const result = await dispatchWithContract({
9242
+ contract: brandBriefWriterContract,
9243
+ helper: brandBriefWriter,
9244
+ callable: callBrandBriefWriter,
9245
+ input: { tenantId, brandId, brandBrief },
9246
+ ctx
9247
+ });
9248
+ const payload = result.state === "success" ? result.structuredOutput : {
9249
+ ok: false,
9250
+ state: result.state,
9251
+ mensaje: result.text,
9252
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9253
+ };
9254
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8190
9255
  }
8191
9256
  );
8192
9257
  server.tool(
8193
9258
  "generate_weekly_content",
8194
9259
  "Aggregate calendar + photos + plan data to generate the week's content. The LLM generates with the system prompt, then uses save_generated_content to persist.",
8195
9260
  {
8196
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8197
- semana: import_zod38.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
8198
- modo: import_zod38.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
9261
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9262
+ semana: import_zod49.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
9263
+ modo: import_zod49.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
8199
9264
  },
8200
9265
  async ({ brandId: inputBrandId, semana, modo }) => {
8201
9266
  const tenantId = session.requireTenant();
8202
9267
  const brandId = inputBrandId ?? session.requireBrand();
8203
- const result = getSdkMode() === "admin" ? await weeklyContentBuilder({ db: getAdminDb(), tenantId, brandId, semana, modo }) : await callWeeklyContentBuilder({ tenantId, brandId, semana, modo });
8204
- const payload = result.ok ? result.payload : result;
9268
+ const ctx = await buildContext(session, brandId);
9269
+ const result = await dispatchWithContract({
9270
+ contract: weeklyContentBuilderContract,
9271
+ helper: weeklyContentBuilder,
9272
+ callable: callWeeklyContentBuilder,
9273
+ input: { tenantId, brandId, semana, modo },
9274
+ ctx
9275
+ });
9276
+ const payload = result.state === "success" ? result.structuredOutput?.payload ?? result.structuredOutput : {
9277
+ ok: false,
9278
+ state: result.state,
9279
+ mensaje: result.text,
9280
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9281
+ };
8205
9282
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
8206
9283
  }
8207
9284
  );
@@ -8211,43 +9288,34 @@ function registerMarketingTools(server, session) {
8211
9288
 
8212
9289
  IMPORTANT: If you selected a photo with get_photos_for_slot, ALWAYS pass fotoId here. Without fotoId the post will be published without an image.`,
8213
9290
  {
8214
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8215
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
8216
- tipo: import_zod38.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
8217
- keyword: import_zod38.z.string().optional().describe("Keyword target"),
8218
- languageCode: import_zod38.z.string().optional().describe("Idioma (es/en)"),
8219
- fotoId: import_zod38.z.string().optional().describe("ID de la foto a asociar"),
8220
- datos: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
8221
- calendarioItemRef: import_zod38.z.string().optional().describe("Referencia al item del calendario")
9291
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9292
+ plataforma: import_zod49.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
9293
+ tipo: import_zod49.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
9294
+ keyword: import_zod49.z.string().optional().describe("Keyword target"),
9295
+ languageCode: import_zod49.z.string().optional().describe("Idioma (es/en)"),
9296
+ fotoId: import_zod49.z.string().optional().describe("ID de la foto a asociar"),
9297
+ datos: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
9298
+ calendarioItemRef: import_zod49.z.string().optional().describe("Referencia al item del calendario")
8222
9299
  },
8223
9300
  async ({ brandId: inputBrandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef }) => {
8224
9301
  const tenantId = session.requireTenant();
8225
9302
  const brandId = inputBrandId ?? session.requireBrand();
8226
9303
  try {
8227
- const result = getSdkMode() === "admin" ? await contenidoWriter({
8228
- db: getAdminDb(),
8229
- tenantId,
8230
- brandId,
8231
- plataforma,
8232
- tipo,
8233
- keyword,
8234
- languageCode,
8235
- fotoId,
8236
- datos,
8237
- calendarioItemRef,
8238
- linkPipeline: linkContenidoAPasoPipeline
8239
- }) : await callContenidoWriter({
8240
- tenantId,
8241
- brandId,
8242
- plataforma,
8243
- tipo,
8244
- keyword,
8245
- languageCode,
8246
- fotoId,
8247
- datos,
8248
- calendarioItemRef
9304
+ const ctx = await buildContext(session, brandId);
9305
+ const result = await dispatchWithContract({
9306
+ contract: contenidoWriterContract,
9307
+ helper: (input) => contenidoWriter({ ...input, linkPipeline: linkContenidoAPasoPipeline }),
9308
+ callable: callContenidoWriter,
9309
+ input: { tenantId, brandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef },
9310
+ ctx
8249
9311
  });
8250
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9312
+ const payload = result.state === "success" ? result.structuredOutput : {
9313
+ ok: false,
9314
+ state: result.state,
9315
+ mensaje: result.text,
9316
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9317
+ };
9318
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8251
9319
  } catch (err) {
8252
9320
  const msg = err instanceof Error ? err.message : String(err);
8253
9321
  console.error("[save_generated_content] UNCAUGHT ERROR:", msg);
@@ -8263,56 +9331,59 @@ Usa para: corregir body, metaTitle, tags, fotoId, o cualquier campo sin tener qu
8263
9331
  NO puede cambiar: tenantId, brandId, id (inmutables).
8264
9332
  Si pasas campos dentro de "datos", se hace merge con los datos existentes (no los reemplaza entero).`,
8265
9333
  {
8266
- contenidoId: import_zod38.z.string().describe("ID del doc en marketing_contenido"),
8267
- datos: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
8268
- fotoId: import_zod38.z.string().nullable().optional().describe("Actualizar foto asociada"),
8269
- keyword: import_zod38.z.string().nullable().optional().describe("Actualizar keyword"),
8270
- languageCode: import_zod38.z.string().optional().describe("Actualizar idioma"),
8271
- estado: import_zod38.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
8272
- calendarioItemRef: import_zod38.z.string().nullable().optional().describe("Vincular a un slot del calendario")
9334
+ contenidoId: import_zod49.z.string().describe("ID del doc en marketing_contenido"),
9335
+ datos: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
9336
+ fotoId: import_zod49.z.string().nullable().optional().describe("Actualizar foto asociada"),
9337
+ keyword: import_zod49.z.string().nullable().optional().describe("Actualizar keyword"),
9338
+ languageCode: import_zod49.z.string().optional().describe("Actualizar idioma"),
9339
+ estado: import_zod49.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
9340
+ calendarioItemRef: import_zod49.z.string().nullable().optional().describe("Vincular a un slot del calendario")
8273
9341
  },
8274
9342
  async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
8275
9343
  const tenantId = session.requireTenant();
8276
- const result = getSdkMode() === "admin" ? await contenidoUpdater({
8277
- db: getAdminDb(),
8278
- tenantId,
8279
- contenidoId,
8280
- datos: newDatos,
8281
- fotoId,
8282
- keyword,
8283
- languageCode,
8284
- estado,
8285
- calendarioItemRef
8286
- }) : await callContenidoUpdater({
8287
- tenantId,
8288
- contenidoId,
8289
- datos: newDatos,
8290
- fotoId,
8291
- keyword,
8292
- languageCode,
8293
- estado,
8294
- calendarioItemRef
9344
+ const ctx = await buildContext(session, null);
9345
+ const result = await dispatchWithContract({
9346
+ contract: contenidoUpdaterContract,
9347
+ helper: contenidoUpdater,
9348
+ callable: callContenidoUpdater,
9349
+ input: {
9350
+ tenantId,
9351
+ contenidoId,
9352
+ datos: newDatos,
9353
+ fotoId,
9354
+ keyword,
9355
+ languageCode,
9356
+ estado,
9357
+ calendarioItemRef
9358
+ },
9359
+ ctx
8295
9360
  });
8296
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9361
+ const payload = result.state === "success" ? result.structuredOutput : {
9362
+ ok: false,
9363
+ state: result.state,
9364
+ mensaje: result.text,
9365
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9366
+ };
9367
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8297
9368
  }
8298
9369
  );
8299
9370
  server.tool(
8300
9371
  "add_calendar_slot",
8301
9372
  "Add a NEW slot to the editorial calendar. To modify an existing slot use update_calendar_slot instead.",
8302
9373
  {
8303
- brandId: import_zod38.z.string().describe('Brand ID (e.g. "ponch", "teleglobos").'),
8304
- mes: import_zod38.z.string().describe('Calendar month in YYYY-MM format (e.g. "2026-05").'),
8305
- semana: import_zod38.z.number().describe("Week number within the month (1-5)."),
8306
- slot: import_zod38.z.object({
8307
- dia: import_zod38.z.string().describe("Slot date in YYYY-MM-DD format. Must fall within the week's fechaInicio/fechaFin range."),
8308
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target publishing platform."),
8309
- tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type."),
8310
- keyword: import_zod38.z.string().describe("Primary keyword for the content."),
8311
- tema: import_zod38.z.string().optional().describe("Content topic/theme. OMIT this field if it does not apply. Do NOT send empty string or null."),
8312
- productoId: import_zod38.z.string().optional().describe("Linked product ID. OMIT this field if no product is linked."),
8313
- estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe('Initial status. OMIT to use default "planificado".'),
8314
- locationId: import_zod38.z.string().optional().describe("GBP location ID \u2014 only when plataforma=gbp AND tenant is multi-location. OMIT otherwise."),
8315
- locationNombre: import_zod38.z.string().optional().describe("Human-readable GBP location name. OMIT if locationId is not provided.")
9374
+ brandId: import_zod49.z.string().describe('Brand ID (e.g. "ponch", "teleglobos").'),
9375
+ mes: import_zod49.z.string().describe('Calendar month in YYYY-MM format (e.g. "2026-05").'),
9376
+ semana: import_zod49.z.number().describe("Week number within the month (1-5)."),
9377
+ slot: import_zod49.z.object({
9378
+ dia: import_zod49.z.string().describe("Slot date in YYYY-MM-DD format. Must fall within the week's fechaInicio/fechaFin range."),
9379
+ plataforma: import_zod49.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target publishing platform."),
9380
+ tipo: import_zod49.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type."),
9381
+ keyword: import_zod49.z.string().describe("Primary keyword for the content."),
9382
+ tema: import_zod49.z.string().optional().describe("Content topic/theme. OMIT this field if it does not apply. Do NOT send empty string or null."),
9383
+ productoId: import_zod49.z.string().optional().describe("Linked product ID. OMIT this field if no product is linked."),
9384
+ estado: import_zod49.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe('Initial status. OMIT to use default "planificado".'),
9385
+ locationId: import_zod49.z.string().optional().describe("GBP location ID \u2014 only when plataforma=gbp AND tenant is multi-location. OMIT otherwise."),
9386
+ locationNombre: import_zod49.z.string().optional().describe("Human-readable GBP location name. OMIT if locationId is not provided.")
8316
9387
  }).describe("New slot data.")
8317
9388
  },
8318
9389
  async ({ brandId, mes, semana, slot }) => {
@@ -8338,27 +9409,27 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8338
9409
  "update_calendar_slot",
8339
9410
  "MODIFY an EXISTING slot in the editorial calendar. To create a new slot use add_calendar_slot. If slotIndex does not exist, returns error SLOT_NOT_FOUND.",
8340
9411
  {
8341
- brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
8342
- mes: import_zod38.z.string().describe("Calendar month in YYYY-MM format."),
8343
- semana: import_zod38.z.number().describe("Week number within the month (1-5)."),
8344
- slotIndex: import_zod38.z.number().describe("Slot index (0-based). Must point to an existing slot \u2014 to add new slots use add_calendar_slot."),
8345
- cambios: import_zod38.z.object({
8346
- dia: import_zod38.z.string().nullable().optional().describe("Slot date in YYYY-MM-DD. OMIT if not changing date. Use null to explicitly clear."),
8347
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional().describe("OMIT if not changing platform."),
8348
- tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional().describe("OMIT if not changing content type."),
8349
- keyword: import_zod38.z.string().nullable().optional().describe("OMIT if not changing keyword."),
8350
- tema: import_zod38.z.string().nullable().optional().describe("OMIT if not changing topic."),
8351
- productoId: import_zod38.z.string().nullable().optional().describe("OMIT if not changing linked product. Use null to unlink."),
8352
- estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("OMIT if not changing status manually."),
8353
- contenidoRef: import_zod38.z.string().nullable().optional().describe("OMIT \u2014 managed by the system, not by callers."),
8354
- fotoIdAsignada: import_zod38.z.string().nullable().optional().describe("Use assign_photo_to_content for photos. OMIT here."),
8355
- notas: import_zod38.z.array(NotaCalendarioSchema).optional().describe("Append-only notes for the slot."),
8356
- locationId: import_zod38.z.string().nullable().optional().describe("GBP location ID \u2014 only when plataforma=gbp."),
8357
- locationNombre: import_zod38.z.string().nullable().optional().describe("Human-readable GBP location name (for UI).")
9412
+ brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
9413
+ mes: import_zod49.z.string().describe("Calendar month in YYYY-MM format."),
9414
+ semana: import_zod49.z.number().describe("Week number within the month (1-5)."),
9415
+ slotIndex: import_zod49.z.number().describe("Slot index (0-based). Must point to an existing slot \u2014 to add new slots use add_calendar_slot."),
9416
+ cambios: import_zod49.z.object({
9417
+ dia: import_zod49.z.string().nullable().optional().describe("Slot date in YYYY-MM-DD. OMIT if not changing date. Use null to explicitly clear."),
9418
+ plataforma: import_zod49.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional().describe("OMIT if not changing platform."),
9419
+ tipo: import_zod49.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional().describe("OMIT if not changing content type."),
9420
+ keyword: import_zod49.z.string().nullable().optional().describe("OMIT if not changing keyword."),
9421
+ tema: import_zod49.z.string().nullable().optional().describe("OMIT if not changing topic."),
9422
+ productoId: import_zod49.z.string().nullable().optional().describe("OMIT if not changing linked product. Use null to unlink."),
9423
+ estado: import_zod49.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("OMIT if not changing status manually."),
9424
+ contenidoRef: import_zod49.z.string().nullable().optional().describe("OMIT \u2014 managed by the system, not by callers."),
9425
+ fotoIdAsignada: import_zod49.z.string().nullable().optional().describe("Use assign_photo_to_content for photos. OMIT here."),
9426
+ notas: import_zod49.z.array(NotaCalendarioSchema).optional().describe("Append-only notes for the slot."),
9427
+ locationId: import_zod49.z.string().nullable().optional().describe("GBP location ID \u2014 only when plataforma=gbp."),
9428
+ locationNombre: import_zod49.z.string().nullable().optional().describe("Human-readable GBP location name (for UI).")
8358
9429
  }).strict().describe("Fields to update on the slot. Only declared fields accepted; unknown fields are rejected. OMIT any field you are not changing."),
8359
- accionContenidoExistente: import_zod38.z.union([
8360
- import_zod38.z.enum(["descartar", "nuevo_slot", "mantener"]),
8361
- import_zod38.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Format: mover:semana:N:slot:M")
9430
+ accionContenidoExistente: import_zod49.z.union([
9431
+ import_zod49.z.enum(["descartar", "nuevo_slot", "mantener"]),
9432
+ import_zod49.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Format: mover:semana:N:slot:M")
8362
9433
  ]).optional().describe(
8363
9434
  'Required ONLY when the slot already has a contenidoRef AND the cambios touch semantic fields (keyword/tema/plataforma/tipo). In that case, the helper returns ACCION_CONTENIDO_EXISTENTE_REQUIRED with the 4 options listed below \u2014 ask the tenant which one to apply. OMIT this field in any other case. Do NOT send null. Valid values: "descartar" (mark existing content as discarded and apply changes to this slot) | "mover:semana:N:slot:M" (move existing content to target empty slot N/M and apply changes to origin slot) | "nuevo_slot" (do NOT touch this slot or its content; create a new slot same day with the changes \u2014 useful for multiple posts per day) | "mantener" (keep existing content here and apply changes anyway \u2014 typo-fix case where old content stays valid).'
8364
9435
  )
@@ -8403,22 +9474,35 @@ ESCRIBE EN DOS LUGARES:
8403
9474
 
8404
9475
  NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agenda, no en el post.`,
8405
9476
  {
8406
- contenidoRef: import_zod38.z.string().describe("ID del doc en marketing_contenido"),
8407
- fotoId: import_zod38.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
8408
- calendarioItemRef: import_zod38.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
9477
+ contenidoRef: import_zod49.z.string().describe("ID del doc en marketing_contenido"),
9478
+ fotoId: import_zod49.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
9479
+ calendarioItemRef: import_zod49.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
8409
9480
  },
8410
9481
  async ({ contenidoRef, fotoId, calendarioItemRef }) => {
8411
9482
  const tenantId = session.requireTenant();
8412
9483
  const brandId = session.requireBrand();
8413
- const result = getSdkMode() === "admin" ? await photoAssigner({ db: getAdminDb(), tenantId, brandId, contenidoRef, fotoId, calendarioItemRef }) : await callPhotoAssigner({ tenantId, brandId, contenidoRef, fotoId, calendarioItemRef });
8414
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9484
+ const ctx = await buildContext(session, brandId);
9485
+ const result = await dispatchWithContract({
9486
+ contract: photoAssignerContract,
9487
+ helper: photoAssigner,
9488
+ callable: callPhotoAssigner,
9489
+ input: { tenantId, brandId, contenidoRef, fotoId, calendarioItemRef },
9490
+ ctx
9491
+ });
9492
+ const payload = result.state === "success" ? result.structuredOutput : {
9493
+ ok: false,
9494
+ state: result.state,
9495
+ mensaje: result.text,
9496
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9497
+ };
9498
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8415
9499
  }
8416
9500
  );
8417
9501
  server.tool(
8418
9502
  "approve_content",
8419
9503
  "Aprueba contenido para publicacion. Valida transicion de estado.",
8420
9504
  {
8421
- contenidoId: import_zod38.z.string().describe("ID del contenido a aprobar")
9505
+ contenidoId: import_zod49.z.string().describe("ID del contenido a aprobar")
8422
9506
  },
8423
9507
  async ({ contenidoId }) => {
8424
9508
  session.requireTenant();
@@ -8442,8 +9526,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8442
9526
  "reject_content",
8443
9527
  "Rechaza contenido con motivo. Valida transicion de estado.",
8444
9528
  {
8445
- contenidoId: import_zod38.z.string().describe("ID del contenido a rechazar"),
8446
- motivo: import_zod38.z.string().describe("Motivo del rechazo")
9529
+ contenidoId: import_zod49.z.string().describe("ID del contenido a rechazar"),
9530
+ motivo: import_zod49.z.string().describe("Motivo del rechazo")
8447
9531
  },
8448
9532
  async ({ contenidoId, motivo }) => {
8449
9533
  session.requireTenant();
@@ -8466,7 +9550,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8466
9550
  "get_collections",
8467
9551
  "Lee todas las colecciones canonicas (Shopify/WordPress/webonly) con sus 6 campos SEO actuales. Incluye best practices 2026.",
8468
9552
  {
8469
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9553
+ brandId: import_zod49.z.string().optional().describe("ID de la brand")
8470
9554
  },
8471
9555
  async ({ brandId: inputBrandId }) => {
8472
9556
  const tenantId = session.requireTenant();
@@ -8553,14 +9637,27 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8553
9637
  "save_collection_suggestions",
8554
9638
  "Guarda sugerencias SEO para colecciones de Shopify. El tenant las aprueba en la UI.",
8555
9639
  {
8556
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
9640
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
8557
9641
  suggestions: CollectionSuggestionsInputArraySchema.describe("Array de sugerencias SEO. Cada una con max 60 chars en metaTitle, max 158 en metaDescription, max 125 en imageAlt")
8558
9642
  },
8559
9643
  async ({ brandId: inputBrandId, suggestions }) => {
8560
9644
  const tenantId = session.requireTenant();
8561
9645
  const brandId = inputBrandId ?? session.requireBrand();
8562
- const result = getSdkMode() === "admin" ? await collectionSuggestionsWriter({ db: getAdminDb(), tenantId, brandId, suggestions }) : await callCollectionSuggestionsWriter({ tenantId, brandId, suggestions });
8563
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9646
+ const ctx = await buildContext(session, brandId);
9647
+ const result = await dispatchWithContract({
9648
+ contract: collectionSuggestionsWriterContract,
9649
+ helper: collectionSuggestionsWriter,
9650
+ callable: callCollectionSuggestionsWriter,
9651
+ input: { tenantId, brandId, suggestions },
9652
+ ctx
9653
+ });
9654
+ const payload = result.state === "success" ? result.structuredOutput : {
9655
+ ok: false,
9656
+ state: result.state,
9657
+ mensaje: result.text,
9658
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9659
+ };
9660
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8564
9661
  }
8565
9662
  );
8566
9663
  }