ponch-mcp-server 1.0.76 → 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 +2264 -977
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -47,14 +47,14 @@ function resolveAuth() {
|
|
|
47
47
|
const resolvedPath = saPath.startsWith("/") ? saPath : (0, import_path.join)(process.cwd(), saPath);
|
|
48
48
|
if ((0, import_fs.existsSync)(resolvedPath)) {
|
|
49
49
|
return {
|
|
50
|
-
|
|
50
|
+
canSwitchTenant: true,
|
|
51
|
+
// service account = admin del SaaS
|
|
51
52
|
tenantId: null,
|
|
52
53
|
brandId: null,
|
|
53
54
|
brands: [],
|
|
54
55
|
userId: null,
|
|
55
56
|
userName: null,
|
|
56
57
|
rol: "super_admin",
|
|
57
|
-
// service account = admin total
|
|
58
58
|
serviceAccountPath: resolvedPath,
|
|
59
59
|
credentialsPath: null
|
|
60
60
|
};
|
|
@@ -65,7 +65,7 @@ function resolveAuth() {
|
|
|
65
65
|
try {
|
|
66
66
|
const creds = JSON.parse((0, import_fs.readFileSync)(CREDENTIALS_PATH, "utf-8"));
|
|
67
67
|
return {
|
|
68
|
-
|
|
68
|
+
canSwitchTenant: creds.rol === "super_admin",
|
|
69
69
|
tenantId: creds.tenantId ?? null,
|
|
70
70
|
brandId: creds.brands?.[0]?.id ?? null,
|
|
71
71
|
brands: creds.brands ?? [],
|
|
@@ -80,7 +80,7 @@ function resolveAuth() {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
return {
|
|
83
|
-
|
|
83
|
+
canSwitchTenant: false,
|
|
84
84
|
tenantId: null,
|
|
85
85
|
brandId: null,
|
|
86
86
|
brands: [],
|
|
@@ -108,6 +108,10 @@ function readCredentials() {
|
|
|
108
108
|
var Session = class {
|
|
109
109
|
context;
|
|
110
110
|
_authContext;
|
|
111
|
+
_clientIdentity = {
|
|
112
|
+
mcpClient: null,
|
|
113
|
+
mcpClientVersion: null
|
|
114
|
+
};
|
|
111
115
|
constructor(auth) {
|
|
112
116
|
this._authContext = auth;
|
|
113
117
|
this.context = {
|
|
@@ -115,8 +119,26 @@ var Session = class {
|
|
|
115
119
|
brandId: auth.brandId
|
|
116
120
|
};
|
|
117
121
|
}
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Captura identidad del cliente MCP. Llamado al recibir handshake
|
|
124
|
+
* `initialize` del SDK. Idempotente — si se llama 2 veces, segunda no
|
|
125
|
+
* sobrescribe (el cliente no cambia mid-session).
|
|
126
|
+
*/
|
|
127
|
+
setMcpClientIdentity(name, version) {
|
|
128
|
+
if (this._clientIdentity.mcpClient === null) {
|
|
129
|
+
this._clientIdentity.mcpClient = name;
|
|
130
|
+
this._clientIdentity.mcpClientVersion = version;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
get mcpClientIdentity() {
|
|
134
|
+
return this._clientIdentity;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* true → super_admin del SaaS (puede cambiar tenant). false → tenant
|
|
138
|
+
* member normal. Independiente del SDK que esté corriendo el MCP.
|
|
139
|
+
*/
|
|
140
|
+
get canSwitchTenant() {
|
|
141
|
+
return this._authContext.canSwitchTenant;
|
|
120
142
|
}
|
|
121
143
|
get tenantId() {
|
|
122
144
|
return this.context.tenantId;
|
|
@@ -137,19 +159,27 @@ var Session = class {
|
|
|
137
159
|
get userId() {
|
|
138
160
|
return this._authContext.userId;
|
|
139
161
|
}
|
|
140
|
-
/** Rol del usuario (
|
|
162
|
+
/** Rol del usuario (super_admin | ROL00X del tenant). */
|
|
141
163
|
get rol() {
|
|
142
164
|
return this._authContext.rol;
|
|
143
165
|
}
|
|
144
166
|
setContext(tenantId, brandId) {
|
|
145
|
-
if (this._authContext.
|
|
146
|
-
throw new Error("set_context solo disponible
|
|
167
|
+
if (!this._authContext.canSwitchTenant) {
|
|
168
|
+
throw new Error("set_context solo disponible para usuarios con permiso multi-tenant (super_admin).");
|
|
147
169
|
}
|
|
148
170
|
this.context.tenantId = tenantId;
|
|
149
171
|
this.context.brandId = brandId;
|
|
150
172
|
}
|
|
151
173
|
/**
|
|
152
|
-
*
|
|
174
|
+
* Revoca canSwitchTenant en runtime. Usado cuando el bootstrap detecta
|
|
175
|
+
* token expirado — el usuario debe re-autenticar via connect_account
|
|
176
|
+
* antes de poder cambiar de tenant.
|
|
177
|
+
*/
|
|
178
|
+
revokeCrossTenant() {
|
|
179
|
+
this._authContext.canSwitchTenant = false;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Actualiza el contexto después de connect_account.
|
|
153
183
|
*/
|
|
154
184
|
setTenantContext(tenantId, brandId, brands, userId, userName) {
|
|
155
185
|
this.context.tenantId = tenantId;
|
|
@@ -160,16 +190,10 @@ var Session = class {
|
|
|
160
190
|
this._authContext.userId = userId;
|
|
161
191
|
this._authContext.userName = userName;
|
|
162
192
|
}
|
|
163
|
-
/**
|
|
164
|
-
* Fuerza el modo (usado cuando credentials existen pero token expiro)
|
|
165
|
-
*/
|
|
166
|
-
forceMode(newMode) {
|
|
167
|
-
this._authContext.mode = newMode;
|
|
168
|
-
}
|
|
169
193
|
requireTenant() {
|
|
170
194
|
if (!this.context.tenantId) {
|
|
171
195
|
throw new Error(
|
|
172
|
-
this._authContext.
|
|
196
|
+
this._authContext.canSwitchTenant ? "Usa set_context para seleccionar un tenant primero." : "No conectado. Usa connect_account para conectar tu cuenta de Ponch."
|
|
173
197
|
);
|
|
174
198
|
}
|
|
175
199
|
return this.context.tenantId;
|
|
@@ -188,11 +212,11 @@ var import_app = require("firebase/app");
|
|
|
188
212
|
var import_lite = require("firebase/firestore/lite");
|
|
189
213
|
var import_auth = require("firebase/auth");
|
|
190
214
|
var import_fs2 = require("fs");
|
|
191
|
-
var
|
|
215
|
+
var sdkMode = "none";
|
|
192
216
|
var adminDb;
|
|
193
217
|
var clientDb;
|
|
194
218
|
function initFirebaseAdmin(serviceAccountPath) {
|
|
195
|
-
if (
|
|
219
|
+
if (sdkMode !== "none") return;
|
|
196
220
|
const serviceAccount = JSON.parse(
|
|
197
221
|
(0, import_fs2.readFileSync)(serviceAccountPath, "utf-8")
|
|
198
222
|
);
|
|
@@ -200,10 +224,10 @@ function initFirebaseAdmin(serviceAccountPath) {
|
|
|
200
224
|
credential: import_firebase_admin.default.credential.cert(serviceAccount)
|
|
201
225
|
});
|
|
202
226
|
adminDb = import_firebase_admin.default.firestore();
|
|
203
|
-
|
|
227
|
+
sdkMode = "admin";
|
|
204
228
|
}
|
|
205
229
|
async function initFirebaseClient() {
|
|
206
|
-
if (
|
|
230
|
+
if (sdkMode !== "none") return true;
|
|
207
231
|
const creds = readCredentials();
|
|
208
232
|
if (!creds) {
|
|
209
233
|
console.error("[firestore] No hay credentials.json");
|
|
@@ -221,7 +245,7 @@ async function initFirebaseClient() {
|
|
|
221
245
|
if (newCustomToken) {
|
|
222
246
|
await (0, import_auth.signInWithCustomToken)(auth, newCustomToken);
|
|
223
247
|
clientDb = (0, import_lite.getFirestore)(app);
|
|
224
|
-
|
|
248
|
+
sdkMode = "client";
|
|
225
249
|
saveCredentials({ ...creds, customToken: newCustomToken });
|
|
226
250
|
console.error("[firestore] Firebase Client re-autenticado con refreshToken");
|
|
227
251
|
return true;
|
|
@@ -241,7 +265,7 @@ async function initFirebaseClient() {
|
|
|
241
265
|
console.error("[firestore] refreshToken guardado para futuras sesiones");
|
|
242
266
|
}
|
|
243
267
|
clientDb = (0, import_lite.getFirestore)(app);
|
|
244
|
-
|
|
268
|
+
sdkMode = "client";
|
|
245
269
|
console.error("[firestore] Firebase Client autenticado con customToken");
|
|
246
270
|
return true;
|
|
247
271
|
} catch (err) {
|
|
@@ -306,27 +330,27 @@ async function reauthClient(customToken) {
|
|
|
306
330
|
console.error("[firestore] refreshToken guardado");
|
|
307
331
|
}
|
|
308
332
|
clientDb = (0, import_lite.getFirestore)(app);
|
|
309
|
-
|
|
333
|
+
sdkMode = "client";
|
|
310
334
|
return true;
|
|
311
335
|
} catch (err) {
|
|
312
336
|
console.error("[firestore] Error re-autenticando:", err);
|
|
313
337
|
return false;
|
|
314
338
|
}
|
|
315
339
|
}
|
|
316
|
-
function
|
|
317
|
-
return
|
|
340
|
+
function getSdkMode() {
|
|
341
|
+
return sdkMode;
|
|
318
342
|
}
|
|
319
343
|
function getAdminDb() {
|
|
320
|
-
if (
|
|
321
|
-
throw new Error("getAdminDb:
|
|
344
|
+
if (sdkMode !== "admin") {
|
|
345
|
+
throw new Error("getAdminDb: SDK admin requerido (actual: " + sdkMode + ")");
|
|
322
346
|
}
|
|
323
347
|
return adminDb;
|
|
324
348
|
}
|
|
325
349
|
async function queryByTenant(session, collectionName, filters = [], options = {}) {
|
|
326
350
|
const tenantId = session.requireTenant();
|
|
327
|
-
if (
|
|
351
|
+
if (sdkMode === "admin") {
|
|
328
352
|
return queryByTenantAdmin(tenantId, collectionName, filters, options);
|
|
329
|
-
} else if (
|
|
353
|
+
} else if (sdkMode === "client") {
|
|
330
354
|
return queryByTenantClient(tenantId, collectionName, filters, options);
|
|
331
355
|
}
|
|
332
356
|
throw new Error("Firebase no inicializado");
|
|
@@ -362,11 +386,11 @@ async function queryByTenantClient(tenantId, collectionName, filters, options) {
|
|
|
362
386
|
return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
|
363
387
|
}
|
|
364
388
|
async function readDoc(collectionName, docId) {
|
|
365
|
-
if (
|
|
389
|
+
if (sdkMode === "admin") {
|
|
366
390
|
const doc = await adminDb.collection(collectionName).doc(docId).get();
|
|
367
391
|
if (!doc.exists) return null;
|
|
368
392
|
return { id: doc.id, ...doc.data() };
|
|
369
|
-
} else if (
|
|
393
|
+
} else if (sdkMode === "client") {
|
|
370
394
|
const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
|
|
371
395
|
const doc = await (0, import_lite.getDoc)(ref);
|
|
372
396
|
if (!doc.exists()) return null;
|
|
@@ -374,10 +398,21 @@ async function readDoc(collectionName, docId) {
|
|
|
374
398
|
}
|
|
375
399
|
throw new Error("Firebase no inicializado");
|
|
376
400
|
}
|
|
401
|
+
async function listCollection(path) {
|
|
402
|
+
if (sdkMode === "admin") {
|
|
403
|
+
const snap = await adminDb.collection(path).get();
|
|
404
|
+
return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
|
405
|
+
} else if (sdkMode === "client") {
|
|
406
|
+
const ref = (0, import_lite.collection)(clientDb, path);
|
|
407
|
+
const snap = await (0, import_lite.getDocs)(ref);
|
|
408
|
+
return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
|
|
409
|
+
}
|
|
410
|
+
throw new Error("Firebase no inicializado");
|
|
411
|
+
}
|
|
377
412
|
async function writeDoc(collectionName, docId, data) {
|
|
378
|
-
if (
|
|
413
|
+
if (sdkMode === "admin") {
|
|
379
414
|
await adminDb.collection(collectionName).doc(docId).set(data, { merge: true });
|
|
380
|
-
} else if (
|
|
415
|
+
} else if (sdkMode === "client") {
|
|
381
416
|
const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
|
|
382
417
|
await (0, import_lite.setDoc)(ref, data, { merge: true });
|
|
383
418
|
} else {
|
|
@@ -385,9 +420,9 @@ async function writeDoc(collectionName, docId, data) {
|
|
|
385
420
|
}
|
|
386
421
|
}
|
|
387
422
|
async function updateDoc(collectionName, docId, data) {
|
|
388
|
-
if (
|
|
423
|
+
if (sdkMode === "admin") {
|
|
389
424
|
await adminDb.collection(collectionName).doc(docId).update(data);
|
|
390
|
-
} else if (
|
|
425
|
+
} else if (sdkMode === "client") {
|
|
391
426
|
const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
|
|
392
427
|
await (0, import_lite.updateDoc)(ref, data);
|
|
393
428
|
} else {
|
|
@@ -395,7 +430,7 @@ async function updateDoc(collectionName, docId, data) {
|
|
|
395
430
|
}
|
|
396
431
|
}
|
|
397
432
|
function serverTimestamp() {
|
|
398
|
-
if (
|
|
433
|
+
if (sdkMode === "admin") {
|
|
399
434
|
return import_firebase_admin.default.firestore.FieldValue.serverTimestamp();
|
|
400
435
|
} else {
|
|
401
436
|
return (0, import_lite.serverTimestamp)();
|
|
@@ -407,10 +442,10 @@ var import_zod = require("zod");
|
|
|
407
442
|
var import_functions = require("firebase/functions");
|
|
408
443
|
var import_app2 = require("firebase/app");
|
|
409
444
|
function registerContextTools(server, session) {
|
|
410
|
-
if (session.
|
|
445
|
+
if (session.canSwitchTenant) {
|
|
411
446
|
server.tool(
|
|
412
447
|
"set_context",
|
|
413
|
-
"Establece el tenant y brand para esta sesion. Solo disponible
|
|
448
|
+
"Establece el tenant y brand para esta sesion. Solo disponible para super_admin.",
|
|
414
449
|
{
|
|
415
450
|
tenantId: import_zod.z.string().describe('ID del tenant (ej: "atteyo")'),
|
|
416
451
|
brandId: import_zod.z.string().describe('ID de la brand (ej: "ponch")')
|
|
@@ -418,8 +453,8 @@ function registerContextTools(server, session) {
|
|
|
418
453
|
async ({ tenantId, brandId }) => {
|
|
419
454
|
const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
|
|
420
455
|
if (!brand) {
|
|
421
|
-
const
|
|
422
|
-
if (
|
|
456
|
+
const brands = await listCollection(`tenants/${tenantId}/marketing_config`);
|
|
457
|
+
if (brands.length === 0) {
|
|
423
458
|
return {
|
|
424
459
|
content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Tenant "${tenantId}" no tiene marketing_config` }) }]
|
|
425
460
|
};
|
|
@@ -430,7 +465,7 @@ function registerContextTools(server, session) {
|
|
|
430
465
|
text: JSON.stringify({
|
|
431
466
|
ok: false,
|
|
432
467
|
error: `Brand "${brandId}" no existe en tenant "${tenantId}"`,
|
|
433
|
-
brandsDisponibles:
|
|
468
|
+
brandsDisponibles: brands.map((b) => b.id)
|
|
434
469
|
})
|
|
435
470
|
}]
|
|
436
471
|
};
|
|
@@ -2120,7 +2155,7 @@ var TenantStatusFieldsSchema = import_zod14.z.object({
|
|
|
2120
2155
|
cancellation: CancellationInfoSchema
|
|
2121
2156
|
}).passthrough();
|
|
2122
2157
|
var AuditActorSchema = import_zod15.z.object({
|
|
2123
|
-
type: import_zod15.z.enum(["user", "martin", "system", "cf_scheduled", "webhook"]),
|
|
2158
|
+
type: import_zod15.z.enum(["user", "martin", "mcp_client", "system", "cf_scheduled", "webhook"]),
|
|
2124
2159
|
uid: import_zod15.z.string().nullable(),
|
|
2125
2160
|
nombre: import_zod15.z.string(),
|
|
2126
2161
|
metadata: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.any()).optional()
|
|
@@ -2194,11 +2229,10 @@ var AddonActivoSchema = import_zod16.z.object({
|
|
|
2194
2229
|
estado: import_zod16.z.enum(["active", "cancelled"])
|
|
2195
2230
|
}).strict();
|
|
2196
2231
|
var TipoMemoriaEnum = import_zod17.z.enum([
|
|
2197
|
-
"filtro",
|
|
2198
2232
|
"preferencia",
|
|
2233
|
+
"regla",
|
|
2199
2234
|
"patron",
|
|
2200
|
-
"
|
|
2201
|
-
"delegacion"
|
|
2235
|
+
"aversion"
|
|
2202
2236
|
]);
|
|
2203
2237
|
var StatusMemoriaEnum = import_zod17.z.enum([
|
|
2204
2238
|
"explicito",
|
|
@@ -2726,33 +2760,48 @@ function registerCoreTools(server, session) {
|
|
|
2726
2760
|
}
|
|
2727
2761
|
|
|
2728
2762
|
// src/tools/marketing.ts
|
|
2729
|
-
var
|
|
2763
|
+
var import_zod49 = require("zod");
|
|
2730
2764
|
|
|
2731
2765
|
// ../packages/marketing-business-logic/dist/index.js
|
|
2732
2766
|
var import_firebase_admin2 = require("firebase-admin");
|
|
2733
|
-
var import_firebase_admin3 = require("firebase-admin");
|
|
2734
2767
|
var import_zod27 = require("zod");
|
|
2735
2768
|
var import_zod28 = require("zod");
|
|
2736
2769
|
var import_zod29 = require("zod");
|
|
2770
|
+
var import_fs3 = require("fs");
|
|
2771
|
+
var import_path2 = require("path");
|
|
2772
|
+
var import_url = require("url");
|
|
2773
|
+
var import_firebase_admin3 = require("firebase-admin");
|
|
2774
|
+
var import_zod30 = require("zod");
|
|
2737
2775
|
var import_firebase_admin4 = require("firebase-admin");
|
|
2776
|
+
var import_zod31 = require("zod");
|
|
2738
2777
|
var import_firebase_admin5 = require("firebase-admin");
|
|
2778
|
+
var import_zod32 = require("zod");
|
|
2739
2779
|
var import_firebase_admin6 = require("firebase-admin");
|
|
2740
|
-
var
|
|
2780
|
+
var import_zod33 = require("zod");
|
|
2741
2781
|
var import_firebase_admin7 = require("firebase-admin");
|
|
2742
|
-
var
|
|
2743
|
-
var
|
|
2782
|
+
var import_zod34 = require("zod");
|
|
2783
|
+
var import_zod35 = require("zod");
|
|
2744
2784
|
var import_firebase_admin8 = require("firebase-admin");
|
|
2745
|
-
var
|
|
2785
|
+
var import_zod36 = require("zod");
|
|
2786
|
+
var import_zod37 = require("zod");
|
|
2787
|
+
var import_zod38 = require("zod");
|
|
2746
2788
|
var import_firebase_admin9 = require("firebase-admin");
|
|
2789
|
+
var import_zod39 = require("zod");
|
|
2747
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");
|
|
2748
2796
|
var import_firestore3 = require("firebase-admin/firestore");
|
|
2749
2797
|
var import_firestore4 = require("firebase-admin/firestore");
|
|
2750
2798
|
var import_firestore5 = require("firebase-admin/firestore");
|
|
2751
2799
|
var import_firestore6 = require("firebase-admin/firestore");
|
|
2752
|
-
var
|
|
2800
|
+
var import_zod45 = require("zod");
|
|
2753
2801
|
var import_firestore7 = require("firebase-admin/firestore");
|
|
2754
|
-
var
|
|
2802
|
+
var import_zod46 = require("zod");
|
|
2755
2803
|
var import_firestore8 = require("firebase-admin/firestore");
|
|
2804
|
+
var import_meta = {};
|
|
2756
2805
|
var RULE_NEGATIVES = {
|
|
2757
2806
|
allowFaces: "no people, no faces, no hands",
|
|
2758
2807
|
allowProductTransform: "no distorted products, no warped objects",
|
|
@@ -2895,6 +2944,7 @@ async function brandBriefWriter(input) {
|
|
|
2895
2944
|
return {
|
|
2896
2945
|
ok: false,
|
|
2897
2946
|
error: "Brand Brief no cumple el schema esperado.",
|
|
2947
|
+
code: "BRAND_BRIEF_VALIDATION_FAILED",
|
|
2898
2948
|
detalle: validation.error,
|
|
2899
2949
|
path: validation.path,
|
|
2900
2950
|
recibido: validation.received,
|
|
@@ -2905,7 +2955,7 @@ async function brandBriefWriter(input) {
|
|
|
2905
2955
|
const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
|
|
2906
2956
|
const brandSnap = await brandRef.get();
|
|
2907
2957
|
if (!brandSnap.exists) {
|
|
2908
|
-
return { ok: false, error: `Brand "${brandId}" no encontrada
|
|
2958
|
+
return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
2909
2959
|
}
|
|
2910
2960
|
const brief = validation.parsed;
|
|
2911
2961
|
const briefWithMeta = {
|
|
@@ -2929,90 +2979,76 @@ async function brandBriefWriter(input) {
|
|
|
2929
2979
|
mensaje: `Brand Brief guardado para "${brandId}". El tenant puede validar los campos en Config > Mi Negocio.`
|
|
2930
2980
|
};
|
|
2931
2981
|
}
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2982
|
+
var _localeCache = {};
|
|
2983
|
+
function _resolveLocaleFile(locale) {
|
|
2984
|
+
let baseDir;
|
|
2985
|
+
try {
|
|
2986
|
+
baseDir = typeof __dirname !== "undefined" ? __dirname : (0, import_path2.dirname)((0, import_url.fileURLToPath)(import_meta.url));
|
|
2987
|
+
} catch {
|
|
2988
|
+
baseDir = process.cwd();
|
|
2938
2989
|
}
|
|
2939
|
-
const
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
};
|
|
2990
|
+
const candidates = [
|
|
2991
|
+
(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`)
|
|
2995
|
+
];
|
|
2996
|
+
for (const candidate of candidates) {
|
|
2997
|
+
try {
|
|
2998
|
+
(0, import_fs3.readFileSync)(candidate, "utf-8");
|
|
2999
|
+
return candidate;
|
|
3000
|
+
} catch {
|
|
3001
|
+
continue;
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
throw new Error(
|
|
3005
|
+
`getMessage: locale file '${locale}.json' not found. Tried:
|
|
3006
|
+
` + candidates.map((c) => ` - ${c}`).join("\n")
|
|
3007
|
+
);
|
|
2958
3008
|
}
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
const
|
|
2962
|
-
const
|
|
2963
|
-
|
|
2964
|
-
|
|
3009
|
+
function _loadLocale(locale) {
|
|
3010
|
+
if (_localeCache[locale]) return _localeCache[locale];
|
|
3011
|
+
const path2 = _resolveLocaleFile(locale);
|
|
3012
|
+
const raw = (0, import_fs3.readFileSync)(path2, "utf-8");
|
|
3013
|
+
const data = JSON.parse(raw);
|
|
3014
|
+
_localeCache[locale] = data;
|
|
3015
|
+
return data;
|
|
3016
|
+
}
|
|
3017
|
+
function _resolvePath(obj, path2) {
|
|
3018
|
+
const parts = path2.split(".");
|
|
3019
|
+
let current = obj;
|
|
3020
|
+
for (const part of parts) {
|
|
3021
|
+
if (current === null || typeof current !== "object") return null;
|
|
3022
|
+
current = current[part];
|
|
2965
3023
|
}
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
if (
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
}
|
|
3024
|
+
return typeof current === "string" ? current : null;
|
|
3025
|
+
}
|
|
3026
|
+
function _interpolate(template, vars, context) {
|
|
3027
|
+
return template.replace(/\{(\w+)\}/g, (_match, key) => {
|
|
3028
|
+
if (!vars || !(key in vars)) {
|
|
3029
|
+
throw new Error(
|
|
3030
|
+
`getMessage: variable '${key}' in template '${context.path}' (${context.locale}) not provided in vars. Pass { ${key}: '...' } when calling getMessage.`
|
|
3031
|
+
);
|
|
2975
3032
|
}
|
|
3033
|
+
return vars[key];
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
var SUPPORTED_LOCALES = ["es", "en"];
|
|
3037
|
+
function getMessage(path2, locale, vars) {
|
|
3038
|
+
if (!SUPPORTED_LOCALES.includes(locale)) {
|
|
3039
|
+
throw new Error(
|
|
3040
|
+
`getMessage: locale '${locale}' not supported. Active locales: ${SUPPORTED_LOCALES.join(", ")}.`
|
|
3041
|
+
);
|
|
2976
3042
|
}
|
|
2977
|
-
const
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
path: validation.path,
|
|
2984
|
-
recibido: validation.received,
|
|
2985
|
-
ejemplo_correcto: PLAN_FIELD_EXAMPLES[field],
|
|
2986
|
-
hint: "Manda el valor como objeto/array nativo, NO como string JSON. Usa el ejemplo arriba como referencia exacta del shape esperado."
|
|
2987
|
-
};
|
|
2988
|
-
}
|
|
2989
|
-
const updatePayload = {
|
|
2990
|
-
id: brandId,
|
|
2991
|
-
brandId,
|
|
2992
|
-
tenantId,
|
|
2993
|
-
schemaVersion: 1,
|
|
2994
|
-
updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
|
|
2995
|
-
};
|
|
2996
|
-
if (field === "blogStrategy") {
|
|
2997
|
-
updatePayload.blogStrategy = validation.parsed;
|
|
2998
|
-
} else {
|
|
2999
|
-
const currentPlan = brandData.plan ?? {};
|
|
3000
|
-
updatePayload.plan = {
|
|
3001
|
-
...currentPlan,
|
|
3002
|
-
[field]: validation.parsed,
|
|
3003
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3004
|
-
};
|
|
3043
|
+
const data = _loadLocale(locale);
|
|
3044
|
+
const template = _resolvePath(data, path2);
|
|
3045
|
+
if (template === null) {
|
|
3046
|
+
throw new Error(
|
|
3047
|
+
`getMessage: path '${path2}' not found in locale '${locale}'. Add it to src/i18n/locales/${locale}.json.`
|
|
3048
|
+
);
|
|
3005
3049
|
}
|
|
3006
|
-
|
|
3007
|
-
return {
|
|
3008
|
-
ok: true,
|
|
3009
|
-
mensaje: `Campo "${field}" actualizado en el plan de "${brandId}"`
|
|
3010
|
-
};
|
|
3050
|
+
return _interpolate(template, vars, { path: path2, locale });
|
|
3011
3051
|
}
|
|
3012
|
-
var planWriter = {
|
|
3013
|
-
save,
|
|
3014
|
-
updateField
|
|
3015
|
-
};
|
|
3016
3052
|
var DisabledReasonCodeSchema = import_zod29.z.enum([
|
|
3017
3053
|
"paywall",
|
|
3018
3054
|
// Acción requiere upgrade de plan
|
|
@@ -3038,44 +3074,8 @@ var DisabledOutputSchema = import_zod29.z.object({
|
|
|
3038
3074
|
*/
|
|
3039
3075
|
detail: import_zod29.z.record(import_zod29.z.string(), import_zod29.z.string()).optional()
|
|
3040
3076
|
});
|
|
3041
|
-
var TEMPLATES = {
|
|
3042
|
-
es: {
|
|
3043
|
-
paywall: "Esta acci\xF3n requiere actualizar tu plan.",
|
|
3044
|
-
oauth_missing: "Necesitas conectar {service} primero.",
|
|
3045
|
-
data_missing: "Falta informaci\xF3n: {detail}",
|
|
3046
|
-
plan_not_includes: "Tu plan no incluye {feature}",
|
|
3047
|
-
feature_disabled: "Esta funci\xF3n est\xE1 deshabilitada para tu cuenta.",
|
|
3048
|
-
other: "{detail}"
|
|
3049
|
-
},
|
|
3050
|
-
en: {
|
|
3051
|
-
paywall: "This action requires upgrading your plan.",
|
|
3052
|
-
oauth_missing: "You need to connect {service} first.",
|
|
3053
|
-
data_missing: "Missing information: {detail}",
|
|
3054
|
-
plan_not_includes: "Your plan doesn't include {feature}",
|
|
3055
|
-
feature_disabled: "This feature is disabled for your account.",
|
|
3056
|
-
other: "{detail}"
|
|
3057
|
-
}
|
|
3058
|
-
};
|
|
3059
3077
|
function getDisabledMessage(code, locale, vars) {
|
|
3060
|
-
|
|
3061
|
-
throw new Error(
|
|
3062
|
-
`Locale '${locale}' no soportado en disabledMessages. Locales activos: ${Object.keys(TEMPLATES).join(", ")}.`
|
|
3063
|
-
);
|
|
3064
|
-
}
|
|
3065
|
-
const template = TEMPLATES[locale][code];
|
|
3066
|
-
if (!template) {
|
|
3067
|
-
throw new Error(
|
|
3068
|
-
`Disabled code '${code}' no tiene template definido para locale '${locale}'.`
|
|
3069
|
-
);
|
|
3070
|
-
}
|
|
3071
|
-
return template.replace(/\{(\w+)\}/g, (_match, key) => {
|
|
3072
|
-
if (!vars || !(key in vars)) {
|
|
3073
|
-
throw new Error(
|
|
3074
|
-
`getDisabledMessage: variable '${key}' del template '${code}' (${locale}) no provista en vars. Helper debe pasar { ${key}: '...' } en detail.`
|
|
3075
|
-
);
|
|
3076
|
-
}
|
|
3077
|
-
return vars[key];
|
|
3078
|
-
});
|
|
3078
|
+
return getMessage(`marketing.disabled.${code}`, locale, vars);
|
|
3079
3079
|
}
|
|
3080
3080
|
var SideEffectEnum = import_zod28.z.enum([
|
|
3081
3081
|
"reads_firestore",
|
|
@@ -3107,6 +3107,10 @@ var ExtractTargetPathSchema = import_zod28.z.custom((val) => typeof val === "fun
|
|
|
3107
3107
|
var ExtractChangesSchema = import_zod28.z.custom((val) => typeof val === "function", {
|
|
3108
3108
|
message: "extractChanges debe ser funci\xF3n (input, output) => { before, after }"
|
|
3109
3109
|
});
|
|
3110
|
+
var InputPredicateSchema = import_zod28.z.custom(
|
|
3111
|
+
(val) => typeof val === "function",
|
|
3112
|
+
{ message: "predicado debe ser funci\xF3n (input) => boolean" }
|
|
3113
|
+
);
|
|
3110
3114
|
var MartinContractSchema = import_zod28.z.object({
|
|
3111
3115
|
// Schema versioning (Dim 4 backbone)
|
|
3112
3116
|
schemaVersion: import_zod28.z.literal(1).default(1),
|
|
@@ -3122,6 +3126,16 @@ var MartinContractSchema = import_zod28.z.object({
|
|
|
3122
3126
|
destructive: import_zod28.z.boolean(),
|
|
3123
3127
|
affectsPublication: import_zod28.z.boolean(),
|
|
3124
3128
|
affectsExternal: import_zod28.z.boolean(),
|
|
3129
|
+
/**
|
|
3130
|
+
* Override runtime del flag `destructive`. Si el predicado retorna
|
|
3131
|
+
* true para un input específico, el wrapper trata la invocación
|
|
3132
|
+
* como destructiva aun si `destructive: false`. Útil cuando una
|
|
3133
|
+
* misma tool tiene 2 modos según args (ej. update con
|
|
3134
|
+
* accionContenidoExistente='descartar').
|
|
3135
|
+
*/
|
|
3136
|
+
isDestructiveForInput: InputPredicateSchema.optional(),
|
|
3137
|
+
/** Mismo patrón para affectsPublication (override runtime). */
|
|
3138
|
+
isPublicationForInput: InputPredicateSchema.optional(),
|
|
3125
3139
|
// Presentación al usuario (i18n)
|
|
3126
3140
|
martinSummaryTemplate: SummaryTemplateSchema,
|
|
3127
3141
|
martinConfirmationTemplate: ConfirmationTemplateSchema.optional(),
|
|
@@ -3240,10 +3254,11 @@ var MartinContractSchema = import_zod28.z.object({
|
|
|
3240
3254
|
}
|
|
3241
3255
|
});
|
|
3242
3256
|
var ParamsSchema = import_zod27.z.object({
|
|
3243
|
-
tenantId: import_zod27.z.string().min(1),
|
|
3244
|
-
brandId: import_zod27.z.string().min(1),
|
|
3245
|
-
|
|
3246
|
-
|
|
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
|
+
)
|
|
3247
3262
|
});
|
|
3248
3263
|
var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
|
|
3249
3264
|
import_zod27.z.object({
|
|
@@ -3253,6 +3268,7 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
|
|
|
3253
3268
|
import_zod27.z.object({
|
|
3254
3269
|
ok: import_zod27.z.literal(false),
|
|
3255
3270
|
error: import_zod27.z.string(),
|
|
3271
|
+
code: import_zod27.z.enum(["BRAND_BRIEF_VALIDATION_FAILED", "BRAND_NOT_FOUND"]).optional(),
|
|
3256
3272
|
detalle: import_zod27.z.unknown().optional(),
|
|
3257
3273
|
path: import_zod27.z.string().optional(),
|
|
3258
3274
|
recibido: import_zod27.z.unknown().optional(),
|
|
@@ -3261,31 +3277,34 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
|
|
|
3261
3277
|
})
|
|
3262
3278
|
]);
|
|
3263
3279
|
var rawContract = {
|
|
3264
|
-
name: "
|
|
3265
|
-
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.",
|
|
3266
3282
|
paramsSchema: ParamsSchema,
|
|
3267
3283
|
outputSchema: OutputSchema,
|
|
3268
|
-
//
|
|
3269
|
-
// no afecta publicación, no llama externo.
|
|
3284
|
+
// Sobrescribe brandBrief existente vía merge — reversible (regenerar el
|
|
3285
|
+
// brief es barato), no afecta publicación, no llama externo.
|
|
3270
3286
|
requiresConfirmation: false,
|
|
3271
3287
|
destructive: false,
|
|
3272
3288
|
affectsPublication: false,
|
|
3273
3289
|
affectsExternal: false,
|
|
3274
3290
|
martinSummaryTemplate: (input, output, locale) => {
|
|
3275
3291
|
if (!output.ok) {
|
|
3276
|
-
if (
|
|
3277
|
-
|
|
3292
|
+
if (output.code) {
|
|
3293
|
+
return getMessage(`marketing.errors.${output.code}`, locale);
|
|
3294
|
+
}
|
|
3295
|
+
return getMessage("marketing.safeError.generic", locale);
|
|
3278
3296
|
}
|
|
3279
|
-
if (locale === "en")
|
|
3280
|
-
|
|
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.`;
|
|
3281
3301
|
},
|
|
3282
|
-
|
|
3283
|
-
auditAction: "marketing.plan.guardar",
|
|
3302
|
+
auditAction: "marketing.brand_brief.guardar",
|
|
3284
3303
|
extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
|
|
3285
3304
|
extractChanges: (input, output) => ({
|
|
3286
3305
|
before: null,
|
|
3287
|
-
// El helper no lee el
|
|
3288
|
-
after: output.ok ? {
|
|
3306
|
+
// El helper no lee el brief previo (set merge).
|
|
3307
|
+
after: output.ok ? { brandBrief: input.brandBrief } : null
|
|
3289
3308
|
}),
|
|
3290
3309
|
quotasConsumed: [],
|
|
3291
3310
|
permissionScope: "module",
|
|
@@ -3293,97 +3312,360 @@ var rawContract = {
|
|
|
3293
3312
|
permissionAction: "editar",
|
|
3294
3313
|
sideEffects: ["writes_firestore", "updates_brand_config"]
|
|
3295
3314
|
};
|
|
3296
|
-
var
|
|
3315
|
+
var brandBriefWriterContract = MartinContractSchema.parse(
|
|
3297
3316
|
rawContract
|
|
3298
3317
|
);
|
|
3299
|
-
async function
|
|
3300
|
-
const { db, tenantId, brandId,
|
|
3301
|
-
const validation = validateCollectionSuggestions(suggestions);
|
|
3302
|
-
if (!validation.ok) {
|
|
3303
|
-
return {
|
|
3304
|
-
ok: false,
|
|
3305
|
-
error: "Collection suggestions no cumplen el schema esperado.",
|
|
3306
|
-
detalle: validation.error,
|
|
3307
|
-
path: validation.path,
|
|
3308
|
-
recibido: validation.received,
|
|
3309
|
-
ejemplo_correcto: validation.example,
|
|
3310
|
-
hint: "Revisa max chars: metaTitle <=60, metaDescription <=158, imageAlt <=125. Corrige el campo en path."
|
|
3311
|
-
};
|
|
3312
|
-
}
|
|
3313
|
-
const validSuggestions = validation.parsed;
|
|
3318
|
+
async function save(input) {
|
|
3319
|
+
const { db, tenantId, brandId, plan } = input;
|
|
3314
3320
|
const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
|
|
3315
3321
|
const brandSnap = await brandRef.get();
|
|
3316
3322
|
if (!brandSnap.exists) {
|
|
3317
|
-
return { ok: false, error: `Brand ${brandId} no encontrada
|
|
3318
|
-
}
|
|
3319
|
-
const brandData = brandSnap.data();
|
|
3320
|
-
const existing = brandData.collectionSuggestions ?? {};
|
|
3321
|
-
const updated = { ...existing };
|
|
3322
|
-
for (const s of validSuggestions) {
|
|
3323
|
-
const id = String(s.collectionId);
|
|
3324
|
-
updated[id] = {
|
|
3325
|
-
suggestedTitle: s.suggestedTitle ?? null,
|
|
3326
|
-
suggestedDescription: s.suggestedDescription ?? null,
|
|
3327
|
-
suggestedMetaTitle: s.suggestedMetaTitle ?? null,
|
|
3328
|
-
suggestedMetaDescription: s.suggestedMetaDescription ?? null,
|
|
3329
|
-
suggestedHandle: s.suggestedHandle ?? null,
|
|
3330
|
-
suggestedImageAlt: s.suggestedImageAlt ?? null,
|
|
3331
|
-
keyword: s.keyword ?? null,
|
|
3332
|
-
notas: s.notas ?? null,
|
|
3333
|
-
estado: "pendiente",
|
|
3334
|
-
generadoAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp(),
|
|
3335
|
-
generadoPor: "mcp-cowork"
|
|
3336
|
-
};
|
|
3323
|
+
return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
3337
3324
|
}
|
|
3325
|
+
const { blogStrategy, ...planSinBlogStrategy } = plan;
|
|
3326
|
+
const planWithMeta = {
|
|
3327
|
+
...planSinBlogStrategy,
|
|
3328
|
+
fechaCreacion: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3329
|
+
creadoPorId: "mcp-cowork"
|
|
3330
|
+
};
|
|
3338
3331
|
await brandRef.set({
|
|
3339
|
-
|
|
3332
|
+
plan: planWithMeta,
|
|
3333
|
+
...blogStrategy ? { blogStrategy } : {},
|
|
3340
3334
|
id: brandId,
|
|
3341
3335
|
brandId,
|
|
3342
3336
|
tenantId,
|
|
3343
3337
|
schemaVersion: 1,
|
|
3344
|
-
updatedAt:
|
|
3338
|
+
updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
|
|
3345
3339
|
}, { merge: true });
|
|
3346
3340
|
return {
|
|
3347
3341
|
ok: true,
|
|
3348
|
-
|
|
3349
|
-
message: `${validSuggestions.length} sugerencias guardadas. El tenant las ver\xE1 en Marketing > Mi Plan > Shopify.`
|
|
3342
|
+
mensaje: `Plan de marketing guardado para brand "${brandId}"` + (blogStrategy ? " (incluye blogStrategy)" : "")
|
|
3350
3343
|
};
|
|
3351
3344
|
}
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
"
|
|
3375
|
-
"
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
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,
|
|
3431
|
+
// No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
|
|
3432
|
+
// no afecta publicación, no llama externo.
|
|
3433
|
+
requiresConfirmation: false,
|
|
3434
|
+
destructive: false,
|
|
3435
|
+
affectsPublication: false,
|
|
3436
|
+
affectsExternal: false,
|
|
3437
|
+
martinSummaryTemplate: (input, output, locale) => {
|
|
3438
|
+
if (!output.ok) {
|
|
3439
|
+
if (output.code) {
|
|
3440
|
+
return getMessage(`marketing.errors.${output.code}`, locale);
|
|
3441
|
+
}
|
|
3442
|
+
return getMessage("marketing.safeError.generic", locale);
|
|
3443
|
+
}
|
|
3444
|
+
if (locale === "en") return `I saved the marketing plan for ${input.brandId}.`;
|
|
3445
|
+
return `Guard\xE9 el plan de marketing de ${input.brandId}.`;
|
|
3446
|
+
},
|
|
3447
|
+
// Auditoría — OBLIGATORIO porque writes_firestore + updates_brand_config.
|
|
3448
|
+
auditAction: "marketing.plan.guardar",
|
|
3449
|
+
extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
|
|
3450
|
+
extractChanges: (input, output) => ({
|
|
3451
|
+
before: null,
|
|
3452
|
+
// El helper no lee el plan previo en save (solo merge).
|
|
3453
|
+
after: output.ok ? { plan: input.plan } : null
|
|
3454
|
+
}),
|
|
3455
|
+
quotasConsumed: [],
|
|
3456
|
+
permissionScope: "module",
|
|
3457
|
+
permissionKey: "marketing",
|
|
3458
|
+
permissionAction: "editar",
|
|
3459
|
+
sideEffects: ["writes_firestore", "updates_brand_config"]
|
|
3460
|
+
};
|
|
3461
|
+
var planWriterSaveContract = MartinContractSchema.parse(
|
|
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
|
|
3512
|
+
);
|
|
3513
|
+
async function collectionSuggestionsWriter(input) {
|
|
3514
|
+
const { db, tenantId, brandId, suggestions } = input;
|
|
3515
|
+
const validation = validateCollectionSuggestions(suggestions);
|
|
3516
|
+
if (!validation.ok) {
|
|
3517
|
+
return {
|
|
3518
|
+
ok: false,
|
|
3519
|
+
error: "Collection suggestions no cumplen el schema esperado.",
|
|
3520
|
+
code: "COLLECTION_SUGGESTIONS_VALIDATION_FAILED",
|
|
3521
|
+
detalle: validation.error,
|
|
3522
|
+
path: validation.path,
|
|
3523
|
+
recibido: validation.received,
|
|
3524
|
+
ejemplo_correcto: validation.example,
|
|
3525
|
+
hint: "Revisa max chars: metaTitle <=60, metaDescription <=158, imageAlt <=125. Corrige el campo en path."
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
const validSuggestions = validation.parsed;
|
|
3529
|
+
const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
|
|
3530
|
+
const brandSnap = await brandRef.get();
|
|
3531
|
+
if (!brandSnap.exists) {
|
|
3532
|
+
return { ok: false, error: `Brand ${brandId} no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
3533
|
+
}
|
|
3534
|
+
const brandData = brandSnap.data();
|
|
3535
|
+
const existing = brandData.collectionSuggestions ?? {};
|
|
3536
|
+
const updated = { ...existing };
|
|
3537
|
+
for (const s of validSuggestions) {
|
|
3538
|
+
const id = String(s.collectionId);
|
|
3539
|
+
updated[id] = {
|
|
3540
|
+
suggestedTitle: s.suggestedTitle ?? null,
|
|
3541
|
+
suggestedDescription: s.suggestedDescription ?? null,
|
|
3542
|
+
suggestedMetaTitle: s.suggestedMetaTitle ?? null,
|
|
3543
|
+
suggestedMetaDescription: s.suggestedMetaDescription ?? null,
|
|
3544
|
+
suggestedHandle: s.suggestedHandle ?? null,
|
|
3545
|
+
suggestedImageAlt: s.suggestedImageAlt ?? null,
|
|
3546
|
+
keyword: s.keyword ?? null,
|
|
3547
|
+
notas: s.notas ?? null,
|
|
3548
|
+
estado: "pendiente",
|
|
3549
|
+
generadoAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp(),
|
|
3550
|
+
generadoPor: "mcp-cowork"
|
|
3551
|
+
};
|
|
3552
|
+
}
|
|
3553
|
+
await brandRef.set({
|
|
3554
|
+
collectionSuggestions: updated,
|
|
3555
|
+
id: brandId,
|
|
3556
|
+
brandId,
|
|
3557
|
+
tenantId,
|
|
3558
|
+
schemaVersion: 1,
|
|
3559
|
+
updatedAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp()
|
|
3560
|
+
}, { merge: true });
|
|
3561
|
+
return {
|
|
3562
|
+
ok: true,
|
|
3563
|
+
saved: validSuggestions.length,
|
|
3564
|
+
message: `${validSuggestions.length} sugerencias guardadas. El tenant las ver\xE1 en Marketing > Mi Plan > Shopify.`
|
|
3565
|
+
};
|
|
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
|
+
);
|
|
3634
|
+
var CAMPOS_PERMITIDOS = {
|
|
3635
|
+
gbp: {
|
|
3636
|
+
required: ["summary", "topicType"],
|
|
3637
|
+
optional: ["callToAction", "perfilId"]
|
|
3638
|
+
},
|
|
3639
|
+
shopify_blog: {
|
|
3640
|
+
// Sesion 188B: sincronizado con DatosBlogSchema v2 (27 campos)
|
|
3641
|
+
// body es optional aqui para permitir save en 2 pasos:
|
|
3642
|
+
// 1. save_generated_content con metadata (19 campos sin body)
|
|
3643
|
+
// 2. update_generated_content con { body: "HTML completo..." }
|
|
3644
|
+
// La CF publishToShopifyBlog valida que body exista antes de publicar.
|
|
3645
|
+
required: [
|
|
3646
|
+
"title",
|
|
3647
|
+
"handle",
|
|
3648
|
+
"metaTitle",
|
|
3649
|
+
"metaDescription",
|
|
3650
|
+
"summary",
|
|
3651
|
+
"imageAltText",
|
|
3652
|
+
"tags",
|
|
3653
|
+
"quickAnswerBlock",
|
|
3654
|
+
"faqItems",
|
|
3655
|
+
"faqTitle",
|
|
3656
|
+
"blogId",
|
|
3657
|
+
"blogHandle",
|
|
3658
|
+
"publishDate",
|
|
3659
|
+
"languageCode",
|
|
3660
|
+
"plataformaDestino"
|
|
3661
|
+
],
|
|
3662
|
+
optional: [
|
|
3663
|
+
"body",
|
|
3664
|
+
"authorUserId",
|
|
3665
|
+
"authorName",
|
|
3666
|
+
"templateSuffix",
|
|
3667
|
+
"isPublished",
|
|
3668
|
+
"structuredData",
|
|
3387
3669
|
"articuloNumero",
|
|
3388
3670
|
"keywordsSecundarios",
|
|
3389
3671
|
"productosLinkeados",
|
|
@@ -3452,11 +3734,15 @@ async function contenidoUpdater(input) {
|
|
|
3452
3734
|
const docRef = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoId}`);
|
|
3453
3735
|
const snap = await docRef.get();
|
|
3454
3736
|
if (!snap.exists) {
|
|
3455
|
-
return { ok: false, error: `Contenido ${contenidoId} no existe
|
|
3737
|
+
return { ok: false, error: `Contenido ${contenidoId} no existe`, code: "CONTENT_NOT_FOUND" };
|
|
3456
3738
|
}
|
|
3457
3739
|
const existing = snap.data();
|
|
3458
3740
|
if (existing.tenantId !== tenantId) {
|
|
3459
|
-
return {
|
|
3741
|
+
return {
|
|
3742
|
+
ok: false,
|
|
3743
|
+
error: "Contenido no pertenece a este tenant",
|
|
3744
|
+
code: "CONTENT_TENANT_MISMATCH"
|
|
3745
|
+
};
|
|
3460
3746
|
}
|
|
3461
3747
|
const plataforma = existing.plataforma;
|
|
3462
3748
|
if (newDatos && Object.keys(newDatos).length > 0) {
|
|
@@ -3466,7 +3752,7 @@ async function contenidoUpdater(input) {
|
|
|
3466
3752
|
};
|
|
3467
3753
|
const schemaError = validateDatosSchema(plataforma, mergedDatos);
|
|
3468
3754
|
if (schemaError) {
|
|
3469
|
-
return { ok: false, error: schemaError };
|
|
3755
|
+
return { ok: false, error: schemaError, code: "CONTENT_DATA_VALIDATION_FAILED" };
|
|
3470
3756
|
}
|
|
3471
3757
|
const zodSchemas = {
|
|
3472
3758
|
shopify_blog: DatosBlogSchema,
|
|
@@ -3508,11 +3794,98 @@ async function contenidoUpdater(input) {
|
|
|
3508
3794
|
camposActualizados: Object.keys(update).filter((k) => k !== "updatedAt")
|
|
3509
3795
|
};
|
|
3510
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
|
+
);
|
|
3511
3880
|
async function addCalendarSlot(input) {
|
|
3512
3881
|
const { db, tenantId, brandId, mes, semana, slot } = input;
|
|
3513
3882
|
const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
|
|
3514
3883
|
if (calQuery.empty) {
|
|
3515
|
-
return {
|
|
3884
|
+
return {
|
|
3885
|
+
ok: false,
|
|
3886
|
+
error: `Calendario ${mes} no encontrado para brand ${brandId}`,
|
|
3887
|
+
code: "CALENDAR_NOT_FOUND"
|
|
3888
|
+
};
|
|
3516
3889
|
}
|
|
3517
3890
|
const calDocRef = calQuery.docs[0].ref;
|
|
3518
3891
|
let resultSlotIndex = -1;
|
|
@@ -3542,40 +3915,47 @@ async function addCalendarSlot(input) {
|
|
|
3542
3915
|
});
|
|
3543
3916
|
return { ok: true, slotIndex: resultSlotIndex };
|
|
3544
3917
|
}
|
|
3545
|
-
var SlotSchema =
|
|
3546
|
-
dia:
|
|
3547
|
-
plataforma:
|
|
3548
|
-
tipo:
|
|
3549
|
-
keyword:
|
|
3550
|
-
tema:
|
|
3551
|
-
productoId:
|
|
3552
|
-
estado:
|
|
3553
|
-
locationId:
|
|
3554
|
-
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.")
|
|
3555
3928
|
});
|
|
3556
|
-
var
|
|
3557
|
-
tenantId:
|
|
3558
|
-
brandId:
|
|
3559
|
-
mes:
|
|
3560
|
-
semana:
|
|
3561
|
-
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.")
|
|
3562
3935
|
});
|
|
3563
|
-
var
|
|
3564
|
-
|
|
3565
|
-
|
|
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
|
+
})
|
|
3566
3943
|
]);
|
|
3567
|
-
var
|
|
3944
|
+
var rawContract5 = {
|
|
3568
3945
|
name: "add_calendar_slot",
|
|
3569
|
-
description: "
|
|
3570
|
-
paramsSchema:
|
|
3571
|
-
outputSchema:
|
|
3946
|
+
description: "Add a new slot to the editorial calendar. Does NOT modify existing slots \u2014 use update_calendar_slot for that.",
|
|
3947
|
+
paramsSchema: ParamsSchema5,
|
|
3948
|
+
outputSchema: OutputSchema5,
|
|
3572
3949
|
requiresConfirmation: false,
|
|
3573
3950
|
destructive: false,
|
|
3574
3951
|
affectsPublication: false,
|
|
3575
3952
|
affectsExternal: false,
|
|
3576
3953
|
martinSummaryTemplate: (input, output, locale) => {
|
|
3577
3954
|
if (!output.ok) {
|
|
3578
|
-
|
|
3955
|
+
if (output.code) {
|
|
3956
|
+
return getMessage(`marketing.errors.${output.code}`, locale);
|
|
3957
|
+
}
|
|
3958
|
+
return getMessage("marketing.safeError.generic", locale);
|
|
3579
3959
|
}
|
|
3580
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}.`;
|
|
3581
3961
|
},
|
|
@@ -3595,7 +3975,7 @@ var rawContract2 = {
|
|
|
3595
3975
|
sideEffects: ["writes_firestore", "updates_calendar_slot"]
|
|
3596
3976
|
};
|
|
3597
3977
|
var addCalendarSlotContract = MartinContractSchema.parse(
|
|
3598
|
-
|
|
3978
|
+
rawContract5
|
|
3599
3979
|
);
|
|
3600
3980
|
var CAMPOS_SEMANTICOS = ["keyword", "tema", "plataforma", "tipo"];
|
|
3601
3981
|
function mapContenidoEstadoToSlotEstado(contenidoEstado) {
|
|
@@ -3636,7 +4016,7 @@ async function calendarSlotUpdater(input) {
|
|
|
3636
4016
|
if (slotIndex >= items.length) {
|
|
3637
4017
|
return {
|
|
3638
4018
|
ok: false,
|
|
3639
|
-
error: `slotIndex
|
|
4019
|
+
error: `slotIndex=${slotIndex} >= items.length=${items.length} for week=${semana}`,
|
|
3640
4020
|
code: "SLOT_NOT_FOUND"
|
|
3641
4021
|
};
|
|
3642
4022
|
}
|
|
@@ -3648,14 +4028,9 @@ async function calendarSlotUpdater(input) {
|
|
|
3648
4028
|
if (oldContenidoRef && tocaSemantica && !accionContenidoExistente) {
|
|
3649
4029
|
return {
|
|
3650
4030
|
ok: false,
|
|
3651
|
-
error: `
|
|
4031
|
+
error: `slot has contenidoRef=${oldContenidoRef} and cambios touch semantic fields=[${CAMPOS_SEMANTICOS.filter((f) => f in cambios).join(",")}]; require accionContenidoExistente`,
|
|
3652
4032
|
code: "ACCION_CONTENIDO_EXISTENTE_REQUIRED",
|
|
3653
|
-
opciones: [
|
|
3654
|
-
`'descartar' \u2014 marcar "${oldContenidoRef}" como descartado y aplicar cambios en este slot`,
|
|
3655
|
-
`'mover:semana:N:slot:M' \u2014 mover "${oldContenidoRef}" al slot destino N/M (target debe estar vacio) y aplicar cambios aqui`,
|
|
3656
|
-
`'nuevo_slot' \u2014 NO tocar este slot ni "${oldContenidoRef}". Crear un slot nuevo el mismo dia con los cambios (util para multiples publicaciones/dia \u2014 ej: almuerzo + cena GBP)`,
|
|
3657
|
-
`'mantener' \u2014 mantener "${oldContenidoRef}" aqui y aplicar cambios (caso typo fix, el contenido viejo sigue valido para el nuevo keyword/tema)`
|
|
3658
|
-
]
|
|
4033
|
+
opciones: ["descartar", "mover", "nuevo_slot", "mantener"]
|
|
3659
4034
|
};
|
|
3660
4035
|
}
|
|
3661
4036
|
let contenidosADescartar = [];
|
|
@@ -3675,7 +4050,7 @@ async function calendarSlotUpdater(input) {
|
|
|
3675
4050
|
if (!m) {
|
|
3676
4051
|
return {
|
|
3677
4052
|
ok: false,
|
|
3678
|
-
error: `accionContenidoExistente
|
|
4053
|
+
error: `accionContenidoExistente="${accionContenidoExistente}" invalid format (expected: mover:semana:N:slot:M)`,
|
|
3679
4054
|
code: "MOVE_TARGET_INVALID"
|
|
3680
4055
|
};
|
|
3681
4056
|
}
|
|
@@ -3684,7 +4059,7 @@ async function calendarSlotUpdater(input) {
|
|
|
3684
4059
|
if (targetSemanaNum === semana && targetSlotIdx === slotIndex) {
|
|
3685
4060
|
return {
|
|
3686
4061
|
ok: false,
|
|
3687
|
-
error: "
|
|
4062
|
+
error: "move target same as origin (semantic noop)",
|
|
3688
4063
|
code: "MOVE_TARGET_INVALID"
|
|
3689
4064
|
};
|
|
3690
4065
|
}
|
|
@@ -3692,7 +4067,7 @@ async function calendarSlotUpdater(input) {
|
|
|
3692
4067
|
if (!targetSemana) {
|
|
3693
4068
|
return {
|
|
3694
4069
|
ok: false,
|
|
3695
|
-
error: `
|
|
4070
|
+
error: `target semana=${targetSemanaNum} does not exist`,
|
|
3696
4071
|
code: "MOVE_TARGET_INVALID"
|
|
3697
4072
|
};
|
|
3698
4073
|
}
|
|
@@ -3700,14 +4075,14 @@ async function calendarSlotUpdater(input) {
|
|
|
3700
4075
|
if (!targetItems[targetSlotIdx]) {
|
|
3701
4076
|
return {
|
|
3702
4077
|
ok: false,
|
|
3703
|
-
error: `
|
|
4078
|
+
error: `target slot=${targetSlotIdx} does not exist in semana=${targetSemanaNum}`,
|
|
3704
4079
|
code: "MOVE_TARGET_INVALID"
|
|
3705
4080
|
};
|
|
3706
4081
|
}
|
|
3707
4082
|
if (targetItems[targetSlotIdx].contenidoRef) {
|
|
3708
4083
|
return {
|
|
3709
4084
|
ok: false,
|
|
3710
|
-
error: `
|
|
4085
|
+
error: `target slot already has contenidoRef=${targetItems[targetSlotIdx].contenidoRef}`,
|
|
3711
4086
|
code: "MOVE_TARGET_OCCUPIED"
|
|
3712
4087
|
};
|
|
3713
4088
|
}
|
|
@@ -3863,44 +4238,55 @@ async function handleNuevoSlot(args) {
|
|
|
3863
4238
|
descartados: 0
|
|
3864
4239
|
};
|
|
3865
4240
|
}
|
|
3866
|
-
var
|
|
3867
|
-
tenantId:
|
|
3868
|
-
brandId:
|
|
3869
|
-
mes:
|
|
3870
|
-
semana:
|
|
3871
|
-
slotIndex:
|
|
3872
|
-
cambios:
|
|
3873
|
-
|
|
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
|
+
)
|
|
3874
4253
|
});
|
|
3875
|
-
var
|
|
3876
|
-
|
|
3877
|
-
ok:
|
|
3878
|
-
action:
|
|
3879
|
-
slotIndex:
|
|
3880
|
-
descartados:
|
|
3881
|
-
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()
|
|
3882
4261
|
}),
|
|
3883
|
-
|
|
3884
|
-
ok:
|
|
3885
|
-
error:
|
|
3886
|
-
code:
|
|
3887
|
-
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()
|
|
3888
4267
|
})
|
|
3889
4268
|
]);
|
|
3890
|
-
var
|
|
4269
|
+
var rawContract6 = {
|
|
3891
4270
|
name: "update_calendar_slot",
|
|
3892
|
-
description: "
|
|
3893
|
-
paramsSchema:
|
|
3894
|
-
outputSchema:
|
|
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.",
|
|
4272
|
+
paramsSchema: ParamsSchema6,
|
|
4273
|
+
outputSchema: OutputSchema6,
|
|
4274
|
+
// Por default, modificar un slot es reversible (cambias campos cosméticos).
|
|
4275
|
+
// PERO si accionContenidoExistente='descartar', el helper marca el
|
|
4276
|
+
// contenido vinculado como descartado — eso SÍ es destructivo. El predicado
|
|
4277
|
+
// runtime `isDestructiveForInput` lo detecta y obliga al wrapper a pedir
|
|
4278
|
+
// confirmación en ese caso específico.
|
|
3895
4279
|
requiresConfirmation: false,
|
|
3896
4280
|
destructive: false,
|
|
3897
|
-
|
|
4281
|
+
isDestructiveForInput: (input) => input.accionContenidoExistente === "descartar",
|
|
3898
4282
|
affectsPublication: false,
|
|
3899
4283
|
affectsExternal: false,
|
|
3900
4284
|
martinSummaryTemplate: (input, output, locale) => {
|
|
3901
4285
|
if (!output.ok) {
|
|
3902
|
-
if (
|
|
3903
|
-
|
|
4286
|
+
if (output.code) {
|
|
4287
|
+
return getMessage(`marketing.errors.${output.code}`, locale);
|
|
4288
|
+
}
|
|
4289
|
+
return getMessage("marketing.safeError.generic", locale);
|
|
3904
4290
|
}
|
|
3905
4291
|
const verb = {
|
|
3906
4292
|
updated: locale === "en" ? "updated" : "actualic\xE9",
|
|
@@ -3912,6 +4298,15 @@ var rawContract3 = {
|
|
|
3912
4298
|
}
|
|
3913
4299
|
return `${verb[0].toUpperCase() + verb.slice(1)} el slot en la semana ${input.semana} (${input.mes}).`;
|
|
3914
4300
|
},
|
|
4301
|
+
// Confirmation message when isDestructiveForInput returns true
|
|
4302
|
+
// (accionContenidoExistente === 'descartar'). Only invoked if the wrapper
|
|
4303
|
+
// detects the destructive runtime case and asks the user for OK.
|
|
4304
|
+
martinConfirmationTemplate: (input, locale) => {
|
|
4305
|
+
if (locale === "en") {
|
|
4306
|
+
return `Are you sure? This will discard the existing content linked to slot ${input.slotIndex} in week ${input.semana} (${input.mes}). The content will be marked as discarded and cannot be easily recovered.`;
|
|
4307
|
+
}
|
|
4308
|
+
return `\xBFConfirmas? Esto descarta el contenido existente del slot ${input.slotIndex} en la semana ${input.semana} (${input.mes}). El contenido queda marcado como descartado y no se recupera f\xE1cilmente.`;
|
|
4309
|
+
},
|
|
3915
4310
|
auditAction: "marketing.calendario.slot.actualizar",
|
|
3916
4311
|
extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_calendario/(brand=${input.brandId},mes=${input.mes}).semana=${input.semana}.slot=${input.slotIndex}`,
|
|
3917
4312
|
extractChanges: (input, output) => ({
|
|
@@ -3932,7 +4327,7 @@ var rawContract3 = {
|
|
|
3932
4327
|
sideEffects: ["writes_firestore", "updates_calendar_slot"]
|
|
3933
4328
|
};
|
|
3934
4329
|
var calendarSlotUpdaterContract = MartinContractSchema.parse(
|
|
3935
|
-
|
|
4330
|
+
rawContract6
|
|
3936
4331
|
);
|
|
3937
4332
|
async function getCalendar(input) {
|
|
3938
4333
|
const { db, tenantId, brandId, mes } = input;
|
|
@@ -3954,23 +4349,23 @@ async function getCalendar(input) {
|
|
|
3954
4349
|
calendario: { id: doc.id, ...doc.data() }
|
|
3955
4350
|
};
|
|
3956
4351
|
}
|
|
3957
|
-
var
|
|
3958
|
-
tenantId:
|
|
3959
|
-
brandId:
|
|
3960
|
-
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.")
|
|
3961
4356
|
});
|
|
3962
|
-
var
|
|
3963
|
-
ok:
|
|
3964
|
-
mes:
|
|
3965
|
-
brandId:
|
|
3966
|
-
calendario:
|
|
3967
|
-
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()
|
|
3968
4363
|
});
|
|
3969
|
-
var
|
|
4364
|
+
var rawContract7 = {
|
|
3970
4365
|
name: "get_calendar",
|
|
3971
|
-
description: "
|
|
3972
|
-
paramsSchema:
|
|
3973
|
-
outputSchema:
|
|
4366
|
+
description: "Read the editorial calendar for a given brand and month. Returns weeks with planned items per platform.",
|
|
4367
|
+
paramsSchema: ParamsSchema7,
|
|
4368
|
+
outputSchema: OutputSchema7,
|
|
3974
4369
|
requiresConfirmation: false,
|
|
3975
4370
|
destructive: false,
|
|
3976
4371
|
affectsPublication: false,
|
|
@@ -3992,7 +4387,7 @@ var rawContract4 = {
|
|
|
3992
4387
|
sideEffects: ["reads_firestore"]
|
|
3993
4388
|
};
|
|
3994
4389
|
var getCalendarContract = MartinContractSchema.parse(
|
|
3995
|
-
|
|
4390
|
+
rawContract7
|
|
3996
4391
|
);
|
|
3997
4392
|
var PLATAFORMA_A_FORMATO = {
|
|
3998
4393
|
gbp: "gbp_4_3",
|
|
@@ -4005,21 +4400,26 @@ async function photoAssigner(input) {
|
|
|
4005
4400
|
const contenidoRefDoc = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoRef}`);
|
|
4006
4401
|
const contenidoSnap = await contenidoRefDoc.get();
|
|
4007
4402
|
if (!contenidoSnap.exists) {
|
|
4008
|
-
return { ok: false, error: `Contenido ${contenidoRef} no encontrado
|
|
4403
|
+
return { ok: false, error: `Contenido ${contenidoRef} no encontrado`, code: "CONTENT_NOT_FOUND" };
|
|
4009
4404
|
}
|
|
4010
4405
|
const contenido = contenidoSnap.data();
|
|
4011
4406
|
if (contenido.tenantId !== tenantId) {
|
|
4012
|
-
return {
|
|
4407
|
+
return {
|
|
4408
|
+
ok: false,
|
|
4409
|
+
error: "Contenido no pertenece a este tenant",
|
|
4410
|
+
code: "CONTENT_TENANT_MISMATCH"
|
|
4411
|
+
};
|
|
4013
4412
|
}
|
|
4014
4413
|
const fotoQuery = await db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("id", "==", fotoId).limit(1).get();
|
|
4015
4414
|
if (fotoQuery.empty) {
|
|
4016
|
-
return { ok: false, error: `Foto ${fotoId} no encontrada
|
|
4415
|
+
return { ok: false, error: `Foto ${fotoId} no encontrada`, code: "PHOTO_NOT_FOUND" };
|
|
4017
4416
|
}
|
|
4018
4417
|
const foto = fotoQuery.docs[0].data();
|
|
4019
4418
|
if (foto.estado !== ESTADO_FOTO.EDITADA && foto.estado !== "usada") {
|
|
4020
4419
|
return {
|
|
4021
4420
|
ok: false,
|
|
4022
|
-
error: `Foto en estado "${foto.estado}", debe ser "editada"
|
|
4421
|
+
error: `Foto en estado "${foto.estado}", debe ser "editada"`,
|
|
4422
|
+
code: "PHOTO_NOT_READY"
|
|
4023
4423
|
};
|
|
4024
4424
|
}
|
|
4025
4425
|
const formato = PLATAFORMA_A_FORMATO[contenido.plataforma] ?? "original";
|
|
@@ -4100,6 +4500,78 @@ async function photoAssigner(input) {
|
|
|
4100
4500
|
formato
|
|
4101
4501
|
};
|
|
4102
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
|
+
);
|
|
4103
4575
|
function findPageByHeuristic(pages, pattern) {
|
|
4104
4576
|
return pages.find((p) => pattern.test(p.title || "")) || pages.find((p) => pattern.test(p.handle || "")) || null;
|
|
4105
4577
|
}
|
|
@@ -4109,7 +4581,7 @@ async function brandBriefBuilder(input) {
|
|
|
4109
4581
|
const { db, tenantId, brandId } = input;
|
|
4110
4582
|
const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
|
|
4111
4583
|
if (!configSnap.exists) {
|
|
4112
|
-
return { ok: false, error: `Brand "${brandId}" no encontrada
|
|
4584
|
+
return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
4113
4585
|
}
|
|
4114
4586
|
const brand = configSnap.data();
|
|
4115
4587
|
const [snapshotMetaSnap, productsSnap, collectionsSnap, pagesSnap, siteMetaSnap, tenantSnap] = await Promise.all([
|
|
@@ -4287,26 +4759,27 @@ var BRAND_BRIEF_SCHEMA_HINT = {
|
|
|
4287
4759
|
escenas: [{ id: string, nombre: string, promptHint: string }]
|
|
4288
4760
|
}`
|
|
4289
4761
|
};
|
|
4290
|
-
var
|
|
4291
|
-
tenantId:
|
|
4292
|
-
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.")
|
|
4293
4765
|
});
|
|
4294
|
-
var
|
|
4295
|
-
|
|
4296
|
-
ok:
|
|
4766
|
+
var OutputSchema9 = import_zod37.z.discriminatedUnion("ok", [
|
|
4767
|
+
import_zod37.z.object({
|
|
4768
|
+
ok: import_zod37.z.literal(true),
|
|
4297
4769
|
/** Payload con instrucción + datos del negocio para que Claude genere el brief. */
|
|
4298
|
-
payload:
|
|
4770
|
+
payload: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown())
|
|
4299
4771
|
}),
|
|
4300
|
-
|
|
4301
|
-
ok:
|
|
4302
|
-
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()
|
|
4303
4776
|
})
|
|
4304
4777
|
]);
|
|
4305
|
-
var
|
|
4778
|
+
var rawContract9 = {
|
|
4306
4779
|
name: "generate_brand_brief",
|
|
4307
|
-
description: "
|
|
4308
|
-
paramsSchema:
|
|
4309
|
-
outputSchema:
|
|
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.",
|
|
4781
|
+
paramsSchema: ParamsSchema9,
|
|
4782
|
+
outputSchema: OutputSchema9,
|
|
4310
4783
|
// Lectura pura, sin side effects de escritura ni publicación.
|
|
4311
4784
|
requiresConfirmation: false,
|
|
4312
4785
|
destructive: false,
|
|
@@ -4314,8 +4787,10 @@ var rawContract5 = {
|
|
|
4314
4787
|
affectsExternal: false,
|
|
4315
4788
|
martinSummaryTemplate: (_input, output, locale) => {
|
|
4316
4789
|
if (!output.ok) {
|
|
4317
|
-
if (
|
|
4318
|
-
|
|
4790
|
+
if (output.code) {
|
|
4791
|
+
return getMessage(`marketing.errors.${output.code}`, locale);
|
|
4792
|
+
}
|
|
4793
|
+
return getMessage("marketing.safeError.generic", locale);
|
|
4319
4794
|
}
|
|
4320
4795
|
if (locale === "en") return `I gathered the data for the brief.`;
|
|
4321
4796
|
return `Reun\xED los datos para el brief.`;
|
|
@@ -4329,7 +4804,7 @@ var rawContract5 = {
|
|
|
4329
4804
|
sideEffects: ["reads_firestore"]
|
|
4330
4805
|
};
|
|
4331
4806
|
var brandBriefBuilderContract = MartinContractSchema.parse(
|
|
4332
|
-
|
|
4807
|
+
rawContract9
|
|
4333
4808
|
);
|
|
4334
4809
|
async function resolveLastImportId(db, tenantId, brandId) {
|
|
4335
4810
|
const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
|
|
@@ -4345,13 +4820,14 @@ async function marketingPlanBuilder(input) {
|
|
|
4345
4820
|
const { db, tenantId, brandId } = input;
|
|
4346
4821
|
const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
|
|
4347
4822
|
if (!configSnap.exists) {
|
|
4348
|
-
return { ok: false, error: `Brand "${brandId}" no encontrada
|
|
4823
|
+
return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
4349
4824
|
}
|
|
4350
4825
|
const brand = configSnap.data();
|
|
4351
4826
|
if (!brand.seoSnapshot) {
|
|
4352
4827
|
return {
|
|
4353
4828
|
ok: false,
|
|
4354
|
-
error: "No hay snapshot SEO. Ejecuta marketingSeoSnapshot primero."
|
|
4829
|
+
error: "No hay snapshot SEO. Ejecuta marketingSeoSnapshot primero.",
|
|
4830
|
+
code: "SEO_SNAPSHOT_MISSING"
|
|
4355
4831
|
};
|
|
4356
4832
|
}
|
|
4357
4833
|
const productosQ = await db.collection("productos").where("tenantId", "==", tenantId).limit(100).get();
|
|
@@ -4516,6 +4992,54 @@ var BLOG_STRATEGY_REGLAS = [
|
|
|
4516
4992
|
"blogId, handle, title, ultimoPostFecha, ultimoPostKeyword, totalArticulos \u2014 copiar del shopifyBlogs (datos reales del import)",
|
|
4517
4993
|
"defaultBlogId y defaultBlogHandle \u2014 usar el primer blog de la lista"
|
|
4518
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
|
+
);
|
|
4519
5043
|
function buildGenId() {
|
|
4520
5044
|
return `mkt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
4521
5045
|
}
|
|
@@ -4560,6 +5084,7 @@ async function contenidoWriter(input) {
|
|
|
4560
5084
|
return {
|
|
4561
5085
|
ok: false,
|
|
4562
5086
|
error: `Limite de frecuencia alcanzado: ${activosEstaSemana.length}/${limite} ${plataforma} esta semana`,
|
|
5087
|
+
code: "CONTENT_FREQUENCY_LIMIT",
|
|
4563
5088
|
activosEstaSemana: activosEstaSemana.length,
|
|
4564
5089
|
limite
|
|
4565
5090
|
};
|
|
@@ -4585,7 +5110,7 @@ async function contenidoWriter(input) {
|
|
|
4585
5110
|
}
|
|
4586
5111
|
const schemaError = validateDatosSchema(plataforma, datos);
|
|
4587
5112
|
if (schemaError) {
|
|
4588
|
-
return { ok: false, error: schemaError };
|
|
5113
|
+
return { ok: false, error: schemaError, code: "CONTENT_DATA_VALIDATION_FAILED" };
|
|
4589
5114
|
}
|
|
4590
5115
|
const zodSchemas = {
|
|
4591
5116
|
shopify_blog: DatosBlogSchema,
|
|
@@ -4754,6 +5279,100 @@ async function contenidoWriter(input) {
|
|
|
4754
5279
|
pipelineLinked
|
|
4755
5280
|
};
|
|
4756
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
|
+
);
|
|
4757
5376
|
function fmtDate(d) {
|
|
4758
5377
|
const y = d.getFullYear();
|
|
4759
5378
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
@@ -4794,7 +5413,7 @@ async function weeklyContentBuilder(input) {
|
|
|
4794
5413
|
const targetSemana = semana ?? Math.ceil(now.getDate() / 7);
|
|
4795
5414
|
const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
|
|
4796
5415
|
if (!configSnap.exists) {
|
|
4797
|
-
return { ok: false, error: `Brand "${brandId}" no encontrada
|
|
5416
|
+
return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
4798
5417
|
}
|
|
4799
5418
|
const brand = configSnap.data();
|
|
4800
5419
|
const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", targetMes).limit(1).get();
|
|
@@ -4858,7 +5477,8 @@ async function weeklyContentBuilder(input) {
|
|
|
4858
5477
|
if (slotsExistentes.length === 0) {
|
|
4859
5478
|
return {
|
|
4860
5479
|
ok: false,
|
|
4861
|
-
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"
|
|
4862
5482
|
};
|
|
4863
5483
|
}
|
|
4864
5484
|
const slotsParaGenerar = slotsExistentes.filter(
|
|
@@ -5026,6 +5646,80 @@ OBLIGATORIO: calendarioItemRef con formato EXACTO "semana:N:slot:M" (ej: "semana
|
|
|
5026
5646
|
OBLIGATORIO: fotoId \u2014 SIEMPRE pasa el ID de la foto que elegiste con get_photos_for_slot.
|
|
5027
5647
|
Si el slot tiene notas[], LEERLAS y usarlas como contexto. Si estado es revisar, regenerar adaptando a las notas.
|
|
5028
5648
|
BLOG SLOTS: Usa _blogJIT para el contexto de cada slot shopify_blog \u2014 blogTarget, tono, author, articulosExistentes para interlinking.`;
|
|
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
|
+
);
|
|
5029
5723
|
var DEFAULT_TEXT_THRESHOLD = 0.35;
|
|
5030
5724
|
var DEFAULT_IMAGE_THRESHOLD = 0.08;
|
|
5031
5725
|
var DEFAULT_CONFLICT_THRESHOLD = 0.85;
|
|
@@ -5060,7 +5754,7 @@ function diversify(items, jaccardThreshold = 0.6) {
|
|
|
5060
5754
|
}
|
|
5061
5755
|
async function contentFinder(input) {
|
|
5062
5756
|
const { db, tenantId, brandId, contexto, fecha, include, limit, diversidad, deps } = input;
|
|
5063
|
-
const
|
|
5757
|
+
const mode = input.mode ?? "text";
|
|
5064
5758
|
const textThreshold = deps.thresholds?.text ?? DEFAULT_TEXT_THRESHOLD;
|
|
5065
5759
|
const imageThreshold = deps.thresholds?.image ?? DEFAULT_IMAGE_THRESHOLD;
|
|
5066
5760
|
const conflictThreshold = deps.thresholds?.conflictSimilarity ?? DEFAULT_CONFLICT_THRESHOLD;
|
|
@@ -5091,7 +5785,7 @@ async function contentFinder(input) {
|
|
|
5091
5785
|
queryEmbedding: queryEmbedding ?? void 0,
|
|
5092
5786
|
queryText
|
|
5093
5787
|
};
|
|
5094
|
-
if (
|
|
5788
|
+
if (mode === "text") {
|
|
5095
5789
|
return safeSearch(true, {
|
|
5096
5790
|
...baseParams,
|
|
5097
5791
|
limit: lim,
|
|
@@ -5213,6 +5907,93 @@ async function contentFinder(input) {
|
|
|
5213
5907
|
}
|
|
5214
5908
|
return result;
|
|
5215
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
|
+
);
|
|
5216
5997
|
var DEFAULT_PHOTO_THRESHOLD = 0.7;
|
|
5217
5998
|
var DEFAULT_SHOPIFY_THRESHOLD = 0.65;
|
|
5218
5999
|
function mapPlataformaAFormato(plataforma) {
|
|
@@ -5244,7 +6025,7 @@ async function slotAssetFinder(input) {
|
|
|
5244
6025
|
const shopifyThreshold = deps.thresholds?.shopify ?? DEFAULT_SHOPIFY_THRESHOLD;
|
|
5245
6026
|
const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
|
|
5246
6027
|
if (!configSnap.exists) {
|
|
5247
|
-
return { ok: false, error: `Brand "${brandId}" no encontrada
|
|
6028
|
+
return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
|
|
5248
6029
|
}
|
|
5249
6030
|
const brand = configSnap.data();
|
|
5250
6031
|
const brandBrief = brand.brandBrief ?? null;
|
|
@@ -5343,6 +6124,7 @@ async function slotAssetFinder(input) {
|
|
|
5343
6124
|
temporada ? `Hay temporada activa (${temporada.coleccion.title ?? temporada.coleccion.id}). Respeta bloqueoProducto si esta activo.` : null
|
|
5344
6125
|
].filter(Boolean).join(" ");
|
|
5345
6126
|
return {
|
|
6127
|
+
ok: true,
|
|
5346
6128
|
fotos,
|
|
5347
6129
|
_instrucciones,
|
|
5348
6130
|
_negativePrompt: negativePrompt,
|
|
@@ -5357,6 +6139,79 @@ async function slotAssetFinder(input) {
|
|
|
5357
6139
|
_fuente: fuente
|
|
5358
6140
|
};
|
|
5359
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
|
+
);
|
|
5360
6215
|
var DEFAULT_SIMILARITY_THRESHOLD = 0.6;
|
|
5361
6216
|
function cosineSimilarity(a, b) {
|
|
5362
6217
|
let dot = 0;
|
|
@@ -5432,6 +6287,72 @@ async function canvaTemplateSelector(input) {
|
|
|
5432
6287
|
_instrucciones: "Plantilla Canva seleccionada. Llama a marketingDesignWithCanva({contenidoId, plantillaId, fotoVariantePath, textos}) para renderizar la pieza final."
|
|
5433
6288
|
};
|
|
5434
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
|
+
);
|
|
5435
6356
|
function buildDirectorPlanInstrucciones(catalogoVisual) {
|
|
5436
6357
|
const etiquetas = catalogoVisual.etiquetas || {};
|
|
5437
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).
|
|
@@ -5654,6 +6575,159 @@ async function photoDirectorExecute(input) {
|
|
|
5654
6575
|
_instrucciones: buildDirectorExecuteSuccessInstrucciones(result.balanceAfter)
|
|
5655
6576
|
};
|
|
5656
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
|
+
);
|
|
5657
6731
|
var PARTE_1_IDENTIDAD = `
|
|
5658
6732
|
Eres el asistente de marketing digital de {{brand_nombre}}.
|
|
5659
6733
|
Tu trabajo es generar contenido de alta calidad para publicar en
|
|
@@ -6320,6 +7394,90 @@ async function checkQuota(tenantId, quotaName) {
|
|
|
6320
7394
|
async function getCurrentUsage(_tenantId, _quotaName) {
|
|
6321
7395
|
return 0;
|
|
6322
7396
|
}
|
|
7397
|
+
var NIVELES_CANONICOS = [
|
|
7398
|
+
"ninguno",
|
|
7399
|
+
"ver",
|
|
7400
|
+
"editar",
|
|
7401
|
+
"completo"
|
|
7402
|
+
];
|
|
7403
|
+
var CACHE_TTL_MS = 60 * 1e3;
|
|
7404
|
+
var _rolesCache = /* @__PURE__ */ new Map();
|
|
7405
|
+
var _userCache = /* @__PURE__ */ new Map();
|
|
7406
|
+
function _getCached(map, key) {
|
|
7407
|
+
const entry = map.get(key);
|
|
7408
|
+
if (!entry) return void 0;
|
|
7409
|
+
if (Date.now() - entry.t > CACHE_TTL_MS) {
|
|
7410
|
+
map.delete(key);
|
|
7411
|
+
return void 0;
|
|
7412
|
+
}
|
|
7413
|
+
return entry.data;
|
|
7414
|
+
}
|
|
7415
|
+
function _setCached(map, key, data) {
|
|
7416
|
+
map.set(key, { t: Date.now(), data });
|
|
7417
|
+
}
|
|
7418
|
+
async function _readRolesDoc(db, tenantId) {
|
|
7419
|
+
const key = `roles:${tenantId}`;
|
|
7420
|
+
const cached = _getCached(_rolesCache, key);
|
|
7421
|
+
if (cached !== void 0) return cached;
|
|
7422
|
+
const ref = db.collection("configuracion").doc(`${tenantId}_roles`);
|
|
7423
|
+
const snap = await ref.get();
|
|
7424
|
+
const data = snap.exists ? snap.data() : null;
|
|
7425
|
+
_setCached(_rolesCache, key, data);
|
|
7426
|
+
return data;
|
|
7427
|
+
}
|
|
7428
|
+
async function _readUserDoc(db, uid) {
|
|
7429
|
+
const key = `user:${uid}`;
|
|
7430
|
+
const cached = _getCached(_userCache, key);
|
|
7431
|
+
if (cached !== void 0) return cached;
|
|
7432
|
+
const ref = db.collection("usuarios").doc(uid);
|
|
7433
|
+
const snap = await ref.get();
|
|
7434
|
+
const data = snap.exists ? snap.data() : null;
|
|
7435
|
+
_setCached(_userCache, key, data);
|
|
7436
|
+
return data;
|
|
7437
|
+
}
|
|
7438
|
+
async function getUserContext({
|
|
7439
|
+
db,
|
|
7440
|
+
tenantId,
|
|
7441
|
+
uid
|
|
7442
|
+
}) {
|
|
7443
|
+
const userDoc = await _readUserDoc(db, uid);
|
|
7444
|
+
if (!userDoc) {
|
|
7445
|
+
throw new Error(`userContext: usuario ${uid} no encontrado.`);
|
|
7446
|
+
}
|
|
7447
|
+
const rolId = userDoc.rol ?? "empleado";
|
|
7448
|
+
const rolNombre = rolId === "super_admin" ? "Super Admin" : await _resolveRolNombre(db, tenantId, rolId);
|
|
7449
|
+
return {
|
|
7450
|
+
uid,
|
|
7451
|
+
nombre: userDoc.nombre ?? userDoc.email ?? "Usuario",
|
|
7452
|
+
email: userDoc.email ?? null,
|
|
7453
|
+
rolId,
|
|
7454
|
+
rolNombre,
|
|
7455
|
+
idiomaPreferido: userDoc.idiomaPreferido ?? "es"
|
|
7456
|
+
};
|
|
7457
|
+
}
|
|
7458
|
+
async function _resolveRolNombre(db, tenantId, rolId) {
|
|
7459
|
+
const rolesData = await _readRolesDoc(db, tenantId);
|
|
7460
|
+
if (!rolesData) return null;
|
|
7461
|
+
const rolData = rolesData[rolId];
|
|
7462
|
+
return rolData?.nombre ?? null;
|
|
7463
|
+
}
|
|
7464
|
+
async function getUserPermissionLevel({
|
|
7465
|
+
db,
|
|
7466
|
+
tenantId,
|
|
7467
|
+
userRol,
|
|
7468
|
+
modulo
|
|
7469
|
+
}) {
|
|
7470
|
+
if (userRol === "super_admin") return "completo";
|
|
7471
|
+
if (!userRol) return "ninguno";
|
|
7472
|
+
const rolesData = await _readRolesDoc(db, tenantId);
|
|
7473
|
+
if (!rolesData) return "ninguno";
|
|
7474
|
+
const rolData = rolesData[userRol];
|
|
7475
|
+
if (!rolData || rolData.activo === false) return "ninguno";
|
|
7476
|
+
const permisos = rolData.permisosPorModulo || {};
|
|
7477
|
+
const nivel = permisos[modulo];
|
|
7478
|
+
if (!nivel || !NIVELES_CANONICOS.includes(nivel)) return "ninguno";
|
|
7479
|
+
return nivel;
|
|
7480
|
+
}
|
|
6323
7481
|
async function resolveTenantIdioma(tenantId) {
|
|
6324
7482
|
const db = (0, import_firestore5.getFirestore)();
|
|
6325
7483
|
const tenantSnap = await db.doc(`tenants/${tenantId}`).get();
|
|
@@ -6335,72 +7493,44 @@ async function resolveTenantIdioma(tenantId) {
|
|
|
6335
7493
|
}
|
|
6336
7494
|
return idioma;
|
|
6337
7495
|
}
|
|
6338
|
-
var MESSAGES = {
|
|
6339
|
-
es: {
|
|
6340
|
-
generic: "Tuve un problema. Intent\xE9moslo de nuevo en un momento.",
|
|
6341
|
-
quota_exceeded: "Alcanzaste el l\xEDmite mensual.",
|
|
6342
|
-
not_found: "No lo encontr\xE9. \xBFPodr\xEDas verificar el dato?",
|
|
6343
|
-
permission: "No tengo permiso para hacer eso desde tu cuenta.",
|
|
6344
|
-
timeout: "Esto est\xE1 tardando m\xE1s de lo normal. \xBFLo intento de nuevo?"
|
|
6345
|
-
},
|
|
6346
|
-
en: {
|
|
6347
|
-
generic: "I had a problem. Let's try again in a moment.",
|
|
6348
|
-
quota_exceeded: "You reached the monthly limit.",
|
|
6349
|
-
not_found: "I couldn't find it. Could you verify the data?",
|
|
6350
|
-
permission: "I don't have permission to do that from your account.",
|
|
6351
|
-
timeout: "This is taking longer than usual. Should I try again?"
|
|
6352
|
-
}
|
|
6353
|
-
};
|
|
6354
7496
|
function martinSafeError(err, locale) {
|
|
6355
|
-
if (!(locale in MESSAGES)) {
|
|
6356
|
-
throw new Error(
|
|
6357
|
-
`Locale '${locale}' no soportado en martinSafeError. Locales activos: ${Object.keys(MESSAGES).join(", ")}. Agregar traducciones antes de usar.`
|
|
6358
|
-
);
|
|
6359
|
-
}
|
|
6360
|
-
const msgs = MESSAGES[locale];
|
|
6361
7497
|
const e = err;
|
|
6362
7498
|
const message = e?.message ?? "";
|
|
6363
7499
|
const code = e?.code ?? "";
|
|
6364
|
-
|
|
6365
|
-
if (/
|
|
6366
|
-
if (/
|
|
6367
|
-
if (
|
|
6368
|
-
|
|
6369
|
-
}
|
|
6370
|
-
|
|
6371
|
-
es: {
|
|
6372
|
-
denied_role: "No tengo permiso para hacer eso desde tu cuenta. P\xEDdelo al encargado o admin que te autorice.",
|
|
6373
|
-
input_invalido: "Falta algo o est\xE1 mal. \xBFLo intentamos de nuevo?",
|
|
6374
|
-
confirm_default: "\xBFConfirmas?"
|
|
6375
|
-
},
|
|
6376
|
-
en: {
|
|
6377
|
-
denied_role: "I don't have permission to do that from your account. Ask the manager or admin to authorize it.",
|
|
6378
|
-
input_invalido: "Something is missing or off. Could you try again?",
|
|
6379
|
-
confirm_default: "Are you sure?"
|
|
6380
|
-
}
|
|
6381
|
-
};
|
|
7500
|
+
let key = "generic";
|
|
7501
|
+
if (/quota|límite|limit/i.test(message)) key = "quota_exceeded";
|
|
7502
|
+
else if (/not.found|no.encontrado|no existe/i.test(message)) key = "not_found";
|
|
7503
|
+
else if (/permission|permiso/i.test(message)) key = "permission";
|
|
7504
|
+
else if (code === "deadline-exceeded" || /timeout/i.test(message)) key = "timeout";
|
|
7505
|
+
return getMessage(`marketing.safeError.${key}`, locale);
|
|
7506
|
+
}
|
|
6382
7507
|
function getWrapperMessage(key, locale) {
|
|
6383
|
-
|
|
6384
|
-
throw new Error(
|
|
6385
|
-
`Locale '${locale}' no soportado en getWrapperMessage. Locales activos: ${Object.keys(MESSAGES2).join(", ")}. Agregar traducciones antes de usar.`
|
|
6386
|
-
);
|
|
6387
|
-
}
|
|
6388
|
-
const msg = MESSAGES2[locale][key];
|
|
6389
|
-
if (!msg) {
|
|
6390
|
-
throw new Error(
|
|
6391
|
-
`Mensaje '${key}' no definido en wrapperMessages para locale '${locale}'.`
|
|
6392
|
-
);
|
|
6393
|
-
}
|
|
6394
|
-
return msg;
|
|
7508
|
+
return getMessage(`marketing.wrapper.${key}`, locale);
|
|
6395
7509
|
}
|
|
6396
7510
|
var NIVELES_ORDER = ["ninguno", "ver", "editar", "completo"];
|
|
6397
|
-
function
|
|
7511
|
+
function nivelAlcanza2(nivel, accion) {
|
|
6398
7512
|
return NIVELES_ORDER.indexOf(nivel) >= NIVELES_ORDER.indexOf(accion);
|
|
6399
7513
|
}
|
|
6400
7514
|
function wrapWithContract(contract, helper, options = {}) {
|
|
6401
7515
|
return async function wrappedTool(input, ctx) {
|
|
6402
7516
|
const startMs = Date.now();
|
|
6403
7517
|
const locale = ctx.user.idiomaPreferido;
|
|
7518
|
+
const actorType = ctx.user.clientType === "martin" ? "martin" : "mcp_client";
|
|
7519
|
+
const buildActor = () => {
|
|
7520
|
+
const base = {
|
|
7521
|
+
type: actorType,
|
|
7522
|
+
uid: ctx.user.uid,
|
|
7523
|
+
nombre: ctx.user.nombre
|
|
7524
|
+
};
|
|
7525
|
+
const cm = ctx.user.clientMetadata;
|
|
7526
|
+
if (cm && (cm.mcpClient || cm.mcpClientVersion)) {
|
|
7527
|
+
base.metadata = {
|
|
7528
|
+
...cm.mcpClient ? { mcpClient: cm.mcpClient } : {},
|
|
7529
|
+
...cm.mcpClientVersion ? { mcpClientVersion: cm.mcpClientVersion } : {}
|
|
7530
|
+
};
|
|
7531
|
+
}
|
|
7532
|
+
return base;
|
|
7533
|
+
};
|
|
6404
7534
|
let accesoOk = false;
|
|
6405
7535
|
let reqDesc = "";
|
|
6406
7536
|
switch (contract.permissionScope) {
|
|
@@ -6425,7 +7555,7 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6425
7555
|
userRol: ctx.user.rol,
|
|
6426
7556
|
modulo: contract.permissionKey
|
|
6427
7557
|
});
|
|
6428
|
-
accesoOk =
|
|
7558
|
+
accesoOk = nivelAlcanza2(nivel, contract.permissionAction);
|
|
6429
7559
|
break;
|
|
6430
7560
|
}
|
|
6431
7561
|
case "self": {
|
|
@@ -6449,7 +7579,7 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6449
7579
|
await writeAuditLog({
|
|
6450
7580
|
tenantId: ctx.tenantId,
|
|
6451
7581
|
brandId: ctx.brandId ?? null,
|
|
6452
|
-
actor:
|
|
7582
|
+
actor: buildActor(),
|
|
6453
7583
|
action: contract.auditAction,
|
|
6454
7584
|
motivo: `Acceso denegado \u2014 rol '${ctx.user.rol}' sin permiso para ${reqDesc}`,
|
|
6455
7585
|
conversacionId: ctx.conversacionId ?? null,
|
|
@@ -6481,7 +7611,7 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6481
7611
|
await writeAuditLog({
|
|
6482
7612
|
tenantId: ctx.tenantId,
|
|
6483
7613
|
brandId: ctx.brandId ?? null,
|
|
6484
|
-
actor:
|
|
7614
|
+
actor: buildActor(),
|
|
6485
7615
|
action: contract.auditAction,
|
|
6486
7616
|
motivo: "Input inv\xE1lido \u2014 Zod parse failed",
|
|
6487
7617
|
conversacionId: ctx.conversacionId ?? null,
|
|
@@ -6497,7 +7627,10 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6497
7627
|
};
|
|
6498
7628
|
}
|
|
6499
7629
|
const parsedInput = parseResult.data;
|
|
6500
|
-
|
|
7630
|
+
const effectiveDestructive = contract.isDestructiveForInput?.(parsedInput) ?? contract.destructive;
|
|
7631
|
+
const effectivePublication = contract.isPublicationForInput?.(parsedInput) ?? contract.affectsPublication;
|
|
7632
|
+
const effectiveRequiresConfirmation = contract.requiresConfirmation || effectiveDestructive || effectivePublication;
|
|
7633
|
+
if (effectiveRequiresConfirmation && !ctx.confirmationGranted) {
|
|
6501
7634
|
const confirmMsg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
|
|
6502
7635
|
return {
|
|
6503
7636
|
text: confirmMsg,
|
|
@@ -6522,7 +7655,7 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6522
7655
|
await writeAuditLog({
|
|
6523
7656
|
tenantId: ctx.tenantId,
|
|
6524
7657
|
brandId: ctx.brandId ?? null,
|
|
6525
|
-
actor:
|
|
7658
|
+
actor: buildActor(),
|
|
6526
7659
|
action: contract.auditAction,
|
|
6527
7660
|
motivo: `Quota excedida \u2014 ${quotaName}: ${check.current}/${check.limit}`,
|
|
6528
7661
|
conversacionId: ctx.conversacionId ?? null,
|
|
@@ -6544,9 +7677,9 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6544
7677
|
await writeAuditLog({
|
|
6545
7678
|
tenantId: ctx.tenantId,
|
|
6546
7679
|
brandId: ctx.brandId ?? null,
|
|
6547
|
-
actor:
|
|
7680
|
+
actor: buildActor(),
|
|
6548
7681
|
action: contract.auditAction,
|
|
6549
|
-
motivo:
|
|
7682
|
+
motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
|
|
6550
7683
|
conversacionId: ctx.conversacionId ?? null,
|
|
6551
7684
|
status: "error",
|
|
6552
7685
|
errorMessage: err?.message ?? "unknown",
|
|
@@ -6573,7 +7706,7 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6573
7706
|
await writeAuditLog({
|
|
6574
7707
|
tenantId: ctx.tenantId,
|
|
6575
7708
|
brandId: ctx.brandId ?? null,
|
|
6576
|
-
actor:
|
|
7709
|
+
actor: buildActor(),
|
|
6577
7710
|
action: contract.auditAction,
|
|
6578
7711
|
motivo: `Output del helper inv\xE1lido \u2014 bug de "${contract.name}"`,
|
|
6579
7712
|
conversacionId: ctx.conversacionId ?? null,
|
|
@@ -6606,7 +7739,7 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6606
7739
|
await writeAuditLog({
|
|
6607
7740
|
tenantId: ctx.tenantId,
|
|
6608
7741
|
brandId: ctx.brandId ?? null,
|
|
6609
|
-
actor:
|
|
7742
|
+
actor: buildActor(),
|
|
6610
7743
|
action: contract.auditAction,
|
|
6611
7744
|
motivo: `disabled \u2014 ${code}${detail ? `: ${JSON.stringify(detail)}` : ""}`,
|
|
6612
7745
|
conversacionId: ctx.conversacionId ?? null,
|
|
@@ -6625,11 +7758,11 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6625
7758
|
await writeAuditLog({
|
|
6626
7759
|
tenantId: ctx.tenantId,
|
|
6627
7760
|
brandId: ctx.brandId ?? null,
|
|
6628
|
-
actor:
|
|
7761
|
+
actor: buildActor(),
|
|
6629
7762
|
action: contract.auditAction,
|
|
6630
7763
|
targetPath,
|
|
6631
7764
|
changes,
|
|
6632
|
-
motivo:
|
|
7765
|
+
motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
|
|
6633
7766
|
conversacionId: ctx.conversacionId ?? null,
|
|
6634
7767
|
status: "success",
|
|
6635
7768
|
durationMs: Date.now() - startMs
|
|
@@ -6642,100 +7775,136 @@ function wrapWithContract(contract, helper, options = {}) {
|
|
|
6642
7775
|
};
|
|
6643
7776
|
};
|
|
6644
7777
|
}
|
|
6645
|
-
var RecordarMemoriaParamsSchema =
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
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).")
|
|
6649
7789
|
});
|
|
6650
|
-
var RecordarMemoriaOutputSchema =
|
|
6651
|
-
memoriaId:
|
|
6652
|
-
status:
|
|
7790
|
+
var RecordarMemoriaOutputSchema = import_zod45.z.object({
|
|
7791
|
+
memoriaId: import_zod45.z.string(),
|
|
7792
|
+
status: import_zod45.z.literal("creada")
|
|
6653
7793
|
});
|
|
6654
|
-
var OlvidarMemoriaParamsSchema =
|
|
6655
|
-
|
|
6656
|
-
|
|
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.")
|
|
6657
7800
|
});
|
|
6658
|
-
var OlvidarMemoriaOutputSchema =
|
|
6659
|
-
status:
|
|
7801
|
+
var OlvidarMemoriaOutputSchema = import_zod45.z.object({
|
|
7802
|
+
status: import_zod45.z.literal("archivada")
|
|
6660
7803
|
});
|
|
6661
|
-
var ConfigInputSchema =
|
|
6662
|
-
diaSemana:
|
|
6663
|
-
diaMes:
|
|
6664
|
-
hora:
|
|
6665
|
-
// Timezone
|
|
6666
|
-
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.")
|
|
6667
7810
|
}).strict();
|
|
6668
|
-
var AccionInputSchema =
|
|
6669
|
-
tool:
|
|
6670
|
-
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.")
|
|
6671
7814
|
}).strict();
|
|
6672
|
-
var ProgramarRutinaParamsSchema =
|
|
6673
|
-
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
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).")
|
|
6678
7826
|
});
|
|
6679
|
-
var ProgramarRutinaOutputSchema =
|
|
6680
|
-
rutinaId:
|
|
6681
|
-
proximaEjecucionAt:
|
|
7827
|
+
var ProgramarRutinaOutputSchema = import_zod46.z.object({
|
|
7828
|
+
rutinaId: import_zod46.z.string(),
|
|
7829
|
+
proximaEjecucionAt: import_zod46.z.string().datetime()
|
|
6682
7830
|
});
|
|
6683
|
-
var PausarRutinaParamsSchema =
|
|
6684
|
-
|
|
6685
|
-
|
|
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.")
|
|
6686
7837
|
});
|
|
6687
|
-
var PausarRutinaOutputSchema =
|
|
6688
|
-
status:
|
|
7838
|
+
var PausarRutinaOutputSchema = import_zod46.z.object({
|
|
7839
|
+
status: import_zod46.z.literal("pausada")
|
|
6689
7840
|
});
|
|
6690
|
-
var ArchivarRutinaParamsSchema =
|
|
6691
|
-
|
|
6692
|
-
|
|
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.")
|
|
6693
7847
|
});
|
|
6694
|
-
var ArchivarRutinaOutputSchema =
|
|
6695
|
-
status:
|
|
7848
|
+
var ArchivarRutinaOutputSchema = import_zod46.z.object({
|
|
7849
|
+
status: import_zod46.z.literal("archivada")
|
|
6696
7850
|
});
|
|
6697
|
-
var ListarRutinasParamsSchema =
|
|
6698
|
-
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
|
+
)
|
|
6699
7855
|
});
|
|
6700
|
-
var ListarRutinasOutputSchema =
|
|
6701
|
-
rutinas:
|
|
7856
|
+
var ListarRutinasOutputSchema = import_zod46.z.object({
|
|
7857
|
+
rutinas: import_zod46.z.array(MartinRutinaSchema)
|
|
6702
7858
|
});
|
|
6703
7859
|
|
|
6704
|
-
// src/tools/
|
|
6705
|
-
function
|
|
7860
|
+
// src/tools/buildContext.ts
|
|
7861
|
+
async function buildContext(session, brandId, opts = {}) {
|
|
7862
|
+
const tenantId = session.requireTenant();
|
|
7863
|
+
const ci = session.mcpClientIdentity;
|
|
7864
|
+
let userCtx = null;
|
|
7865
|
+
if (session.userId && getSdkMode() === "admin") {
|
|
7866
|
+
try {
|
|
7867
|
+
userCtx = await getUserContext({
|
|
7868
|
+
db: getAdminDb(),
|
|
7869
|
+
tenantId,
|
|
7870
|
+
uid: session.userId
|
|
7871
|
+
});
|
|
7872
|
+
} catch {
|
|
7873
|
+
userCtx = null;
|
|
7874
|
+
}
|
|
7875
|
+
}
|
|
6706
7876
|
return {
|
|
6707
|
-
tenantId
|
|
7877
|
+
tenantId,
|
|
6708
7878
|
brandId,
|
|
6709
7879
|
user: {
|
|
6710
7880
|
uid: session.userId ?? "cowork-admin",
|
|
6711
|
-
nombre: session.userName ?? "Cowork Admin",
|
|
6712
|
-
rol: session.rol ?? "super_admin",
|
|
6713
|
-
|
|
6714
|
-
|
|
7881
|
+
nombre: userCtx?.nombre ?? session.userName ?? "Cowork Admin",
|
|
7882
|
+
rol: userCtx?.rolId ?? session.rol ?? "super_admin",
|
|
7883
|
+
rolNombre: userCtx?.rolNombre ?? null,
|
|
7884
|
+
idiomaPreferido: userCtx?.idiomaPreferido ?? "es",
|
|
7885
|
+
// Any caller entering through this factory is an MCP client (Claude
|
|
7886
|
+
// Desktop, Cursor, custom LLM scripts), NOT the Martin product app.
|
|
7887
|
+
// When Martin product app ships (211.3+), its server-side caller will
|
|
7888
|
+
// override clientType to 'martin' via the same `_martinContext` channel.
|
|
7889
|
+
clientType: "mcp_client",
|
|
7890
|
+
clientMetadata: {
|
|
7891
|
+
mcpClient: ci.mcpClient,
|
|
7892
|
+
mcpClientVersion: ci.mcpClientVersion
|
|
7893
|
+
}
|
|
6715
7894
|
},
|
|
6716
7895
|
conversacionId: opts.conversacionId ?? null,
|
|
6717
|
-
|
|
7896
|
+
// HITO 6 A7.8: default false — secure default para acciones destructivas.
|
|
7897
|
+
// Tools sin requiresConfirmation lo ignoran; tools con
|
|
7898
|
+
// requiresConfirmation: true necesitan el flag explícito para ejecutar.
|
|
7899
|
+
confirmationGranted: opts.confirmationGranted ?? false,
|
|
6718
7900
|
doubleConfirmationGranted: opts.doubleConfirmationGranted ?? false
|
|
6719
7901
|
};
|
|
6720
7902
|
}
|
|
6721
7903
|
async function dispatchWithContract(args) {
|
|
6722
7904
|
const { contract, helper, callable, input, ctx } = args;
|
|
6723
|
-
if (
|
|
7905
|
+
if (getSdkMode() === "admin") {
|
|
6724
7906
|
const db = getAdminDb();
|
|
6725
|
-
const permissionResolver =
|
|
6726
|
-
if (args2.userRol === "super_admin") return "completo";
|
|
6727
|
-
const ref = db.collection("configuracion").doc(`${args2.tenantId}_roles`);
|
|
6728
|
-
const snap = await ref.get();
|
|
6729
|
-
if (!snap.exists) return "ninguno";
|
|
6730
|
-
const rolesData = snap.data();
|
|
6731
|
-
const rolData = rolesData[args2.userRol];
|
|
6732
|
-
if (!rolData || rolData.activo === false) return "ninguno";
|
|
6733
|
-
const nivel = rolData.permisosPorModulo?.[args2.modulo];
|
|
6734
|
-
if (nivel === "completo" || nivel === "editar" || nivel === "ver" || nivel === "ninguno") {
|
|
6735
|
-
return nivel;
|
|
6736
|
-
}
|
|
6737
|
-
return "ninguno";
|
|
6738
|
-
};
|
|
7907
|
+
const permissionResolver = (args2) => getUserPermissionLevel({ db, ...args2 });
|
|
6739
7908
|
const wrapped = wrapWithContract(
|
|
6740
7909
|
contract,
|
|
6741
7910
|
async (i) => helper({ ...i, db }),
|
|
@@ -6743,15 +7912,20 @@ async function dispatchWithContract(args) {
|
|
|
6743
7912
|
);
|
|
6744
7913
|
return wrapped(input, ctx);
|
|
6745
7914
|
}
|
|
6746
|
-
|
|
7915
|
+
const result = await callable({
|
|
6747
7916
|
...input,
|
|
6748
7917
|
_martinContext: {
|
|
6749
7918
|
conversacionId: ctx.conversacionId ?? null,
|
|
6750
7919
|
confirmationGranted: ctx.confirmationGranted === true,
|
|
6751
7920
|
doubleConfirmationGranted: ctx.doubleConfirmationGranted === true,
|
|
6752
|
-
userIdiomaPreferido: ctx.user.idiomaPreferido
|
|
7921
|
+
userIdiomaPreferido: ctx.user.idiomaPreferido,
|
|
7922
|
+
// HITO 6 A7.5: propagar clientType + identidad concreta para que
|
|
7923
|
+
// el factory CF arme actor.type + actor.metadata en el audit log.
|
|
7924
|
+
clientType: ctx.user.clientType ?? "mcp_client",
|
|
7925
|
+
clientMetadata: ctx.user.clientMetadata ?? null
|
|
6753
7926
|
}
|
|
6754
7927
|
});
|
|
7928
|
+
return result;
|
|
6755
7929
|
}
|
|
6756
7930
|
|
|
6757
7931
|
// src/services/marketingHelperCallables.ts
|
|
@@ -6828,7 +8002,7 @@ function callPhotoDirectorExecute(input) {
|
|
|
6828
8002
|
}
|
|
6829
8003
|
|
|
6830
8004
|
// src/tools/marketing/photos.ts
|
|
6831
|
-
var
|
|
8005
|
+
var import_zod47 = require("zod");
|
|
6832
8006
|
|
|
6833
8007
|
// src/services/marketingEmbeddings.ts
|
|
6834
8008
|
var import_google_auth_library = require("google-auth-library");
|
|
@@ -6883,7 +8057,7 @@ async function generateTextEmbedding(text, session) {
|
|
|
6883
8057
|
if (!trimmed) {
|
|
6884
8058
|
throw new Error("generateTextEmbedding: text no puede estar vacio");
|
|
6885
8059
|
}
|
|
6886
|
-
if (
|
|
8060
|
+
if (getSdkMode() === "client") {
|
|
6887
8061
|
return null;
|
|
6888
8062
|
}
|
|
6889
8063
|
return callEmbeddingCF({ text: trimmed }, session);
|
|
@@ -6977,7 +8151,7 @@ async function findNearestInCollection(params) {
|
|
|
6977
8151
|
`findNearestInCollection: vectorField obligatorio (embeddingText | embeddingImage), recibido ${vectorField}`
|
|
6978
8152
|
);
|
|
6979
8153
|
}
|
|
6980
|
-
if (
|
|
8154
|
+
if (getSdkMode() === "client") {
|
|
6981
8155
|
if (!queryText || typeof queryText !== "string") {
|
|
6982
8156
|
throw new Error(
|
|
6983
8157
|
"findNearestInCollection en Modo B (client) requiere queryText. El MCP cliente no puede generar embeddings localmente; la CF puente marketingVectorSearchCallable los genera server-side."
|
|
@@ -7095,202 +8269,6 @@ async function findNearestInCollectionWithOverride(params) {
|
|
|
7095
8269
|
return findNearestInCollection(params);
|
|
7096
8270
|
}
|
|
7097
8271
|
|
|
7098
|
-
// src/tools/marketing/content.ts
|
|
7099
|
-
var import_zod36 = require("zod");
|
|
7100
|
-
var _logOverride = null;
|
|
7101
|
-
async function logToMcpLogs(entry) {
|
|
7102
|
-
if (_logOverride) return _logOverride(entry);
|
|
7103
|
-
try {
|
|
7104
|
-
const db = getAdminDb();
|
|
7105
|
-
await db.collection("marketing_mcp_logs").add({
|
|
7106
|
-
...entry,
|
|
7107
|
-
timestamp: import_firebase_admin.default.firestore.FieldValue.serverTimestamp()
|
|
7108
|
-
});
|
|
7109
|
-
} catch {
|
|
7110
|
-
}
|
|
7111
|
-
}
|
|
7112
|
-
var IncludeSchema = import_zod36.z.object({
|
|
7113
|
-
products: import_zod36.z.boolean().default(true),
|
|
7114
|
-
collections: import_zod36.z.boolean().default(true),
|
|
7115
|
-
articles: import_zod36.z.boolean().default(true),
|
|
7116
|
-
pages: import_zod36.z.boolean().default(false)
|
|
7117
|
-
}).default({
|
|
7118
|
-
products: true,
|
|
7119
|
-
collections: true,
|
|
7120
|
-
articles: true,
|
|
7121
|
-
pages: false
|
|
7122
|
-
});
|
|
7123
|
-
var LimitSchema = import_zod36.z.object({
|
|
7124
|
-
products: import_zod36.z.number().int().min(0).max(20).default(5),
|
|
7125
|
-
collections: import_zod36.z.number().int().min(0).max(10).default(3),
|
|
7126
|
-
articles: import_zod36.z.number().int().min(0).max(20).default(5),
|
|
7127
|
-
pages: import_zod36.z.number().int().min(0).max(10).default(2)
|
|
7128
|
-
}).default({ products: 5, collections: 3, articles: 5, pages: 2 });
|
|
7129
|
-
async function findContentForTopicHandler(input, session) {
|
|
7130
|
-
const tenantId = session.requireTenant();
|
|
7131
|
-
const brandId = input.brandId;
|
|
7132
|
-
const mode2 = getMode();
|
|
7133
|
-
const r = mode2 === "admin" ? await contentFinder({
|
|
7134
|
-
db: getAdminDb(),
|
|
7135
|
-
tenantId,
|
|
7136
|
-
brandId,
|
|
7137
|
-
contexto: input.contexto,
|
|
7138
|
-
fecha: input.fecha,
|
|
7139
|
-
include: input.include,
|
|
7140
|
-
limit: input.limit,
|
|
7141
|
-
diversidad: input.diversidad,
|
|
7142
|
-
mode: input.mode,
|
|
7143
|
-
deps: {
|
|
7144
|
-
generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
|
|
7145
|
-
findNearestInCollection: (params) => findNearestInCollectionWithOverride(
|
|
7146
|
-
params
|
|
7147
|
-
),
|
|
7148
|
-
rrfMerge,
|
|
7149
|
-
thresholds: {
|
|
7150
|
-
text: MARKETING_THRESHOLDS.content.text,
|
|
7151
|
-
image: MARKETING_THRESHOLDS.content.image,
|
|
7152
|
-
conflictSimilarity: MARKETING_THRESHOLDS.content.conflictSimilarity
|
|
7153
|
-
}
|
|
7154
|
-
}
|
|
7155
|
-
}) : await callContentFinder({
|
|
7156
|
-
tenantId,
|
|
7157
|
-
brandId,
|
|
7158
|
-
contexto: input.contexto,
|
|
7159
|
-
fecha: input.fecha,
|
|
7160
|
-
include: input.include,
|
|
7161
|
-
limit: input.limit,
|
|
7162
|
-
diversidad: input.diversidad,
|
|
7163
|
-
mode: input.mode
|
|
7164
|
-
});
|
|
7165
|
-
logToMcpLogs({
|
|
7166
|
-
toolName: "find_content_for_topic",
|
|
7167
|
-
tenantId,
|
|
7168
|
-
brandId,
|
|
7169
|
-
input: {
|
|
7170
|
-
contexto: input.contexto.slice(0, 500),
|
|
7171
|
-
fecha: input.fecha,
|
|
7172
|
-
include: input.include,
|
|
7173
|
-
diversidad: input.diversidad
|
|
7174
|
-
},
|
|
7175
|
-
output: {
|
|
7176
|
-
productsCount: r.productos.length,
|
|
7177
|
-
collectionsCount: r.colecciones.length,
|
|
7178
|
-
articlesCount: r.articles.length,
|
|
7179
|
-
pagesCount: r.pages.length,
|
|
7180
|
-
_suggestedActionsCount: r._suggestedActions.length,
|
|
7181
|
-
_hadConflict: !!r._detectedConflict,
|
|
7182
|
-
conflictType: r._detectedConflict?.type || null
|
|
7183
|
-
}
|
|
7184
|
-
}).catch((err) => console.error("[mcp_logs] write failed:", err));
|
|
7185
|
-
return r;
|
|
7186
|
-
}
|
|
7187
|
-
function registerContentTools(server, session) {
|
|
7188
|
-
server.tool(
|
|
7189
|
-
"find_content_for_topic",
|
|
7190
|
-
`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.
|
|
7191
|
-
|
|
7192
|
-
RETORNA:
|
|
7193
|
-
- productos, colecciones, articles, pages (segun include)
|
|
7194
|
-
- _instrucciones: reglas para linkear contenido
|
|
7195
|
-
- _negativePrompt: negativos visuales del brand brief
|
|
7196
|
-
- _temporadaActiva: si hay coleccion priorizada para la fecha
|
|
7197
|
-
- _detectedConflict / _suggestedActions: si detecta article muy similar (>85%)
|
|
7198
|
-
|
|
7199
|
-
USAR:
|
|
7200
|
-
(a) cuando escribas blogs, captions o contenido que linkee material del negocio (internal linking tier 1).
|
|
7201
|
-
(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.
|
|
7202
|
-
(c) antes de proponer un tema nuevo al calendario, para validar que el catalogo lo soporte.
|
|
7203
|
-
|
|
7204
|
-
MODOS (parametro mode):
|
|
7205
|
-
- 'text' (default): busca contra embeddingText. Rapido, preciso cuando las descripciones del catalogo estan optimizadas. Un solo query por coleccion.
|
|
7206
|
-
- '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:
|
|
7207
|
-
* El modo text devuelve pocos resultados
|
|
7208
|
-
* Exploraras tu catalogo y quieres cobertura maxima
|
|
7209
|
-
* Vas a hacer recomendaciones de SEO (items que solo aparecen en image = se\xF1al de texto debil)
|
|
7210
|
-
|
|
7211
|
-
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.`,
|
|
7212
|
-
{
|
|
7213
|
-
brandId: import_zod36.z.string().optional().describe("ID de la brand"),
|
|
7214
|
-
contexto: import_zod36.z.string().min(1).describe("Parrafo, keyword o intencion"),
|
|
7215
|
-
fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
|
|
7216
|
-
include: IncludeSchema.optional(),
|
|
7217
|
-
limit: LimitSchema.optional(),
|
|
7218
|
-
diversidad: import_zod36.z.boolean().default(true),
|
|
7219
|
-
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)")
|
|
7220
|
-
},
|
|
7221
|
-
async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode: mode2 }) => {
|
|
7222
|
-
const brandId = inputBrandId ?? session.requireBrand();
|
|
7223
|
-
const resolvedInclude = include || {
|
|
7224
|
-
products: true,
|
|
7225
|
-
collections: true,
|
|
7226
|
-
articles: true,
|
|
7227
|
-
pages: false
|
|
7228
|
-
};
|
|
7229
|
-
const resolvedLimit = limit || {
|
|
7230
|
-
products: 5,
|
|
7231
|
-
collections: 3,
|
|
7232
|
-
articles: 5,
|
|
7233
|
-
pages: 2
|
|
7234
|
-
};
|
|
7235
|
-
try {
|
|
7236
|
-
const result = await findContentForTopicHandler(
|
|
7237
|
-
{
|
|
7238
|
-
brandId,
|
|
7239
|
-
contexto,
|
|
7240
|
-
fecha,
|
|
7241
|
-
include: resolvedInclude,
|
|
7242
|
-
limit: resolvedLimit,
|
|
7243
|
-
diversidad,
|
|
7244
|
-
mode: mode2
|
|
7245
|
-
},
|
|
7246
|
-
session
|
|
7247
|
-
);
|
|
7248
|
-
const slimResult = (item) => ({
|
|
7249
|
-
id: item.id,
|
|
7250
|
-
similarity: item.similarity,
|
|
7251
|
-
title: item.title ?? item.nombre ?? null,
|
|
7252
|
-
handle: item.handle ?? null,
|
|
7253
|
-
description: typeof item.description === "string" ? item.description.slice(0, 200) : null,
|
|
7254
|
-
image: item.image ? { src: item.image.src, alt: item.image.alt } : null,
|
|
7255
|
-
collectionHandles: item.collectionHandles ?? null,
|
|
7256
|
-
modesFound: item.modesFound ?? null,
|
|
7257
|
-
rrfScore: item.rrfScore ?? null
|
|
7258
|
-
});
|
|
7259
|
-
const slimmed = {
|
|
7260
|
-
productos: result.productos.map(slimResult),
|
|
7261
|
-
colecciones: result.colecciones.map(slimResult),
|
|
7262
|
-
articles: result.articles.map(slimResult),
|
|
7263
|
-
pages: result.pages.map(slimResult),
|
|
7264
|
-
_instrucciones: result._instrucciones,
|
|
7265
|
-
_negativePrompt: result._negativePrompt,
|
|
7266
|
-
_temporadaActiva: result._temporadaActiva,
|
|
7267
|
-
_suggestedActions: result._suggestedActions,
|
|
7268
|
-
...result._detectedConflict ? { _detectedConflict: result._detectedConflict } : {}
|
|
7269
|
-
};
|
|
7270
|
-
return {
|
|
7271
|
-
content: [
|
|
7272
|
-
{
|
|
7273
|
-
type: "text",
|
|
7274
|
-
text: JSON.stringify(slimmed, null, 2)
|
|
7275
|
-
}
|
|
7276
|
-
]
|
|
7277
|
-
};
|
|
7278
|
-
} catch (err) {
|
|
7279
|
-
return {
|
|
7280
|
-
content: [
|
|
7281
|
-
{
|
|
7282
|
-
type: "text",
|
|
7283
|
-
text: JSON.stringify({
|
|
7284
|
-
error: err.message || "Error desconocido"
|
|
7285
|
-
})
|
|
7286
|
-
}
|
|
7287
|
-
]
|
|
7288
|
-
};
|
|
7289
|
-
}
|
|
7290
|
-
}
|
|
7291
|
-
);
|
|
7292
|
-
}
|
|
7293
|
-
|
|
7294
8272
|
// src/tools/marketing/photos.ts
|
|
7295
8273
|
async function compressImageForTransport(imageUrl) {
|
|
7296
8274
|
const sharp = (await import("sharp")).default;
|
|
@@ -7320,34 +8298,41 @@ REGLAS:
|
|
|
7320
8298
|
|
|
7321
8299
|
USAR: antes de generar contenido de cualquier slot del calendario.`,
|
|
7322
8300
|
{
|
|
7323
|
-
brandId:
|
|
7324
|
-
keyword:
|
|
7325
|
-
plataforma:
|
|
7326
|
-
fecha:
|
|
7327
|
-
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)
|
|
7328
8306
|
},
|
|
7329
8307
|
async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
|
|
7330
8308
|
const tenantId = session.requireTenant();
|
|
7331
8309
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7332
|
-
const
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
photos: MARKETING_THRESHOLDS.fotos.capa1Tenant,
|
|
7346
|
-
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
|
+
}
|
|
7347
8323
|
}
|
|
7348
|
-
}
|
|
7349
|
-
|
|
7350
|
-
|
|
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) }] };
|
|
7351
8336
|
}
|
|
7352
8337
|
);
|
|
7353
8338
|
server.tool(
|
|
@@ -7365,18 +8350,33 @@ DESPUES de ver la foto, decide:
|
|
|
7365
8350
|
|
|
7366
8351
|
Luego llama execute_photo_edit con tu analisis y prompt.`,
|
|
7367
8352
|
{
|
|
7368
|
-
fotoId:
|
|
8353
|
+
fotoId: import_zod47.z.string().describe("ID de la foto")
|
|
7369
8354
|
},
|
|
7370
8355
|
async ({ fotoId }) => {
|
|
7371
8356
|
const tenantId = session.requireTenant();
|
|
7372
8357
|
const lang = await resolveTenantIdioma(tenantId);
|
|
7373
|
-
const
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
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;
|
|
7380
8380
|
if (!result.ok) {
|
|
7381
8381
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
7382
8382
|
}
|
|
@@ -7398,33 +8398,44 @@ Retorna la foto editada para que la revises. Si no te gusta:
|
|
|
7398
8398
|
|
|
7399
8399
|
Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si se genera thumbnail y embedding con tus tags.`,
|
|
7400
8400
|
{
|
|
7401
|
-
fotoId:
|
|
7402
|
-
prompt:
|
|
7403
|
-
acciones:
|
|
7404
|
-
descripcion:
|
|
7405
|
-
tipo:
|
|
7406
|
-
tagsPrimarios:
|
|
7407
|
-
tagsSecundarios:
|
|
7408
|
-
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())
|
|
7409
8409
|
},
|
|
7410
8410
|
async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
|
|
7411
8411
|
const tenantId = session.requireTenant();
|
|
7412
8412
|
const lang = await resolveTenantIdioma(tenantId);
|
|
7413
|
-
|
|
7414
|
-
const
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
7419
|
-
|
|
7420
|
-
|
|
7421
|
-
|
|
7422
|
-
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
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,
|
|
7428
8439
|
fotoId,
|
|
7429
8440
|
prompt,
|
|
7430
8441
|
acciones,
|
|
@@ -7432,32 +8443,20 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
|
|
|
7432
8443
|
tipo,
|
|
7433
8444
|
tagsPrimarios,
|
|
7434
8445
|
tagsSecundarios,
|
|
7435
|
-
tagsContexto
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
});
|
|
7439
|
-
if (!result2.ok) {
|
|
7440
|
-
return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
|
|
7441
|
-
}
|
|
7442
|
-
const contentArray2 = [];
|
|
7443
|
-
if (result2.imageBase64) {
|
|
7444
|
-
contentArray2.push({ type: "image", data: result2.imageBase64, mimeType: "image/jpeg" });
|
|
7445
|
-
}
|
|
7446
|
-
const { imageBase64: _unused2, ...textFields2 } = result2;
|
|
7447
|
-
contentArray2.push({ type: "text", text: JSON.stringify(textFields2, null, 2) });
|
|
7448
|
-
return { content: contentArray2 };
|
|
7449
|
-
}
|
|
7450
|
-
const result = await callPhotoDirectorExecute({
|
|
7451
|
-
tenantId,
|
|
7452
|
-
fotoId,
|
|
7453
|
-
prompt,
|
|
7454
|
-
acciones,
|
|
7455
|
-
descripcion,
|
|
7456
|
-
tipo,
|
|
7457
|
-
tagsPrimarios,
|
|
7458
|
-
tagsSecundarios,
|
|
7459
|
-
tagsContexto
|
|
8446
|
+
tagsContexto
|
|
8447
|
+
},
|
|
8448
|
+
ctx
|
|
7460
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;
|
|
7461
8460
|
if (!result.ok) {
|
|
7462
8461
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
7463
8462
|
}
|
|
@@ -7482,7 +8481,7 @@ Retorna:
|
|
|
7482
8481
|
- creditos: { balance, planId, periodEnd, costoPhotoEdit }
|
|
7483
8482
|
- _instrucciones: que hacer segun el estado`,
|
|
7484
8483
|
{
|
|
7485
|
-
fotoId:
|
|
8484
|
+
fotoId: import_zod47.z.string().describe("ID de la foto")
|
|
7486
8485
|
},
|
|
7487
8486
|
async ({ fotoId }) => {
|
|
7488
8487
|
const tenantId = session.requireTenant();
|
|
@@ -7582,19 +8581,34 @@ Retorna:
|
|
|
7582
8581
|
"find_products_for_content",
|
|
7583
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.`,
|
|
7584
8583
|
{
|
|
7585
|
-
brandId:
|
|
7586
|
-
contexto:
|
|
7587
|
-
fecha:
|
|
7588
|
-
limit:
|
|
7589
|
-
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)
|
|
7590
8589
|
},
|
|
7591
8590
|
async ({ brandId: inputBrandId, contexto, fecha, limit, diversidad }) => {
|
|
7592
8591
|
console.warn(
|
|
7593
8592
|
"[deprecated] find_products_for_content \u2192 use find_content_for_topic with include.products"
|
|
7594
8593
|
);
|
|
8594
|
+
const tenantId = session.requireTenant();
|
|
7595
8595
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7596
|
-
const
|
|
7597
|
-
|
|
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,
|
|
7598
8612
|
brandId,
|
|
7599
8613
|
contexto,
|
|
7600
8614
|
fecha,
|
|
@@ -7602,8 +8616,18 @@ Retorna:
|
|
|
7602
8616
|
limit: { products: limit, collections: 0, articles: 0, pages: 0 },
|
|
7603
8617
|
diversidad
|
|
7604
8618
|
},
|
|
7605
|
-
|
|
7606
|
-
);
|
|
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;
|
|
7607
8631
|
const productos = (newResult.productos || []).map((r) => ({
|
|
7608
8632
|
productId: r.id,
|
|
7609
8633
|
similarity: r.similarity,
|
|
@@ -7624,7 +8648,6 @@ Retorna:
|
|
|
7624
8648
|
titulo: newResult._temporadaActiva.titulo,
|
|
7625
8649
|
bloqueo: true
|
|
7626
8650
|
} : null,
|
|
7627
|
-
// Campo nuevo aditivo (no rompe consumers) — senaliza deprecation
|
|
7628
8651
|
_deprecated: "Usa find_content_for_topic con include.products en su lugar"
|
|
7629
8652
|
}, null, 2)
|
|
7630
8653
|
}]
|
|
@@ -7639,32 +8662,47 @@ RETORNA { plantillaId, titulo, thumbnailUrl } o { plantillaId: null, motivo: 'no
|
|
|
7639
8662
|
|
|
7640
8663
|
USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/{brandId}.canva.connected=true).`,
|
|
7641
8664
|
{
|
|
7642
|
-
brandId:
|
|
7643
|
-
plataforma:
|
|
7644
|
-
tipoContenido:
|
|
7645
|
-
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")
|
|
7646
8669
|
},
|
|
7647
8670
|
async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
|
|
7648
8671
|
const tenantId = session.requireTenant();
|
|
7649
8672
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7650
|
-
const
|
|
7651
|
-
|
|
7652
|
-
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
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
|
+
};
|
|
7663
8701
|
}
|
|
7664
8702
|
);
|
|
7665
8703
|
server.tool(
|
|
7666
8704
|
"request_photo_shoot",
|
|
7667
|
-
`Agrega necesidades al FotoBriefing semanal del tenant.
|
|
8705
|
+
`Agrega necesidades al FotoBriefing semanal del tenant. The LLM uses this when it detects photo gaps mientras planifica contenido (get_photos_for_slot retorna < 3 fotos).
|
|
7668
8706
|
|
|
7669
8707
|
EFECTO: merge de necesidades en marketing_fotobriefings/{tenantId}_{brandId}_{semana}. Si el doc no existe, lo crea. Idempotente por tema.
|
|
7670
8708
|
|
|
@@ -7672,15 +8710,15 @@ USAR: cuando get_photos_for_slot retorna pocas fotos y el slot aun no esta cubie
|
|
|
7672
8710
|
|
|
7673
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.`,
|
|
7674
8712
|
{
|
|
7675
|
-
brandId:
|
|
7676
|
-
semana:
|
|
7677
|
-
necesidades:
|
|
7678
|
-
|
|
7679
|
-
tema:
|
|
7680
|
-
keyword:
|
|
7681
|
-
cantidadSugerida:
|
|
7682
|
-
razon:
|
|
7683
|
-
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")')
|
|
7684
8722
|
})
|
|
7685
8723
|
).min(1)
|
|
7686
8724
|
},
|
|
@@ -7742,6 +8780,193 @@ IMPORTANTE \u2014 campo 'razon': Este texto lo ve el tenant en la app. Escribe e
|
|
|
7742
8780
|
);
|
|
7743
8781
|
}
|
|
7744
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
|
+
|
|
7745
8970
|
// src/services/pipelineLink.ts
|
|
7746
8971
|
var PLATAFORMA_A_PASO = {
|
|
7747
8972
|
shopify_blog: "blog",
|
|
@@ -7826,14 +9051,14 @@ function registerMarketingTools(server, session) {
|
|
|
7826
9051
|
"get_calendar",
|
|
7827
9052
|
"Lee el calendario editorial del mes. Muestra semanas con items planificados por plataforma.",
|
|
7828
9053
|
{
|
|
7829
|
-
brandId:
|
|
7830
|
-
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)")
|
|
7831
9056
|
},
|
|
7832
9057
|
async ({ brandId: inputBrandId, mes }) => {
|
|
7833
9058
|
const tenantId = session.requireTenant();
|
|
7834
9059
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7835
9060
|
const targetMes = mes ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
|
|
7836
|
-
const ctx =
|
|
9061
|
+
const ctx = await buildContext(session, brandId);
|
|
7837
9062
|
const result = await dispatchWithContract({
|
|
7838
9063
|
contract: getCalendarContract,
|
|
7839
9064
|
helper: getCalendar,
|
|
@@ -7860,7 +9085,7 @@ function registerMarketingTools(server, session) {
|
|
|
7860
9085
|
"get_seo_snapshot",
|
|
7861
9086
|
"Lee el snapshot SEO de la brand: rank, keywords, oportunidades, competidores.",
|
|
7862
9087
|
{
|
|
7863
|
-
brandId:
|
|
9088
|
+
brandId: import_zod49.z.string().optional().describe("ID de la brand")
|
|
7864
9089
|
},
|
|
7865
9090
|
async ({ brandId: inputBrandId }) => {
|
|
7866
9091
|
const tenantId = session.requireTenant();
|
|
@@ -7879,8 +9104,8 @@ function registerMarketingTools(server, session) {
|
|
|
7879
9104
|
"get_photo_gallery",
|
|
7880
9105
|
"Fotos disponibles filtradas por estado. Muestra fotos con metadata y conteo.",
|
|
7881
9106
|
{
|
|
7882
|
-
brandId:
|
|
7883
|
-
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)")
|
|
7884
9109
|
},
|
|
7885
9110
|
async ({ brandId: inputBrandId, estado }) => {
|
|
7886
9111
|
session.requireTenant();
|
|
@@ -7918,32 +9143,32 @@ function registerMarketingTools(server, session) {
|
|
|
7918
9143
|
);
|
|
7919
9144
|
server.tool(
|
|
7920
9145
|
"generate_marketing_plan",
|
|
7921
|
-
"
|
|
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.",
|
|
7922
9147
|
{
|
|
7923
|
-
brandId:
|
|
9148
|
+
brandId: import_zod49.z.string().optional().describe("ID de la brand")
|
|
7924
9149
|
},
|
|
7925
9150
|
async ({ brandId: inputBrandId }) => {
|
|
7926
9151
|
const tenantId = session.requireTenant();
|
|
7927
9152
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7928
|
-
const result =
|
|
9153
|
+
const result = getSdkMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
|
|
7929
9154
|
const payload = result.ok ? result.payload : result;
|
|
7930
9155
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
7931
9156
|
}
|
|
7932
9157
|
);
|
|
7933
9158
|
server.tool(
|
|
7934
9159
|
"save_marketing_plan",
|
|
7935
|
-
"
|
|
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).",
|
|
7936
9161
|
{
|
|
7937
|
-
brandId:
|
|
7938
|
-
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.")
|
|
7939
9164
|
},
|
|
7940
9165
|
async ({ brandId: inputBrandId, plan }) => {
|
|
7941
9166
|
const tenantId = session.requireTenant();
|
|
7942
9167
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7943
|
-
const ctx =
|
|
9168
|
+
const ctx = await buildContext(session, brandId);
|
|
7944
9169
|
const result = await dispatchWithContract({
|
|
7945
9170
|
contract: planWriterSaveContract,
|
|
7946
|
-
helper: planWriter.save,
|
|
9171
|
+
helper: ({ db, ...rest }) => planWriter.save({ db, ...rest }),
|
|
7947
9172
|
callable: callPlanWriterSave,
|
|
7948
9173
|
input: { tenantId, brandId, plan },
|
|
7949
9174
|
ctx
|
|
@@ -7952,8 +9177,8 @@ function registerMarketingTools(server, session) {
|
|
|
7952
9177
|
ok: false,
|
|
7953
9178
|
state: result.state,
|
|
7954
9179
|
mensaje: result.text,
|
|
7955
|
-
// HITO 6 A6.7:
|
|
7956
|
-
//
|
|
9180
|
+
// HITO 6 A6.7: include structured Zod details so
|
|
9181
|
+
// the LLM can self-recover on the next attempt.
|
|
7957
9182
|
...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
|
|
7958
9183
|
};
|
|
7959
9184
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
@@ -7963,27 +9188,27 @@ function registerMarketingTools(server, session) {
|
|
|
7963
9188
|
"update_marketing_plan_field",
|
|
7964
9189
|
"Actualiza UN campo del plan de marketing sin sobreescribir el resto. Merge parcial. Ideal para agregar coleccionesPriorizadas, actualizar quickWins, etc.",
|
|
7965
9190
|
{
|
|
7966
|
-
brandId:
|
|
7967
|
-
field:
|
|
7968
|
-
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")
|
|
7969
9194
|
},
|
|
7970
9195
|
async ({ brandId: inputBrandId, field, value }) => {
|
|
7971
9196
|
const tenantId = session.requireTenant();
|
|
7972
9197
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7973
|
-
const result =
|
|
9198
|
+
const result = getSdkMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
|
|
7974
9199
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
7975
9200
|
}
|
|
7976
9201
|
);
|
|
7977
9202
|
server.tool(
|
|
7978
9203
|
"generate_brand_brief",
|
|
7979
|
-
"
|
|
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.",
|
|
7980
9205
|
{
|
|
7981
|
-
brandId:
|
|
9206
|
+
brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context.")
|
|
7982
9207
|
},
|
|
7983
9208
|
async ({ brandId: inputBrandId }) => {
|
|
7984
9209
|
const tenantId = session.requireTenant();
|
|
7985
9210
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
7986
|
-
const ctx =
|
|
9211
|
+
const ctx = await buildContext(session, brandId);
|
|
7987
9212
|
const result = await dispatchWithContract({
|
|
7988
9213
|
contract: brandBriefBuilderContract,
|
|
7989
9214
|
helper: brandBriefBuilder,
|
|
@@ -8004,77 +9229,93 @@ function registerMarketingTools(server, session) {
|
|
|
8004
9229
|
);
|
|
8005
9230
|
server.tool(
|
|
8006
9231
|
"save_brand_brief",
|
|
8007
|
-
"
|
|
9232
|
+
"Save the Brand Brief at tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Partial merge \u2014 only the brandBrief field is overwritten.",
|
|
8008
9233
|
{
|
|
8009
|
-
brandId:
|
|
8010
|
-
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.")
|
|
8011
9236
|
},
|
|
8012
9237
|
async ({ brandId: inputBrandId, brandBrief }) => {
|
|
8013
9238
|
const tenantId = session.requireTenant();
|
|
8014
9239
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
8015
|
-
const
|
|
8016
|
-
|
|
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) }] };
|
|
8017
9255
|
}
|
|
8018
9256
|
);
|
|
8019
9257
|
server.tool(
|
|
8020
9258
|
"generate_weekly_content",
|
|
8021
|
-
"
|
|
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.",
|
|
8022
9260
|
{
|
|
8023
|
-
brandId:
|
|
8024
|
-
semana:
|
|
8025
|
-
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'.")
|
|
8026
9264
|
},
|
|
8027
9265
|
async ({ brandId: inputBrandId, semana, modo }) => {
|
|
8028
9266
|
const tenantId = session.requireTenant();
|
|
8029
9267
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
8030
|
-
const
|
|
8031
|
-
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
|
+
};
|
|
8032
9282
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
8033
9283
|
}
|
|
8034
9284
|
);
|
|
8035
9285
|
server.tool(
|
|
8036
9286
|
"save_generated_content",
|
|
8037
|
-
`
|
|
9287
|
+
`Save generated content to Firestore. Uses buildContenido to validate the structure.
|
|
8038
9288
|
|
|
8039
|
-
|
|
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.`,
|
|
8040
9290
|
{
|
|
8041
|
-
brandId:
|
|
8042
|
-
plataforma:
|
|
8043
|
-
tipo:
|
|
8044
|
-
keyword:
|
|
8045
|
-
languageCode:
|
|
8046
|
-
fotoId:
|
|
8047
|
-
datos:
|
|
8048
|
-
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")
|
|
8049
9299
|
},
|
|
8050
9300
|
async ({ brandId: inputBrandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef }) => {
|
|
8051
9301
|
const tenantId = session.requireTenant();
|
|
8052
9302
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
8053
9303
|
try {
|
|
8054
|
-
const
|
|
8055
|
-
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8059
|
-
tipo,
|
|
8060
|
-
|
|
8061
|
-
languageCode,
|
|
8062
|
-
fotoId,
|
|
8063
|
-
datos,
|
|
8064
|
-
calendarioItemRef,
|
|
8065
|
-
linkPipeline: linkContenidoAPasoPipeline
|
|
8066
|
-
}) : await callContenidoWriter({
|
|
8067
|
-
tenantId,
|
|
8068
|
-
brandId,
|
|
8069
|
-
plataforma,
|
|
8070
|
-
tipo,
|
|
8071
|
-
keyword,
|
|
8072
|
-
languageCode,
|
|
8073
|
-
fotoId,
|
|
8074
|
-
datos,
|
|
8075
|
-
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
|
|
8076
9311
|
});
|
|
8077
|
-
|
|
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) }] };
|
|
8078
9319
|
} catch (err) {
|
|
8079
9320
|
const msg = err instanceof Error ? err.message : String(err);
|
|
8080
9321
|
console.error("[save_generated_content] UNCAUGHT ERROR:", msg);
|
|
@@ -8090,61 +9331,64 @@ Usa para: corregir body, metaTitle, tags, fotoId, o cualquier campo sin tener qu
|
|
|
8090
9331
|
NO puede cambiar: tenantId, brandId, id (inmutables).
|
|
8091
9332
|
Si pasas campos dentro de "datos", se hace merge con los datos existentes (no los reemplaza entero).`,
|
|
8092
9333
|
{
|
|
8093
|
-
contenidoId:
|
|
8094
|
-
datos:
|
|
8095
|
-
fotoId:
|
|
8096
|
-
keyword:
|
|
8097
|
-
languageCode:
|
|
8098
|
-
estado:
|
|
8099
|
-
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")
|
|
8100
9341
|
},
|
|
8101
9342
|
async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
|
|
8102
9343
|
const tenantId = session.requireTenant();
|
|
8103
|
-
const
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
languageCode,
|
|
8120
|
-
estado,
|
|
8121
|
-
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
|
|
8122
9360
|
});
|
|
8123
|
-
|
|
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) }] };
|
|
8124
9368
|
}
|
|
8125
9369
|
);
|
|
8126
9370
|
server.tool(
|
|
8127
9371
|
"add_calendar_slot",
|
|
8128
|
-
"
|
|
9372
|
+
"Add a NEW slot to the editorial calendar. To modify an existing slot use update_calendar_slot instead.",
|
|
8129
9373
|
{
|
|
8130
|
-
brandId:
|
|
8131
|
-
mes:
|
|
8132
|
-
semana:
|
|
8133
|
-
slot:
|
|
8134
|
-
dia:
|
|
8135
|
-
plataforma:
|
|
8136
|
-
tipo:
|
|
8137
|
-
keyword:
|
|
8138
|
-
tema:
|
|
8139
|
-
productoId:
|
|
8140
|
-
estado:
|
|
8141
|
-
locationId:
|
|
8142
|
-
locationNombre:
|
|
8143
|
-
}).describe("
|
|
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.")
|
|
9387
|
+
}).describe("New slot data.")
|
|
8144
9388
|
},
|
|
8145
9389
|
async ({ brandId, mes, semana, slot }) => {
|
|
8146
9390
|
const tenantId = session.requireTenant();
|
|
8147
|
-
const ctx =
|
|
9391
|
+
const ctx = await buildContext(session, brandId);
|
|
8148
9392
|
const result = await dispatchWithContract({
|
|
8149
9393
|
contract: addCalendarSlotContract,
|
|
8150
9394
|
helper: addCalendarSlot,
|
|
@@ -8163,40 +9407,48 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
|
|
|
8163
9407
|
);
|
|
8164
9408
|
server.tool(
|
|
8165
9409
|
"update_calendar_slot",
|
|
8166
|
-
"
|
|
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.",
|
|
8167
9411
|
{
|
|
8168
|
-
brandId:
|
|
8169
|
-
mes:
|
|
8170
|
-
semana:
|
|
8171
|
-
slotIndex:
|
|
8172
|
-
cambios:
|
|
8173
|
-
dia:
|
|
8174
|
-
plataforma:
|
|
8175
|
-
tipo:
|
|
8176
|
-
keyword:
|
|
8177
|
-
tema:
|
|
8178
|
-
productoId:
|
|
8179
|
-
estado:
|
|
8180
|
-
contenidoRef:
|
|
8181
|
-
fotoIdAsignada:
|
|
8182
|
-
notas:
|
|
8183
|
-
locationId:
|
|
8184
|
-
locationNombre:
|
|
8185
|
-
}).strict().describe("
|
|
8186
|
-
accionContenidoExistente:
|
|
8187
|
-
|
|
8188
|
-
|
|
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).")
|
|
9429
|
+
}).strict().describe("Fields to update on the slot. Only declared fields accepted; unknown fields are rejected. OMIT any field you are not changing."),
|
|
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")
|
|
8189
9433
|
]).optional().describe(
|
|
8190
|
-
|
|
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).'
|
|
8191
9435
|
)
|
|
8192
9436
|
},
|
|
8193
9437
|
async ({ brandId: inputBrandId, mes, semana, slotIndex, cambios, accionContenidoExistente }) => {
|
|
8194
9438
|
const tenantId = session.requireTenant();
|
|
8195
9439
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
8196
|
-
const ctx =
|
|
9440
|
+
const ctx = await buildContext(session, brandId);
|
|
8197
9441
|
const result = await dispatchWithContract({
|
|
8198
9442
|
contract: calendarSlotUpdaterContract,
|
|
8199
|
-
|
|
9443
|
+
// Wrap en arrow para que TS infiera el tipo del input desde el
|
|
9444
|
+
// schema del contract (acepta `cambios: Record<string, unknown>`),
|
|
9445
|
+
// no desde la interface estricta CalendarSlotUpdaterInput.
|
|
9446
|
+
// El helper internamente acepta el shape — Zod ya valido en el wrapper.
|
|
9447
|
+
helper: (input) => calendarSlotUpdater({
|
|
9448
|
+
...input,
|
|
9449
|
+
cambios: input.cambios,
|
|
9450
|
+
accionContenidoExistente: input.accionContenidoExistente
|
|
9451
|
+
}),
|
|
8200
9452
|
callable: callCalendarSlotUpdater,
|
|
8201
9453
|
input: { tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente },
|
|
8202
9454
|
ctx
|
|
@@ -8205,8 +9457,8 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
|
|
|
8205
9457
|
ok: false,
|
|
8206
9458
|
state: result.state,
|
|
8207
9459
|
mensaje: result.text,
|
|
8208
|
-
// HITO 6 A6.7:
|
|
8209
|
-
//
|
|
9460
|
+
// HITO 6 A6.7: include structured Zod details so
|
|
9461
|
+
// the LLM can self-recover on the next attempt.
|
|
8210
9462
|
...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
|
|
8211
9463
|
};
|
|
8212
9464
|
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
@@ -8222,22 +9474,35 @@ ESCRIBE EN DOS LUGARES:
|
|
|
8222
9474
|
|
|
8223
9475
|
NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agenda, no en el post.`,
|
|
8224
9476
|
{
|
|
8225
|
-
contenidoRef:
|
|
8226
|
-
fotoId:
|
|
8227
|
-
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')
|
|
8228
9480
|
},
|
|
8229
9481
|
async ({ contenidoRef, fotoId, calendarioItemRef }) => {
|
|
8230
9482
|
const tenantId = session.requireTenant();
|
|
8231
9483
|
const brandId = session.requireBrand();
|
|
8232
|
-
const
|
|
8233
|
-
|
|
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) }] };
|
|
8234
9499
|
}
|
|
8235
9500
|
);
|
|
8236
9501
|
server.tool(
|
|
8237
9502
|
"approve_content",
|
|
8238
9503
|
"Aprueba contenido para publicacion. Valida transicion de estado.",
|
|
8239
9504
|
{
|
|
8240
|
-
contenidoId:
|
|
9505
|
+
contenidoId: import_zod49.z.string().describe("ID del contenido a aprobar")
|
|
8241
9506
|
},
|
|
8242
9507
|
async ({ contenidoId }) => {
|
|
8243
9508
|
session.requireTenant();
|
|
@@ -8261,8 +9526,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
|
|
|
8261
9526
|
"reject_content",
|
|
8262
9527
|
"Rechaza contenido con motivo. Valida transicion de estado.",
|
|
8263
9528
|
{
|
|
8264
|
-
contenidoId:
|
|
8265
|
-
motivo:
|
|
9529
|
+
contenidoId: import_zod49.z.string().describe("ID del contenido a rechazar"),
|
|
9530
|
+
motivo: import_zod49.z.string().describe("Motivo del rechazo")
|
|
8266
9531
|
},
|
|
8267
9532
|
async ({ contenidoId, motivo }) => {
|
|
8268
9533
|
session.requireTenant();
|
|
@@ -8285,7 +9550,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
|
|
|
8285
9550
|
"get_collections",
|
|
8286
9551
|
"Lee todas las colecciones canonicas (Shopify/WordPress/webonly) con sus 6 campos SEO actuales. Incluye best practices 2026.",
|
|
8287
9552
|
{
|
|
8288
|
-
brandId:
|
|
9553
|
+
brandId: import_zod49.z.string().optional().describe("ID de la brand")
|
|
8289
9554
|
},
|
|
8290
9555
|
async ({ brandId: inputBrandId }) => {
|
|
8291
9556
|
const tenantId = session.requireTenant();
|
|
@@ -8372,22 +9637,35 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
|
|
|
8372
9637
|
"save_collection_suggestions",
|
|
8373
9638
|
"Guarda sugerencias SEO para colecciones de Shopify. El tenant las aprueba en la UI.",
|
|
8374
9639
|
{
|
|
8375
|
-
brandId:
|
|
9640
|
+
brandId: import_zod49.z.string().optional().describe("ID de la brand"),
|
|
8376
9641
|
suggestions: CollectionSuggestionsInputArraySchema.describe("Array de sugerencias SEO. Cada una con max 60 chars en metaTitle, max 158 en metaDescription, max 125 en imageAlt")
|
|
8377
9642
|
},
|
|
8378
9643
|
async ({ brandId: inputBrandId, suggestions }) => {
|
|
8379
9644
|
const tenantId = session.requireTenant();
|
|
8380
9645
|
const brandId = inputBrandId ?? session.requireBrand();
|
|
8381
|
-
const
|
|
8382
|
-
|
|
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) }] };
|
|
8383
9661
|
}
|
|
8384
9662
|
);
|
|
8385
9663
|
}
|
|
8386
9664
|
|
|
8387
9665
|
// src/index.ts
|
|
8388
9666
|
async function buildSystemPrompt2(session) {
|
|
8389
|
-
const
|
|
8390
|
-
if (
|
|
9667
|
+
const mode = getSdkMode();
|
|
9668
|
+
if (mode === "admin") {
|
|
8391
9669
|
const db = getAdminDb();
|
|
8392
9670
|
return buildSystemPrompt({
|
|
8393
9671
|
db,
|
|
@@ -8404,7 +9682,9 @@ async function buildSystemPrompt2(session) {
|
|
|
8404
9682
|
async function main() {
|
|
8405
9683
|
const authContext = resolveAuth();
|
|
8406
9684
|
const session = new Session(authContext);
|
|
8407
|
-
console.error(
|
|
9685
|
+
console.error(
|
|
9686
|
+
`[ponch-mcp] Auth resuelto. canSwitchTenant=${authContext.canSwitchTenant} tenantId=${authContext.tenantId ?? "(none)"} rol=${authContext.rol ?? "(none)"}`
|
|
9687
|
+
);
|
|
8408
9688
|
let firebaseReady = false;
|
|
8409
9689
|
if (authContext.serviceAccountPath) {
|
|
8410
9690
|
initFirebaseAdmin(authContext.serviceAccountPath);
|
|
@@ -8416,7 +9696,7 @@ async function main() {
|
|
|
8416
9696
|
console.error(`[ponch-mcp] Firebase Client autenticado. Tenant: ${authContext.tenantId}`);
|
|
8417
9697
|
} else {
|
|
8418
9698
|
console.error("[ponch-mcp] Token expirado. Usa connect_account para reconectar.");
|
|
8419
|
-
session.
|
|
9699
|
+
session.revokeCrossTenant();
|
|
8420
9700
|
}
|
|
8421
9701
|
} else {
|
|
8422
9702
|
console.error("[ponch-mcp] Sin conexion. Usa connect_account para conectar.");
|
|
@@ -8425,6 +9705,13 @@ async function main() {
|
|
|
8425
9705
|
name: "ponch",
|
|
8426
9706
|
version: "1.0.1"
|
|
8427
9707
|
});
|
|
9708
|
+
server.server.oninitialized = () => {
|
|
9709
|
+
const ci = server.server.getClientVersion();
|
|
9710
|
+
if (ci) {
|
|
9711
|
+
session.setMcpClientIdentity(ci.name ?? null, ci.version ?? null);
|
|
9712
|
+
console.error(`[ponch-mcp] Cliente MCP: ${ci.name ?? "(sin name)"} v${ci.version ?? "(sin version)"}`);
|
|
9713
|
+
}
|
|
9714
|
+
};
|
|
8428
9715
|
server.resource(
|
|
8429
9716
|
"system-prompt",
|
|
8430
9717
|
"ponch://system-prompt",
|