doc2vec 2.9.2 → 2.10.2

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/README.md CHANGED
@@ -218,6 +218,8 @@ Configuration is managed through two files:
218
218
  * `start_date`: (Optional) Only process tickets/articles updated since this date (e.g., `'2025-01-01'`).
219
219
  * `ticket_status`: (Optional) Filter tickets by status (defaults to `['new', 'open', 'pending', 'hold', 'solved']`).
220
220
  * `ticket_priority`: (Optional) Filter tickets by priority (defaults to all priorities).
221
+ * `excluded_organizations`: (Optional) An array of Zendesk organization names whose tickets should be skipped. The sync will abort if any name cannot be resolved.
222
+ * `include_internal_comments`: (Optional) Include non-public (internal/agent-only) comments in the indexed content (defaults to `false`, i.e. only public comments are indexed). Internal comments are labeled `(internal)` in the generated markdown.
221
223
 
222
224
  For S3 buckets (`type: 's3'`):
223
225
  * `bucket`: The S3 bucket name.
@@ -359,6 +361,7 @@ Configuration is managed through two files:
359
361
  start_date: '2025-01-01'
360
362
  ticket_status: ['open', 'pending']
361
363
  ticket_priority: ['high']
364
+ excluded_organizations: ['Acme Corp', 'Internal Testing']
362
365
  max_size: 1048576
363
366
  database_config:
364
367
  type: 'sqlite'
package/dist/doc2vec.js CHANGED
@@ -1195,6 +1195,8 @@ class Doc2Vec {
1195
1195
  // Status filter applied client-side (includes 'closed' by default so tickets
1196
1196
  // transitioning to closed get updated rather than left stale)
1197
1197
  const statusFilter = new Set(config.ticket_status || ['new', 'open', 'pending', 'hold', 'solved', 'closed']);
1198
+ const excludedOrgNames = new Set((config.excluded_organizations || []).map(n => n.toLowerCase()));
1199
+ const excludedOrgIds = new Set();
1198
1200
  const fetchWithRetry = async (url, retries = 3) => {
1199
1201
  for (let attempt = 0; attempt < retries; attempt++) {
1200
1202
  try {
@@ -1243,13 +1245,16 @@ class Doc2Vec {
1243
1245
  if (comments && comments.length > 0) {
1244
1246
  md += `## Comments\n\n`;
1245
1247
  for (const comment of comments) {
1246
- if (comment.public) {
1247
- md += `### ${comment.author_id} - ${new Date(comment.created_at).toDateString()}\n\n`;
1248
- // Handle comment body
1249
- const rawBody = comment.plain_body || comment.html_body || comment.body || '';
1250
- const commentBody = rawBody.replace(/&nbsp;/g, " ") || '_No content._';
1251
- md += `${commentBody}\n\n---\n\n`;
1248
+ // Skip non-public comments unless internal comments are explicitly enabled
1249
+ if (!comment.public && !config.include_internal_comments) {
1250
+ continue;
1252
1251
  }
1252
+ const visibility = comment.public ? '' : ' (internal)';
1253
+ md += `### ${comment.author_id} - ${new Date(comment.created_at).toDateString()}${visibility}\n\n`;
1254
+ // Handle comment body
1255
+ const rawBody = comment.plain_body || comment.html_body || comment.body || '';
1256
+ const commentBody = rawBody.replace(/&nbsp;/g, " ") || '_No content._';
1257
+ md += `${commentBody}\n\n---\n\n`;
1253
1258
  }
1254
1259
  }
1255
1260
  else {
@@ -1271,16 +1276,29 @@ class Doc2Vec {
1271
1276
  }
1272
1277
  return;
1273
1278
  }
1279
+ // Skip tickets belonging to excluded organizations
1280
+ if (ticket.organization_id && excludedOrgIds.has(ticket.organization_id)) {
1281
+ logger.debug(`Ticket #${ticketId} belongs to excluded organization ${ticket.organization_id} — skipping`);
1282
+ return;
1283
+ }
1274
1284
  // Skip tickets whose status is outside the configured filter
1275
1285
  if (!statusFilter.has(ticket.status)) {
1276
1286
  logger.debug(`Ticket #${ticketId} has status '${ticket.status}' outside configured filter — skipping`);
1277
1287
  return;
1278
1288
  }
1279
1289
  logger.info(`Processing ticket #${ticketId}`);
1280
- // Fetch ticket comments
1281
- const commentsUrl = `${baseUrl}/tickets/${ticketId}/comments.json`;
1282
- const commentsData = await fetchWithRetry(commentsUrl);
1283
- const comments = commentsData?.comments || [];
1290
+ // Fetch ticket comments. Zendesk returns at most 100 comments per page
1291
+ // (ordered oldest-first), so follow next_page to capture the newest
1292
+ // comments on tickets with more than 100 — otherwise they're silently dropped.
1293
+ const comments = [];
1294
+ let commentsUrl = `${baseUrl}/tickets/${ticketId}/comments.json`;
1295
+ while (commentsUrl) {
1296
+ const commentsData = await fetchWithRetry(commentsUrl);
1297
+ comments.push(...(commentsData?.comments || []));
1298
+ commentsUrl = commentsData?.next_page || null;
1299
+ if (commentsUrl)
1300
+ await new Promise(res => setTimeout(res, 1000));
1301
+ }
1284
1302
  // Generate markdown for the ticket
1285
1303
  const markdown = generateMarkdownForTicket(ticket, comments);
1286
1304
  // Chunk the markdown content
@@ -1296,18 +1314,37 @@ class Doc2Vec {
1296
1314
  // so stale chunks from previous versions are never left behind.
1297
1315
  await this.processChunksForUrl(chunks, url, dbConnection, logger);
1298
1316
  };
1317
+ if (excludedOrgNames.size > 0) {
1318
+ logger.info(`Resolving ${excludedOrgNames.size} excluded organization name(s) to IDs`);
1319
+ const resolvedNames = new Set();
1320
+ let orgsUrl = `${baseUrl}/organizations.json?page[size]=100`;
1321
+ while (orgsUrl) {
1322
+ const orgsData = await fetchWithRetry(orgsUrl);
1323
+ for (const org of orgsData?.organizations || []) {
1324
+ const orgName = (org.name || '').toLowerCase();
1325
+ if (excludedOrgNames.has(orgName)) {
1326
+ excludedOrgIds.add(org.id);
1327
+ resolvedNames.add(orgName);
1328
+ }
1329
+ }
1330
+ orgsUrl = orgsData?.meta?.has_more ? orgsData?.links?.next : null;
1331
+ }
1332
+ logger.info(`Excluding tickets from ${excludedOrgIds.size} organization(s): ${[...excludedOrgIds].join(', ')}`);
1333
+ const unresolved = [...excludedOrgNames].filter(name => !resolvedNames.has(name));
1334
+ if (unresolved.length > 0) {
1335
+ throw new Error(`Cannot resolve excluded organization(s): ${unresolved.join(', ')}. Aborting to avoid syncing data for them.`);
1336
+ }
1337
+ }
1299
1338
  logger.info(`Fetching Zendesk tickets updated since ${lastRunDate}`);
1300
1339
  // Build query parameters — use the status filter for the search query
1301
1340
  const statusList = Array.from(statusFilter);
1302
- const query = `updated>${lastRunDate.split('T')[0]} status:${statusList.join(',status:')}`;
1303
- let nextPage = `${baseUrl}/search.json?query=${encodeURIComponent(query)}&sort_by=updated_at&sort_order=asc`;
1341
+ const statusClause = `status:${statusList.join(',status:')}`;
1342
+ // Zendesk /search.json caps results at 1000 (10 pages * 100). Bisect the date range
1343
+ // into smaller windows whenever a window would exceed that cap, so we never hit page 11.
1304
1344
  let totalTickets = 0;
1305
1345
  let failedTickets = 0;
1306
- while (nextPage) {
1307
- const data = await fetchWithRetry(nextPage);
1308
- const tickets = data.results || [];
1309
- logger.info(`Processing batch of ${tickets.length} tickets`);
1310
- for (const ticket of tickets) {
1346
+ const processResults = async (results) => {
1347
+ for (const ticket of results) {
1311
1348
  try {
1312
1349
  await processTicket(ticket);
1313
1350
  totalTickets++;
@@ -1317,11 +1354,39 @@ class Doc2Vec {
1317
1354
  logger.error(`Failed to process ticket #${ticket.id}, will retry next run: ${error.message}`);
1318
1355
  }
1319
1356
  }
1320
- nextPage = data.next_page || null;
1321
- if (nextPage) {
1357
+ };
1358
+ // Work queue of [start, end) date windows (ISO strings). Seed with [lastRunDate, syncStartDate).
1359
+ const windows = [[lastRunDate, syncStartDate]];
1360
+ const MAX_PER_WINDOW = 1000;
1361
+ while (windows.length > 0) {
1362
+ const [wStart, wEnd] = windows.shift();
1363
+ const q = `updated>${wStart} updated<${wEnd} ${statusClause}`;
1364
+ const firstUrl = `${baseUrl}/search.json?query=${encodeURIComponent(q)}&sort_by=updated_at&sort_order=asc`;
1365
+ logger.debug(`Searching window ${wStart} .. ${wEnd}`);
1366
+ const firstData = await fetchWithRetry(firstUrl);
1367
+ const count = firstData?.count ?? 0;
1368
+ if (count > MAX_PER_WINDOW) {
1369
+ const startMs = new Date(wStart).getTime();
1370
+ const endMs = new Date(wEnd).getTime();
1371
+ if (endMs - startMs <= 1000) {
1372
+ logger.warn(`Window ${wStart} .. ${wEnd} has ${count} tickets but is already ≤1s wide — processing first 1000 only, some may be missed`);
1373
+ }
1374
+ else {
1375
+ const midIso = new Date(startMs + Math.floor((endMs - startMs) / 2)).toISOString();
1376
+ logger.debug(`Window has ${count} tickets (>${MAX_PER_WINDOW}) — bisecting at ${midIso}`);
1377
+ windows.unshift([wStart, midIso], [midIso, wEnd]);
1378
+ continue;
1379
+ }
1380
+ }
1381
+ logger.info(`Processing window ${wStart}..${wEnd}: ${count} tickets`);
1382
+ await processResults(firstData.results || []);
1383
+ let nextPage = firstData.next_page || null;
1384
+ while (nextPage) {
1322
1385
  logger.debug(`Fetching next page: ${nextPage}`);
1323
- // Rate limiting: wait between requests
1324
1386
  await new Promise(res => setTimeout(res, 1000));
1387
+ const data = await fetchWithRetry(nextPage);
1388
+ await processResults(data.results || []);
1389
+ nextPage = data.next_page || null;
1325
1390
  }
1326
1391
  }
1327
1392
  // Only advance the watermark when all tickets succeeded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.9.2",
3
+ "version": "2.10.2",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",