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 +1827 -730
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
"
|
|
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
|
|
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
|
|
2780
|
+
var import_zod33 = require("zod");
|
|
2779
2781
|
var import_firebase_admin7 = require("firebase-admin");
|
|
2780
|
-
var
|
|
2781
|
-
var
|
|
2782
|
+
var import_zod34 = require("zod");
|
|
2783
|
+
var import_zod35 = require("zod");
|
|
2782
2784
|
var import_firebase_admin8 = require("firebase-admin");
|
|
2783
|
-
var
|
|
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
|
|
2800
|
+
var import_zod45 = require("zod");
|
|
2791
2801
|
var import_firestore7 = require("firebase-admin/firestore");
|
|
2792
|
-
var
|
|
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
|
-
|
|
3331
|
-
|
|
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: "
|
|
3350
|
-
description: "
|
|
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 (
|
|
3362
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 =
|
|
3631
|
-
dia:
|
|
3632
|
-
plataforma:
|
|
3633
|
-
tipo:
|
|
3634
|
-
keyword:
|
|
3635
|
-
tema:
|
|
3636
|
-
productoId:
|
|
3637
|
-
estado:
|
|
3638
|
-
locationId:
|
|
3639
|
-
locationNombre:
|
|
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
|
|
3642
|
-
tenantId:
|
|
3643
|
-
brandId:
|
|
3644
|
-
mes:
|
|
3645
|
-
semana:
|
|
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
|
|
3649
|
-
|
|
3650
|
-
|
|
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
|
|
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:
|
|
3656
|
-
outputSchema:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3947
|
-
tenantId:
|
|
3948
|
-
brandId:
|
|
3949
|
-
mes:
|
|
3950
|
-
semana:
|
|
3951
|
-
slotIndex:
|
|
3952
|
-
cambios:
|
|
3953
|
-
|
|
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
|
|
3956
|
-
|
|
3957
|
-
ok:
|
|
3958
|
-
action:
|
|
3959
|
-
slotIndex:
|
|
3960
|
-
descartados:
|
|
3961
|
-
movedTo:
|
|
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
|
-
|
|
3964
|
-
ok:
|
|
3965
|
-
error:
|
|
3966
|
-
code:
|
|
3967
|
-
opciones:
|
|
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
|
|
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:
|
|
3974
|
-
outputSchema:
|
|
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
|
-
|
|
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
|
|
4054
|
-
tenantId:
|
|
4055
|
-
brandId:
|
|
4056
|
-
mes:
|
|
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
|
|
4059
|
-
ok:
|
|
4060
|
-
mes:
|
|
4061
|
-
brandId:
|
|
4062
|
-
calendario:
|
|
4063
|
-
mensaje:
|
|
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
|
|
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:
|
|
4069
|
-
outputSchema:
|
|
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
|
-
|
|
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 {
|
|
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
|
|
4387
|
-
tenantId:
|
|
4388
|
-
brandId:
|
|
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
|
|
4391
|
-
|
|
4392
|
-
ok:
|
|
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:
|
|
4770
|
+
payload: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown())
|
|
4395
4771
|
}),
|
|
4396
|
-
|
|
4397
|
-
ok:
|
|
4398
|
-
error:
|
|
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
|
|
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:
|
|
4405
|
-
outputSchema:
|
|
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 (
|
|
4414
|
-
|
|
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
|
-
|
|
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
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
)
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
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 =
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
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 =
|
|
6806
|
-
memoriaId:
|
|
6807
|
-
status:
|
|
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 =
|
|
6810
|
-
|
|
6811
|
-
|
|
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 =
|
|
6814
|
-
status:
|
|
7801
|
+
var OlvidarMemoriaOutputSchema = import_zod45.z.object({
|
|
7802
|
+
status: import_zod45.z.literal("archivada")
|
|
6815
7803
|
});
|
|
6816
|
-
var ConfigInputSchema =
|
|
6817
|
-
diaSemana:
|
|
6818
|
-
diaMes:
|
|
6819
|
-
hora:
|
|
6820
|
-
// Timezone
|
|
6821
|
-
fechaPuntual:
|
|
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 =
|
|
6824
|
-
tool:
|
|
6825
|
-
params:
|
|
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 =
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
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 =
|
|
6835
|
-
rutinaId:
|
|
6836
|
-
proximaEjecucionAt:
|
|
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 =
|
|
6839
|
-
|
|
6840
|
-
|
|
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 =
|
|
6843
|
-
status:
|
|
7838
|
+
var PausarRutinaOutputSchema = import_zod46.z.object({
|
|
7839
|
+
status: import_zod46.z.literal("pausada")
|
|
6844
7840
|
});
|
|
6845
|
-
var ArchivarRutinaParamsSchema =
|
|
6846
|
-
|
|
6847
|
-
|
|
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 =
|
|
6850
|
-
status:
|
|
7848
|
+
var ArchivarRutinaOutputSchema = import_zod46.z.object({
|
|
7849
|
+
status: import_zod46.z.literal("archivada")
|
|
6851
7850
|
});
|
|
6852
|
-
var ListarRutinasParamsSchema =
|
|
6853
|
-
uid:
|
|
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 =
|
|
6856
|
-
rutinas:
|
|
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
|
|
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/
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
if (
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
7363
|
-
`Busca
|
|
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
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
|
|
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:
|
|
7497
|
-
keyword:
|
|
7498
|
-
plataforma:
|
|
7499
|
-
fecha:
|
|
7500
|
-
limit:
|
|
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
|
|
7506
|
-
|
|
7507
|
-
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
7515
|
-
|
|
7516
|
-
|
|
7517
|
-
|
|
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
|
-
|
|
7523
|
-
|
|
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:
|
|
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
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
|
|
7552
|
-
|
|
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:
|
|
7575
|
-
prompt:
|
|
7576
|
-
acciones:
|
|
7577
|
-
descripcion:
|
|
7578
|
-
tipo:
|
|
7579
|
-
tagsPrimarios:
|
|
7580
|
-
tagsSecundarios:
|
|
7581
|
-
tagsContexto:
|
|
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
|
-
|
|
7587
|
-
const
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
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
|
-
|
|
7610
|
-
|
|
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:
|
|
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:
|
|
7759
|
-
contexto:
|
|
7760
|
-
fecha:
|
|
7761
|
-
limit:
|
|
7762
|
-
diversidad:
|
|
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
|
|
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
|
-
|
|
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:
|
|
7816
|
-
plataforma:
|
|
7817
|
-
tipoContenido:
|
|
7818
|
-
keyword:
|
|
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
|
|
7824
|
-
|
|
7825
|
-
|
|
7826
|
-
|
|
7827
|
-
|
|
7828
|
-
|
|
7829
|
-
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
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:
|
|
7849
|
-
semana:
|
|
7850
|
-
necesidades:
|
|
7851
|
-
|
|
7852
|
-
tema:
|
|
7853
|
-
keyword:
|
|
7854
|
-
cantidadSugerida:
|
|
7855
|
-
razon:
|
|
7856
|
-
slotsAfectados:
|
|
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:
|
|
8003
|
-
mes:
|
|
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:
|
|
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:
|
|
8056
|
-
estado:
|
|
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:
|
|
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:
|
|
8111
|
-
plan:
|
|
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:
|
|
8140
|
-
field:
|
|
8141
|
-
value:
|
|
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:
|
|
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:
|
|
8183
|
-
brandBrief:
|
|
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
|
|
8189
|
-
|
|
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:
|
|
8197
|
-
semana:
|
|
8198
|
-
modo:
|
|
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
|
|
8204
|
-
const
|
|
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:
|
|
8215
|
-
plataforma:
|
|
8216
|
-
tipo:
|
|
8217
|
-
keyword:
|
|
8218
|
-
languageCode:
|
|
8219
|
-
fotoId:
|
|
8220
|
-
datos:
|
|
8221
|
-
calendarioItemRef:
|
|
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
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
tipo,
|
|
8233
|
-
|
|
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
|
-
|
|
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:
|
|
8267
|
-
datos:
|
|
8268
|
-
fotoId:
|
|
8269
|
-
keyword:
|
|
8270
|
-
languageCode:
|
|
8271
|
-
estado:
|
|
8272
|
-
calendarioItemRef:
|
|
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
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
|
|
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
|
-
|
|
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:
|
|
8304
|
-
mes:
|
|
8305
|
-
semana:
|
|
8306
|
-
slot:
|
|
8307
|
-
dia:
|
|
8308
|
-
plataforma:
|
|
8309
|
-
tipo:
|
|
8310
|
-
keyword:
|
|
8311
|
-
tema:
|
|
8312
|
-
productoId:
|
|
8313
|
-
estado:
|
|
8314
|
-
locationId:
|
|
8315
|
-
locationNombre:
|
|
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:
|
|
8342
|
-
mes:
|
|
8343
|
-
semana:
|
|
8344
|
-
slotIndex:
|
|
8345
|
-
cambios:
|
|
8346
|
-
dia:
|
|
8347
|
-
plataforma:
|
|
8348
|
-
tipo:
|
|
8349
|
-
keyword:
|
|
8350
|
-
tema:
|
|
8351
|
-
productoId:
|
|
8352
|
-
estado:
|
|
8353
|
-
contenidoRef:
|
|
8354
|
-
fotoIdAsignada:
|
|
8355
|
-
notas:
|
|
8356
|
-
locationId:
|
|
8357
|
-
locationNombre:
|
|
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:
|
|
8360
|
-
|
|
8361
|
-
|
|
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:
|
|
8407
|
-
fotoId:
|
|
8408
|
-
calendarioItemRef:
|
|
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
|
|
8414
|
-
|
|
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:
|
|
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:
|
|
8446
|
-
motivo:
|
|
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:
|
|
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:
|
|
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
|
|
8563
|
-
|
|
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
|
}
|