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 +2 -0
- package/dist/content-processor.js +15 -17
- package/dist/doc2vec.js +64 -10
- package/package.json +1 -1
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
|
|
1765
|
-
const
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
1843
|
-
// 💡
|
|
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
|
|
1846
|
-
|
|
1847
|
-
|
|
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
|
|
1874
|
-
const hasBufferContent =
|
|
1875
|
-
const bufferIsSmall =
|
|
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 (
|
|
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
|
|
1303
|
-
|
|
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
|
-
|
|
1307
|
-
const
|
|
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
|
-
|
|
1321
|
-
|
|
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
|