Prechádzať zdrojové kódy

feat: implement Qdrant vector store integration foundation

- Add comprehensive environment configuration for Qdrant, OpenRouter, and MCP
- Install required dependencies: @qdrant/js-client-rest, openai, @modelcontextprotocol/sdk
- Extend database schema with qdrant_enabled field and new tables:
  * shop_embeddings: track embedding status per content
  * qdrant_deletion_queue: manage delayed collection deletions
  * qdrant_errors: comprehensive error tracking and resolution
  * mcp_logs: detailed MCP tool call logging and analytics
- Implement QdrantService with collection management and search capabilities
- Create EmbeddingService with OpenRouter integration using text-embedding-3-large
- Build QdrantCleanupService for automated maintenance and error handling
- Support shop lifecycle management with 24-hour deletion delays
- Add comprehensive database operations for all new Qdrant features

Foundation ready for MCP server implementation and scraping pipeline integration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh 8 mesiacov pred
rodič
commit
fe8b78464e

+ 13 - 0
.env.example

@@ -9,3 +9,16 @@ PORT=3000
 
 
 # Maximum number of concurrent scraping jobs
 # Maximum number of concurrent scraping jobs
 MAX_CONCURRENT_JOBS=3
 MAX_CONCURRENT_JOBS=3
+
+# Qdrant Configuration
+QDRANT_ENABLED=true
+QDRANT_API_URL=http://localhost:6333
+QDRANT_API_KEY=your-qdrant-api-key
+QDRANT_DELETION_DELAY_HOURS=24
+
+# OpenRouter Configuration (for embeddings)
+OPENROUTER_API_KEY=your-openrouter-api-key
+
+# MCP Server Configuration
+MCP_ENABLED=true
+MCP_PORT=3001

+ 667 - 1
package-lock.json

@@ -9,6 +9,8 @@
       "version": "1.0.0",
       "version": "1.0.0",
       "license": "ISC",
       "license": "ISC",
       "dependencies": {
       "dependencies": {
+        "@modelcontextprotocol/sdk": "^1.22.0",
+        "@qdrant/js-client-rest": "^1.16.0",
         "@types/better-sqlite3": "^7.6.13",
         "@types/better-sqlite3": "^7.6.13",
         "@types/node-cron": "^3.0.11",
         "@types/node-cron": "^3.0.11",
         "axios": "^1.6.0",
         "axios": "^1.6.0",
@@ -16,6 +18,7 @@
         "cheerio": "^1.0.0-rc.12",
         "cheerio": "^1.0.0-rc.12",
         "express": "^4.18.2",
         "express": "^4.18.2",
         "node-cron": "^4.2.1",
         "node-cron": "^4.2.1",
+        "openai": "^6.9.1",
         "turndown": "^7.1.2",
         "turndown": "^7.1.2",
         "uuid": "^9.0.1",
         "uuid": "^9.0.1",
         "xml2js": "^0.6.2"
         "xml2js": "^0.6.2"
@@ -72,6 +75,372 @@
       "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
       "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
       "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
       "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
     },
     },
+    "node_modules/@modelcontextprotocol/sdk": {
+      "version": "1.22.0",
+      "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz",
+      "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.17.1",
+        "ajv-formats": "^3.0.1",
+        "content-type": "^1.0.5",
+        "cors": "^2.8.5",
+        "cross-spawn": "^7.0.5",
+        "eventsource": "^3.0.2",
+        "eventsource-parser": "^3.0.0",
+        "express": "^5.0.1",
+        "express-rate-limit": "^7.5.0",
+        "pkce-challenge": "^5.0.0",
+        "raw-body": "^3.0.0",
+        "zod": "^3.23.8",
+        "zod-to-json-schema": "^3.24.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@cfworker/json-schema": "^4.1.1"
+      },
+      "peerDependenciesMeta": {
+        "@cfworker/json-schema": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+      "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.0",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.6.3",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.0",
+        "raw-body": "^3.0.0",
+        "type-is": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+      "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/express": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+      "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.0",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+      "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/qs": {
+      "version": "6.14.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+      "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
+      "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "3.1.2",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.7.0",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body/node_modules/iconv-lite": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+      "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/send": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+      "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.3.5",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "mime-types": "^3.0.1",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+      "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^1.0.5",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/@qdrant/js-client-rest": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.16.0.tgz",
+      "integrity": "sha512-Tppb9SzBdfdU2U6u/wizxzIjB/onOOgcCohUrlw0l+aH1ELC/oKEBCCCIW/CrE+G6c+GLr2aatOqZp/Vahiz+A==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@qdrant/openapi-typescript-fetch": "1.2.6",
+        "undici": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=18.17.0",
+        "pnpm": ">=8"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.7"
+      }
+    },
+    "node_modules/@qdrant/js-client-rest/node_modules/undici": {
+      "version": "6.22.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
+      "integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.17"
+      }
+    },
+    "node_modules/@qdrant/openapi-typescript-fetch": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/@qdrant/openapi-typescript-fetch/-/openapi-typescript-fetch-1.2.6.tgz",
+      "integrity": "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0",
+        "pnpm": ">=8"
+      }
+    },
     "node_modules/@tsconfig/node10": {
     "node_modules/@tsconfig/node10": {
       "version": "1.0.12",
       "version": "1.0.12",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
       "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -272,6 +641,39 @@
         "node": ">=0.4.0"
         "node": ">=0.4.0"
       }
       }
     },
     },
+    "node_modules/ajv": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
+      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "fast-uri": "^3.0.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-formats": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependencies": {
+        "ajv": "^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ajv": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/arg": {
     "node_modules/arg": {
       "version": "4.1.3",
       "version": "4.1.3",
       "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
       "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@@ -533,12 +935,39 @@
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
       "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
       "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
     },
+    "node_modules/cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/create-require": {
     "node_modules/create-require": {
       "version": "1.1.1",
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
       "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
       "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
       "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/css-select": {
     "node_modules/css-select": {
       "version": "5.2.2",
       "version": "5.2.2",
       "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
       "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -799,6 +1228,27 @@
         "node": ">= 0.6"
         "node": ">= 0.6"
       }
       }
     },
     },
+    "node_modules/eventsource": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
+      "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
+      "license": "MIT",
+      "dependencies": {
+        "eventsource-parser": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/eventsource-parser": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
+      "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/expand-template": {
     "node_modules/expand-template": {
       "version": "2.0.3",
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
       "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -811,6 +1261,7 @@
       "version": "4.21.2",
       "version": "4.21.2",
       "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
       "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
       "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
       "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "accepts": "~1.3.8",
         "accepts": "~1.3.8",
         "array-flatten": "1.1.1",
         "array-flatten": "1.1.1",
@@ -852,6 +1303,43 @@
         "url": "https://opencollective.com/express"
         "url": "https://opencollective.com/express"
       }
       }
     },
     },
+    "node_modules/express-rate-limit": {
+      "version": "7.5.1",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+      "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
+    "node_modules/fast-uri": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
+      "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
     "node_modules/file-uri-to-path": {
     "node_modules/file-uri-to-path": {
       "version": "1.0.0",
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
       "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1116,6 +1604,24 @@
         "node": ">= 0.10"
         "node": ">= 0.10"
       }
       }
     },
     },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "license": "ISC"
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "license": "MIT"
+    },
     "node_modules/make-error": {
     "node_modules/make-error": {
       "version": "1.3.6",
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
       "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
@@ -1256,6 +1762,15 @@
         "url": "https://github.com/fb55/nth-check?sponsor=1"
         "url": "https://github.com/fb55/nth-check?sponsor=1"
       }
       }
     },
     },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/object-inspect": {
     "node_modules/object-inspect": {
       "version": "1.13.4",
       "version": "1.13.4",
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
       "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1286,6 +1801,27 @@
         "wrappy": "1"
         "wrappy": "1"
       }
       }
     },
     },
+    "node_modules/openai": {
+      "version": "6.9.1",
+      "resolved": "https://registry.npmjs.org/openai/-/openai-6.9.1.tgz",
+      "integrity": "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "openai": "bin/cli"
+      },
+      "peerDependencies": {
+        "ws": "^8.18.0",
+        "zod": "^3.25 || ^4.0"
+      },
+      "peerDependenciesMeta": {
+        "ws": {
+          "optional": true
+        },
+        "zod": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/parse5": {
     "node_modules/parse5": {
       "version": "7.3.0",
       "version": "7.3.0",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
@@ -1339,11 +1875,29 @@
         "node": ">= 0.8"
         "node": ">= 0.8"
       }
       }
     },
     },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/path-to-regexp": {
     "node_modules/path-to-regexp": {
       "version": "0.1.12",
       "version": "0.1.12",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
       "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
       "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
     },
     },
+    "node_modules/pkce-challenge": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz",
+      "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
     "node_modules/prebuild-install": {
     "node_modules/prebuild-install": {
       "version": "7.1.3",
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
       "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -1469,6 +2023,64 @@
         "node": ">= 6"
         "node": ">= 6"
       }
       }
     },
     },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/router/node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/router/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/router/node_modules/path-to-regexp": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+      "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/safe-buffer": {
     "node_modules/safe-buffer": {
       "version": "5.2.1",
       "version": "5.2.1",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1564,6 +2176,27 @@
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
       "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
       "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
     },
     },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/side-channel": {
     "node_modules/side-channel": {
       "version": "1.1.0",
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -1811,7 +2444,6 @@
       "version": "5.9.3",
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
-      "dev": true,
       "peer": true,
       "peer": true,
       "bin": {
       "bin": {
         "tsc": "bin/tsc",
         "tsc": "bin/tsc",
@@ -1900,6 +2532,21 @@
         "node": ">=18"
         "node": ">=18"
       }
       }
     },
     },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/wrappy": {
     "node_modules/wrappy": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -1933,6 +2580,25 @@
       "engines": {
       "engines": {
         "node": ">=6"
         "node": ">=6"
       }
       }
+    },
+    "node_modules/zod": {
+      "version": "3.25.76",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+      "license": "MIT",
+      "peer": true,
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
+    },
+    "node_modules/zod-to-json-schema": {
+      "version": "3.25.0",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
+      "integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
+      "license": "ISC",
+      "peerDependencies": {
+        "zod": "^3.25 || ^4"
+      }
     }
     }
   }
   }
 }
 }

+ 3 - 0
package.json

@@ -21,6 +21,8 @@
   "author": "",
   "author": "",
   "license": "ISC",
   "license": "ISC",
   "dependencies": {
   "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.22.0",
+    "@qdrant/js-client-rest": "^1.16.0",
     "@types/better-sqlite3": "^7.6.13",
     "@types/better-sqlite3": "^7.6.13",
     "@types/node-cron": "^3.0.11",
     "@types/node-cron": "^3.0.11",
     "axios": "^1.6.0",
     "axios": "^1.6.0",
@@ -28,6 +30,7 @@
     "cheerio": "^1.0.0-rc.12",
     "cheerio": "^1.0.0-rc.12",
     "express": "^4.18.2",
     "express": "^4.18.2",
     "node-cron": "^4.2.1",
     "node-cron": "^4.2.1",
+    "openai": "^6.9.1",
     "turndown": "^7.1.2",
     "turndown": "^7.1.2",
     "uuid": "^9.0.1",
     "uuid": "^9.0.1",
     "xml2js": "^0.6.2"
     "xml2js": "^0.6.2"

+ 48 - 2
src/config/index.ts

@@ -5,6 +5,19 @@ export interface AppConfig {
   port: number;
   port: number;
   apiKey: string;
   apiKey: string;
   maxConcurrentJobs: number;
   maxConcurrentJobs: number;
+  qdrant: {
+    enabled: boolean;
+    apiUrl: string;
+    apiKey: string;
+    deletionDelayHours: number;
+  };
+  openrouter: {
+    apiKey: string;
+  };
+  mcp: {
+    enabled: boolean;
+    port: number;
+  };
 }
 }
 
 
 export function loadConfig(): AppConfig {
 export function loadConfig(): AppConfig {
@@ -19,12 +32,45 @@ export function loadConfig(): AppConfig {
   const port = parseInt(process.env.PORT || '3000', 10);
   const port = parseInt(process.env.PORT || '3000', 10);
   const maxConcurrentJobs = parseInt(process.env.MAX_CONCURRENT_JOBS || '3', 10);
   const maxConcurrentJobs = parseInt(process.env.MAX_CONCURRENT_JOBS || '3', 10);
 
 
-  logger.info('Configuration loaded', { host, port, maxConcurrentJobs });
+  // Qdrant configuration
+  const qdrantEnabled = process.env.QDRANT_ENABLED === 'true';
+  const qdrantApiUrl = process.env.QDRANT_API_URL || 'http://localhost:6333';
+  const qdrantApiKey = process.env.QDRANT_API_KEY || '';
+  const qdrantDeletionDelayHours = parseInt(process.env.QDRANT_DELETION_DELAY_HOURS || '24', 10);
+
+  // OpenRouter configuration
+  const openrouterApiKey = process.env.OPENROUTER_API_KEY || '';
+
+  // MCP configuration
+  const mcpEnabled = process.env.MCP_ENABLED === 'true';
+  const mcpPort = parseInt(process.env.MCP_PORT || '3001', 10);
+
+  logger.info('Configuration loaded', {
+    host,
+    port,
+    maxConcurrentJobs,
+    qdrantEnabled,
+    mcpEnabled,
+    mcpPort
+  });
 
 
   return {
   return {
     host,
     host,
     port,
     port,
     apiKey,
     apiKey,
-    maxConcurrentJobs
+    maxConcurrentJobs,
+    qdrant: {
+      enabled: qdrantEnabled,
+      apiUrl: qdrantApiUrl,
+      apiKey: qdrantApiKey,
+      deletionDelayHours: qdrantDeletionDelayHours,
+    },
+    openrouter: {
+      apiKey: openrouterApiKey,
+    },
+    mcp: {
+      enabled: mcpEnabled,
+      port: mcpPort,
+    },
   };
   };
 }
 }

+ 312 - 8
src/database/Database.ts

@@ -9,6 +9,7 @@ export interface Shop {
   url: string;
   url: string;
   sitemap_url: string;
   sitemap_url: string;
   webshop_type: string;
   webshop_type: string;
+  qdrant_enabled: boolean;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
 }
 }
@@ -93,6 +94,49 @@ export interface CustomUrl {
   created_at: string;
   created_at: string;
 }
 }
 
 
+export interface ShopEmbedding {
+  id: string;
+  shop_content_id: string;
+  collection_name: string;
+  point_id: string;
+  embedding_status: 'pending' | 'completed' | 'failed';
+  created_at: string;
+  updated_at: string;
+}
+
+export interface QdrantDeletionQueue {
+  id: string;
+  shop_id: string;
+  collection_names: string; // JSON array of collection names
+  scheduled_deletion_at: string;
+  reason: 'shop_deleted' | 'qdrant_disabled';
+  status: 'pending' | 'processing' | 'completed' | 'failed';
+  created_at: string;
+}
+
+export interface QdrantError {
+  id: string;
+  shop_id: string;
+  operation_type: 'embedding' | 'collection_create' | 'collection_delete' | 'point_insert' | 'search';
+  error_message: string;
+  error_details: string | null;
+  created_at: string;
+  resolved_at: string | null;
+}
+
+export interface McpLog {
+  id: string;
+  shop_id: string; // This maps to custom_id in shops table
+  tool_name: string;
+  query: string;
+  response_summary: string | null;
+  execution_time_ms: number;
+  results_count: number;
+  created_at: string;
+  ip_address: string | null;
+  user_agent: string | null;
+}
+
 export class ShopDatabase {
 export class ShopDatabase {
   private db: Database.Database;
   private db: Database.Database;
 
 
@@ -118,6 +162,7 @@ export class ShopDatabase {
         url TEXT NOT NULL UNIQUE,
         url TEXT NOT NULL UNIQUE,
         sitemap_url TEXT NOT NULL,
         sitemap_url TEXT NOT NULL,
         webshop_type TEXT NOT NULL,
         webshop_type TEXT NOT NULL,
+        qdrant_enabled INTEGER DEFAULT 0,
         created_at TEXT NOT NULL,
         created_at TEXT NOT NULL,
         updated_at TEXT NOT NULL
         updated_at TEXT NOT NULL
       )
       )
@@ -134,6 +179,17 @@ export class ShopDatabase {
       }
       }
     }
     }
 
 
+    // Add qdrant_enabled column if it doesn't exist (for existing databases)
+    try {
+      this.db.exec(`ALTER TABLE shops ADD COLUMN qdrant_enabled INTEGER DEFAULT 0`);
+      logger.info('Added qdrant_enabled column to shops table');
+    } catch (error: any) {
+      // Column likely already exists, ignore error
+      if (!error.message.includes('duplicate column name')) {
+        logger.error('Error adding qdrant_enabled column:', error);
+      }
+    }
+
     // Shop analytics table
     // Shop analytics table
     this.db.exec(`
     this.db.exec(`
       CREATE TABLE IF NOT EXISTS shop_analytics (
       CREATE TABLE IF NOT EXISTS shop_analytics (
@@ -257,21 +313,101 @@ export class ShopDatabase {
       ON custom_urls(shop_id, url)
       ON custom_urls(shop_id, url)
     `);
     `);
 
 
+    // Shop embeddings table (tracks Qdrant embedding status)
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS shop_embeddings (
+        id TEXT PRIMARY KEY,
+        shop_content_id TEXT NOT NULL,
+        collection_name TEXT NOT NULL,
+        point_id TEXT NOT NULL,
+        embedding_status TEXT NOT NULL DEFAULT 'pending',
+        created_at TEXT NOT NULL,
+        updated_at TEXT NOT NULL,
+        FOREIGN KEY (shop_content_id) REFERENCES shop_content(id) ON DELETE CASCADE
+      )
+    `);
+
+    // Qdrant deletion queue table (tracks scheduled collection deletions)
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS qdrant_deletion_queue (
+        id TEXT PRIMARY KEY,
+        shop_id TEXT NOT NULL,
+        collection_names TEXT NOT NULL,
+        scheduled_deletion_at TEXT NOT NULL,
+        reason TEXT NOT NULL,
+        status TEXT NOT NULL DEFAULT 'pending',
+        created_at TEXT NOT NULL,
+        FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
+      )
+    `);
+
+    // Qdrant errors table (tracks Qdrant operation errors)
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS qdrant_errors (
+        id TEXT PRIMARY KEY,
+        shop_id TEXT NOT NULL,
+        operation_type TEXT NOT NULL,
+        error_message TEXT NOT NULL,
+        error_details TEXT,
+        created_at TEXT NOT NULL,
+        resolved_at TEXT,
+        FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
+      )
+    `);
+
+    // MCP logs table (tracks MCP tool calls)
+    this.db.exec(`
+      CREATE TABLE IF NOT EXISTS mcp_logs (
+        id TEXT PRIMARY KEY,
+        shop_id TEXT NOT NULL,
+        tool_name TEXT NOT NULL,
+        query TEXT NOT NULL,
+        response_summary TEXT,
+        execution_time_ms INTEGER NOT NULL,
+        results_count INTEGER NOT NULL DEFAULT 0,
+        created_at TEXT NOT NULL,
+        ip_address TEXT,
+        user_agent TEXT
+      )
+    `);
+
+    // Create indexes for better performance
+    this.db.exec(`
+      CREATE INDEX IF NOT EXISTS idx_shop_embeddings_content
+      ON shop_embeddings(shop_content_id)
+    `);
+
+    this.db.exec(`
+      CREATE INDEX IF NOT EXISTS idx_qdrant_deletion_queue_scheduled
+      ON qdrant_deletion_queue(scheduled_deletion_at, status)
+    `);
+
+    this.db.exec(`
+      CREATE INDEX IF NOT EXISTS idx_qdrant_errors_shop_unresolved
+      ON qdrant_errors(shop_id, resolved_at)
+    `);
+
+    this.db.exec(`
+      CREATE INDEX IF NOT EXISTS idx_mcp_logs_shop_created
+      ON mcp_logs(shop_id, created_at)
+    `);
+
     logger.info('Database tables initialized');
     logger.info('Database tables initialized');
   }
   }
 
 
   // Shop operations
   // Shop operations
-  createShop(url: string, sitemapUrl: string, webshopType: string, customId?: string): Shop {
+  createShop(url: string, sitemapUrl: string, webshopType: string, customId?: string, qdrantEnabled?: boolean): Shop {
     const id = uuidv4();
     const id = uuidv4();
     const now = new Date().toISOString();
     const now = new Date().toISOString();
     const custom_id = customId || null;
     const custom_id = customId || null;
+    const qdrant_enabled = qdrantEnabled !== undefined ? qdrantEnabled : true; // Default true for new shops
 
 
     const stmt = this.db.prepare(`
     const stmt = this.db.prepare(`
-      INSERT INTO shops (id, custom_id, url, sitemap_url, webshop_type, created_at, updated_at)
-      VALUES (?, ?, ?, ?, ?, ?, ?)
+      INSERT INTO shops (id, custom_id, url, sitemap_url, webshop_type, qdrant_enabled, created_at, updated_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
     `);
     `);
 
 
-    stmt.run(id, custom_id, url, sitemapUrl, webshopType, now, now);
+    stmt.run(id, custom_id, url, sitemapUrl, webshopType, qdrant_enabled ? 1 : 0, now, now);
 
 
     // Initialize analytics for this shop
     // Initialize analytics for this shop
     this.db.prepare(`
     this.db.prepare(`
@@ -287,6 +423,7 @@ export class ShopDatabase {
       url,
       url,
       sitemap_url: sitemapUrl,
       sitemap_url: sitemapUrl,
       webshop_type: webshopType,
       webshop_type: webshopType,
+      qdrant_enabled,
       created_at: now,
       created_at: now,
       updated_at: now
       updated_at: now
     };
     };
@@ -294,17 +431,20 @@ export class ShopDatabase {
 
 
   getShopByUrl(url: string): Shop | null {
   getShopByUrl(url: string): Shop | null {
     const stmt = this.db.prepare('SELECT * FROM shops WHERE url = ?');
     const stmt = this.db.prepare('SELECT * FROM shops WHERE url = ?');
-    return stmt.get(url) as Shop | null;
+    const result = stmt.get(url) as any;
+    return result ? { ...result, qdrant_enabled: Boolean(result.qdrant_enabled) } : null;
   }
   }
 
 
   getShopById(id: string): Shop | null {
   getShopById(id: string): Shop | null {
     const stmt = this.db.prepare('SELECT * FROM shops WHERE id = ?');
     const stmt = this.db.prepare('SELECT * FROM shops WHERE id = ?');
-    return stmt.get(id) as Shop | null;
+    const result = stmt.get(id) as any;
+    return result ? { ...result, qdrant_enabled: Boolean(result.qdrant_enabled) } : null;
   }
   }
 
 
   getShopByCustomId(customId: string): Shop | null {
   getShopByCustomId(customId: string): Shop | null {
     const stmt = this.db.prepare('SELECT * FROM shops WHERE custom_id = ?');
     const stmt = this.db.prepare('SELECT * FROM shops WHERE custom_id = ?');
-    return stmt.get(customId) as Shop | null;
+    const result = stmt.get(customId) as any;
+    return result ? { ...result, qdrant_enabled: Boolean(result.qdrant_enabled) } : null;
   }
   }
 
 
   // Unified method to get shop by either internal ID or custom ID
   // Unified method to get shop by either internal ID or custom ID
@@ -319,7 +459,8 @@ export class ShopDatabase {
 
 
   getAllShops(): Shop[] {
   getAllShops(): Shop[] {
     const stmt = this.db.prepare('SELECT * FROM shops ORDER BY created_at DESC');
     const stmt = this.db.prepare('SELECT * FROM shops ORDER BY created_at DESC');
-    return stmt.all() as Shop[];
+    const results = stmt.all() as any[];
+    return results.map(result => ({ ...result, qdrant_enabled: Boolean(result.qdrant_enabled) }));
   }
   }
 
 
   updateShop(id: string, updates: Partial<Shop>): void {
   updateShop(id: string, updates: Partial<Shop>): void {
@@ -919,4 +1060,167 @@ export class ShopDatabase {
     const result = stmt.get(shop.id, url) as { count: number };
     const result = stmt.get(shop.id, url) as { count: number };
     return result.count > 0;
     return result.count > 0;
   }
   }
+
+  // Qdrant deletion queue operations
+  createQdrantDeletionItem(item: Omit<QdrantDeletionQueue, 'id'> & { id: string }): void {
+    this.db.prepare(`
+      INSERT INTO qdrant_deletion_queue (id, shop_id, collection_names, scheduled_deletion_at, reason, status, created_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+    `).run(item.id, item.shop_id, item.collection_names, item.scheduled_deletion_at, item.reason, item.status, item.created_at);
+  }
+
+  getQdrantDeletionItems(cutoffDate: string): QdrantDeletionQueue[] {
+    const stmt = this.db.prepare(`
+      SELECT * FROM qdrant_deletion_queue
+      WHERE scheduled_deletion_at <= ? AND status = 'pending'
+      ORDER BY scheduled_deletion_at ASC
+    `);
+    return stmt.all(cutoffDate) as QdrantDeletionQueue[];
+  }
+
+  updateQdrantDeletionItem(id: string, updates: Partial<QdrantDeletionQueue>): void {
+    const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
+    const values = [...Object.values(updates), id];
+
+    this.db.prepare(`
+      UPDATE qdrant_deletion_queue
+      SET ${fields}
+      WHERE id = ?
+    `).run(...values);
+  }
+
+  cancelQdrantDeletion(shopId: string): number {
+    const stmt = this.db.prepare(`
+      DELETE FROM qdrant_deletion_queue
+      WHERE shop_id = ? AND status = 'pending'
+    `);
+    const result = stmt.run(shopId);
+    return result.changes;
+  }
+
+  getPendingDeletionCount(): number {
+    const stmt = this.db.prepare(`
+      SELECT COUNT(*) as count FROM qdrant_deletion_queue
+      WHERE status = 'pending'
+    `);
+    const result = stmt.get() as { count: number };
+    return result.count;
+  }
+
+  getUpcomingDeletions(limit: number): QdrantDeletionQueue[] {
+    const stmt = this.db.prepare(`
+      SELECT * FROM qdrant_deletion_queue
+      WHERE status = 'pending'
+      ORDER BY scheduled_deletion_at ASC
+      LIMIT ?
+    `);
+    return stmt.all(limit) as QdrantDeletionQueue[];
+  }
+
+  // Qdrant error operations
+  createQdrantError(error: Omit<QdrantError, 'id'> & { id: string }): void {
+    this.db.prepare(`
+      INSERT INTO qdrant_errors (id, shop_id, operation_type, error_message, error_details, created_at, resolved_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+    `).run(error.id, error.shop_id, error.operation_type, error.error_message, error.error_details, error.created_at, error.resolved_at);
+  }
+
+  updateQdrantError(id: string, updates: Partial<QdrantError>): void {
+    const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
+    const values = [...Object.values(updates), id];
+
+    this.db.prepare(`
+      UPDATE qdrant_errors
+      SET ${fields}
+      WHERE id = ?
+    `).run(...values);
+  }
+
+  getUnresolvedQdrantErrors(shopId: string): QdrantError[] {
+    const stmt = this.db.prepare(`
+      SELECT * FROM qdrant_errors
+      WHERE shop_id = ? AND resolved_at IS NULL
+      ORDER BY created_at DESC
+    `);
+    return stmt.all(shopId) as QdrantError[];
+  }
+
+  getUnresolvedErrorCount(): number {
+    const stmt = this.db.prepare(`
+      SELECT COUNT(*) as count FROM qdrant_errors
+      WHERE resolved_at IS NULL
+    `);
+    const result = stmt.get() as { count: number };
+    return result.count;
+  }
+
+  deleteOldQdrantErrors(cutoffDate: string): number {
+    const stmt = this.db.prepare(`
+      DELETE FROM qdrant_errors
+      WHERE resolved_at IS NOT NULL AND resolved_at < ?
+    `);
+    const result = stmt.run(cutoffDate);
+    return result.changes;
+  }
+
+  // MCP logs operations
+  createMcpLog(log: Omit<McpLog, 'id'> & { id: string }): void {
+    this.db.prepare(`
+      INSERT INTO mcp_logs (id, shop_id, tool_name, query, response_summary, execution_time_ms, results_count, created_at, ip_address, user_agent)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    `).run(log.id, log.shop_id, log.tool_name, log.query, log.response_summary, log.execution_time_ms, log.results_count, log.created_at, log.ip_address, log.user_agent);
+  }
+
+  getMcpLogs(shopId?: string, limit: number = 100): McpLog[] {
+    let query = 'SELECT * FROM mcp_logs';
+    const params: any[] = [];
+
+    if (shopId) {
+      query += ' WHERE shop_id = ?';
+      params.push(shopId);
+    }
+
+    query += ' ORDER BY created_at DESC LIMIT ?';
+    params.push(limit);
+
+    const stmt = this.db.prepare(query);
+    return stmt.all(...params) as McpLog[];
+  }
+
+  deleteOldMcpLogs(cutoffDate: string): number {
+    const stmt = this.db.prepare(`
+      DELETE FROM mcp_logs
+      WHERE created_at < ?
+    `);
+    const result = stmt.run(cutoffDate);
+    return result.changes;
+  }
+
+  // Shop embedding operations
+  createShopEmbedding(embedding: Omit<ShopEmbedding, 'id'> & { id: string }): void {
+    this.db.prepare(`
+      INSERT INTO shop_embeddings (id, shop_content_id, collection_name, point_id, embedding_status, created_at, updated_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+    `).run(embedding.id, embedding.shop_content_id, embedding.collection_name, embedding.point_id, embedding.embedding_status, embedding.created_at, embedding.updated_at);
+  }
+
+  updateShopEmbedding(id: string, updates: Partial<ShopEmbedding>): void {
+    const fields = Object.keys(updates).map(key => `${key} = ?`).join(', ');
+    const values = [...Object.values(updates), id];
+
+    this.db.prepare(`
+      UPDATE shop_embeddings
+      SET ${fields}
+      WHERE id = ?
+    `).run(...values);
+  }
+
+  getShopEmbeddings(shopContentId: string): ShopEmbedding[] {
+    const stmt = this.db.prepare(`
+      SELECT * FROM shop_embeddings
+      WHERE shop_content_id = ?
+      ORDER BY created_at DESC
+    `);
+    return stmt.all(shopContentId) as ShopEmbedding[];
+  }
 }
 }

+ 313 - 0
src/services/EmbeddingService.ts

@@ -0,0 +1,313 @@
+import OpenAI from 'openai';
+import { logger } from '../utils/logger';
+import type { AppConfig } from '../config';
+
+export interface EmbeddingRequest {
+  text: string;
+  contentId: string;
+  shopId: string;
+  contentType: 'shipping' | 'contacts' | 'terms' | 'faq';
+}
+
+export interface EmbeddingResponse {
+  contentId: string;
+  embedding: number[];
+  tokenCount: number;
+  success: boolean;
+  error?: string;
+}
+
+export class EmbeddingService {
+  private client: OpenAI;
+  private enabled: boolean;
+  private model = 'openai/text-embedding-3-large';
+
+  constructor(private config: AppConfig) {
+    this.enabled = config.qdrant.enabled && !!config.openrouter.apiKey;
+
+    if (this.enabled) {
+      this.client = new OpenAI({
+        apiKey: config.openrouter.apiKey,
+        baseURL: 'https://openrouter.ai/api/v1',
+      });
+      logger.info('EmbeddingService initialized with OpenRouter');
+    } else {
+      logger.info('EmbeddingService disabled (missing API key or Qdrant disabled)');
+    }
+  }
+
+  /**
+   * Check if embedding service is available
+   */
+  isEnabled(): boolean {
+    return this.enabled;
+  }
+
+  /**
+   * Preprocess content for embedding
+   * Combine title and content with proper formatting
+   */
+  preprocessContent(title: string | null, content: string): string {
+    // Remove excessive whitespace and normalize
+    const cleanContent = content
+      .replace(/\s+/g, ' ')
+      .replace(/\n\s+/g, '\n')
+      .trim();
+
+    // Combine title and content
+    if (title && title.trim()) {
+      const cleanTitle = title.trim();
+      return `Title: ${cleanTitle}\n\nContent: ${cleanContent}`;
+    }
+
+    return cleanContent;
+  }
+
+  /**
+   * Generate embedding for a single piece of content
+   */
+  async generateEmbedding(request: EmbeddingRequest): Promise<EmbeddingResponse> {
+    if (!this.enabled) {
+      return {
+        contentId: request.contentId,
+        embedding: [],
+        tokenCount: 0,
+        success: false,
+        error: 'EmbeddingService is not enabled',
+      };
+    }
+
+    try {
+      logger.debug(`Generating embedding for content ${request.contentId}`);
+
+      const response = await this.client.embeddings.create({
+        model: this.model,
+        input: request.text,
+        encoding_format: 'float',
+      });
+
+      if (!response.data || response.data.length === 0) {
+        throw new Error('No embedding data received from OpenRouter');
+      }
+
+      const embedding = response.data[0].embedding;
+      const tokenCount = response.usage?.total_tokens || 0;
+
+      logger.debug(`Generated embedding for content ${request.contentId}: ${embedding.length} dimensions, ${tokenCount} tokens`);
+
+      return {
+        contentId: request.contentId,
+        embedding,
+        tokenCount,
+        success: true,
+      };
+
+    } catch (error: any) {
+      logger.error(`Failed to generate embedding for content ${request.contentId}:`, error);
+
+      return {
+        contentId: request.contentId,
+        embedding: [],
+        tokenCount: 0,
+        success: false,
+        error: error.message || 'Unknown embedding error',
+      };
+    }
+  }
+
+  /**
+   * Generate embeddings for multiple pieces of content in batch
+   */
+  async generateEmbeddingsBatch(requests: EmbeddingRequest[]): Promise<EmbeddingResponse[]> {
+    if (!this.enabled) {
+      return requests.map(req => ({
+        contentId: req.contentId,
+        embedding: [],
+        tokenCount: 0,
+        success: false,
+        error: 'EmbeddingService is not enabled',
+      }));
+    }
+
+    if (requests.length === 0) {
+      return [];
+    }
+
+    // For now, process sequentially to avoid rate limits
+    // In the future, we could implement proper batching with rate limiting
+    const results: EmbeddingResponse[] = [];
+
+    for (const request of requests) {
+      try {
+        const result = await this.generateEmbedding(request);
+        results.push(result);
+
+        // Small delay to avoid rate limiting
+        if (requests.length > 1) {
+          await new Promise(resolve => setTimeout(resolve, 100));
+        }
+      } catch (error) {
+        logger.error(`Failed to process embedding request for ${request.contentId}:`, error);
+        results.push({
+          contentId: request.contentId,
+          embedding: [],
+          tokenCount: 0,
+          success: false,
+          error: 'Failed to process embedding request',
+        });
+      }
+    }
+
+    const successCount = results.filter(r => r.success).length;
+    const totalTokens = results.reduce((sum, r) => sum + r.tokenCount, 0);
+
+    logger.info(`Batch embedding completed: ${successCount}/${requests.length} successful, ${totalTokens} total tokens`);
+
+    return results;
+  }
+
+  /**
+   * Generate embedding for search query
+   */
+  async generateQueryEmbedding(query: string): Promise<{ embedding: number[]; success: boolean; error?: string }> {
+    if (!this.enabled) {
+      return {
+        embedding: [],
+        success: false,
+        error: 'EmbeddingService is not enabled',
+      };
+    }
+
+    try {
+      logger.debug(`Generating query embedding for: "${query.substring(0, 50)}..."`);
+
+      const response = await this.client.embeddings.create({
+        model: this.model,
+        input: query.trim(),
+        encoding_format: 'float',
+      });
+
+      if (!response.data || response.data.length === 0) {
+        throw new Error('No embedding data received from OpenRouter');
+      }
+
+      const embedding = response.data[0].embedding;
+
+      logger.debug(`Generated query embedding: ${embedding.length} dimensions`);
+
+      return {
+        embedding,
+        success: true,
+      };
+
+    } catch (error: any) {
+      logger.error(`Failed to generate query embedding:`, error);
+
+      return {
+        embedding: [],
+        success: false,
+        error: error.message || 'Unknown embedding error',
+      };
+    }
+  }
+
+  /**
+   * Retry failed embeddings with exponential backoff
+   */
+  async retryEmbedding(
+    request: EmbeddingRequest,
+    maxRetries: number = 3,
+    baseDelay: number = 1000
+  ): Promise<EmbeddingResponse> {
+    let lastError: string | undefined;
+
+    for (let attempt = 0; attempt <= maxRetries; attempt++) {
+      if (attempt > 0) {
+        const delay = baseDelay * Math.pow(2, attempt - 1);
+        logger.debug(`Retrying embedding for ${request.contentId} (attempt ${attempt}/${maxRetries}) after ${delay}ms`);
+        await new Promise(resolve => setTimeout(resolve, delay));
+      }
+
+      const result = await this.generateEmbedding(request);
+
+      if (result.success) {
+        if (attempt > 0) {
+          logger.info(`Embedding succeeded on retry ${attempt} for content ${request.contentId}`);
+        }
+        return result;
+      }
+
+      lastError = result.error;
+
+      // Don't retry on certain types of errors
+      if (result.error?.includes('rate limit') || result.error?.includes('quota')) {
+        logger.warn(`Not retrying embedding for ${request.contentId} due to rate limit/quota error`);
+        break;
+      }
+    }
+
+    logger.error(`All embedding retries failed for content ${request.contentId}`);
+    return {
+      contentId: request.contentId,
+      embedding: [],
+      tokenCount: 0,
+      success: false,
+      error: lastError || 'All retries failed',
+    };
+  }
+
+  /**
+   * Estimate token count for text (approximate)
+   */
+  estimateTokenCount(text: string): number {
+    // Rough estimation: ~4 characters per token for English text
+    // This is approximate and may not be perfectly accurate for all models
+    return Math.ceil(text.length / 4);
+  }
+
+  /**
+   * Check if text is too long for embedding
+   */
+  isTextTooLong(text: string): boolean {
+    // text-embedding-3-large has a context length of 8191 tokens
+    const estimatedTokens = this.estimateTokenCount(text);
+    return estimatedTokens > 8000; // Leave some buffer
+  }
+
+  /**
+   * Truncate text if it's too long for embedding
+   */
+  truncateText(text: string, maxTokens: number = 8000): string {
+    if (!this.isTextTooLong(text)) {
+      return text;
+    }
+
+    // Rough truncation based on character count
+    // This is approximate and could be improved with a proper tokenizer
+    const maxChars = maxTokens * 4;
+    const truncated = text.substring(0, maxChars);
+
+    // Try to truncate at a word boundary
+    const lastSpace = truncated.lastIndexOf(' ');
+    if (lastSpace > maxChars * 0.8) {
+      return truncated.substring(0, lastSpace) + '...';
+    }
+
+    return truncated + '...';
+  }
+
+  /**
+   * Get service statistics
+   */
+  getStats(): {
+    enabled: boolean;
+    model: string;
+    maxTokens: number;
+  } {
+    return {
+      enabled: this.enabled,
+      model: this.model,
+      maxTokens: 8191,
+    };
+  }
+}

+ 276 - 0
src/services/QdrantCleanupService.ts

@@ -0,0 +1,276 @@
+import { v4 as uuidv4 } from 'uuid';
+import { logger } from '../utils/logger';
+import type { AppConfig } from '../config';
+import { ShopDatabase, QdrantDeletionQueue, QdrantError } from '../database/Database';
+import { QdrantService } from './QdrantService';
+
+export interface CleanupStats {
+  collectionsDeleted: number;
+  errorsResolved: number;
+  deletionQueueProcessed: number;
+  mcpLogsArchived: number;
+}
+
+export class QdrantCleanupService {
+  private qdrantService: QdrantService;
+  private deletionDelayHours: number;
+
+  constructor(
+    private config: AppConfig,
+    private database: ShopDatabase
+  ) {
+    this.qdrantService = new QdrantService(config);
+    this.deletionDelayHours = config.qdrant.deletionDelayHours;
+  }
+
+  /**
+   * Main cleanup method - called by scheduler
+   */
+  async performCleanup(): Promise<CleanupStats> {
+    const stats: CleanupStats = {
+      collectionsDeleted: 0,
+      errorsResolved: 0,
+      deletionQueueProcessed: 0,
+      mcpLogsArchived: 0,
+    };
+
+    if (!this.config.qdrant.enabled) {
+      logger.debug('Qdrant cleanup skipped - service disabled');
+      return stats;
+    }
+
+    try {
+      logger.info('Starting Qdrant cleanup process');
+
+      // Process deletion queue
+      const deletionStats = await this.processDeletionQueue();
+      stats.collectionsDeleted = deletionStats.collectionsDeleted;
+      stats.deletionQueueProcessed = deletionStats.queueItemsProcessed;
+
+      // Clean up old resolved errors (30+ days old)
+      stats.errorsResolved = await this.cleanupResolvedErrors();
+
+      // Archive old MCP logs (90+ days old)
+      stats.mcpLogsArchived = await this.archiveMcpLogs();
+
+      logger.info('Qdrant cleanup completed', stats);
+      return stats;
+
+    } catch (error) {
+      logger.error('Error during Qdrant cleanup:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * Process items in the deletion queue that are past their delay period
+   */
+  private async processDeletionQueue(): Promise<{ collectionsDeleted: number; queueItemsProcessed: number }> {
+    const now = new Date();
+    const deletionCutoff = new Date(now.getTime() - (this.deletionDelayHours * 60 * 60 * 1000));
+    const cutoffIso = deletionCutoff.toISOString();
+
+    // Get items ready for deletion
+    const pendingItems = this.database.getQdrantDeletionItems(cutoffIso);
+
+    if (pendingItems.length === 0) {
+      logger.debug('No items in deletion queue ready for processing');
+      return { collectionsDeleted: 0, queueItemsProcessed: 0 };
+    }
+
+    logger.info(`Processing ${pendingItems.length} items from deletion queue`);
+
+    let collectionsDeleted = 0;
+    let queueItemsProcessed = 0;
+
+    for (const item of pendingItems) {
+      try {
+        // Mark as processing
+        this.database.updateQdrantDeletionItem(item.id, { status: 'processing' });
+
+        // Parse collection names
+        const collectionNames = JSON.parse(item.collection_names) as string[];
+
+        // Delete each collection
+        for (const collectionName of collectionNames) {
+          try {
+            await this.qdrantService.deleteCollection(collectionName);
+            collectionsDeleted++;
+          } catch (error) {
+            logger.warn(`Failed to delete collection ${collectionName}:`, error);
+          }
+        }
+
+        // Mark as completed
+        this.database.updateQdrantDeletionItem(item.id, { status: 'completed' });
+        queueItemsProcessed++;
+
+        logger.info(`Processed deletion queue item ${item.id}: deleted ${collectionNames.length} collections`);
+
+      } catch (error) {
+        logger.error(`Failed to process deletion queue item ${item.id}:`, error);
+        this.database.updateQdrantDeletionItem(item.id, { status: 'failed' });
+      }
+    }
+
+    return { collectionsDeleted, queueItemsProcessed };
+  }
+
+  /**
+   * Schedule collections for deletion after delay period
+   */
+  async scheduleCollectionsDeletion(
+    shopId: string,
+    collectionNames: string[],
+    reason: 'shop_deleted' | 'qdrant_disabled'
+  ): Promise<void> {
+    if (collectionNames.length === 0) {
+      logger.debug(`No collections to schedule for deletion for shop ${shopId}`);
+      return;
+    }
+
+    const now = new Date();
+    const scheduledDeletion = new Date(now.getTime() + (this.deletionDelayHours * 60 * 60 * 1000));
+
+    // Handle immediate deletion for shop_deleted
+    if (reason === 'shop_deleted') {
+      logger.info(`Immediately deleting ${collectionNames.length} collections for deleted shop ${shopId}`);
+
+      for (const collectionName of collectionNames) {
+        try {
+          await this.qdrantService.deleteCollection(collectionName);
+        } catch (error) {
+          logger.warn(`Failed to immediately delete collection ${collectionName}:`, error);
+        }
+      }
+      return;
+    }
+
+    // For qdrant_disabled, schedule for delayed deletion
+    const id = uuidv4();
+    this.database.createQdrantDeletionItem({
+      id,
+      shop_id: shopId,
+      collection_names: JSON.stringify(collectionNames),
+      scheduled_deletion_at: scheduledDeletion.toISOString(),
+      reason,
+      status: 'pending',
+      created_at: now.toISOString(),
+    });
+
+    logger.info(`Scheduled ${collectionNames.length} collections for deletion in ${this.deletionDelayHours} hours for shop ${shopId}`);
+  }
+
+  /**
+   * Cancel scheduled deletion (when Qdrant is re-enabled within delay period)
+   */
+  async cancelScheduledDeletion(shopId: string): Promise<number> {
+    const cancelledCount = this.database.cancelQdrantDeletion(shopId);
+
+    if (cancelledCount > 0) {
+      logger.info(`Cancelled ${cancelledCount} scheduled deletions for shop ${shopId}`);
+    }
+
+    return cancelledCount;
+  }
+
+  /**
+   * Clean up resolved errors older than 30 days
+   */
+  private async cleanupResolvedErrors(): Promise<number> {
+    const cutoffDate = new Date();
+    cutoffDate.setDate(cutoffDate.getDate() - 30);
+    const cutoffIso = cutoffDate.toISOString();
+
+    const deletedCount = this.database.deleteOldQdrantErrors(cutoffIso);
+
+    if (deletedCount > 0) {
+      logger.info(`Cleaned up ${deletedCount} old resolved Qdrant errors`);
+    }
+
+    return deletedCount;
+  }
+
+  /**
+   * Archive MCP logs older than 90 days
+   */
+  private async archiveMcpLogs(): Promise<number> {
+    const cutoffDate = new Date();
+    cutoffDate.setDate(cutoffDate.getDate() - 90);
+    const cutoffIso = cutoffDate.toISOString();
+
+    const deletedCount = this.database.deleteOldMcpLogs(cutoffIso);
+
+    if (deletedCount > 0) {
+      logger.info(`Archived ${deletedCount} old MCP logs`);
+    }
+
+    return deletedCount;
+  }
+
+  /**
+   * Log Qdrant operation error
+   */
+  async logQdrantError(
+    shopId: string,
+    operationType: QdrantError['operation_type'],
+    errorMessage: string,
+    errorDetails?: any
+  ): Promise<void> {
+    const id = uuidv4();
+    const now = new Date().toISOString();
+
+    this.database.createQdrantError({
+      id,
+      shop_id: shopId,
+      operation_type: operationType,
+      error_message: errorMessage,
+      error_details: errorDetails ? JSON.stringify(errorDetails) : null,
+      created_at: now,
+      resolved_at: null,
+    });
+
+    logger.error(`Qdrant error logged for shop ${shopId} (${operationType}): ${errorMessage}`);
+  }
+
+  /**
+   * Mark error as resolved
+   */
+  async resolveQdrantError(errorId: string): Promise<void> {
+    const now = new Date().toISOString();
+    this.database.updateQdrantError(errorId, { resolved_at: now });
+    logger.info(`Marked Qdrant error ${errorId} as resolved`);
+  }
+
+  /**
+   * Get unresolved errors for a shop
+   */
+  getUnresolvedErrors(shopId: string): QdrantError[] {
+    return this.database.getUnresolvedQdrantErrors(shopId);
+  }
+
+  /**
+   * Get cleanup statistics
+   */
+  getCleanupStats(): {
+    pendingDeletions: number;
+    unresolvedErrors: number;
+    upcomingDeletions: QdrantDeletionQueue[];
+  } {
+    const now = new Date().toISOString();
+
+    return {
+      pendingDeletions: this.database.getPendingDeletionCount(),
+      unresolvedErrors: this.database.getUnresolvedErrorCount(),
+      upcomingDeletions: this.database.getUpcomingDeletions(5), // Next 5 deletions
+    };
+  }
+
+  /**
+   * Force cleanup now (for testing/manual triggers)
+   */
+  async forceCleanup(): Promise<CleanupStats> {
+    logger.info('Force cleanup triggered');
+    return this.performCleanup();
+  }
+}

+ 356 - 0
src/services/QdrantService.ts

@@ -0,0 +1,356 @@
+import { QdrantClient } from '@qdrant/js-client-rest';
+import { logger } from '../utils/logger';
+import type { AppConfig } from '../config';
+
+export interface QdrantPoint {
+  id: string;
+  vector: number[];
+  payload: {
+    shop_id: string;
+    content_id: string;
+    content_type: 'shipping' | 'contacts' | 'terms' | 'faq';
+    url: string;
+    title?: string;
+    content_hash: string;
+    created_at: string;
+  };
+}
+
+export interface QdrantSearchResult {
+  id: string;
+  score: number;
+  payload: QdrantPoint['payload'];
+}
+
+export class QdrantService {
+  private client: QdrantClient;
+  private enabled: boolean;
+
+  constructor(private config: AppConfig) {
+    this.enabled = config.qdrant.enabled;
+
+    if (this.enabled) {
+      this.client = new QdrantClient({
+        url: config.qdrant.apiUrl,
+        apiKey: config.qdrant.apiKey || undefined,
+      });
+      logger.info('QdrantService initialized', { url: config.qdrant.apiUrl });
+    } else {
+      logger.info('QdrantService disabled by configuration');
+    }
+  }
+
+  async isHealthy(): Promise<boolean> {
+    if (!this.enabled) return false;
+
+    try {
+      // Try to get cluster info to test connection
+      await this.client.getClusterInfo();
+      return true;
+    } catch (error) {
+      logger.error('Qdrant health check failed:', error);
+      return false;
+    }
+  }
+
+  /**
+   * Generate collection name using the format: {shop_custom_id}-{category_name}_{iterator}
+   */
+  generateCollectionName(shopCustomId: string, category: string, iterator: number): string {
+    return `${shopCustomId}-${category}_${iterator}`;
+  }
+
+  /**
+   * Parse collection name to extract shop custom ID and category
+   */
+  parseCollectionName(collectionName: string): { shopCustomId: string; category: string; iterator: number } | null {
+    const match = collectionName.match(/^(.+)-(.+)_(\d+)$/);
+    if (!match) return null;
+
+    return {
+      shopCustomId: match[1],
+      category: match[2],
+      iterator: parseInt(match[3], 10)
+    };
+  }
+
+  /**
+   * Check if collection exists
+   */
+  async collectionExists(collectionName: string): Promise<boolean> {
+    if (!this.enabled) return false;
+
+    try {
+      await this.client.getCollection(collectionName);
+      return true;
+    } catch (error: any) {
+      if (error.status === 404) {
+        return false;
+      }
+      logger.error(`Error checking collection existence for ${collectionName}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Create collection with proper configuration for text-embedding-3-large (3072 dimensions)
+   */
+  async createCollection(collectionName: string): Promise<void> {
+    if (!this.enabled) {
+      logger.warn('Attempted to create collection while Qdrant is disabled');
+      return;
+    }
+
+    try {
+      await this.client.createCollection(collectionName, {
+        vectors: {
+          size: 3072, // text-embedding-3-large dimension
+          distance: 'Cosine',
+        },
+        optimizers_config: {
+          default_segment_number: 2,
+        },
+        replication_factor: 1,
+      });
+
+      logger.info(`Created Qdrant collection: ${collectionName}`);
+    } catch (error: any) {
+      // If collection already exists, that's okay
+      if (error.status === 409 || error.message?.includes('already exists')) {
+        logger.debug(`Collection ${collectionName} already exists`);
+        return;
+      }
+      logger.error(`Failed to create collection ${collectionName}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Delete collection
+   */
+  async deleteCollection(collectionName: string): Promise<void> {
+    if (!this.enabled) {
+      logger.warn('Attempted to delete collection while Qdrant is disabled');
+      return;
+    }
+
+    try {
+      await this.client.deleteCollection(collectionName);
+      logger.info(`Deleted Qdrant collection: ${collectionName}`);
+    } catch (error: any) {
+      // If collection doesn't exist, that's okay
+      if (error.status === 404) {
+        logger.debug(`Collection ${collectionName} does not exist (already deleted)`);
+        return;
+      }
+      logger.error(`Failed to delete collection ${collectionName}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Insert or update point in collection
+   */
+  async upsertPoint(collectionName: string, point: QdrantPoint): Promise<void> {
+    if (!this.enabled) {
+      logger.warn('Attempted to upsert point while Qdrant is disabled');
+      return;
+    }
+
+    try {
+      await this.client.upsert(collectionName, {
+        wait: true,
+        points: [{
+          id: point.id,
+          vector: point.vector,
+          payload: point.payload,
+        }],
+      });
+
+      logger.debug(`Upserted point ${point.id} in collection ${collectionName}`);
+    } catch (error) {
+      logger.error(`Failed to upsert point ${point.id} in collection ${collectionName}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Search in a single collection
+   */
+  async searchInCollection(
+    collectionName: string,
+    queryVector: number[],
+    limit: number = 10
+  ): Promise<QdrantSearchResult[]> {
+    if (!this.enabled) {
+      logger.warn('Attempted to search while Qdrant is disabled');
+      return [];
+    }
+
+    try {
+      const response = await this.client.search(collectionName, {
+        vector: queryVector,
+        limit,
+        with_payload: true,
+        with_vector: false,
+      });
+
+      return response.map(result => ({
+        id: String(result.id),
+        score: result.score || 0,
+        payload: result.payload as QdrantPoint['payload'],
+      }));
+    } catch (error: any) {
+      if (error.status === 404) {
+        logger.debug(`Collection ${collectionName} does not exist for search`);
+        return [];
+      }
+      logger.error(`Failed to search in collection ${collectionName}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Search across multiple collections for a category
+   */
+  async searchInCategory(
+    shopCustomId: string,
+    category: string,
+    queryVector: number[],
+    limit: number = 10
+  ): Promise<QdrantSearchResult[]> {
+    if (!this.enabled) return [];
+
+    try {
+      // Get all collections that match the pattern
+      const collections = await this.getCollectionsForCategory(shopCustomId, category);
+
+      if (collections.length === 0) {
+        logger.debug(`No collections found for shop ${shopCustomId} category ${category}`);
+        return [];
+      }
+
+      // Search in all collections and combine results
+      const allResults: QdrantSearchResult[] = [];
+
+      for (const collectionName of collections) {
+        try {
+          const results = await this.searchInCollection(collectionName, queryVector, limit);
+          allResults.push(...results);
+        } catch (error) {
+          logger.warn(`Failed to search in collection ${collectionName}, skipping:`, error);
+        }
+      }
+
+      // Sort by score and limit results
+      allResults.sort((a, b) => b.score - a.score);
+      return allResults.slice(0, limit);
+
+    } catch (error) {
+      logger.error(`Failed to search in category ${category} for shop ${shopCustomId}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Get all collection names for a shop and category
+   */
+  async getCollectionsForCategory(shopCustomId: string, category: string): Promise<string[]> {
+    if (!this.enabled) return [];
+
+    try {
+      const response = await this.client.getCollections();
+      const collections = response.collections || [];
+
+      // Filter collections that match the pattern
+      const matchingCollections = collections
+        .map(col => col.name)
+        .filter(name => {
+          const parsed = this.parseCollectionName(name);
+          return parsed && parsed.shopCustomId === shopCustomId && parsed.category === category;
+        })
+        .sort(); // Sort to ensure consistent ordering
+
+      return matchingCollections;
+    } catch (error) {
+      logger.error(`Failed to get collections for shop ${shopCustomId} category ${category}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Get all collection names for a shop
+   */
+  async getCollectionsForShop(shopCustomId: string): Promise<string[]> {
+    if (!this.enabled) return [];
+
+    try {
+      const response = await this.client.getCollections();
+      const collections = response.collections || [];
+
+      // Filter collections that match the shop
+      const matchingCollections = collections
+        .map(col => col.name)
+        .filter(name => {
+          const parsed = this.parseCollectionName(name);
+          return parsed && parsed.shopCustomId === shopCustomId;
+        });
+
+      return matchingCollections;
+    } catch (error) {
+      logger.error(`Failed to get collections for shop ${shopCustomId}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Delete all collections for a shop
+   */
+  async deleteShopCollections(shopCustomId: string): Promise<void> {
+    if (!this.enabled) return;
+
+    try {
+      const collections = await this.getCollectionsForShop(shopCustomId);
+
+      for (const collectionName of collections) {
+        try {
+          await this.deleteCollection(collectionName);
+        } catch (error) {
+          logger.warn(`Failed to delete collection ${collectionName}:`, error);
+        }
+      }
+
+      logger.info(`Deleted ${collections.length} collections for shop ${shopCustomId}`);
+    } catch (error) {
+      logger.error(`Failed to delete collections for shop ${shopCustomId}:`, error);
+      throw error;
+    }
+  }
+
+  /**
+   * Get next iterator number for a category
+   */
+  async getNextIterator(shopCustomId: string, category: string): Promise<number> {
+    if (!this.enabled) return 1;
+
+    try {
+      const collections = await this.getCollectionsForCategory(shopCustomId, category);
+
+      if (collections.length === 0) return 1;
+
+      // Find the highest iterator number
+      let maxIterator = 0;
+      for (const collectionName of collections) {
+        const parsed = this.parseCollectionName(collectionName);
+        if (parsed && parsed.iterator > maxIterator) {
+          maxIterator = parsed.iterator;
+        }
+      }
+
+      return maxIterator + 1;
+    } catch (error) {
+      logger.error(`Failed to get next iterator for shop ${shopCustomId} category ${category}:`, error);
+      throw error;
+    }
+  }
+}