doc2vec 2.9.1 → 2.10.1

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,7 @@ 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.
221
222
 
222
223
  For S3 buckets (`type: 's3'`):
223
224
  * `bucket`: The S3 bucket name.
@@ -359,6 +360,7 @@ Configuration is managed through two files:
359
360
  start_date: '2025-01-01'
360
361
  ticket_status: ['open', 'pending']
361
362
  ticket_priority: ['high']
363
+ excluded_organizations: ['Acme Corp', 'Internal Testing']
362
364
  max_size: 1048576
363
365
  database_config:
364
366
  type: 'sqlite'
@@ -1760,9 +1760,9 @@ class ContentProcessor {
1760
1760
  }
1761
1761
  async chunkMarkdown(markdown, sourceConfig, url) {
1762
1762
  const logger = this.logger.child('chunker');
1763
- // --- Configuration ---
1764
- const MAX_TOKENS = 1000;
1765
- const MIN_TOKENS = 150; // 💡 Merges "OpenAI-compatible" sentence into the next block
1763
+ // --- Configuration (character-based, ~4 chars ≈ 1 token) ---
1764
+ const MAX_CHARS = 4000;
1765
+ const MIN_CHARS = 600; // 💡 Merges short sections into the next block
1766
1766
  const OVERLAP_PERCENT = 0.1; // 10% overlap for large splits
1767
1767
  const chunks = [];
1768
1768
  const lines = markdown.split("\n");
@@ -1824,29 +1824,27 @@ class ContentProcessor {
1824
1824
  };
1825
1825
  /**
1826
1826
  * Flushes the current buffer into the chunks array.
1827
- * Uses sub-splitting logic if the buffer exceeds MAX_TOKENS.
1827
+ * Uses sub-splitting logic if the buffer exceeds MAX_CHARS.
1828
1828
  */
1829
1829
  const flushBuffer = (force = false) => {
1830
1830
  const trimmedBuffer = buffer.trim();
1831
1831
  if (!trimmedBuffer)
1832
1832
  return;
1833
- const tokenCount = utils_1.Utils.tokenize(trimmedBuffer).length;
1833
+ const charCount = trimmedBuffer.length;
1834
1834
  // 💡 SEMANTIC MERGING
1835
1835
  // If the current section is too short (like just a title or a one-liner),
1836
1836
  // we don't flush yet unless it's the end of the file (force=true).
1837
- if (tokenCount < MIN_TOKENS && !force) {
1837
+ if (charCount < MIN_CHARS && !force) {
1838
1838
  return;
1839
1839
  }
1840
1840
  // Compute the appropriate topic hierarchy for merged content
1841
1841
  const topicHierarchy = computeTopicHierarchy();
1842
- if (tokenCount > MAX_TOKENS) {
1843
- // 💡 RECURSIVE OVERLAP SPLITTING
1842
+ if (charCount > MAX_CHARS) {
1843
+ // 💡 OVERLAP SPLITTING
1844
1844
  // If the section is a massive guide, split it but keep headers on every sub-piece.
1845
- const tokens = utils_1.Utils.tokenize(trimmedBuffer);
1846
- const overlapSize = Math.floor(MAX_TOKENS * OVERLAP_PERCENT);
1847
- for (let i = 0; i < tokens.length; i += (MAX_TOKENS - overlapSize)) {
1848
- const subTokens = tokens.slice(i, i + MAX_TOKENS);
1849
- const subContent = subTokens.join("");
1845
+ const overlapSize = Math.floor(MAX_CHARS * OVERLAP_PERCENT);
1846
+ for (let i = 0; i < trimmedBuffer.length; i += (MAX_CHARS - overlapSize)) {
1847
+ const subContent = trimmedBuffer.slice(i, i + MAX_CHARS);
1850
1848
  chunks.push(createDocumentChunk(subContent, topicHierarchy));
1851
1849
  }
1852
1850
  }
@@ -1870,9 +1868,9 @@ class ContentProcessor {
1870
1868
  .replace(/\[\]\(#[^)]*\)/g, "") // Remove [](#anchor) patterns
1871
1869
  .trim();
1872
1870
  // Check if we should merge with previous content
1873
- const currentTokenCount = utils_1.Utils.tokenize(buffer.trim()).length;
1874
- const hasBufferContent = currentTokenCount > 0;
1875
- const bufferIsSmall = currentTokenCount < MIN_TOKENS;
1871
+ const currentCharCount = buffer.trim().length;
1872
+ const hasBufferContent = currentCharCount > 0;
1873
+ const bufferIsSmall = currentCharCount < MIN_CHARS;
1876
1874
  // Only merge if:
1877
1875
  // 1. Buffer has content and is small
1878
1876
  // 2. Buffer has tracked headings (we're merging sections, not just content)
@@ -1898,7 +1896,7 @@ class ContentProcessor {
1898
1896
  else {
1899
1897
  buffer += `${line}\n`;
1900
1898
  // Safety valve: if a single section is huge, flush it periodically
1901
- if (utils_1.Utils.tokenize(buffer).length >= MAX_TOKENS) {
1899
+ if (buffer.length >= MAX_CHARS) {
1902
1900
  flushBuffer();
1903
1901
  }
1904
1902
  }
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 {
@@ -1271,6 +1273,11 @@ class Doc2Vec {
1271
1273
  }
1272
1274
  return;
1273
1275
  }
1276
+ // Skip tickets belonging to excluded organizations
1277
+ if (ticket.organization_id && excludedOrgIds.has(ticket.organization_id)) {
1278
+ logger.debug(`Ticket #${ticketId} belongs to excluded organization ${ticket.organization_id} — skipping`);
1279
+ return;
1280
+ }
1274
1281
  // Skip tickets whose status is outside the configured filter
1275
1282
  if (!statusFilter.has(ticket.status)) {
1276
1283
  logger.debug(`Ticket #${ticketId} has status '${ticket.status}' outside configured filter — skipping`);
@@ -1296,18 +1303,37 @@ class Doc2Vec {
1296
1303
  // so stale chunks from previous versions are never left behind.
1297
1304
  await this.processChunksForUrl(chunks, url, dbConnection, logger);
1298
1305
  };
1306
+ if (excludedOrgNames.size > 0) {
1307
+ logger.info(`Resolving ${excludedOrgNames.size} excluded organization name(s) to IDs`);
1308
+ const resolvedNames = new Set();
1309
+ let orgsUrl = `${baseUrl}/organizations.json?page[size]=100`;
1310
+ while (orgsUrl) {
1311
+ const orgsData = await fetchWithRetry(orgsUrl);
1312
+ for (const org of orgsData?.organizations || []) {
1313
+ const orgName = (org.name || '').toLowerCase();
1314
+ if (excludedOrgNames.has(orgName)) {
1315
+ excludedOrgIds.add(org.id);
1316
+ resolvedNames.add(orgName);
1317
+ }
1318
+ }
1319
+ orgsUrl = orgsData?.meta?.has_more ? orgsData?.links?.next : null;
1320
+ }
1321
+ logger.info(`Excluding tickets from ${excludedOrgIds.size} organization(s): ${[...excludedOrgIds].join(', ')}`);
1322
+ const unresolved = [...excludedOrgNames].filter(name => !resolvedNames.has(name));
1323
+ if (unresolved.length > 0) {
1324
+ throw new Error(`Cannot resolve excluded organization(s): ${unresolved.join(', ')}. Aborting to avoid syncing data for them.`);
1325
+ }
1326
+ }
1299
1327
  logger.info(`Fetching Zendesk tickets updated since ${lastRunDate}`);
1300
1328
  // Build query parameters — use the status filter for the search query
1301
1329
  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`;
1330
+ const statusClause = `status:${statusList.join(',status:')}`;
1331
+ // Zendesk /search.json caps results at 1000 (10 pages * 100). Bisect the date range
1332
+ // into smaller windows whenever a window would exceed that cap, so we never hit page 11.
1304
1333
  let totalTickets = 0;
1305
1334
  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) {
1335
+ const processResults = async (results) => {
1336
+ for (const ticket of results) {
1311
1337
  try {
1312
1338
  await processTicket(ticket);
1313
1339
  totalTickets++;
@@ -1317,11 +1343,39 @@ class Doc2Vec {
1317
1343
  logger.error(`Failed to process ticket #${ticket.id}, will retry next run: ${error.message}`);
1318
1344
  }
1319
1345
  }
1320
- nextPage = data.next_page || null;
1321
- if (nextPage) {
1346
+ };
1347
+ // Work queue of [start, end) date windows (ISO strings). Seed with [lastRunDate, syncStartDate).
1348
+ const windows = [[lastRunDate, syncStartDate]];
1349
+ const MAX_PER_WINDOW = 1000;
1350
+ while (windows.length > 0) {
1351
+ const [wStart, wEnd] = windows.shift();
1352
+ const q = `updated>${wStart} updated<${wEnd} ${statusClause}`;
1353
+ const firstUrl = `${baseUrl}/search.json?query=${encodeURIComponent(q)}&sort_by=updated_at&sort_order=asc`;
1354
+ logger.debug(`Searching window ${wStart} .. ${wEnd}`);
1355
+ const firstData = await fetchWithRetry(firstUrl);
1356
+ const count = firstData?.count ?? 0;
1357
+ if (count > MAX_PER_WINDOW) {
1358
+ const startMs = new Date(wStart).getTime();
1359
+ const endMs = new Date(wEnd).getTime();
1360
+ if (endMs - startMs <= 1000) {
1361
+ logger.warn(`Window ${wStart} .. ${wEnd} has ${count} tickets but is already ≤1s wide — processing first 1000 only, some may be missed`);
1362
+ }
1363
+ else {
1364
+ const midIso = new Date(startMs + Math.floor((endMs - startMs) / 2)).toISOString();
1365
+ logger.debug(`Window has ${count} tickets (>${MAX_PER_WINDOW}) — bisecting at ${midIso}`);
1366
+ windows.unshift([wStart, midIso], [midIso, wEnd]);
1367
+ continue;
1368
+ }
1369
+ }
1370
+ logger.info(`Processing window ${wStart}..${wEnd}: ${count} tickets`);
1371
+ await processResults(firstData.results || []);
1372
+ let nextPage = firstData.next_page || null;
1373
+ while (nextPage) {
1322
1374
  logger.debug(`Fetching next page: ${nextPage}`);
1323
- // Rate limiting: wait between requests
1324
1375
  await new Promise(res => setTimeout(res, 1000));
1376
+ const data = await fetchWithRetry(nextPage);
1377
+ await processResults(data.results || []);
1378
+ nextPage = data.next_page || null;
1325
1379
  }
1326
1380
  }
1327
1381
  // 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.1",
3
+ "version": "2.10.1",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",