backend-manager 5.10.2 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,24 @@
1
1
  /**
2
2
  * Shared content-source resolution for blog + newsletter pipelines.
3
3
  *
4
- * Both blog-auto-publisher and newsletter-generator need to:
5
- * - Resolve $feed:, $parent, $brand, URL, and text sources
6
- * - Track used sources in Firestore (content-sources collection)
7
- * - Deduplicate across runs
8
- * - Follow anti-traceability rules
4
+ * Both blog-auto-publisher and newsletter-generator resolve their sources
5
+ * through the SAME function resolveSources(). It:
6
+ * - Picks random source(s) from the entry's sources array (never anything
7
+ * outside the array)
8
+ * - Resolves each pick to a concrete item ($feed: article, $parent source,
9
+ * $brand seed, URL content, or text seed)
10
+ * - Checks Firestore (content-sources collection) so used items are never
11
+ * re-picked, and a session-used set so the same item is never returned
12
+ * twice in one call
13
+ * - Falls back on failure following a strict type hierarchy:
14
+ * $feed → other items in the same feed → other $feed sources
15
+ * → $parent (if listed) → give up
16
+ * $parent → other unused parent items → give up
17
+ * $brand → pick-only; NOTHING ever falls back to $brand
18
+ * URL/text→ pick-only; no fallback in or out
19
+ * - Leaves marking-as-used to the CALLER (trackContentSource) so sources
20
+ * are only marked used after the content that used them actually
21
+ * generated/published successfully
9
22
  *
10
23
  * This module is the single source of truth for all of that.
11
24
  */
@@ -18,6 +31,9 @@ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
18
31
  const FEED_PREFIX = '$feed:';
19
32
  const CONTENT_SOURCES_COLLECTION = 'content-sources';
20
33
 
34
+ // Parent sources fetched per category when the $parent pool is built
35
+ const PARENT_SOURCES_PER_CATEGORY = 3;
36
+
21
37
  // ── Prompt constants ─────────────────────────────────────────────────
22
38
 
23
39
  const ANTI_TRACEABILITY = [
@@ -57,9 +73,44 @@ const PROMPT = `
57
73
  {suggestion}
58
74
  `;
59
75
 
60
- // ── Feed source processing ───────────────────────────────────────────
76
+ // ── Resolver state ───────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Create the per-call resolver state. Caches parsed feeds and the parent
80
+ * pool so a single resolveSources() call never fetches the same feed or
81
+ * the parent endpoint twice.
82
+ *
83
+ * @param {object} args
84
+ * @param {string[]} args.sources - The entry's source pool ($feed:/$parent/$brand/URL/text)
85
+ * @param {string[]} [args.categories] - Categories for $parent fetches
86
+ * @param {object} [args.admin] - firebase-admin (used-source checks; omit to skip)
87
+ * @param {object} [args.Manager] - Manager instance (parent URL resolution)
88
+ * @param {object} args.assistant - logger
89
+ * @returns {object} state
90
+ */
91
+ function createResolverState({ sources, categories, admin, Manager, assistant }) {
92
+ return {
93
+ pool: [...(sources || [])],
94
+ categories: categories || [],
95
+ admin,
96
+ Manager,
97
+ assistant,
98
+ sessionUsed: new Set(), // item url/id already returned this call
99
+ feedCache: new Map(), // feedUrl → { items: [...], dead: bool }
100
+ parentPool: null, // null = not fetched yet; [] = fetched (possibly exhausted)
101
+ };
102
+ }
103
+
104
+ // ── Feed resolution ──────────────────────────────────────────────────
105
+
106
+ async function loadFeed(state, feedUrl) {
107
+ if (state.feedCache.has(feedUrl)) {
108
+ return state.feedCache.get(feedUrl);
109
+ }
110
+
111
+ const entry = { items: [], dead: false };
112
+ state.feedCache.set(feedUrl, entry);
61
113
 
62
- async function processFeedSource(assistant, feedUrl, brandId, admin) {
63
114
  const feedText = await fetch(feedUrl, {
64
115
  timeout: 30000,
65
116
  tries: 2,
@@ -68,37 +119,53 @@ async function processFeedSource(assistant, feedUrl, brandId, admin) {
68
119
  }).catch((e) => e);
69
120
 
70
121
  if (feedText instanceof Error) {
71
- assistant.error(`processFeedSource(): Failed to fetch feed: ${feedUrl}`, feedText);
72
- return null;
122
+ state.assistant.error(`loadFeed(): Failed to fetch feed: ${feedUrl}`, feedText);
123
+ entry.dead = true;
124
+ return entry;
73
125
  }
74
126
 
75
127
  const { items } = parseFeed(feedText);
76
128
  if (!items.length) {
77
- assistant.log(`processFeedSource(): No items in feed: ${feedUrl}`);
78
- return null;
129
+ state.assistant.log(`loadFeed(): No items in feed: ${feedUrl}`);
130
+ entry.dead = true;
131
+ return entry;
79
132
  }
80
133
 
81
- assistant.log(`processFeedSource(): Parsed ${items.length} items from feed`);
82
-
83
- const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
84
- assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
134
+ const processedIds = await getProcessedItemIds(state.admin, feedUrl).catch((e) => {
135
+ state.assistant.error('loadFeed(): Error querying tracked items (continuing)', e);
85
136
  return new Set();
86
137
  });
87
138
 
88
- const unprocessed = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
89
- if (!unprocessed.length) {
90
- assistant.log(`processFeedSource(): All ${items.length} items already processed for: ${feedUrl}`);
139
+ entry.items = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
140
+
141
+ state.assistant.log(`loadFeed(): ${entry.items.length}/${items.length} unprocessed items in ${feedUrl}`);
142
+
143
+ return entry;
144
+ }
145
+
146
+ async function resolveFeedItem(state, feedUrl) {
147
+ const feed = await loadFeed(state, feedUrl);
148
+
149
+ if (feed.dead) {
91
150
  return null;
92
151
  }
93
152
 
94
- assistant.log(`processFeedSource(): ${unprocessed.length} unprocessed items available`);
153
+ // Newest-first among items not yet used this session
154
+ const item = feed.items.find((i) => !state.sessionUsed.has(i.id) && !state.sessionUsed.has(i.url));
155
+ if (!item) {
156
+ state.assistant.log(`resolveFeedItem(): Feed exhausted for this session: ${feedUrl}`);
157
+ return null;
158
+ }
95
159
 
96
- const item = unprocessed[0];
160
+ state.sessionUsed.add(item.id);
161
+ if (item.url) {
162
+ state.sessionUsed.add(item.url);
163
+ }
97
164
 
98
165
  let content = '';
99
166
  if (item.url) {
100
167
  content = await extractArticleContent(item.url).catch((e) => {
101
- assistant.error(`processFeedSource(): Failed to extract content from ${item.url} (using summary)`, e);
168
+ state.assistant.error(`resolveFeedItem(): Failed to extract content from ${item.url} (using summary)`, e);
102
169
  return '';
103
170
  });
104
171
  }
@@ -107,26 +174,45 @@ async function processFeedSource(assistant, feedUrl, brandId, admin) {
107
174
  content = item.content || item.summary || '';
108
175
  }
109
176
 
110
- return { item, content };
177
+ return {
178
+ type: 'feed',
179
+ id: item.id || item.url || '',
180
+ title: item.title || '',
181
+ url: item.url || '',
182
+ content,
183
+ feedUrl,
184
+ raw: item,
185
+ trackingData: {
186
+ url: item.url || item.id,
187
+ origin: `${FEED_PREFIX}${feedUrl}`,
188
+ feedUrl,
189
+ itemId: item.id,
190
+ itemTitle: item.title,
191
+ },
192
+ };
111
193
  }
112
194
 
113
- // ── Parent source processing ─────────────────────────────────────────
195
+ // ── Parent resolution ────────────────────────────────────────────────
196
+
197
+ async function loadParentPool(state) {
198
+ if (state.parentPool !== null) {
199
+ return state.parentPool;
200
+ }
114
201
 
115
- async function processParentSource(assistant, entry, admin, Manager) {
116
- const parentUrl = Manager?.getParentApiUrl?.();
202
+ state.parentPool = [];
117
203
 
204
+ const parentUrl = state.Manager?.getParentApiUrl?.();
118
205
  if (!parentUrl) {
119
- assistant.log('processParentSource(): No parent URL configured');
120
- return null;
206
+ state.assistant.log('loadParentPool(): No parent URL configured');
207
+ return state.parentPool;
121
208
  }
122
209
 
123
- const categories = entry.categories || [];
124
- const allSources = [];
125
- const categoriesToFetch = categories.length ? categories : [''];
210
+ const categoriesToFetch = state.categories.length ? state.categories : [''];
211
+ const fetched = [];
126
212
 
127
213
  for (const category of categoriesToFetch) {
128
214
  const query = {
129
- limit: 3,
215
+ limit: PARENT_SOURCES_PER_CATEGORY,
130
216
  backendManagerKey: process.env.BACKEND_MANAGER_KEY,
131
217
  };
132
218
 
@@ -140,29 +226,37 @@ async function processParentSource(assistant, entry, admin, Manager) {
140
226
  timeout: 60000,
141
227
  query: query,
142
228
  }).catch((e) => {
143
- assistant.error(`processParentSource(): Failed to fetch sources for category=${category}: ${e.message}`);
229
+ state.assistant.error(`loadParentPool(): Failed to fetch sources for category=${category}: ${e.message}`);
144
230
  return null;
145
231
  });
146
232
 
147
233
  if (data?.sources?.length) {
148
- allSources.push(...data.sources);
234
+ fetched.push(...data.sources);
149
235
  }
150
236
  }
151
237
 
152
- if (!allSources.length) {
153
- assistant.log('processParentSource(): No sources available from parent');
154
- return null;
155
- }
238
+ // Dedupe across categories (same source can appear in multiple categories)
239
+ const seen = new Set();
240
+ const deduped = fetched.filter((s) => {
241
+ const key = s.url || s.id;
242
+ if (!key || seen.has(key)) {
243
+ return false;
244
+ }
245
+ seen.add(key);
246
+ return true;
247
+ });
156
248
 
157
- if (admin) {
249
+ // Filter out sources already used (Firestore content-sources)
250
+ let available = deduped;
251
+ if (state.admin) {
158
252
  const usedUrls = new Set();
159
- const snapshot = await admin.firestore()
253
+ const snapshot = await state.admin.firestore()
160
254
  .collection(CONTENT_SOURCES_COLLECTION)
161
255
  .where('origin', '==', '$parent')
162
256
  .select('url')
163
257
  .get()
164
258
  .catch((e) => {
165
- assistant.error('processParentSource(): Error querying tracked sources (continuing)', e);
259
+ state.assistant.error('loadParentPool(): Error querying tracked sources (continuing)', e);
166
260
  return { docs: [] };
167
261
  });
168
262
 
@@ -173,100 +267,195 @@ async function processParentSource(assistant, entry, admin, Manager) {
173
267
  }
174
268
  });
175
269
 
176
- const available = allSources.filter((s) => !usedUrls.has(s.url || s.id));
270
+ available = deduped.filter((s) => !usedUrls.has(s.url || s.id));
271
+ }
177
272
 
178
- if (!available.length) {
179
- assistant.log('processParentSource(): All parent sources already used');
180
- return null;
181
- }
273
+ state.assistant.log(`loadParentPool(): ${available.length}/${fetched.length} parent sources available`);
274
+
275
+ state.parentPool = available;
276
+ return state.parentPool;
277
+ }
278
+
279
+ async function resolveParentItem(state) {
280
+ const pool = await loadParentPool(state);
281
+
282
+ const candidates = pool.filter((s) => {
283
+ const key = s.url || s.id;
284
+ return key && !state.sessionUsed.has(key);
285
+ });
182
286
 
183
- return available;
287
+ if (!candidates.length) {
288
+ state.assistant.log('resolveParentItem(): Parent pool exhausted for this session');
289
+ return null;
184
290
  }
185
291
 
186
- return allSources;
292
+ // Random pick among remaining candidates
293
+ const item = candidates[Math.floor(Math.random() * candidates.length)];
294
+ state.sessionUsed.add(item.url || item.id);
295
+
296
+ return {
297
+ type: 'parent',
298
+ id: item.id || '',
299
+ title: item.title || item.subject || '',
300
+ url: (item.url && item.url.startsWith('http')) ? item.url : '',
301
+ content: item.source?.content || item.content || item.summary || '',
302
+ raw: item,
303
+ trackingData: {
304
+ url: item.url || item.id,
305
+ origin: '$parent',
306
+ itemId: item.id,
307
+ itemTitle: item.title || item.subject || '',
308
+ },
309
+ };
187
310
  }
188
311
 
189
- // ── Parent source fetching for newsletter ────────────────────────────
190
-
191
- async function fetchParentSources(parentUrl, categories, assistant) {
192
- const allSources = [];
193
-
194
- for (const category of categories) {
195
- try {
196
- const data = await fetch(`${parentUrl}/newsletter-sources`, {
197
- method: 'get',
198
- response: 'json',
199
- timeout: 60000,
200
- query: {
201
- category,
202
- limit: 3,
203
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
204
- },
205
- });
312
+ // ── Pick resolution (type-aware fallback) ────────────────────────────
206
313
 
207
- if (data.sources?.length) {
208
- allSources.push(...data.sources);
314
+ /**
315
+ * Resolve ONE picked source, following the fallback hierarchy for its type.
316
+ * Exported for deterministic testing — production goes through resolveSources().
317
+ *
318
+ * @param {object} state - from createResolverState()
319
+ * @param {string} source - the picked source ($feed:X / $parent / $brand / URL / text)
320
+ * @returns {Promise<object|null>} resolved source or null when the chain is exhausted
321
+ */
322
+ async function resolvePick(state, source) {
323
+ // --- $feed: → same feed → other feeds → $parent (if listed) ---
324
+ if (typeof source === 'string' && source.startsWith(FEED_PREFIX)) {
325
+ const startUrl = source.slice(FEED_PREFIX.length);
326
+ const otherFeeds = shuffle(
327
+ state.pool
328
+ .filter((s) => typeof s === 'string' && s.startsWith(FEED_PREFIX) && s !== source)
329
+ .map((s) => s.slice(FEED_PREFIX.length))
330
+ );
331
+
332
+ for (const feedUrl of [startUrl, ...otherFeeds]) {
333
+ const resolved = await resolveFeedItem(state, feedUrl);
334
+ if (resolved) {
335
+ return resolved;
209
336
  }
210
- } catch (e) {
211
- assistant.error(`fetchParentSources(): Failed to fetch ${category} sources: ${e.message}`);
212
337
  }
338
+
339
+ if (state.pool.includes('$parent')) {
340
+ state.assistant.log('resolvePick(): All feeds exhausted, falling back to $parent');
341
+ return resolveParentItem(state);
342
+ }
343
+
344
+ return null;
213
345
  }
214
346
 
215
- return allSources;
216
- }
347
+ // --- $parent → other parent items only ---
348
+ if (source === '$parent') {
349
+ return resolveParentItem(state);
350
+ }
351
+
352
+ // --- $brand → pick-only seed (nothing ever falls back TO this) ---
353
+ if (source === '$brand') {
354
+ return {
355
+ type: 'brand',
356
+ id: '',
357
+ title: '',
358
+ url: '',
359
+ content: '',
360
+ raw: null,
361
+ trackingData: null,
362
+ };
363
+ }
217
364
 
218
- // ── Feed-to-newsletter normalization ─────────────────────────────────
365
+ // --- URL fetch content, no fallback ---
366
+ if (isURL(source)) {
367
+ const content = await getURLContent(source).catch((e) => {
368
+ state.assistant.error(`resolvePick(): Error fetching URL ${source}`, e);
369
+ return null;
370
+ });
371
+
372
+ if (content === null) {
373
+ return null;
374
+ }
219
375
 
220
- function normalizeFeedItemForNewsletter(feedResult, feedUrl, categories) {
221
- const item = feedResult.item;
222
- let hostname = '';
223
- try { hostname = new URL(feedUrl).hostname; } catch (e) { /* ignore */ }
376
+ return {
377
+ type: 'url',
378
+ id: source,
379
+ title: '',
380
+ url: source,
381
+ content,
382
+ raw: null,
383
+ trackingData: null,
384
+ };
385
+ }
224
386
 
387
+ // --- Text seed → always resolves ---
225
388
  return {
226
- id: item.id || item.url || '',
227
- title: item.title || '',
228
- subject: item.title || '',
229
- category: (categories && categories[0]) || '',
230
- categories: categories || [],
231
- url: item.url || '',
232
- source: {
233
- from: hostname,
234
- subject: item.title || '',
235
- content: feedResult.content || item.content || item.summary || '',
236
- },
237
- ai: null,
389
+ type: 'text',
390
+ id: '',
391
+ title: '',
392
+ url: '',
393
+ content: source,
394
+ raw: null,
395
+ trackingData: null,
238
396
  };
239
397
  }
240
398
 
241
- // ── Resolve newsletter sources (feeds + parent + fallback) ───────────
399
+ // ── Main entry: resolve N sources from a pool ────────────────────────
400
+
401
+ /**
402
+ * Resolve `count` sources from the entry's source pool.
403
+ *
404
+ * Each pick starts with a RANDOM source from the pool, then follows the
405
+ * type fallback hierarchy (see module doc). Returns however many sources
406
+ * could be resolved (may be fewer than count when pools are exhausted).
407
+ *
408
+ * The caller is responsible for marking returned sources as used via
409
+ * trackContentSource(resolved.trackingData) AFTER the content that used
410
+ * them succeeds.
411
+ *
412
+ * @param {object} args
413
+ * @param {string[]} args.sources - The entry's source pool
414
+ * @param {number} [args.count=1] - How many resolved sources to return
415
+ * @param {string[]} [args.categories] - Categories for $parent fetches
416
+ * @param {object} [args.admin] - firebase-admin
417
+ * @param {object} [args.Manager]
418
+ * @param {object} args.assistant
419
+ * @returns {Promise<object[]>} resolved sources ({ type, id, title, url, content, feedUrl?, raw?, trackingData })
420
+ */
421
+ async function resolveSources({ sources, count, categories, admin, Manager, assistant }) {
422
+ count = count || 1;
423
+
424
+ const state = createResolverState({ sources, categories, admin, Manager, assistant });
425
+
426
+ if (!state.pool.length) {
427
+ assistant.log('resolveSources(): Empty source pool');
428
+ return [];
429
+ }
242
430
 
243
- async function resolveNewsletterSources({ sources, categories, admin, Manager, assistant }) {
244
431
  const resolved = [];
245
432
 
246
- const feedUrls = (sources || []).filter((s) => typeof s === 'string' && s.startsWith(FEED_PREFIX));
247
- const hasParent = (sources || []).includes('$parent');
433
+ for (let i = 0; i < count; i++) {
434
+ const pick = state.pool[Math.floor(Math.random() * state.pool.length)];
248
435
 
249
- const brandId = Manager?.config?.brand?.id || '';
436
+ assistant.log(`resolveSources(): Pick ${i + 1}/${count} → ${typeof pick === 'string' ? pick.slice(0, 80) : pick}`);
250
437
 
251
- for (const source of feedUrls) {
252
- const feedUrl = source.slice(FEED_PREFIX.length);
253
- const feedResult = await processFeedSource(assistant, feedUrl, brandId, admin);
254
- if (feedResult) {
255
- resolved.push(normalizeFeedItemForNewsletter(feedResult, feedUrl, categories));
256
- }
257
- }
438
+ const result = await resolvePick(state, pick).catch((e) => {
439
+ assistant.error('resolveSources(): Error resolving pick', e);
440
+ return null;
441
+ });
258
442
 
259
- if (hasParent) {
260
- const parentUrl = Manager?.getParentApiUrl?.();
261
- if (parentUrl) {
262
- const parentSources = await fetchParentSources(parentUrl, categories || [], assistant);
263
- resolved.push(...parentSources);
443
+ if (result) {
444
+ resolved.push(result);
445
+ } else {
446
+ assistant.log(`resolveSources(): Pick ${i + 1}/${count} exhausted its fallback chain`);
264
447
  }
265
448
  }
266
449
 
450
+ assistant.log(`resolveSources(): Resolved ${resolved.length}/${count} sources`);
451
+
267
452
  return resolved;
268
453
  }
269
454
 
455
+ function shuffle(array) {
456
+ return [...array].sort(() => Math.random() - 0.5);
457
+ }
458
+
270
459
  // ── Firestore tracking ──────────────────────────────────────────────
271
460
 
272
461
  async function trackContentSource(admin, { url, origin, feedUrl, itemId, itemTitle, usedBy, brandId, postUrl, postSlug, postTitle }) {
@@ -411,11 +600,9 @@ module.exports = {
411
600
  FEED_PREFIX,
412
601
  CONTENT_SOURCES_COLLECTION,
413
602
  USER_AGENT,
414
- processFeedSource,
415
- processParentSource,
416
- fetchParentSources,
417
- resolveNewsletterSources,
418
- normalizeFeedItemForNewsletter,
603
+ resolveSources,
604
+ createResolverState,
605
+ resolvePick,
419
606
  trackContentSource,
420
607
  contentSourceHash,
421
608
  getProcessedItemIds,