|
|
@@ -1331,6 +1331,76 @@ export class SiteDatabase {
|
|
|
return removedUrls;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Remove rows whose stored content_type no longer matches what classifyFn
|
|
|
+ * returns for the URL today. Caused by the classifier's keyword list growing
|
|
|
+ * over time — a URL stored as 'contacts' in April may classify as 'team' now,
|
|
|
+ * leaving a stale orphan row in the old category.
|
|
|
+ *
|
|
|
+ * Custom URLs (which have a user-specified content_type via the custom_urls
|
|
|
+ * table) take precedence over the classifier output. They define the
|
|
|
+ * "expected" type for their URL.
|
|
|
+ *
|
|
|
+ * Returns the deleted (url, content_type) tuples so the caller can clean up
|
|
|
+ * matching Qdrant points.
|
|
|
+ */
|
|
|
+ cleanupStaleCategoryRows(
|
|
|
+ siteId: string,
|
|
|
+ classifyFn: (url: string) => ContentType
|
|
|
+ ): Array<{ url: string; content_type: ContentType }> {
|
|
|
+ const rows = this.db.prepare(`
|
|
|
+ SELECT id, url, content_type FROM site_content WHERE site_id = ?
|
|
|
+ `).all(siteId) as { id: string; url: string; content_type: ContentType }[];
|
|
|
+
|
|
|
+ const customRows = this.db.prepare(`
|
|
|
+ SELECT url, content_type FROM custom_urls WHERE site_id = ? AND enabled = 1
|
|
|
+ `).all(siteId) as { url: string; content_type: string }[];
|
|
|
+ const customMap = new Map<string, ContentType>();
|
|
|
+ for (const cu of customRows) {
|
|
|
+ const norm = normalizeUrl(cu.url);
|
|
|
+ if (norm) customMap.set(norm, cu.content_type as ContentType);
|
|
|
+ }
|
|
|
+
|
|
|
+ const idsToDelete: string[] = [];
|
|
|
+ const removed: Array<{ url: string; content_type: ContentType }> = [];
|
|
|
+ for (const row of rows) {
|
|
|
+ const expected = customMap.get(row.url) ?? classifyFn(row.url);
|
|
|
+ if (row.content_type !== expected) {
|
|
|
+ idsToDelete.push(row.id);
|
|
|
+ removed.push({ url: row.url, content_type: row.content_type });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (idsToDelete.length > 0) {
|
|
|
+ const placeholders = idsToDelete.map(() => '?').join(',');
|
|
|
+ this.db.prepare(`DELETE FROM site_content WHERE id IN (${placeholders})`).run(...idsToDelete);
|
|
|
+ const preview = removed.slice(0, 5).map(r => `${r.url}[${r.content_type}]`).join(', ');
|
|
|
+ logger.warn(`Removed ${idsToDelete.length} stale category rows for site ${siteId}: ${preview}${removed.length > 5 ? `, +${removed.length - 5} more` : ''}`);
|
|
|
+ }
|
|
|
+ return removed;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Return every URL the DB currently knows about for this site, grouped by
|
|
|
+ * content_type. Used by the Qdrant orphan-prune pass to figure out which
|
|
|
+ * points are still backed by a row.
|
|
|
+ */
|
|
|
+ getContentUrlsByCategory(siteId: string): Map<ContentType, Set<string>> {
|
|
|
+ const rows = this.db.prepare(`
|
|
|
+ SELECT url, content_type FROM site_content WHERE site_id = ?
|
|
|
+ `).all(siteId) as { url: string; content_type: ContentType }[];
|
|
|
+ const out = new Map<ContentType, Set<string>>();
|
|
|
+ for (const r of rows) {
|
|
|
+ let set = out.get(r.content_type);
|
|
|
+ if (!set) {
|
|
|
+ set = new Set();
|
|
|
+ out.set(r.content_type, set);
|
|
|
+ }
|
|
|
+ set.add(r.url);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Clean up duplicate content entries by content_hash
|
|
|
* When the same content appears in multiple categories, keep only the first occurrence
|