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 +3 -1
- package/dist/doc2vec.js +79 -71
- package/package.json +1 -1
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: '
|
|
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
|
-
|
|
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
|
-
//
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
|
1308
|
-
const query = `updated>${lastRunDate.split('T')[0]} 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
|
-
|
|
1317
|
-
|
|
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
|
-
//
|
|
1327
|
-
|
|
1328
|
-
|
|
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`;
|