doc2vec 2.7.0 → 2.9.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.
package/README.md CHANGED
@@ -229,6 +229,8 @@ Configuration is managed through two files:
229
229
  * `encoding`: (Optional) Text file encoding (defaults to `'utf8'`). Does not apply to binary files (PDF, DOC, DOCX).
230
230
  * `url_rewrite_prefix`: (Optional) URL prefix to rewrite `s3://` URLs (e.g., `'https://docs.example.com'`).
231
231
 
232
+ **S3 user metadata resolution:** The `product_name` and `version` fields support a `metadata(...)` syntax to dynamically resolve values from S3 object user metadata. For example, `product_name: 'metadata(x-amz-meta-product-name)'` will set `product_name` to the value of the `x-amz-meta-product-name` user metadata on each S3 object. If the metadata key doesn't exist on an object, an empty string is used. Literal values (without the `metadata(...)` wrapper) work as before.
233
+
232
234
  Authentication uses the AWS SDK default credential chain: environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), `~/.aws/credentials`, IAM roles, etc.
233
235
 
234
236
  Incremental sync tracks object `LastModified` timestamps so only new or updated objects are processed on subsequent runs. Deleted objects are automatically cleaned up.
@@ -365,7 +367,7 @@ Configuration is managed through two files:
365
367
 
366
368
  # S3 bucket source example
367
369
  - type: 's3'
368
- product_name: 'my-docs'
370
+ product_name: 'metadata(x-amz-meta-product-name)'
369
371
  version: 'latest'
370
372
  bucket: 'my-documentation-bucket'
371
373
  prefix: 'docs/v2/'
package/dist/doc2vec.js CHANGED
@@ -783,7 +783,14 @@ class Doc2Vec {
783
783
  else {
784
784
  fileUrl = `s3://${config.bucket}/${obj.key}`;
785
785
  }
786
- const chunks = await this.contentProcessor.chunkMarkdown(content, config, fileUrl);
786
+ // Resolve metadata(...) references in product_name and version
787
+ const s3Meta = getResponse.Metadata || {};
788
+ const resolvedConfig = {
789
+ ...config,
790
+ product_name: this.resolveS3MetadataValue(config.product_name, s3Meta),
791
+ version: this.resolveS3MetadataValue(config.version, s3Meta),
792
+ };
793
+ const chunks = await this.contentProcessor.chunkMarkdown(content, resolvedConfig, fileUrl);
787
794
  logger.info(`Created ${chunks.length} chunks for ${obj.key}`);
788
795
  await this.processChunksForUrl(chunks, fileUrl, dbConnection, logger);
789
796
  processedCount++;
@@ -828,6 +835,21 @@ class Doc2Vec {
828
835
  await database_1.DatabaseManager.setMetadataValue(dbConnection, lastSyncKey, `${syncStartTimestamp}`, logger, this.embeddingDimension);
829
836
  logger.info(`Finished processing S3 bucket: ${config.bucket}`);
830
837
  }
838
+ /**
839
+ * Resolves a config value that may use the metadata(...) syntax.
840
+ * e.g. "metadata(x-amz-meta-product-name)" looks up "product-name" in the S3 object's user metadata.
841
+ * Returns the original value if no metadata(...) pattern is found.
842
+ * Returns empty string if the referenced metadata key doesn't exist on the object.
843
+ */
844
+ resolveS3MetadataValue(configValue, s3Metadata) {
845
+ const match = configValue.match(/^metadata\((.+)\)$/);
846
+ if (!match)
847
+ return configValue;
848
+ const metaKey = match[1];
849
+ // AWS SDK returns user metadata keys without the x-amz-meta- prefix
850
+ const lookupKey = metaKey.replace(/^x-amz-meta-/, '');
851
+ return s3Metadata[lookupKey] ?? '';
852
+ }
831
853
  async processCodeSource(config, parentLogger) {
832
854
  const logger = parentLogger.child('process');
833
855
  logger.info(`Starting processing for code source (${config.source})`);
@@ -1170,6 +1192,9 @@ class Doc2Vec {
1170
1192
  // Get the last run date from the database
1171
1193
  const startDate = config.start_date || `${new Date().getFullYear()}-01-01`;
1172
1194
  const lastRunDate = await database_1.DatabaseManager.getLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, `${startDate}T00:00:00Z`, logger);
1195
+ // Status filter applied client-side (includes 'closed' by default so tickets
1196
+ // transitioning to closed get updated rather than left stale)
1197
+ const statusFilter = new Set(config.ticket_status || ['new', 'open', 'pending', 'hold', 'solved', 'closed']);
1173
1198
  const fetchWithRetry = async (url, retries = 3) => {
1174
1199
  for (let attempt = 0; attempt < retries; attempt++) {
1175
1200
  try {
@@ -1179,15 +1204,19 @@ class Doc2Vec {
1179
1204
  'Content-Type': 'application/json',
1180
1205
  },
1181
1206
  });
1182
- if (response.status === 429) {
1183
- const retryAfter = parseInt(response.headers['retry-after'] || '60');
1184
- logger.warn(`Rate limited, waiting ${retryAfter}s before retry`);
1185
- await new Promise(res => setTimeout(res, retryAfter * 1000));
1186
- continue;
1187
- }
1188
1207
  return response.data;
1189
1208
  }
1190
1209
  catch (error) {
1210
+ // 403 is a permissions error — retrying won't help
1211
+ if (error.response?.status === 403)
1212
+ throw error;
1213
+ if (error.response?.status === 429) {
1214
+ const retryAfter = parseInt(error.response.headers?.['retry-after'] || '60', 10);
1215
+ logger.warn(`Rate limited by Zendesk, waiting ${retryAfter}s before retry`);
1216
+ await new Promise(res => setTimeout(res, retryAfter * 1000));
1217
+ attempt--; // Don't burn a retry on rate-limit waits
1218
+ continue;
1219
+ }
1191
1220
  logger.error(`Zendesk API error (attempt ${attempt + 1}):`, error.message);
1192
1221
  if (attempt === retries - 1)
1193
1222
  throw error;
@@ -1231,6 +1260,22 @@ class Doc2Vec {
1231
1260
  const processTicket = async (ticket) => {
1232
1261
  const ticketId = ticket.id;
1233
1262
  const url = `https://${config.zendesk_subdomain}.zendesk.com/agent/tickets/${ticketId}`;
1263
+ // Deleted tickets — remove their chunks and stop
1264
+ if (ticket.status === 'deleted') {
1265
+ logger.info(`Ticket #${ticketId} was deleted in Zendesk — removing its chunks`);
1266
+ if (dbConnection.type === 'sqlite') {
1267
+ database_1.DatabaseManager.removeChunksByUrlSQLite(dbConnection.db, url, logger);
1268
+ }
1269
+ else {
1270
+ await database_1.DatabaseManager.removeChunksByUrlQdrant(dbConnection, url, logger);
1271
+ }
1272
+ return;
1273
+ }
1274
+ // Skip tickets whose status is outside the configured filter
1275
+ if (!statusFilter.has(ticket.status)) {
1276
+ logger.debug(`Ticket #${ticketId} has status '${ticket.status}' outside configured filter — skipping`);
1277
+ return;
1278
+ }
1234
1279
  logger.info(`Processing ticket #${ticketId}`);
1235
1280
  // Fetch ticket comments
1236
1281
  const commentsUrl = `${baseUrl}/tickets/${ticketId}/comments.json`;
@@ -1246,86 +1291,49 @@ class Doc2Vec {
1246
1291
  };
1247
1292
  const chunks = await this.contentProcessor.chunkMarkdown(markdown, ticketConfig, url);
1248
1293
  logger.info(`Ticket #${ticketId}: Created ${chunks.length} chunks`);
1249
- // Process and store each chunk
1250
- for (const chunk of chunks) {
1251
- const chunkHash = utils_1.Utils.generateHash(chunk.content);
1252
- const chunkId = chunk.metadata.chunk_id.substring(0, 8) + '...';
1253
- if (dbConnection.type === 'sqlite') {
1254
- const { checkHashStmt } = database_1.DatabaseManager.prepareSQLiteStatements(dbConnection.db);
1255
- const existing = checkHashStmt.get(chunk.metadata.chunk_id);
1256
- if (existing && existing.hash === chunkHash) {
1257
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
1258
- continue;
1259
- }
1260
- const embeddings = await this.createEmbeddings([chunk.content]);
1261
- if (embeddings.length) {
1262
- database_1.DatabaseManager.insertVectorsSQLite(dbConnection.db, chunk, embeddings[0], logger, chunkHash);
1263
- logger.debug(`Stored chunk ${chunkId} in SQLite`);
1264
- }
1265
- else {
1266
- logger.error(`Embedding failed for chunk: ${chunkId}`);
1267
- }
1268
- }
1269
- else if (dbConnection.type === 'qdrant') {
1270
- try {
1271
- let pointId;
1272
- try {
1273
- pointId = chunk.metadata.chunk_id;
1274
- if (!utils_1.Utils.isValidUuid(pointId)) {
1275
- pointId = utils_1.Utils.hashToUuid(chunk.metadata.chunk_id);
1276
- }
1277
- }
1278
- catch (e) {
1279
- pointId = crypto_1.default.randomUUID();
1280
- }
1281
- const existingPoints = await dbConnection.client.retrieve(dbConnection.collectionName, {
1282
- ids: [pointId],
1283
- with_payload: true,
1284
- with_vector: false,
1285
- });
1286
- if (existingPoints.length > 0 && existingPoints[0].payload && existingPoints[0].payload.hash === chunkHash) {
1287
- logger.info(`Skipping unchanged chunk: ${chunkId}`);
1288
- continue;
1289
- }
1290
- const embeddings = await this.createEmbeddings([chunk.content]);
1291
- if (embeddings.length) {
1292
- await database_1.DatabaseManager.storeChunkInQdrant(dbConnection, chunk, embeddings[0], chunkHash);
1293
- logger.debug(`Stored chunk ${chunkId} in Qdrant (${dbConnection.collectionName})`);
1294
- }
1295
- else {
1296
- logger.error(`Embedding failed for chunk: ${chunkId}`);
1297
- }
1298
- }
1299
- catch (error) {
1300
- logger.error(`Error processing chunk in Qdrant:`, error);
1301
- }
1302
- }
1303
- }
1294
+ // Use processChunksForUrl which performs a URL-level diff:
1295
+ // deletes all existing chunks for this URL before reinserting,
1296
+ // so stale chunks from previous versions are never left behind.
1297
+ await this.processChunksForUrl(chunks, url, dbConnection, logger);
1304
1298
  };
1305
1299
  logger.info(`Fetching Zendesk tickets updated since ${lastRunDate}`);
1306
- // Build query parameters
1307
- const statusFilter = config.ticket_status || ['new', 'open', 'pending', 'hold', 'solved'];
1308
- const query = `updated>${lastRunDate.split('T')[0]} status:${statusFilter.join(',status:')}`;
1300
+ // Build query parameters — use the status filter for the search query
1301
+ const statusList = Array.from(statusFilter);
1302
+ const query = `updated>${lastRunDate.split('T')[0]} status:${statusList.join(',status:')}`;
1309
1303
  let nextPage = `${baseUrl}/search.json?query=${encodeURIComponent(query)}&sort_by=updated_at&sort_order=asc`;
1310
1304
  let totalTickets = 0;
1305
+ let failedTickets = 0;
1311
1306
  while (nextPage) {
1312
1307
  const data = await fetchWithRetry(nextPage);
1313
1308
  const tickets = data.results || [];
1314
1309
  logger.info(`Processing batch of ${tickets.length} tickets`);
1315
1310
  for (const ticket of tickets) {
1316
- await processTicket(ticket);
1317
- totalTickets++;
1311
+ try {
1312
+ await processTicket(ticket);
1313
+ totalTickets++;
1314
+ }
1315
+ catch (error) {
1316
+ failedTickets++;
1317
+ logger.error(`Failed to process ticket #${ticket.id}, will retry next run: ${error.message}`);
1318
+ }
1318
1319
  }
1319
- nextPage = data.next_page;
1320
+ nextPage = data.next_page || null;
1320
1321
  if (nextPage) {
1321
1322
  logger.debug(`Fetching next page: ${nextPage}`);
1322
1323
  // Rate limiting: wait between requests
1323
1324
  await new Promise(res => setTimeout(res, 1000));
1324
1325
  }
1325
1326
  }
1326
- // Update the last run date in the database
1327
- await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension, syncStartDate);
1328
- logger.info(`Successfully processed ${totalTickets} tickets`);
1327
+ // Only advance the watermark when all tickets succeeded
1328
+ if (failedTickets === 0) {
1329
+ await database_1.DatabaseManager.updateLastRunDate(dbConnection, `zendesk_tickets_${config.zendesk_subdomain}`, logger, this.embeddingDimension, syncStartDate);
1330
+ logger.info(`Successfully processed ${totalTickets} tickets`);
1331
+ }
1332
+ else {
1333
+ logger.warn(`Run completed with ${failedTickets} ticket failure(s). ` +
1334
+ `Watermark NOT advanced — failed tickets will be retried next run. ` +
1335
+ `Successfully processed: ${totalTickets}.`);
1336
+ }
1329
1337
  }
1330
1338
  async fetchAndProcessZendeskArticles(config, dbConnection, logger) {
1331
1339
  const baseUrl = `https://${config.zendesk_subdomain}.zendesk.com/api/v2/help_center`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doc2vec",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "type": "commonjs",
5
5
  "description": "",
6
6
  "main": "dist/doc2vec.js",