From 7c3ac20a04d1d561443185371f93b3600450b33d Mon Sep 17 00:00:00 2001 From: Ashutosh Mishra Date: Sat, 14 Mar 2026 23:41:23 -0700 Subject: [PATCH 1/3] feat(chatbot): fix RAG pipeline and improve sources display - Use HuggingFace router v1/chat/completions; fallback when LLM says no-info but matches exist - Modal embedding timeout and Pinecone API version/namespace support - Update system prompt; remove reliance on Xenova/local model (rebuild dist) - Frontend: segregated source cards with document/link/video icons and clickable links - Add test-huggingface.js and dotenv in test-chatbot.js --- package-lock.json | 556 ++++++++++++++++++++++++++++ package.json | 6 +- src/routes/chatbotRouter.js | 24 ++ src/services/chatbotService copy.js | 332 +++++++++++++++++ src/services/chatbotService.js | 362 ++++++++++++++++++ src/startup/middleware.js | 6 +- src/startup/routes.js | 3 +- test-chatbot.js | 35 ++ test-huggingface.js | 119 ++++++ 9 files changed, 1437 insertions(+), 6 deletions(-) create mode 100644 src/routes/chatbotRouter.js create mode 100644 src/services/chatbotService copy.js create mode 100644 src/services/chatbotService.js create mode 100644 test-chatbot.js create mode 100644 test-huggingface.js diff --git a/package-lock.json b/package-lock.json index 04bccb815..f586b8a71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@sentry/integrations": "^7.110.0", "@sentry/node": "^7.120.3", "@types/node-fetch": "^2.6.12", + "@xenova/transformers": "^2.17.2", "abort-controller": "^3.0.0", "async-exit-hook": "^2.0.1", "aws-sdk": "^2.1692.0", @@ -3052,6 +3053,15 @@ "@hapi/hoek": "^11.0.2" } }, + "node_modules/@huggingface/jinja": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.2.2.tgz", + "integrity": "sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "dev": true, @@ -4103,6 +4113,7 @@ "node": ">=4" } }, +<<<<<<< Updated upstream "node_modules/@puppeteer/browsers": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", @@ -4144,6 +4155,71 @@ "engines": { "node": ">=10" } +======= + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" +>>>>>>> Stashed changes }, "node_modules/@redis/bloom": { "version": "1.2.0", @@ -5206,6 +5282,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "dev": true, @@ -5344,6 +5426,55 @@ "dev": true, "license": "ISC" }, + "node_modules/@xenova/transformers": { + "version": "2.17.2", + "resolved": "https://registry.npmjs.org/@xenova/transformers/-/transformers-2.17.2.tgz", + "integrity": "sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.2.2", + "onnxruntime-web": "1.14.0", + "sharp": "^0.32.0" + }, + "optionalDependencies": { + "onnxruntime-node": "1.14.0" + } + }, + "node_modules/@xenova/transformers/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@xenova/transformers/node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "license": "MIT", @@ -6822,6 +6953,7 @@ "fsevents": "~2.3.2" } }, +<<<<<<< Updated upstream "node_modules/chromium-bidi": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", @@ -6843,6 +6975,13 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } +======= + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" +>>>>>>> Stashed changes }, "node_modules/ci-info": { "version": "3.9.0", @@ -7021,6 +7160,19 @@ "dev": true, "license": "MIT" }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -7035,6 +7187,16 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -7469,6 +7631,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.0", "dev": true, @@ -7482,6 +7659,15 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "license": "MIT" @@ -7776,6 +7962,11 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", +<<<<<<< Updated upstream +======= + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", +>>>>>>> Stashed changes "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -8809,6 +9000,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "dev": true, @@ -9006,6 +9206,12 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "license": "MIT" @@ -9177,6 +9383,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/flatted": { "version": "3.3.3", "dev": true, @@ -9285,7 +9497,12 @@ }, "node_modules/fs-constants": { "version": "1.0.0", +<<<<<<< Updated upstream "dev": true, +======= + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", +>>>>>>> Stashed changes "license": "MIT" }, "node_modules/fs-readdir-recursive": { @@ -9573,6 +9790,12 @@ "assert-plus": "^1.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "license": "ISC", @@ -9751,6 +9974,12 @@ "node": ">=10" } }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/gulp-eslint": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/gulp-eslint/-/gulp-eslint-3.0.1.tgz", @@ -10604,6 +10833,12 @@ "version": "2.0.4", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/inquirer": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", @@ -12961,6 +13196,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -13160,6 +13401,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "license": "ISC", @@ -13201,6 +13454,12 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "license": "MIT", @@ -13592,6 +13851,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "license": "MIT" @@ -13645,6 +13910,36 @@ "node": ">=18.20.0 <20 || >=20.12.1" } }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "license": "MIT" + }, "node_modules/node-cache": { "version": "5.1.2", "license": "MIT", @@ -14015,6 +14310,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnx-proto": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/onnx-proto/-/onnx-proto-4.0.4.tgz", + "integrity": "sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==", + "license": "MIT", + "dependencies": { + "protobufjs": "^6.8.8" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.14.0.tgz", + "integrity": "sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.14.0.tgz", + "integrity": "sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==", + "license": "MIT", + "optional": true, + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "onnxruntime-common": "~1.14.0" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.14.0.tgz", + "integrity": "sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^4.0.0", + "onnx-proto": "^4.0.4", + "onnxruntime-common": "~1.14.0", + "platform": "^1.3.6" + } + }, "node_modules/optional-require": { "version": "1.1.10", "license": "Apache-2.0", @@ -14465,6 +14804,12 @@ "node": ">=4" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/pluralize": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", @@ -14509,6 +14854,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -14617,6 +15001,47 @@ "node": ">= 8" } }, + "node_modules/protobufjs": { + "version": "6.11.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.4.tgz", + "integrity": "sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/protobufjs/node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/protobufjs/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "license": "MIT", @@ -14813,6 +15238,7 @@ "node": ">= 0.8" } }, +<<<<<<< Updated upstream "node_modules/raw-body/node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -14840,6 +15266,30 @@ "license": "MIT", "engines": { "node": ">= 0.8" +======= + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" +>>>>>>> Stashed changes } }, "node_modules/react-is": { @@ -15736,6 +16186,66 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "dev": true, @@ -16523,9 +17033,15 @@ } }, "node_modules/tar-fs": { +<<<<<<< Updated upstream "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", +======= + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz", + "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==", +>>>>>>> Stashed changes "license": "MIT", "dependencies": { "pump": "^3.0.0", @@ -16550,7 +17066,12 @@ }, "node_modules/tar-stream": { "version": "2.2.0", +<<<<<<< Updated upstream "dev": true, +======= + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", +>>>>>>> Stashed changes "license": "MIT", "dependencies": { "bl": "^4.0.3", @@ -16565,7 +17086,12 @@ }, "node_modules/tar-stream/node_modules/bl": { "version": "4.1.0", +<<<<<<< Updated upstream "dev": true, +======= + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", +>>>>>>> Stashed changes "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -16573,6 +17099,36 @@ "readable-stream": "^3.4.0" } }, +<<<<<<< Updated upstream + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", +======= + "node_modules/tar-stream/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], +>>>>>>> Stashed changes + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", diff --git a/package.json b/package.json index 07f83b741..8e5b6e5a7 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@sentry/integrations": "^7.110.0", "@sentry/node": "^7.120.3", "@types/node-fetch": "^2.6.12", + "@xenova/transformers": "^2.17.2", "abort-controller": "^3.0.0", "async-exit-hook": "^2.0.1", "aws-sdk": "^2.1692.0", @@ -90,9 +91,8 @@ "compression": "^1.8.0", "cors": "^2.8.4", "cron": "^1.8.2", - "date-fns": "^2.30.0", - "date-fns-tz": "^2.0.1", - "dotenv": "^5.0.1", + "date-fns": "^2.30.0","date-fns-tz": "^2.0.1", + "dotenv": "^16.4.5", "dropbox": "^10.34.0", "express": "^4.22.1", "express-validator": "^7.0.1", diff --git a/src/routes/chatbotRouter.js b/src/routes/chatbotRouter.js new file mode 100644 index 000000000..7920b2551 --- /dev/null +++ b/src/routes/chatbotRouter.js @@ -0,0 +1,24 @@ +const express = require('express'); +const chatbotService = require('../services/chatbotService'); + +const router = express.Router(); + +router.post('/chatbot/query', (req, res) => { + const { message, history } = req.body || {}; + const normalizedHistory = Array.isArray(history) ? history : []; + + chatbotService + .getChatbotReply(message, normalizedHistory) + .then((result) => { + res.status(200).json(result); + }) + .catch((err) => { + res.status(500).json({ + reply: 'An error occurred while processing your request.', + sources: [], + error: process.env.NODE_ENV === 'development' ? err.message : undefined, + }); + }); +}); + +module.exports = router; diff --git a/src/services/chatbotService copy.js b/src/services/chatbotService copy.js new file mode 100644 index 000000000..97c919c81 --- /dev/null +++ b/src/services/chatbotService copy.js @@ -0,0 +1,332 @@ +// const axios = require('axios'); +// const PINECONE_API_KEY = process.env.PINECONE_API_KEY; +// const PINECONE_INDEX_NAME = process.env.PINECONE_INDEX || 'hgn-chatbot'; +// const PINECONE_HOST = process.env.PINECONE_HOST; +// const HUGGINGFACE_API_KEY = process.env.HUGGINGFACE_API_KEY; + +// // Use the correct free-tier API and an open model +// const HUGGINGFACE_INFERENCE_URL = process.env.HUGGINGFACE_API_URL || 'https://router.huggingface.co'; +// const HUGGINGFACE_TEXT_MODEL = process.env.HUGGINGFACE_TEXT_MODEL || 'Qwen/Qwen2.5-1.5B-Instruct'; + +// const TOP_K = parseInt(process.env.CHATBOT_TOP_K || '3', 10); + +// // --- 1.5. MODAL EMBEDDING SERVICE --- +// async function getEmbedding(text) { +// const input = text.slice(0, 8000); + +// if (!process.env.EMBEDDING_SERVICE_URL) { +// throw new Error('EMBEDDING_SERVICE_URL is missing in .env'); +// } + +// try { +// const response = await axios.post( +// process.env.EMBEDDING_SERVICE_URL, +// { inputs: input }, // Matches your Python EmbedRequest(BaseModel) +// { timeout: 20000 } +// ); + +// // Your Python code returns: return embeddings.tolist()[0] +// // which is a flat array [0.1, 0.2, ...] +// if (Array.isArray(response.data)) { +// return response.data; +// } else { +// throw new Error('Modal returned non-array data. Check Python return statement.'); +// } +// } catch (err) { +// // If this throws, you'll see the REAL error (404, 502, etc.) in your logs +// const status = err.response?.status; +// const message = err.response?.data || err.message; +// throw new Error(`Modal Service Error [${status}]: ${JSON.stringify(message)}`); +// } +// } + +// function cleanContextText(text) { +// if (!text || typeof text !== 'string') return ''; + +// return text +// .split('\n') +// .map(line => line.trim()) +// // Remove lines that look like Base64 (long strings with no spaces) +// .filter(line => line.length > 0 && !(line.length > 100 && !line.includes(' '))) +// .join(' ') +// .slice(0, 2000); +// } + +// // --- 2. PINECONE SEARCH --- +// async function queryPinecone(vector, options = {}) { +// if (!PINECONE_API_KEY) throw new Error('PINECONE_API_KEY is not configured.'); +// const indexName = options.indexName || PINECONE_INDEX_NAME; +// const host = PINECONE_HOST || `${indexName}.svc.${process.env.PINECONE_ENVIRONMENT || 'gcp-starter'}.pinecone.io`; +// const topK = options.topK ?? TOP_K; + +// const url = `https://${host}/query`; +// const body = { vector, topK, includeMetadata: true, includeValues: false }; + +// const response = await axios.post(url, body, { +// headers: { 'Api-Key': PINECONE_API_KEY, 'Content-Type': 'application/json' }, +// timeout: 10000, +// }); + +// return (response.data?.matches || []).map((m) => ({ +// id: m.id, +// score: m.score, +// metadata: m.metadata || {}, +// source_document: m.metadata?.source_document || m.id, +// text: m.metadata?.text || m.metadata?.content || JSON.stringify(m.metadata || {}), +// })); +// } + +// function buildReplyFromMatches(matches) { +// if (!matches || matches.length === 0) { +// return "I couldn't find relevant information for that question. Try rephrasing or ask something else."; +// } + +// // Better formatting for the raw Pinecone results +// const contextParts = matches.map((m, i) => { +// const cleanText = cleanContextText(m.text); +// const source = m.source_document || m.id; +// return `**Source ${i + 1}** (${source}):\n${cleanText}`; +// }); + +// return `I found the following information for you:\n\n${contextParts.join('\n\n')}\n\n*Note: This is raw information from our knowledge base. For a more conversational response, the AI generation service is currently being updated.*`; +// } + +// async function rewriteFollowUpQuestion(question, history = []) { +// // No AI rewrite, just return the raw question +// return question; +// } + +// // --- 3. HUGGING FACE GENERATION --- +// async function generateWithHuggingFace(prompt, options = {}) { +// if (!HUGGINGFACE_API_KEY) throw new Error('HUGGINGFACE_API_KEY is not configured.'); + +// const model = options.model || HUGGINGFACE_TEXT_MODEL; +// const hfInput = { +// inputs: prompt, +// parameters: { max_new_tokens: 512, temperature: 0.1, return_full_text: false }, +// }; + +// // We intentionally let errors throw here so the orchestrator can catch them! +// const response = await axios.post(`${HUGGINGFACE_INFERENCE_URL}/models/${model}`, hfInput, { +// headers: { Authorization: `Bearer ${HUGGINGFACE_API_KEY}`, 'Content-Type': 'application/json' }, +// timeout: 30000, +// }); + +// const data = response.data; +// if (typeof data === 'string') return data; +// if (Array.isArray(data) && data.length > 0 && data[0].generated_text) return data[0].generated_text; +// if (data && data.generated_text) return data.generated_text; + +// throw new Error('Unexpected response from HuggingFace inference'); +// } + +// async function chatWithHuggingFace(userMessage, contextText, history = []) { +// const systemPrompt = +// 'You are an administrative assistant chatbot answering procedural queries. Use the following pieces of retrieved context to answer the question. If you do not know the answer, say so. At the end of your response, cite the source_document and include any relevant URLs or Video Links from the context.'; + +// const contextBlock = contextText ? `Context:\n${contextText}\n\nUser question: ${userMessage}` : userMessage; + +// let prompt = `<|im_start|>system\n${systemPrompt}<|im_end|>\n`; +// for (const msg of history.slice(-10)) { +// prompt += `<|im_start|>${msg.role}\n${msg.content}<|im_end|>\n`; +// } +// prompt += `<|im_start|>user\n${contextBlock}<|im_end|>\n<|im_start|>assistant\n`; + +// // We intentionally do NOT use try/catch here so it bubbles up correctly! +// return await generateWithHuggingFace(prompt, { +// model: HUGGINGFACE_TEXT_MODEL, +// maxNewTokens: 512, +// temperature: 0.1, +// }); +// } + +// // --- 4. MAIN ORCHESTRATOR --- +// async function getChatbotReply(message, history = []) { +// if (!message || typeof message !== 'string' || !message.trim()) { +// return { reply: 'Please enter a question.', sources: [] }; +// } + +// const trimmedMessage = message.trim(); + +// if (!PINECONE_API_KEY) { +// return { reply: 'Chatbot is not fully configured. Set PINECONE_API_KEY.', sources: [] }; +// } + +// try { +// const rewritten = await rewriteFollowUpQuestion(trimmedMessage, history); +// const embedding = await getEmbedding(rewritten); +// const matches = await queryPinecone(embedding, { topK: TOP_K }); +// const contextText = matches.map((m) => cleanContextText(m.text)).join('\n\n'); + +// const useLLM = !!HUGGINGFACE_API_KEY && matches.length > 0; +// let reply; + +// if (useLLM) { +// try { +// // Attempt to generate the AI response +// reply = await chatWithHuggingFace(rewritten, contextText, history); +// } catch (err) { +// // SUCCESSFUL FALLBACK ROUTING: Output the raw Pinecone text! +// console.warn(`HuggingFace generation failed: ${err.message}. Falling back to raw results.`); +// reply = buildReplyFromMatches(matches); +// } +// } else { +// reply = buildReplyFromMatches(matches); +// } + +// return { +// reply, +// sources: matches.slice(0, 3).map((m) => ({ +// id: m.id, +// text: m.text.slice(0, 200), +// score: m.score, +// source_document: m.source_document, +// metadata: m.metadata, +// })), +// }; +// } catch (err) { +// console.error('Chatbot error:', err.message); +// return { reply: `Sorry, something went wrong: ${err.message}`, sources: [] }; +// } +// } + +// module.exports = { +// getChatbotReply, +// getEmbedding, +// queryPinecone, +// }; + +// console.log("Modal URL:", process.env.EMBEDDING_SERVICE_URL); +// console.log("HF Key exists:", !!process.env.HUGGINGFACE_API_KEY); + +const axios = require('axios'); + +// Configuration from Environment +const { PINECONE_API_KEY } = process.env; +const { PINECONE_HOST } = process.env; +const HF_API_KEY = process.env.HUGGINGFACE_API_KEY; +const HF_MODEL = process.env.HUGGINGFACE_TEXT_MODEL || 'Qwen/Qwen2.5-7B-Instruct'; +const MODAL_URL = process.env.EMBEDDING_SERVICE_URL; +const TOP_K = parseInt(process.env.CHATBOT_TOP_K || '5', 10); + +/** + * 1. GET EMBEDDING (Modal BGE-M3) + * Fetches 1024-dimension vector from your Modal deployment. + */ +async function getEmbedding(text) { + try { + const response = await axios.post( + MODAL_URL, + { inputs: text.slice(0, 8000) }, + { timeout: 15000 }, + ); + // Return flat array: [0.1, -0.2, ...] + return Array.isArray(response.data) ? response.data : response.data.embedding; + } catch (err) { + throw new Error(`Embedding Service (Modal) Failed: ${err.message}`); + } +} + +/** + * 2. PINECONE QUERY + */ +async function queryPinecone(vector) { + const url = `https://${PINECONE_HOST}/query`; + const response = await axios.post( + url, + { vector, topK: TOP_K, includeMetadata: true }, + { headers: { 'Api-Key': PINECONE_API_KEY, 'Content-Type': 'application/json' } }, + ); + + return (response.data?.matches || []).map((m) => ({ + text: m.metadata?.text || m.metadata?.content || '', + source: m.metadata?.source_document || 'Unknown Source', + score: m.score, + })); +} + +/** + * 3. HUGGING FACE GENERATION (Qwen) + */ +async function generateAnswer(question, context) { + const prompt = `<|im_start|>system\nYou are a helpful administrative assistant. Use the provided context to answer the user question accurately. If the context does not contain the answer, politely say you don't know.<|im_end|>\n<|im_start|>user\nContext:\n${context}\n\nQuestion: ${question}<|im_end|>\n<|im_start|>assistant\n`; + + try { + const response = await axios.post( + `https://router.huggingface.co/models/${HF_MODEL}`, + { + inputs: prompt, + parameters: { max_new_tokens: 500, temperature: 0.1, return_full_text: false }, + }, + { headers: { Authorization: `Bearer ${HF_API_KEY}` }, timeout: 30000 }, + ); + + const { data } = response; + const result = Array.isArray(data) ? data[0].generated_text : data.generated_text; + return result || "I'm sorry, I couldn't generate a response."; + } catch (err) { + throw new Error(`LLM Generation (Qwen) Failed: ${err.message}`); + } +} + +/** + * 4. CLEAN DATA (Prevents Base64 garbage from entering LLM) + */ +function isCleanText(text) { + // Reject strings that look like Base64 (long, no spaces, special chars) + if (text.length > 60 && !text.includes(' ')) return false; + if (text.includes('/xYtWh')) return false; // Specific filter for your current garbage data + return true; +} + +/** + * 5. MAIN ORCHESTRATOR + */ +async function getChatbotReply(message) { + if (!message?.trim()) return { reply: 'Please provide a question.' }; + + try { + // A. Vectorize + const vector = await getEmbedding(message.trim()); + + // B. Search Pinecone + const matches = await queryPinecone(vector); + + // C. Filter for Quality + // We only keep matches that aren't "garbage" and have a decent score + const validMatches = matches.filter((m) => m.score > 0.6 && isCleanText(m.text)); + + if (validMatches.length === 0) { + return { + reply: + "I couldn't find any relevant information in our documents to answer that. Could you please rephrase?", + sources: [], + }; + } + + // D. Build Context + const contextText = validMatches + .map((m) => `[Source: ${m.source}]: ${m.text}`) + .join('\n\n') + .slice(0, 3000); // Token limit safety + + // E. Generate with Qwen + const reply = await generateAnswer(message.trim(), contextText); + + return { + reply, + sources: validMatches.slice(0, 3).map((m) => ({ + source: m.source, + score: m.score.toFixed(2), + })), + }; + } catch (err) { + console.error('Chatbot Error:', err.message); + return { + reply: `I encountered an error while processing your request: ${err.message}`, + sources: [], + }; + } +} + +module.exports = { getChatbotReply }; diff --git a/src/services/chatbotService.js b/src/services/chatbotService.js new file mode 100644 index 000000000..8467724bf --- /dev/null +++ b/src/services/chatbotService.js @@ -0,0 +1,362 @@ +const axios = require('axios'); + +const { PINECONE_API_KEY } = process.env; +const PINECONE_INDEX_NAME = process.env.PINECONE_INDEX || 'hgn-chatbot'; +const { PINECONE_HOST } = process.env; +const { HUGGINGFACE_API_KEY } = process.env; + +// Use the correct free-tier API and an open model. If you get 404, try HUGGINGFACE_API_URL=https://api-inference.huggingface.co +const HUGGINGFACE_INFERENCE_URL = + process.env.HUGGINGFACE_API_URL || 'https://router.huggingface.co'; +const HUGGINGFACE_TEXT_MODEL = process.env.HUGGINGFACE_TEXT_MODEL || 'Qwen/Qwen2.5-1.5B-Instruct'; + +const TOP_K = parseInt(process.env.CHATBOT_TOP_K || '3', 10); + +// --- 1.5. MODAL EMBEDDING SERVICE --- +async function getEmbedding(text) { + const input = text.slice(0, 8000); + + if (!process.env.EMBEDDING_SERVICE_URL) { + throw new Error('EMBEDDING_SERVICE_URL is missing in .env'); + } + + const url = process.env.EMBEDDING_SERVICE_URL.replace(/\/$/, ''); + + const timeoutMs = parseInt(process.env.EMBEDDING_SERVICE_TIMEOUT_MS || '60000', 10); + try { + const response = await axios.post( + url, + { inputs: input }, // Modal modal_service.py EmbedRequest.inputs + { timeout: timeoutMs }, + ); + + // Modal modal_service.py returns flat array; some deployments return { embedding: [...] } + if (Array.isArray(response.data)) { + return response.data; + } + if (response.data && Array.isArray(response.data.embedding)) { + return response.data.embedding; + } + throw new Error( + 'Embedding service returned unexpected format (expected array or { embedding }).', + ); + } catch (err) { + const status = err.response?.status; + const message = err.response?.data ?? err.message; + throw new Error(`Modal Service Error [${status}]: ${JSON.stringify(message)}`); + } +} + +function cleanContextText(text) { + if (!text || typeof text !== 'string') return ''; + + return ( + text + .split('\n') + .map((line) => line.trim()) + // Remove lines that look like Base64 (long strings with no spaces) + .filter((line) => line.length > 0 && !(line.length > 100 && !line.includes(' '))) + .join(' ') + .slice(0, 2000) + ); +} + +// --- 2. PINECONE SEARCH --- +async function queryPinecone(vector, options = {}) { + if (!PINECONE_API_KEY) throw new Error('PINECONE_API_KEY is not configured.'); + const indexName = options.indexName || PINECONE_INDEX_NAME; + const host = + PINECONE_HOST || + `${indexName}.svc.${process.env.PINECONE_ENVIRONMENT || 'gcp-starter'}.pinecone.io`; + const topK = options.topK ?? TOP_K; + const namespace = options.namespace ?? process.env.PINECONE_NAMESPACE ?? ''; + + const url = `https://${host}/query`; + const body = { vector, topK, includeMetadata: true, includeValues: false }; + if (namespace) body.namespace = namespace; + + const headers = { + 'Api-Key': PINECONE_API_KEY, + 'Content-Type': 'application/json', + 'X-Pinecone-Api-Version': process.env.PINECONE_API_VERSION || '2024-07', + }; + + const response = await axios.post(url, body, { headers, timeout: 10000 }); + + return (response.data?.matches || []).map((m) => ({ + id: m.id, + score: m.score, + metadata: m.metadata || {}, + source_document: m.metadata?.source_document || m.id, + text: m.metadata?.text || m.metadata?.content || JSON.stringify(m.metadata || {}), + })); +} + +function buildReplyFromMatches(matches) { + if (!matches || matches.length === 0) { + return "I couldn't find relevant information for that question. Try rephrasing or ask something else."; + } + + // Better formatting for the raw Pinecone results + const contextParts = matches.map((m, i) => { + const cleanText = cleanContextText(m.text); + const source = m.source_document || m.id; + return `**Source ${i + 1}** (${source}):\n${cleanText}`; + }); + + return `I found the following information for you:\n\n${contextParts.join('\n\n')}\n\n*Note: This is raw information from our knowledge base. For a more conversational response, the AI generation service is currently being updated.*`; +} + +async function rewriteFollowUpQuestion(question, history = []) { + // No AI rewrite, just return the raw question + return question; +} + +// --- 3. HUGGING FACE GENERATION (router v1/chat/completions) --- +async function generateWithHuggingFace(messages, options = {}) { + if (!HUGGINGFACE_API_KEY) throw new Error('HUGGINGFACE_API_KEY is not configured.'); + + const model = options.model || HUGGINGFACE_TEXT_MODEL; + const baseUrl = (HUGGINGFACE_INFERENCE_URL || '').replace(/\/$/, ''); + const url = `${baseUrl}/v1/chat/completions`; + const body = { + model, + messages, + max_tokens: options.maxNewTokens ?? 512, + temperature: options.temperature ?? 0.1, + }; + + const response = await axios.post(url, body, { + headers: { Authorization: `Bearer ${HUGGINGFACE_API_KEY}`, 'Content-Type': 'application/json' }, + timeout: 30000, + }); + + const content = response.data?.choices?.[0]?.message?.content; + if (typeof content === 'string') return content.trim(); + throw new Error('Unexpected response from HuggingFace inference'); +} + +function looksLikeNoInfoReply(reply) { + if (!reply || typeof reply !== 'string') return true; + const r = reply.trim().toLowerCase(); + return ( + r.includes("couldn't find relevant") || + r.includes('could not find relevant') || + r.includes("don't have relevant") || + r.includes('do not have relevant') || + (r.includes('rephras') && r.length < 150) + ); +} + +async function chatWithHuggingFace(userMessage, contextText, history = []) { + const systemPrompt = + 'You are an administrative assistant chatbot answering procedural queries. ' + + 'Use the following pieces of retrieved context to answer the question. ' + + "If you don't know the answer, just say that you don't know. " + + "At the end of your response, you MUST cite the 'source_document' and " + + 'provide any relevant URLs or Video Links found in the text.'; + + const contextBlock = contextText + ? `Context:\n${contextText}\n\nUser question: ${userMessage}` + : userMessage; + + const messages = [ + { role: 'system', content: systemPrompt }, + ...history.slice(-10).map((msg) => ({ role: msg.role, content: msg.content })), + { role: 'user', content: contextBlock }, + ]; + + return generateWithHuggingFace(messages, { + model: HUGGINGFACE_TEXT_MODEL, + maxNewTokens: 512, + temperature: 0.1, + }); +} + +// --- 4. MAIN ORCHESTRATOR --- +async function getChatbotReply(message, history = []) { + if (!message || typeof message !== 'string' || !message.trim()) { + return { reply: 'Please enter a question.', sources: [] }; + } + + const trimmedMessage = message.trim(); + + if (!PINECONE_API_KEY) { + return { reply: 'Chatbot is not fully configured. Set PINECONE_API_KEY.', sources: [] }; + } + + try { + const rewritten = await rewriteFollowUpQuestion(trimmedMessage, history); + const embedding = await getEmbedding(rewritten); + const matches = await queryPinecone(embedding, { topK: TOP_K }); + const contextText = matches.map((m) => cleanContextText(m.text)).join('\n\n'); + + const useLLM = !!HUGGINGFACE_API_KEY && matches.length > 0; + let reply; + + if (useLLM) { + try { + reply = await chatWithHuggingFace(rewritten, contextText, history); + // If the LLM said "no relevant info" but we have matches, show the context instead + if (looksLikeNoInfoReply(reply) && matches.length > 0) { + reply = buildReplyFromMatches(matches); + } + } catch (err) { + console.warn(`HuggingFace generation failed: ${err.message}. Falling back to raw results.`); + reply = buildReplyFromMatches(matches); + } + } else { + reply = buildReplyFromMatches(matches); + } + + return { + reply, + sources: matches.slice(0, 3).map((m) => ({ + id: m.id, + text: m.text.slice(0, 200), + score: m.score, + source_document: m.source_document, + metadata: m.metadata, + })), + }; + } catch (err) { + console.error('Chatbot error:', err.message); + return { reply: `Sorry, something went wrong: ${err.message}`, sources: [] }; + } +} + +module.exports = { + getChatbotReply, + getEmbedding, + queryPinecone, +}; + +// const axios = require('axios'); + +// // Configuration from Environment +// const PINECONE_API_KEY = process.env.PINECONE_API_KEY; +// const PINECONE_HOST = process.env.PINECONE_HOST; +// const HF_API_KEY = process.env.HUGGINGFACE_API_KEY; +// const HF_MODEL = process.env.HUGGINGFACE_TEXT_MODEL || 'Qwen/Qwen2.5-7B-Instruct'; +// const MODAL_URL = process.env.EMBEDDING_SERVICE_URL; +// const TOP_K = parseInt(process.env.CHATBOT_TOP_K || '5', 10); + +// /** +// * 1. GET EMBEDDING (Modal BGE-M3) +// * Fetches 1024-dimension vector from your Modal deployment. +// */ +// async function getEmbedding(text) { +// try { +// const response = await axios.post(MODAL_URL, +// { inputs: text.slice(0, 8000) }, +// { timeout: 15000 } +// ); +// // Return flat array: [0.1, -0.2, ...] +// return Array.isArray(response.data) ? response.data : response.data.embedding; +// } catch (err) { +// throw new Error(`Embedding Service (Modal) Failed: ${err.message}`); +// } +// } + +// /** +// * 2. PINECONE QUERY +// */ +// async function queryPinecone(vector) { +// const url = `https://${PINECONE_HOST}/query`; +// const response = await axios.post(url, +// { vector, topK: TOP_K, includeMetadata: true }, +// { headers: { 'Api-Key': PINECONE_API_KEY, 'Content-Type': 'application/json' } } +// ); + +// return (response.data?.matches || []).map(m => ({ +// text: m.metadata?.text || m.metadata?.content || "", +// source: m.metadata?.source_document || "Unknown Source", +// score: m.score +// })); +// } + +// /** +// * 3. HUGGING FACE GENERATION (Qwen) +// */ +// async function generateAnswer(question, context) { +// const prompt = `<|im_start|>system\nYou are a helpful administrative assistant. Use the provided context to answer the user question accurately. If the context does not contain the answer, politely say you don't know.<|im_end|>\n<|im_start|>user\nContext:\n${context}\n\nQuestion: ${question}<|im_end|>\n<|im_start|>assistant\n`; + +// try { +// const response = await axios.post( +// `https://router.huggingface.co/models/${HF_MODEL}`, +// { +// inputs: prompt, +// parameters: { max_new_tokens: 500, temperature: 0.1, return_full_text: false } +// }, +// { headers: { Authorization: `Bearer ${HF_API_KEY}` }, timeout: 30000 } +// ); + +// const data = response.data; +// const result = Array.isArray(data) ? data[0].generated_text : data.generated_text; +// return result || "I'm sorry, I couldn't generate a response."; +// } catch (err) { +// throw new Error(`LLM Generation (Qwen) Failed: ${err.message}`); +// } +// } + +// /** +// * 4. CLEAN DATA (Prevents Base64 garbage from entering LLM) +// */ +// function isCleanText(text) { +// // Reject strings that look like Base64 (long, no spaces, special chars) +// if (text.length > 60 && !text.includes(' ')) return false; +// if (text.includes('/xYtWh')) return false; // Specific filter for your current garbage data +// return true; +// } + +// /** +// * 5. MAIN ORCHESTRATOR +// */ +// async function getChatbotReply(message) { +// if (!message?.trim()) return { reply: "Please provide a question." }; + +// try { +// // A. Vectorize +// const vector = await getEmbedding(message.trim()); + +// // B. Search Pinecone +// const matches = await queryPinecone(vector); + +// // C. Filter for Quality +// // We only keep matches that aren't "garbage" and have a decent score +// const validMatches = matches.filter(m => m.score > 0.6 && isCleanText(m.text)); + +// if (validMatches.length === 0) { +// return { +// reply: "I couldn't find any relevant information in our documents to answer that. Could you please rephrase?", +// sources: [] +// }; +// } + +// // D. Build Context +// const contextText = validMatches +// .map(m => `[Source: ${m.source}]: ${m.text}`) +// .join('\n\n') +// .slice(0, 3000); // Token limit safety + +// // E. Generate with Qwen +// const reply = await generateAnswer(message.trim(), contextText); + +// return { +// reply, +// sources: validMatches.slice(0, 3).map(m => ({ +// source: m.source, +// score: m.score.toFixed(2) +// })) +// }; + +// } catch (err) { +// console.error("Chatbot Error:", err.message); +// return { +// reply: `I encountered an error while processing your request: ${err.message}`, +// sources: [] +// }; +// } +// } + +// module.exports = { getChatbotReply }; diff --git a/src/startup/middleware.js b/src/startup/middleware.js index d3406775e..f1a1d12af 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -58,7 +58,9 @@ module.exports = function (app) { if ( ((req.originalUrl === '/api/ProfileInitialSetup' || req.originalUrl === '/api/validateToken' || - req.originalUrl === '/api/getTimeZoneAPIKeyByToken') && + req.originalUrl === '/api/getTimeZoneAPIKeyByToken' || + req.originalUrl === '/api/chatbot/query' || + req.originalUrl === '/chatbot/query') && req.method === 'POST') || (req.originalUrl === '/api/getTotalCountryCount' && req.method === 'GET') || (req.originalUrl.includes('/api/timezone') && req.method === 'POST') @@ -129,4 +131,4 @@ module.exports = function (app) { }); // Apply PayPal middleware only to specific route app.post('/api/lb/myWebhooks/', paypalAuthMiddleware, webhookTest); -}; \ No newline at end of file +}; diff --git a/src/startup/routes.js b/src/startup/routes.js index b828af6f8..c7bbaac34 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -379,11 +379,13 @@ const badgeSystemRouter = require('../routes/educationPortal/badgeSystemRouter') const promotionDetailsRouter = require('../routes/promotionDetailsRouter'); const summaryDashboardRouter = require('../routes/summaryDashboard.routes'); +const chatbotRouter = require('../routes/chatbotRouter'); // Actual Cost const actualCostRouter = require('../routes/actualCostRouter')(); module.exports = function (app) { + app.use('/api', chatbotRouter); app.use('/api/bm/summary-dashboard', summaryDashboardRouter); app.use('/api', forgotPwdRouter); app.use('/api', loginRouter); @@ -498,7 +500,6 @@ module.exports = function (app) { app.use('/api', toolUtilizationRouter); // lb dashboard - app.use('/api', toolAvailabilityRouter); app.use('/api', projectCostTrackingRouter); diff --git a/test-chatbot.js b/test-chatbot.js new file mode 100644 index 000000000..a5451eb1f --- /dev/null +++ b/test-chatbot.js @@ -0,0 +1,35 @@ +require('dotenv').config(); +const { getChatbotReply } = require('./src/services/chatbotService'); + +async function testChatbot() { + console.log('๐Ÿงช Testing Chatbot Service...\n'); + + try { + // Test 1: Simple question + console.log('๐Ÿ“ Test 1: Simple question'); + const result1 = await getChatbotReply('What is this project about?', []); + console.log('Response:', result1); + console.log(`\n${'='.repeat(50)}\n`); + + // Test 2: Question with history + console.log('๐Ÿ“ Test 2: Question with history'); + const history = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi! How can I help you today?' }, + ]; + const result2 = await getChatbotReply('What teams can I join?', history); + console.log('Response:', result2); + console.log(`\n${'='.repeat(50)}\n`); + + // Test 3: Empty question + console.log('๐Ÿ“ Test 3: Empty question'); + const result3 = await getChatbotReply('', []); + console.log('Response:', result3); + } catch (error) { + console.error('โŒ Test failed:', error.message); + console.error('Stack:', error.stack); + } +} + +// Run the test +testChatbot(); diff --git a/test-huggingface.js b/test-huggingface.js new file mode 100644 index 000000000..9af23030c --- /dev/null +++ b/test-huggingface.js @@ -0,0 +1,119 @@ +/** + * Test HuggingFace API key and connectivity. + * Run from HGNRest: node test-huggingface.js + * + * Tests in order: + * 1. Router v1 chat completions (recommended) with your model + * 2. Fallback: legacy /models/{model} endpoint + */ +require('dotenv').config(); +const axios = require('axios'); + +const { HUGGINGFACE_API_KEY } = process.env; +const HUGGINGFACE_INFERENCE_URL = + process.env.HUGGINGFACE_API_URL || 'https://router.huggingface.co'; +const HUGGINGFACE_TEXT_MODEL = process.env.HUGGINGFACE_TEXT_MODEL || 'Qwen/Qwen2.5-1.5B-Instruct'; + +const headers = { + Authorization: `Bearer ${HUGGINGFACE_API_KEY}`, + 'Content-Type': 'application/json', +}; + +async function testV1ChatCompletions() { + const url = `${HUGGINGFACE_INFERENCE_URL.replace(/\/$/, '')}/v1/chat/completions`; + const body = { + model: HUGGINGFACE_TEXT_MODEL, + messages: [{ role: 'user', content: 'Say "Hello" in one word.' }], + max_tokens: 10, + temperature: 0.1, + }; + const response = await axios.post(url, body, { headers, timeout: 30000 }); + const content = response.data?.choices?.[0]?.message?.content; + if (content != null) return { ok: true, text: content.trim() }; + throw new Error('Unexpected response shape'); +} + +async function testLegacyModelsEndpoint() { + const url = `${HUGGINGFACE_INFERENCE_URL}/models/${HUGGINGFACE_TEXT_MODEL}`; + const body = { + inputs: 'Say "Hello" in one word.', + parameters: { max_new_tokens: 10, temperature: 0.1, return_full_text: false }, + }; + const response = await axios.post(url, body, { headers, timeout: 30000 }); + const { data } = response; + const text = + typeof data === 'string' + ? data + : Array.isArray(data) && data[0]?.generated_text + ? data[0].generated_text + : data?.generated_text; + if (text != null) return { ok: true, text }; + throw new Error('Unexpected response shape'); +} + +async function testHuggingFace() { + console.log('๐Ÿ”‘ HuggingFace API connection test\n'); + console.log(' HUGGINGFACE_API_URL:', HUGGINGFACE_INFERENCE_URL); + console.log(' HUGGINGFACE_TEXT_MODEL:', HUGGINGFACE_TEXT_MODEL); + console.log( + ' API key set:', + HUGGINGFACE_API_KEY ? `${HUGGINGFACE_API_KEY.slice(0, 10)}...` : 'NO', + ); + + if (!HUGGINGFACE_API_KEY) { + console.log('\nโŒ FAIL: HUGGINGFACE_API_KEY is missing in .env'); + process.exit(1); + } + + // 1. Try v1 chat completions (router recommendation) + try { + const result = await testV1ChatCompletions(); + console.log('\nโœ… SUCCESS: HuggingFace API is connected (v1/chat/completions).'); + console.log(' Response:', result.text); + process.exit(0); + } catch (err) { + const status = err.response?.status; + const resData = err.response?.data; + if (status === 401) { + console.log('\nโŒ FAIL: Invalid API key (401). Check HUGGINGFACE_API_KEY in .env'); + if (resData) console.log(' Body:', JSON.stringify(resData).slice(0, 200)); + process.exit(1); + } + if (status === 403) { + console.log('\nโŒ FAIL: Forbidden (403). Token may lack Inference permissions.'); + process.exit(1); + } + console.log('\n v1/chat/completions:', status || err.code || err.message); + } + + // 2. Fallback: legacy /models/{model} + try { + const result = await testLegacyModelsEndpoint(); + console.log('\nโœ… SUCCESS: HuggingFace API is connected (legacy /models endpoint).'); + console.log(' Response:', result.text); + process.exit(0); + } catch (err) { + const status = err.response?.status; + const resData = err.response?.data; + console.log('\nโŒ FAIL: HuggingFace request failed.'); + console.log(' Status:', status || err.code || 'N/A'); + if (status === 401) + console.log(' โ†’ Invalid or expired API key. Check HUGGINGFACE_API_KEY in .env'); + if (status === 403) + console.log(' โ†’ Access forbidden. Model may require gated access or paid tier.'); + if (status === 404) + console.log( + ' โ†’ Model not on router. Try a model from https://huggingface.co/models?inference_provider=all&other=conversational', + ); + if (status === 503) console.log(' โ†’ Model is loading. Retry in a minute.'); + if (resData) + console.log( + ' Body:', + typeof resData === 'object' ? JSON.stringify(resData).slice(0, 300) : resData, + ); + console.log(' Error:', err.message); + process.exit(1); + } +} + +testHuggingFace(); From 07575b0f6938c7cdbca6997a1923b185ea72fe30 Mon Sep 17 00:00:00 2001 From: Ashutosh Mishra Date: Wed, 8 Apr 2026 07:28:32 -0700 Subject: [PATCH 2/3] feat(chatbot): add protected document indexing endpoints - add Pinecone document metadata model with hash+namespace uniqueness - add list/upload/reindex endpoints under /chatbot/documents - enforce role-based access (Owner/Administrator/Manager, env-overridable) - implement hashing/chunking/index upsert and reindex-by-filehash flow - harden Modal embedding URL/payload fallback behavior - add route tests for success, validation, and unauthorized access --- src/models/pineconeDocument.js | 52 +++++ src/routes/chatbotRouter.js | 67 ++++++ src/routes/chatbotRouter.test.js | 134 ++++++++++++ src/services/chatbotService.js | 365 +++++++++++++++++++++++++++++-- 4 files changed, 597 insertions(+), 21 deletions(-) create mode 100644 src/models/pineconeDocument.js create mode 100644 src/routes/chatbotRouter.test.js diff --git a/src/models/pineconeDocument.js b/src/models/pineconeDocument.js new file mode 100644 index 000000000..1673e5d5a --- /dev/null +++ b/src/models/pineconeDocument.js @@ -0,0 +1,52 @@ +const mongoose = require('mongoose'); + +const pineconeDocumentSchema = new mongoose.Schema( + { + filename: { + type: String, + required: true, + trim: true, + }, + fileHash: { + type: String, + required: true, + lowercase: true, + trim: true, + }, + namespace: { + type: String, + default: '', + trim: true, + }, + size: { + type: Number, + default: 0, + }, + status: { + type: String, + enum: ['uploaded', 'indexing', 'indexed', 'failed'], + default: 'uploaded', + }, + chunkCount: { + type: Number, + default: 0, + }, + chunks: { + type: [String], + default: [], + }, + lastIndexedAt: { + type: Date, + default: null, + }, + errorMessage: { + type: String, + default: '', + }, + }, + { timestamps: true }, +); + +pineconeDocumentSchema.index({ namespace: 1, fileHash: 1 }, { unique: true }); + +module.exports = mongoose.model('PineconeDocument', pineconeDocumentSchema, 'pineconeDocuments'); diff --git a/src/routes/chatbotRouter.js b/src/routes/chatbotRouter.js index 7920b2551..ef89009bb 100644 --- a/src/routes/chatbotRouter.js +++ b/src/routes/chatbotRouter.js @@ -1,8 +1,36 @@ const express = require('express'); const chatbotService = require('../services/chatbotService'); +const upload = require('../middleware/multerMiddleware'); const router = express.Router(); +const DEFAULT_ALLOWED_DOCUMENT_ROLES = ['Owner', 'Administrator', 'Manager']; + +const getAllowedDocumentRoles = () => { + const configuredRoles = process.env.CHATBOT_DOCUMENT_ALLOWED_ROLES; + if (!configuredRoles || !configuredRoles.trim()) { + return DEFAULT_ALLOWED_DOCUMENT_ROLES; + } + + return configuredRoles + .split(',') + .map((role) => role.trim()) + .filter(Boolean); +}; + +const requireDocumentAccess = (req, res, next) => { + const requestorRole = req.body?.requestor?.role; + const allowedRoles = getAllowedDocumentRoles(); + + if (!requestorRole || !allowedRoles.includes(requestorRole)) { + return res.status(403).json({ + error: 'You are not authorized to manage chatbot documents.', + }); + } + + return next(); +}; + router.post('/chatbot/query', (req, res) => { const { message, history } = req.body || {}; const normalizedHistory = Array.isArray(history) ? history : []; @@ -21,4 +49,43 @@ router.post('/chatbot/query', (req, res) => { }); }); +router.get('/chatbot/documents', requireDocumentAccess, async (req, res) => { + try { + const { namespace = '' } = req.query || {}; + const result = await chatbotService.listDocuments(namespace); + res.status(200).json(result); + } catch (err) { + res.status(500).json({ + error: process.env.NODE_ENV === 'development' ? err.message : 'Unable to list documents.', + }); + } +}); + +router.post( + '/chatbot/documents/upload', + upload.single('file'), + requireDocumentAccess, + async (req, res) => { + try { + const result = await chatbotService.uploadAndIndexDocument(req.file, req.body || {}); + res.status(200).json(result); + } catch (err) { + res.status(400).json({ + error: err.message || 'Unable to upload and index document.', + }); + } + }, +); + +router.post('/chatbot/documents/reindex', requireDocumentAccess, async (req, res) => { + try { + const result = await chatbotService.reindexByHash(req.body || {}); + res.status(200).json(result); + } catch (err) { + res.status(400).json({ + error: err.message || 'Unable to reindex by file hash.', + }); + } +}); + module.exports = router; diff --git a/src/routes/chatbotRouter.test.js b/src/routes/chatbotRouter.test.js new file mode 100644 index 000000000..a2948de0d --- /dev/null +++ b/src/routes/chatbotRouter.test.js @@ -0,0 +1,134 @@ +const express = require('express'); +const request = require('supertest'); + +jest.mock('../services/chatbotService', () => ({ + getChatbotReply: jest.fn(), + listDocuments: jest.fn(), + uploadAndIndexDocument: jest.fn(), + reindexByHash: jest.fn(), +})); + +jest.mock('../middleware/multerMiddleware', () => ({ + single: jest.fn(() => (req, res, next) => { + req.file = { + originalname: 'doc.txt', + size: 12, + buffer: Buffer.from('hello world'), + }; + next(); + }), +})); + +const chatbotService = require('../services/chatbotService'); +const router = require('./chatbotRouter'); + +describe('chatbotRouter document management endpoints', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.body = req.body || {}; + if (!req.body.requestor) { + req.body.requestor = { requestorId: 'test-user', role: 'Administrator' }; + } + next(); + }); + app.use('/', router); + }); + + test('GET /chatbot/documents returns document list', async () => { + chatbotService.listDocuments.mockResolvedValue({ + documents: [{ filename: 'doc.txt', fileHash: 'a'.repeat(64) }], + }); + + const res = await request(app).get('/chatbot/documents?namespace=test-space'); + + expect(res.status).toBe(200); + expect(res.body.documents).toHaveLength(1); + expect(chatbotService.listDocuments).toHaveBeenCalledWith('test-space'); + }); + + test('GET /chatbot/documents returns 500 on error', async () => { + chatbotService.listDocuments.mockRejectedValue(new Error('db down')); + + const res = await request(app).get('/chatbot/documents'); + + expect(res.status).toBe(500); + expect(res.body.error).toBeDefined(); + }); + + test('POST /chatbot/documents/upload uploads and indexes document', async () => { + chatbotService.uploadAndIndexDocument.mockResolvedValue({ + message: 'Document uploaded and indexed successfully.', + }); + + const res = await request(app) + .post('/chatbot/documents/upload') + .field('fileHash', 'a'.repeat(64)) + .field('namespace', 'demo'); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('uploaded'); + expect(chatbotService.uploadAndIndexDocument).toHaveBeenCalled(); + }); + + test('POST /chatbot/documents/upload returns 400 on validation error', async () => { + chatbotService.uploadAndIndexDocument.mockRejectedValue(new Error('hash mismatch')); + + const res = await request(app) + .post('/chatbot/documents/upload') + .field('fileHash', 'b'.repeat(64)); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('hash mismatch'); + }); + + test('POST /chatbot/documents/reindex reindexes by hash', async () => { + chatbotService.reindexByHash.mockResolvedValue({ + message: 'Document reindexed successfully by file hash.', + }); + + const res = await request(app) + .post('/chatbot/documents/reindex') + .send({ fileHash: 'a'.repeat(64), namespace: 'demo' }); + + expect(res.status).toBe(200); + expect(res.body.message).toContain('reindexed'); + expect(chatbotService.reindexByHash).toHaveBeenCalledWith( + expect.objectContaining({ + fileHash: 'a'.repeat(64), + namespace: 'demo', + }), + ); + }); + + test('POST /chatbot/documents/reindex returns 400 on error', async () => { + chatbotService.reindexByHash.mockRejectedValue(new Error('not found')); + + const res = await request(app) + .post('/chatbot/documents/reindex') + .send({ fileHash: 'c'.repeat(64) }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('not found'); + }); + + test('GET /chatbot/documents returns 403 for unauthorized role', async () => { + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.body = req.body || {}; + req.body.requestor = { requestorId: 'test-user', role: 'Volunteer' }; + next(); + }); + app.use('/', router); + + const res = await request(app).get('/chatbot/documents'); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('not authorized'); + }); +}); diff --git a/src/services/chatbotService.js b/src/services/chatbotService.js index 8467724bf..85a814a90 100644 --- a/src/services/chatbotService.js +++ b/src/services/chatbotService.js @@ -1,4 +1,7 @@ +/* eslint-disable no-use-before-define */ +const crypto = require('crypto'); const axios = require('axios'); +const PineconeDocument = require('../models/pineconeDocument'); const { PINECONE_API_KEY } = process.env; const PINECONE_INDEX_NAME = process.env.PINECONE_INDEX || 'hgn-chatbot'; @@ -12,6 +15,286 @@ const HUGGINGFACE_TEXT_MODEL = process.env.HUGGINGFACE_TEXT_MODEL || 'Qwen/Qwen2 const TOP_K = parseInt(process.env.CHATBOT_TOP_K || '3', 10); +const MAX_CHUNK_LENGTH = parseInt(process.env.CHATBOT_DOC_CHUNK_SIZE || '1200', 10); +const CHUNK_OVERLAP = parseInt(process.env.CHATBOT_DOC_CHUNK_OVERLAP || '150', 10); + +function isValidSha256(hash) { + return typeof hash === 'string' && /^[a-f0-9]{64}$/i.test(hash); +} + +function normalizeNamespace(namespace) { + return typeof namespace === 'string' ? namespace.trim() : ''; +} + +function decodeFileText(buffer) { + if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return ''; + // UTF-8 decode works for plain text formats and gracefully degrades for unknown text-like files. + return buffer.toString('utf8').split('\u0000').join(' ').trim(); +} + +function chunkText(text, maxLength = MAX_CHUNK_LENGTH, overlap = CHUNK_OVERLAP) { + if (!text) return []; + + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) return []; + + const chunks = []; + let start = 0; + + while (start < normalized.length) { + let end = Math.min(start + maxLength, normalized.length); + + if (end < normalized.length) { + const breakAt = normalized.lastIndexOf(' ', end); + if (breakAt > start + 100) { + end = breakAt; + } + } + + const chunk = normalized.slice(start, end).trim(); + if (chunk) chunks.push(chunk); + + if (end >= normalized.length) break; + start = Math.max(end - overlap, start + 1); + } + + return chunks; +} + +async function upsertPineconeVectors(vectors, namespace = '') { + if (!PINECONE_API_KEY) throw new Error('PINECONE_API_KEY is not configured.'); + + const host = + PINECONE_HOST || + `${PINECONE_INDEX_NAME}.svc.${process.env.PINECONE_ENVIRONMENT || 'gcp-starter'}.pinecone.io`; + + const url = `https://${host}/vectors/upsert`; + const body = { vectors }; + if (namespace) body.namespace = namespace; + + await axios.post(url, body, { + headers: { + 'Api-Key': PINECONE_API_KEY, + 'Content-Type': 'application/json', + 'X-Pinecone-Api-Version': process.env.PINECONE_API_VERSION || '2024-07', + }, + timeout: 30000, + }); +} + +async function deletePineconeVectorsByHash(fileHash, namespace = '') { + if (!PINECONE_API_KEY) throw new Error('PINECONE_API_KEY is not configured.'); + + const host = + PINECONE_HOST || + `${PINECONE_INDEX_NAME}.svc.${process.env.PINECONE_ENVIRONMENT || 'gcp-starter'}.pinecone.io`; + const url = `https://${host}/vectors/delete`; + + const body = { + deleteAll: false, + filter: { + fileHash: { $eq: fileHash }, + }, + }; + + if (namespace) body.namespace = namespace; + + await axios.post(url, body, { + headers: { + 'Api-Key': PINECONE_API_KEY, + 'Content-Type': 'application/json', + 'X-Pinecone-Api-Version': process.env.PINECONE_API_VERSION || '2024-07', + }, + timeout: 20000, + }); +} + +async function indexDocumentBuffer({ buffer, filename, fileHash, namespace = '' }) { + const text = decodeFileText(buffer); + if (!text) { + throw new Error('Uploaded file does not contain readable text content.'); + } + + const chunks = chunkText(text); + if (!chunks.length) { + throw new Error('Unable to build document chunks for indexing.'); + } + + const chunkCount = await upsertDocumentChunks({ chunks, filename, fileHash, namespace }); + return { chunkCount, chunks }; +} + +async function upsertDocumentChunks({ chunks, filename, fileHash, namespace = '' }) { + if (!Array.isArray(chunks) || !chunks.length) { + throw new Error('No chunks available for indexing.'); + } + + const vectors = []; + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + const values = await getEmbedding(chunk); + vectors.push({ + id: `${fileHash}-${i + 1}`, + values, + metadata: { + text: chunk, + source_document: filename, + fileHash, + filename, + chunkIndex: i + 1, + }, + }); + } + + await upsertPineconeVectors(vectors, namespace); + return chunks.length; +} + +async function listDocuments(namespace = '') { + const normalizedNamespace = normalizeNamespace(namespace); + const query = normalizedNamespace ? { namespace: normalizedNamespace } : {}; + + const documents = await PineconeDocument.find(query).sort({ updatedAt: -1 }).lean(); + + return { + documents: documents.map((doc) => ({ + filename: doc.filename, + fileHash: doc.fileHash, + namespace: doc.namespace, + size: doc.size, + status: doc.status, + chunkCount: doc.chunkCount, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + lastIndexedAt: doc.lastIndexedAt, + errorMessage: doc.errorMessage, + })), + }; +} + +async function uploadAndIndexDocument(file, payload = {}) { + if (!file || !file.buffer) { + throw new Error('No file uploaded.'); + } + + const namespace = normalizeNamespace(payload.namespace); + const receivedHash = (payload.fileHash || '').toLowerCase().trim(); + if (!isValidSha256(receivedHash)) { + throw new Error('fileHash must be a valid SHA-256 hex string.'); + } + + const computedHash = crypto.createHash('sha256').update(file.buffer).digest('hex'); + if (computedHash !== receivedHash) { + throw new Error('Provided fileHash does not match uploaded file content.'); + } + + const filename = file.originalname || `document-${Date.now()}`; + + const doc = await PineconeDocument.findOneAndUpdate( + { namespace, fileHash: receivedHash }, + { + $set: { + filename, + size: file.size || file.buffer.length, + status: 'indexing', + errorMessage: '', + }, + $setOnInsert: { + namespace, + fileHash: receivedHash, + }, + }, + { upsert: true, new: true }, + ); + + try { + // Best effort cleanup of previously indexed chunks before re-upsert. + await deletePineconeVectorsByHash(receivedHash, namespace); + } catch (cleanupError) { + console.warn(`Pinecone cleanup skipped for ${receivedHash}: ${cleanupError.message}`); + } + + try { + const { chunkCount, chunks } = await indexDocumentBuffer({ + buffer: file.buffer, + filename, + fileHash: receivedHash, + namespace, + }); + + doc.status = 'indexed'; + doc.chunkCount = chunkCount; + doc.chunks = chunks; + doc.lastIndexedAt = new Date(); + await doc.save(); + + return { + message: 'Document uploaded and indexed successfully.', + document: { + filename: doc.filename, + fileHash: doc.fileHash, + namespace: doc.namespace, + size: doc.size, + status: doc.status, + chunkCount: doc.chunkCount, + updatedAt: doc.updatedAt, + }, + }; + } catch (err) { + doc.status = 'failed'; + doc.errorMessage = err.message; + await doc.save(); + throw err; + } +} + +async function reindexByHash({ fileHash, namespace = '' }) { + const hash = (fileHash || '').toLowerCase().trim(); + if (!isValidSha256(hash)) { + throw new Error('fileHash must be a valid SHA-256 hex string.'); + } + + const normalizedNamespace = normalizeNamespace(namespace); + const doc = await PineconeDocument.findOne({ fileHash: hash, namespace: normalizedNamespace }); + if (!doc) { + throw new Error('Document not found for this namespace and hash.'); + } + + // Reindex by removing existing vectors and marking for re-upload. + await deletePineconeVectorsByHash(hash, normalizedNamespace); + + const chunks = Array.isArray(doc.chunks) ? doc.chunks.filter(Boolean) : []; + if (!chunks.length) { + throw new Error('Document has no stored chunks. Re-upload the file once to enable reindexing.'); + } + + doc.status = 'indexing'; + doc.errorMessage = ''; + + const chunkCount = await upsertDocumentChunks({ + chunks, + filename: doc.filename, + fileHash: doc.fileHash, + namespace: normalizedNamespace, + }); + + doc.status = 'indexed'; + doc.chunkCount = chunkCount; + doc.lastIndexedAt = new Date(); + await doc.save(); + + return { + message: 'Document reindexed successfully by file hash.', + document: { + filename: doc.filename, + fileHash: doc.fileHash, + namespace: doc.namespace, + status: doc.status, + updatedAt: doc.updatedAt, + }, + }; +} + // --- 1.5. MODAL EMBEDDING SERVICE --- async function getEmbedding(text) { const input = text.slice(0, 8000); @@ -20,31 +303,68 @@ async function getEmbedding(text) { throw new Error('EMBEDDING_SERVICE_URL is missing in .env'); } - const url = process.env.EMBEDDING_SERVICE_URL.replace(/\/$/, ''); + const baseUrl = process.env.EMBEDDING_SERVICE_URL.replace(/\/$/, ''); + const candidateUrls = []; + + // Modal supports multiple styles: + // 1) web_endpoint URLs that already represent the callable function URL. + // 2) ASGI app base URLs where embedding lives at /embed. + candidateUrls.push(baseUrl); + if (baseUrl.endsWith('/embed')) { + candidateUrls.push(baseUrl.slice(0, -'/embed'.length)); + } else { + candidateUrls.push(`${baseUrl}/embed`); + } + + const dedupedCandidateUrls = [...new Set(candidateUrls.filter(Boolean))]; const timeoutMs = parseInt(process.env.EMBEDDING_SERVICE_TIMEOUT_MS || '60000', 10); - try { - const response = await axios.post( - url, - { inputs: input }, // Modal modal_service.py EmbedRequest.inputs - { timeout: timeoutMs }, - ); - - // Modal modal_service.py returns flat array; some deployments return { embedding: [...] } - if (Array.isArray(response.data)) { - return response.data; - } - if (response.data && Array.isArray(response.data.embedding)) { - return response.data.embedding; + let lastError = null; + const payloadVariants = [{ inputs: input }, { text: input }]; + + for (let i = 0; i < dedupedCandidateUrls.length; i += 1) { + const url = dedupedCandidateUrls[i]; + + for (let j = 0; j < payloadVariants.length; j += 1) { + const payload = payloadVariants[j]; + + try { + const response = await axios.post(url, payload, { timeout: timeoutMs }); + + // Modal returns flat array; some deployments return { embedding: [...] } + if (Array.isArray(response.data)) { + return response.data; + } + if (response.data && Array.isArray(response.data.embedding)) { + return response.data.embedding; + } + throw new Error( + 'Embedding service returned unexpected format (expected array or { embedding }).', + ); + } catch (err) { + lastError = err; + const status = err.response?.status; + const responseText = + typeof err.response?.data === 'string' + ? err.response.data + : JSON.stringify(err.response?.data || err.message); + + const hasPayloadFallback = j < payloadVariants.length - 1; + const hasUrlFallback = i < dedupedCandidateUrls.length - 1; + const shouldContinue = hasPayloadFallback || hasUrlFallback; + + if (!shouldContinue) { + throw new Error( + `Modal Service Error [${status} @ ${url} payload=${Object.keys(payload)[0]}]: ${responseText}`, + ); + } + } } - throw new Error( - 'Embedding service returned unexpected format (expected array or { embedding }).', - ); - } catch (err) { - const status = err.response?.status; - const message = err.response?.data ?? err.message; - throw new Error(`Modal Service Error [${status}]: ${JSON.stringify(message)}`); } + + const status = lastError?.response?.status; + const message = lastError?.response?.data ?? lastError?.message; + throw new Error(`Modal Service Error [${status}]: ${JSON.stringify(message)}`); } function cleanContextText(text) { @@ -229,6 +549,9 @@ module.exports = { getChatbotReply, getEmbedding, queryPinecone, + listDocuments, + uploadAndIndexDocument, + reindexByHash, }; // const axios = require('axios'); From c5d7ab270e97e4cca1309e9b19ba216f8584e796 Mon Sep 17 00:00:00 2001 From: Ashutosh Mishra Date: Mon, 25 May 2026 12:00:52 -0400 Subject: [PATCH 3/3] feat(chatbot): add modal changes --- .../__pycache__/app.cpython-313.pyc | Bin 0 -> 5090 bytes .../__pycache__/modal.cpython-313.pyc | Bin 0 -> 2460 bytes .../__pycache__/modal_service.cpython-313.pyc | Bin 0 -> 2468 bytes modal/modal-embed-service/app.py | 163 ++++++++++++++++++ modal/modal-embed-service/modal_service.py | 46 +++++ modal/modal-embed-service/requirements.txt | 4 + 6 files changed, 213 insertions(+) create mode 100644 modal/modal-embed-service/__pycache__/app.cpython-313.pyc create mode 100644 modal/modal-embed-service/__pycache__/modal.cpython-313.pyc create mode 100644 modal/modal-embed-service/__pycache__/modal_service.cpython-313.pyc create mode 100644 modal/modal-embed-service/app.py create mode 100644 modal/modal-embed-service/modal_service.py create mode 100644 modal/modal-embed-service/requirements.txt diff --git a/modal/modal-embed-service/__pycache__/app.cpython-313.pyc b/modal/modal-embed-service/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8e50fea973d3bf83dcbc71c0b9c3af9eeaf8894 GIT binary patch literal 5090 zcmaJ^TWk|o8a{LL-OfGEjbs8T)Re?PfYjkqFfoCI1lEH~+qgTL*kfnF9=m78KrFQi z>;?K%&=x^=tKGD!RCb^8un(-d()RMQ537}3Q<2Wng0$TS-a>;^wc3aM&p2ZTy6Ulf z=FB<&IsbqD%lH4Y>h-!1wDC9oscdaU=-=eUYL2zRDorDF9SKOFCK0YaDNMmTJxNb6 zn3-TPJHcU&Cu_{4ZNiT2=9ryyOgOO<#vFDDHtZJcT?l*PRJ6-tQh!D_)W_CHa0t#2 z745U$>rcTYxCM{k-OX0#qW$$XYZUkFrmADy1Uv_<`TBDs5`6I{p&>-$mhA|)#+^cA zoY_SSOpw%X4ZK4>D$9hIe|j02S63`?3kl}pMQcxjQec{P#9sk(eY&q}J4mT_@IcK&P* z2lM&Ng+VzxD<=of?HJrXv}bm3d^~ofXbWb^?t~>Dd4ie{QTA#_)gdWxKr}(AXo6NL z9?g(-vkAi2a_8^oSj=HVW>QBLD zo%8J4WD+Eq0%wjfC+VqPgxrWnDyuRvwnov6(2UnQL$KF*`_M3LohMT>4K*4^7mBeh zH6z4Wf#P6sH}E1&<<{NQ8ROuFu9?Q_8miuxv(AP4f~(ef#<)7A;0`gMHIT2`q5uF- zH`rS{nS@)2h8=#3s=fv?_=ahr!IF_ipaUk_X;Idnv30TmT)@xT^MnN(>o-V)Re1QT zObYd*ZK$SM2<{ZD^nuQKG~KtK=|d?h&@_#Sq5`zhFqabN3bSG|cV5lpq@>Z1Q8Zn| zay}=fl#HwuJC4YDVop)hJbCBIGM~b^EMH`XhK9g(?Iy93EOJBXbRkvrPv>~zpC-M= zr*Z`~$txfK@V*=sFX%-&hthz7}I2|1to2+py#x? zY-l7nCTnN)Ts}B8&yN+d`QQmv<`3tx*@CL*^Za-wH!EdG&`FuKc!U$qDG50! z8Tlww-ZAzE>K*F+(eE`9rp^7a>OnvGSPRn2jFv%!iY#QRn)(l7trRVqn zgGq224nm|xi6f-A2fnRf9gEEhyBEEgZ{I|=EHlL z&-S{;oc1C+JhWp6_=7J$ug~RF@f?H%q^V+43euO9S7xABlM+t8YB=)qNlDd}1SXJG zY|Lb&tTdR&=Lf5Mi|uo1b&y!($>1O%sAM6b84QHU6DUB;#6Uc*4nee6Q2>&EHDghL zbp+DQHbW@Q1}Mq^in10W0*XRziV_EWs#$39|FV>|rZPXm*Loh}PBXyG_M^v6zw0s|%po9F`&(E<<*xN!JnjL*v19G(a8HmKmKBT%cjOd>HaL5ModLWAr# z?BEQuIZZY=b3Nb$3?ZCEusI$CZg?Z<2!t6dhO)-sG#x8>Oe$Bynaxz8PcyhgMwW1Z z#m^CXb0-O3ycs%;WC9YY)j)M*l$5OHB^8HYUL^#-fQ~@qf7m_<3zzRlfza;1k-C=p z&D;(3np$b!e8p{s0|_Bl$-^&pFM3OZ2TQI)ci8ZkoxS(k`>xI`Qj59Z3V&o; z9q%V2(h*rSQlnl*%btc`PvWE23RJtM-VE=d0w5JuiY5;NE>cF3G*X6`_c6TEdV&N= z_f{i#LfmOJT*I3gYYj!$Q?AWMYY0>$gU=JI?jp!zRa#Pn)FQRFU1(jc4b{v~;FEQl zK#xM5nS?O@X?lSnLdEFO^9V_hb7yK*xWEdWU<=VuDSIq=1v{ZOE6rN$8?bDhqYBgX|ZaX74i3;$qwYGBL#nk(VD5Jw7Dmn{o|$ z!xK|2i^xeR!`#Fn3YwUJj~Bz1lyynTU{VVjY*K|g_)et<)jFCO*|{lSD896 z;0-vdY&6z3LmEuzIjj++C)i}6lgp$K$u}B)71pl6U%L(+!6yKYj;?f$PDdOlzlr|u4nlm|vC1EC5(di!S;{=mnBcf+xAI93UtF1_?}Is8iL z=V~Ra-fQl=b@;Zg+&o_LjDO>4yP7V0dKV%qHq_X5x8a#`!!wKN57qb7O2h7jqs#uj zMO)dw>mN158T})w)=Zi{7c~t;XW!2GLm-iVG{a>#1o43>keu%s80{D z29wAa48N3tk{RryCJGn>B8gRSFxtuYsRRL!MI{NCpetE9SI`Z6b-(rmC=mxBz_dzI z;&oJyvjG&KNL_9}FVBK0@+KHm1Kq6EID(2YPQkX{!Cxbx1KMpwo|c7alP5b*S}&%lx@X>?4HZYC8gB(>=IYD?O9;J3B3&q&IeTM3hkrX zZ`xN8JXRS*x0w@%T6NP4_QxKIYP=eHjNkzW(9X*}7kjSERVY6c z*8N)+^`#fe{NYDj1J$$a?Ob8t`Jko!X6v=l$7GJ2-myZ%Xk`G=9NE0=a4+}tEq8BN z_BOv2e=~m9yQ%EmR2tY*@s5-{&#yQU?OQ<{O~Vyv`{mwCy%nnI5lvAo*`{{tCigR=kt literal 0 HcmV?d00001 diff --git a/modal/modal-embed-service/__pycache__/modal.cpython-313.pyc b/modal/modal-embed-service/__pycache__/modal.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9833970bae9cdf29e373ada101f6039c601d6c65 GIT binary patch literal 2460 zcmZt{O>YxNboSGqv6F%maA>gtLk zfM0|k%yTkAf3ZUw;2c0J$szO*sYvA}5oRkt$z9$*J9W1 zGdqoLk&5a4JXKO^c=KSm--(YQHQJF$971r8Wsw>$@ol|0 zSxOZ9cisv2rI?yflWNzv5Zp?^t@J1#^4eD8RF%thZ!+R^j-#>jIx(i4iec3g=O-pE z7v`(RaP4$CycA;Q4f|Rr0YMx&h#$Jb*+@aNVBMS;ia}(yJX25=<%DU>o_@^H1FB(r zhFvyhux^tD2iFXo6KIBPku`6dPB6aPg`(Ju!6=OF$q@vqEPIv&6uppp!-4 zbI4+Ce5`Q6AfI`TTbNo>F8DRKaLqQ9kDOY~w@q(Jxnw!>x>cw-72O&(YV$^A7&P26 z%SK4cyGt}xac99&+k_bgWJ-B0d|DpS`|06jz{ggF&qUgfZG0+qy)r1C?+feMwYVosvTi_H3> zanm=5w|NLyR5oqb_sC<&WQ0aF&DLv%rqQ^@9Pur-PioptU$?e6X-!)&G4U+ZHf#sj z0`V{fs&3nkr+cPj6QE`^4IK2$vZi|;Hs^iM05X0J-oy!pSadKe{{S&tNCeO-`aPMx zI~mF_Hs!sM&Cg*2>6XlzUNxvtb$!}fc5F`vBd}&xz)sJs8IJGmich<$TDRc-?)a3% z|LfIboz7JGY-f*UkWPV$Jpf$=oj=a0QYkvZt8x~7Dt3A#rbckQ8qLLMcM$c8X;;;- zY&MU8qViUu2Rb|L@dX@rvb^p70Q?>-lCxdwXB%7=O~x1&nh*e=HMme_M%}2>`n$0NHz9IKIqn<+3I~v z3%IPt*{d23XH zVuV<2byfxOaW&@>ibpddvt3}8f@~iH2A30La;EYp%x>AbB!s@rSS+XH0mx@ zbQ=aGW&sQeJ@|W2>2fga5*1_K)NPGeW{oCX)74BH+Rd_P68pBc;M-+3b1^fL3KOnN z1>JQ6*QijoNZ`R9*J$)?INi_T58)b{MC3l8roeIBA1L`8y#bD<`j_K>M|p0bnM6{2 zb#i5LL+oD@``5+SUr606a}N$Tj+}n-@w)WE3#n(N^dP@39c@MsCqpPA$yM`?+2}sL zE)OgVuVjwvelY$D!2${ArPaQbzI*pF literal 0 HcmV?d00001 diff --git a/modal/modal-embed-service/__pycache__/modal_service.cpython-313.pyc b/modal/modal-embed-service/__pycache__/modal_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2148ca80a0f4545b5be2bbbfa06ac862c89b5f85 GIT binary patch literal 2468 zcmZuy&2JM&6rcU{XY8cl1RUBf5E9%HyDf>LP)ZdFLJ$&=m#7kPwc6MlXWRAeGP4Vf z)k8uh!hr)lpmOM;$0AiN$NmZJ&1s5KrbQfj;$~{9Ui)U(cD_2&zL|M%=FR*3ZL6mz zj$r&E{9vAu5&Da5Is^8>XeBv>9v~H|+%&>$=BK$!JmwXo3RqCZEW%<56$4G3%i&z? z*|}%C@iwcN-YZijRaPV8LMSQr?LAQv$H5vla17quAFj94BS?*Q&m;~Y*vGO+jhFb2 zUYsl?ii6wlgzHjFO{ht=XHp1mrQlY2oDXFkt8uEz<$7N*=4_6mv9mfcW}J#))e~o@ zrY;s1s>W#TWI4PPQsxc&TE~Gv965*|y2RN?L33c;yciyX$h19EP!;8bS)XNx2#;SoCVz~)SQZLjT*HDqcRE_ zZklBywL_FF82BMKgop`!y z*1MjJZuf*ro`cIv^}eO5z4Pfk(Pipd3Xlh=gfOQf%&Qz0j-irRl2m>KBv7exRm@2= zd7gPuG;a6?@m?GN5tU8b^*!fV4|yO|KeMsJcGwFFUrUgArIWD`2N*)(pq@cEqPsU9FpN ze`kD3;{WyRk#2{oe73vBGMr99XLj929>`c&-pN=%L5cr}`f(cU2H z71OS&Vfnl`1d7V-f)8|d+vE$_Zfg@xLG!GJgP{srO)|$p^K?L}+dM@U)LldEg2o)O zy1KS$mA152N1ouQM@HX32Ip}ZO>6IT%A*DM$N&?%B>~C4#eFID3EIGk$lj# zLDSU-m=TmLfFlci{X77 z1qM`tL#4o`98X1JSc}0K!mvV7f{LQ?ttO;{y91x!621v{$U8t*&_6PYo%u7`(@2f{ z7CrtnbNu`4MrLd?GqIkT_(|T#e0W#97Z2obJ&C>@R3;U#UQw?B2Ff+VTXHHi;_Ayx zdASXlpco+*?Ew5REFJ*h6&ZKeD6$i~#vE7dlVAf9<<`+ghXRJY8!p#|v1(nW$4=0rRUDyR&|3wgrq zUqofku}tFS1k5l6pJ2TB)~U*+?Jx_TBaLu}1V*kcJ!=*g2a^;C-MC zvp*uvFwzess1@C!68l$R%>F<$dCOSPI#CGW=RJ1*AS1iLNnt!jSi6$5K<*$gMoz5F z-=6>Slb5_CooES2-q&QSW(p*m(!j6Mz{8vC(r}ZPrQwIA7J`YL8fh^`h-*w~E{(@v zqEUCbqT2wJm<2E>^x*G7rHcXBB`U^(soNT{%o(YMPCb1^fL z3RA921>JQ6*QijoNZ`RP*J$)~i0)_bhj5J{5xEDXDR3P32TDFeZ-Aqz!Ik*mQJx!W zCXp0hn_ivX6bIME!42{C=Th(L{QZNCLnj}9ydiz?TDM2;I990=rLItXmIcU%lC1^O#}oY#h-r3wn3!+ E0~1gsyZ`_I literal 0 HcmV?d00001 diff --git a/modal/modal-embed-service/app.py b/modal/modal-embed-service/app.py new file mode 100644 index 000000000..459cce962 --- /dev/null +++ b/modal/modal-embed-service/app.py @@ -0,0 +1,163 @@ +# from fastapi import FastAPI, HTTPException, Header +# from pydantic import BaseModel +# from transformers import AutoModel, AutoTokenizer +# import torch + +# app = FastAPI( +# title="Embedding Service", +# description="A small service that returns embeddings for text using a GPU-backed model.", +# version="0.1.0", +# ) + +# # Change this to the model you want to serve (e.g., BAAI/bge-m3) +# MODEL_NAME = "BAAI/bge-m3" + +# # Optional API key protection. Set this in the Modal environment as EMBEDDING_API_KEY. +# EXPECTED_API_KEY = None # set in runtime via environment variable + + +# class EmbedRequest(BaseModel): +# text: str + + +# class EmbedResponse(BaseModel): +# embedding: list[float] + + +# @app.on_event("startup") +# async def load_model(): +# global tokenizer, model +# tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) +# model = AutoModel.from_pretrained(MODEL_NAME) + +# # Prefer GPU if available (Modal T4), otherwise fallback to CPU. +# device = torch.device("cuda" if torch.cuda.is_available() else "cpu") +# model.to(device) + + +# @app.post("/embed", response_model=EmbedResponse) +# async def embed_text( +# request: EmbedRequest, +# authorization: str | None = Header(None, alias="Authorization"), +# ): +# if EXPECTED_API_KEY: +# if not authorization or authorization.split()[1] != EXPECTED_API_KEY: +# raise HTTPException(status_code=401, detail="Unauthorized") + +# inputs = tokenizer( +# request.text, +# return_tensors="pt", +# truncation=True, +# max_length=1024, +# ) + +# # Move inputs to the same device as the model +# device = next(model.parameters()).device +# inputs = {k: v.to(device) for k, v in inputs.items()} + +# with torch.no_grad(): +# outputs = model(**inputs) +# hidden = outputs.last_hidden_state +# embedding = hidden.mean(dim=1).squeeze().cpu().tolist() + +# return {"embedding": embedding} + +import os +import modal +from fastapi import FastAPI, HTTPException, Header +from pydantic import BaseModel +from contextlib import asynccontextmanager + +MODEL_REPO = "mykor/pplx-embed-v1-0.6b-GGUF" +MODEL_DIR = "/model" + +# 1. Auto-Detect and Download +def download_model(): + from huggingface_hub import hf_hub_download, list_repo_files + + print(f"Fetching file list from {MODEL_REPO}...") + files = list_repo_files(repo_id=MODEL_REPO) + + # Find all GGUF files in the repo + gguf_files = [f for f in files if f.endswith(".gguf")] + if not gguf_files: + raise ValueError(f"No GGUF files found in {MODEL_REPO}") + + # Prefer an 8-bit quantized file, but fallback to whatever is available (like F16) + target_file = next((f for f in gguf_files if "q8_0" in f.lower()), gguf_files[0]) + + print(f"Found target file: {target_file}. Downloading to {MODEL_DIR}...") + hf_hub_download(repo_id=MODEL_REPO, filename=target_file, local_dir=MODEL_DIR) + + # Write the detected file name to a text file so the FastAPI lifespan can read it later + os.makedirs(MODEL_DIR, exist_ok=True) + with open(f"{MODEL_DIR}/filename.txt", "w") as f: + f.write(target_file) + print("Download and caching complete!") + +# 2. Build the Image +image = ( + modal.Image.debian_slim(python_version="3.11") + .pip_install("fastapi[standard]", "pydantic", "huggingface_hub", "llama-cpp-python") + .run_function(download_model) +) + +app = modal.App("hgn-gguf-embed-service", image=image) + +# 3. Handle global state natively in FastAPI +ml_models = {} + +@asynccontextmanager +async def lifespan(app: FastAPI): + from llama_cpp import Llama + + # Read the text file we saved during the build phase to get the exact file name + with open(f"{MODEL_DIR}/filename.txt", "r") as f: + target_file = f.read().strip() + + print(f"Loading GGUF model: {target_file} into CPU memory...") + ml_models["model"] = Llama( + model_path=f"{MODEL_DIR}/{target_file}", + embedding=True, # Enables embedding generation + verbose=False # Suppress noisy logs + ) + yield + ml_models.clear() + +web_app = FastAPI( + title="HGN Embedding Service", + description="CPU-backed GGUF embedding service for the Agentic System.", + lifespan=lifespan +) + +class EmbedRequest(BaseModel): + inputs: str + +@web_app.post("/embed") +async def embed_text( + request: EmbedRequest, + authorization: str | None = Header(None, alias="Authorization"), +): + expected_api_key = os.environ.get("EMBEDDING_API_KEY") + if expected_api_key: + if not authorization or len(authorization.split()) < 2 or authorization.split()[1] != expected_api_key: + raise HTTPException(status_code=401, detail="Unauthorized") + + model = ml_models["model"] + + # Generate embedding + response = model.create_embedding(request.inputs) + embedding_vector = response["data"][0]["embedding"] + + return embedding_vector + +# 4. Deploy to Modal's CPU tier +@app.function( + cpu=2.0, + allow_concurrent_inputs=100, + container_idle_timeout=15, + secrets=[modal.Secret.from_name("embedding-secret")] +) +@modal.asgi_app() +def serve(): + return web_app \ No newline at end of file diff --git a/modal/modal-embed-service/modal_service.py b/modal/modal-embed-service/modal_service.py new file mode 100644 index 000000000..a4a0b565c --- /dev/null +++ b/modal/modal-embed-service/modal_service.py @@ -0,0 +1,46 @@ +import modal +from pydantic import BaseModel + +# 1. Define the model we want to use +MODEL_NAME = "BAAI/bge-m3" + +# 2. This function runs ONCE in the cloud during `modal deploy` +# It bakes the 2.2GB model directly into your container image so it boots instantly. +def download_model(): + from sentence_transformers import SentenceTransformer + SentenceTransformer(MODEL_NAME) + +# 3. Build the Image +image = ( + modal.Image.debian_slim() + .pip_install("fastapi", "sentence-transformers", "torch") + .run_function(download_model) # Trigger the download during build +) + +# 4. Initialize the App (Modal replaced 'Stub' with 'App') +app = modal.App("hgn-embed-service") + +# 5. Define the Request Payload (Matches your Node.js backend) +class EmbedRequest(BaseModel): + inputs: str + +# 6. Create the GPU Class +@app.cls(image=image, gpu="t4", container_idle_timeout=120) +class EmbeddingService: + + @modal.enter() + def load_model(self): + """Loads the model into GPU memory when the container starts.""" + from sentence_transformers import SentenceTransformer + print("Loading model to GPU...") + self.model = SentenceTransformer(MODEL_NAME).to("cuda") + print("Model loaded successfully!") + + @modal.web_endpoint(method="POST") + def embed(self, payload: EmbedRequest): + """The actual API endpoint.""" + # BGE-M3 expects a list of strings + embeddings = self.model.encode([payload.inputs], normalize_embeddings=True) + + # Return the flat array, exactly how your Node.js script expects it + return embeddings.tolist()[0] \ No newline at end of file diff --git a/modal/modal-embed-service/requirements.txt b/modal/modal-embed-service/requirements.txt new file mode 100644 index 000000000..cc955a4cc --- /dev/null +++ b/modal/modal-embed-service/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.103.0 +uvicorn[standard]==0.23.2 +transformers==4.34.0 +torch==2.1.1