diff --git a/README.md b/README.md index f109910..8c19dc5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Next.js TypeScript PostgreSQL - AWS Bedrock + OpenAI + Groq Gmail API Docker NextAuth @@ -51,7 +52,7 @@ Everything runs inside a single Next.js App Router project (frontend + backend + - [JUNOCHAT & AI](#future-enhancements-for-junochat--ai) 6. [Local Development](#local-development) 7. [Docker & Deployment](#docker--deployment) -8. [AWS Bedrock & AI Setup](#aws-bedrock--ai-setup) +8. [OpenAI, Groq & AI Setup](#openai-groq--ai-setup) 9. [Prisma & Database](#prisma--database) 10. [License](#license) @@ -74,7 +75,7 @@ JUNO centralizes communication and operations between **vendors** (Shopify store - Edit synced product price/quantity **per vendor** with inline controls. - Both sides are supported by: - - **JUNOCHAT** – an AI assistant powered by AWS Bedrock (Titan + Claude) that can answer questions based on the knowledge base and FAQs. + - **JUNOCHAT** – an AI assistant powered by **OpenAI** (embeddings + chat where configured) and **Groq** (fast chat for storefront widget, Gmail flows, and KB fallback) that can answer questions using the knowledge base, FAQs, and store context. - A **Juno Engine** area for Gmail and ticketing workflows. --- @@ -167,9 +168,10 @@ JUNO centralizes communication and operations between **vendors** (Shopify store JUNOCHAT (a.k.a. Juno Engine chat) is the AI layer that: -- Uses **AWS Bedrock**: - - **Amazon Titan Text Embeddings V2** for vector embeddings. - - **Anthropic Claude 3 Haiku** for chat and knowledge‑base answers. +- Uses **OpenAI** and **Groq**: + - **OpenAI** (`text-embedding-3-small`, etc.) for knowledge‑base **embeddings** stored in Postgres/pgvector. + - **OpenAI** (`gpt-4o-mini` by default, configurable) for **KB “Ask”** answers when `OPENAI_API_KEY` is set. + - **Groq** for **storefront widget** replies, **Gmail** classification/safety/preview flows, and **KB fallback** if OpenAI chat fails. - Supports: - FAQ ingestion and retrieval. - Knowledge document upload + chunking. @@ -217,7 +219,7 @@ JUNOCHAT (a.k.a. Juno Engine chat) is the AI layer that: - **Background / Integrations**: - Gmail APIs (OAuth) - Shopify APIs (orders, products, inventory) - - AWS Bedrock (Titan embeddings + Claude 3 Haiku chat) + - OpenAI (embeddings + optional chat) and Groq (chat / classification) - **Deployment**: Docker + GitHub Actions → AWS EC2 --- @@ -277,7 +279,7 @@ JUNOCHAT (a.k.a. Juno Engine chat) is the AI layer that: **How JUNO helps:** - **JUNOCHAT + Juno Engine**: - - Ingest FAQs and internal docs into a vectorized knowledge base (via Bedrock embeddings). + - Ingest FAQs and internal docs into a vectorized knowledge base (via OpenAI embeddings). - Answer questions using **RAG** against that knowledge. - Integrate with Gmail so tickets and AI context live in the same place. @@ -307,9 +309,9 @@ Copy `.env.example` (or the variables listed below) into a local `.env` file and - `SMTP_SECURE` - `SMTP_USER` - `SMTP_PASSWORD` -- `AWS_ACCESS_KEY_ID` (for AWS Bedrock) -- `AWS_SECRET_ACCESS_KEY` (for AWS Bedrock) -- `AWS_REGION` (default: `us-east-1`) +- `OPENAI_API_KEY` (embeddings + KB chat; optional `OPENAI_CHAT_MODEL`, `OPENAI_EMBEDDING_MODEL`, `OPENAI_EMBEDDING_DIMENSIONS`) +- `GROQ_API_KEY` (storefront chat, Gmail AI helpers, KB chat fallback; optional `GROQ_MODEL`) +- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_REGION` (if you use S3 or other AWS services—not required for AI-only local dev) - `ADMIN_EMAIL` - `ADMIN_PASSWORD` @@ -376,51 +378,34 @@ This gives you a sharp deployment with an automatic rollback path. --- -## AWS Bedrock & AI Setup +## OpenAI, Groq & AI Setup -The knowledge base and JUNOCHAT features use AWS Bedrock for AI‑powered embeddings and chat completion. +### OpenAI (embeddings + KB answers) -### Prerequisites - -1. **AWS Account**: Create an account at `https://aws.amazon.com/` -2. **Enable Bedrock Access**: - - Go to `https://console.aws.amazon.com/bedrock/` - - Navigate to **Model access** - - Request access to: - - **Amazon Titan Text Embeddings V2** (for embeddings) - - **Anthropic Claude 3 Haiku** (for chat completion) - -### IAM Setup - -Create an IAM user or role with Bedrock permissions: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["bedrock:InvokeModel"], - "Resource": [ - "arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v2:0", - "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-haiku-20240307-v1:0" - ] - } - ] -} -``` - -Create access keys for that user and add them to `.env`: +1. Create an API key in the [OpenAI dashboard](https://platform.openai.com/). +2. Add to `.env`: ```bash -AWS_ACCESS_KEY_ID="your-access-key-id" -AWS_SECRET_ACCESS_KEY="your-secret-access-key" -AWS_REGION="us-east-1" # or your preferred region +OPENAI_API_KEY="sk-..." +# Optional overrides: +# OPENAI_EMBEDDING_MODEL="text-embedding-3-small" +# OPENAI_EMBEDDING_DIMENSIONS="1024" +# OPENAI_CHAT_MODEL="gpt-4o-mini" ``` -### Database Migration & Re‑Embedding +Embeddings are used when indexing knowledge documents, FAQs, and the structured store profile. KB **Ask** uses OpenAI chat first, then falls back to Groq. -After setting up AWS Bedrock: +### Groq (storefront chat, Gmail helpers, KB fallback) + +1. Create an API key at [Groq Console](https://console.groq.com/). +2. Add to `.env`: + +```bash +GROQ_API_KEY="gsk_..." +# Optional: GROQ_MODEL="llama-3.1-8b-instant" +``` + +### Database migration & re‑embedding 1. Apply Prisma migrations: @@ -428,17 +413,13 @@ After setting up AWS Bedrock: npx prisma migrate deploy ``` -2. Re‑embed existing knowledge base data: +2. After changing embedding provider or dimensions, re‑embed: ```bash npm run re-embed-knowledge-base ``` -This script: - -- Reads all existing `KnowledgeChunk` records. -- Generates new embeddings using Titan V2. -- Updates the database in batches to avoid rate limiting. +This regenerates vectors and tags chunk metadata with `provider: openai` (required for KB similarity search filters). --- diff --git a/package-lock.json b/package-lock.json index eb8717a..ff478bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@auth/core": "^0.41.0", "@auth/prisma-adapter": "^2.11.1", - "@aws-sdk/client-bedrock-runtime": "^3.989.0", "@aws-sdk/client-s3": "^3.989.0", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", @@ -32,6 +31,7 @@ "next": "^16.2.1", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.7", + "openai": "^4.104.0", "otplib": "^13.2.1", "pdf-parse": "^2.4.5", "pg": "^8.18.0", @@ -297,63 +297,6 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.989.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.989.0.tgz", - "integrity": "sha512-qVa5B0wXjIuPRhX1dcZo1sa9Y4ycI9tiqK7B4FLok67gUWckiKmEf1xQDFrTmc2eCK5g0CTaeiRdbeM1eWmW1Q==", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.9", - "@aws-sdk/credential-provider-node": "^3.972.8", - "@aws-sdk/eventstream-handler-node": "^3.972.5", - "@aws-sdk/middleware-eventstream": "^3.972.3", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.9", - "@aws-sdk/middleware-websocket": "^3.972.6", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/token-providers": "3.989.0", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.989.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.7", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", - "@smithy/eventstream-serde-browser": "^4.2.8", - "@smithy/eventstream-serde-config-resolver": "^4.3.8", - "@smithy/eventstream-serde-node": "^4.2.8", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/middleware-retry": "^4.4.31", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.30", - "@smithy/util-defaults-mode-node": "^4.2.33", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/client-s3": { "version": "3.990.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.990.0.tgz", @@ -951,20 +894,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.5.tgz", - "integrity": "sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/eventstream-codec": "^4.2.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", @@ -982,20 +911,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.3.tgz", - "integrity": "sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/middleware-expect-continue": { "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", @@ -1158,76 +1073,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.6.tgz", - "integrity": "sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-format-url": "^3.972.3", - "@smithy/eventstream-codec": "^4.2.8", - "@smithy/eventstream-serde-browser": "^4.2.8", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.989.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.989.0.tgz", - "integrity": "sha512-Dbk2HMPU3mb6RrSRzgf0WCaWSbgtZG258maCpuN2/ONcAQNpOTw99V5fU5CA1qVK6Vkm4Fwj2cnOnw7wbGVlOw==", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.9", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.9", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.989.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.7", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/middleware-retry": "^4.4.31", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.30", - "@smithy/util-defaults-mode-node": "^4.2.33", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/region-config-resolver": { "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", @@ -1259,23 +1104,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.989.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.989.0.tgz", - "integrity": "sha512-OdBByMv+OjOZoekrk4THPFpLuND5aIQbDHCGh3n2rvifAbm31+6e0OLhxSeCF1UMPm+nKq12bXYYEoCIx5SQBg==", - "dependencies": { - "@aws-sdk/core": "^3.973.9", - "@aws-sdk/nested-clients": "3.989.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/types": { "version": "3.973.1", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", @@ -1299,35 +1127,6 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.989.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.989.0.tgz", - "integrity": "sha512-eKmAOeQM4Qusq0jtcbZPiNWky8XaojByKC/n+THbJ8vJf7t4ys8LlcZ4PrBSHZISe9cC484mQsPVOQh6iySjqw==", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.3.tgz", - "integrity": "sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.965.4", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", @@ -4956,6 +4755,15 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/nodemailer": { "version": "7.0.9", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", @@ -5686,6 +5494,17 @@ "node": ">=10.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5715,6 +5534,17 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5955,6 +5785,11 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6454,6 +6289,17 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6768,6 +6614,14 @@ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -7035,7 +6889,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -7554,6 +7407,14 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -7792,6 +7653,38 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/framer-motion": { "version": "12.34.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz", @@ -8192,6 +8085,14 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -9313,6 +9214,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -9572,6 +9492,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -9740,6 +9698,48 @@ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, "node_modules/operetta": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/operetta/-/operetta-0.0.5.tgz", @@ -11378,6 +11378,11 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -12422,6 +12427,28 @@ "node": ">= 16" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ebde78c..03fdd70 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "dependencies": { "@auth/core": "^0.41.0", "@auth/prisma-adapter": "^2.11.1", - "@aws-sdk/client-bedrock-runtime": "^3.989.0", "@aws-sdk/client-s3": "^3.989.0", "@prisma/adapter-pg": "^7.6.0", "@prisma/client": "^7.6.0", @@ -41,6 +40,7 @@ "next": "^16.2.1", "next-auth": "^5.0.0-beta.30", "nodemailer": "^7.0.7", + "openai": "^4.104.0", "otplib": "^13.2.1", "pdf-parse": "^2.4.5", "pg": "^8.18.0", diff --git a/prisma/migrations/20260405120000_knowledge_base_mode_structured/migration.sql b/prisma/migrations/20260405120000_knowledge_base_mode_structured/migration.sql new file mode 100644 index 0000000..8a32088 --- /dev/null +++ b/prisma/migrations/20260405120000_knowledge_base_mode_structured/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "KnowledgeBaseMode" AS ENUM ('UNSET', 'STRUCTURED', 'DOCUMENTS'); + +-- AlterTable +ALTER TABLE "Store" ADD COLUMN "knowledgeBaseMode" "KnowledgeBaseMode" NOT NULL DEFAULT 'UNSET'; +ALTER TABLE "Store" ADD COLUMN "knowledgeTemplate" JSONB; + +-- AlterEnum: add STRUCTURED to KnowledgeChunkSourceType +ALTER TYPE "KnowledgeChunkSourceType" ADD VALUE 'STRUCTURED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d21c117..92255a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -114,6 +114,11 @@ model Store { storefrontChatBrandColor String? storefrontChatFontFamily String? storefrontChatLogoUrl String? + + /** Primary knowledge source: structured questionnaire vs uploaded documents (mutually exclusive). */ + knowledgeBaseMode KnowledgeBaseMode @default(UNSET) + /** Saved structured KB answers (version, per-field text, misc). Embedded as STRUCTURED chunks. */ + knowledgeTemplate Json? // Relations connections Connection[] @@ -485,6 +490,13 @@ enum KnowledgeDocumentStatus { enum KnowledgeChunkSourceType { DOCUMENT FAQ + STRUCTURED +} + +enum KnowledgeBaseMode { + UNSET + STRUCTURED + DOCUMENTS } model KnowledgeDocument { diff --git a/scripts/re-embed-knowledge-base.ts b/scripts/re-embed-knowledge-base.ts index 0b06f0b..163e094 100644 --- a/scripts/re-embed-knowledge-base.ts +++ b/scripts/re-embed-knowledge-base.ts @@ -1,40 +1,37 @@ #!/usr/bin/env tsx /** - * Re-embedding script for Knowledge Base - * - * This script re-embeds all existing KnowledgeChunk records using AWS Bedrock - * Titan Text Embeddings V2 (1024 dimensions) to replace Gemini embeddings (1536 dimensions). - * - * Usage: - * npm run re-embed-knowledge-base - * or - * tsx scripts/re-embed-knowledge-base.ts + * Re-embed all KnowledgeChunk rows with OpenAI (text-embedding-3-small @ 1024 dims) + * and tag metadata with provider openai (required for KB vector search filters). + * + * Usage: npm run re-embed-knowledge-base */ import { prisma } from "../src/lib/prisma" -import { embedBatch } from "../src/lib/kb/bedrockEmbeddings" +import { embedBatch } from "../src/lib/kb/openaiEmbeddings" +import { + kbEmbeddingMetadata, + KB_EMBEDDING_DIMENSIONS, +} from "../src/lib/kb/embeddingMeta" import { logger } from "../src/lib/logger" -// AWS Bedrock Titan V2 limit: 2000 requests/minute -// Use smaller batches with delays to stay under limit -const BATCH_SIZE = 10 // Process chunks in smaller batches to avoid rate limits +const BATCH_SIZE = 32 -interface KnowledgeChunk { +interface KnowledgeChunkRow { id: string content: string storeId: string + metadata: unknown } async function reEmbedKnowledgeBase() { try { - logger.info("Starting knowledge base re-embedding process...") + logger.info("Starting knowledge base re-embedding (OpenAI)...") - // Fetch all knowledge chunks - const sql = `SELECT "id", "content", "storeId" + const sql = `SELECT "id", "content", "storeId", "metadata" FROM "KnowledgeChunk" WHERE "content" IS NOT NULL AND "content" != '' ORDER BY "createdAt" ASC` - const chunks = await prisma.$queryRawUnsafe(sql) + const chunks = await prisma.$queryRawUnsafe(sql) if (chunks.length === 0) { logger.info("No knowledge chunks found to re-embed.") @@ -47,7 +44,6 @@ async function reEmbedKnowledgeBase() { let failed = 0 const totalBatches = Math.ceil(chunks.length / BATCH_SIZE) - // Process in batches for (let i = 0; i < chunks.length; i += BATCH_SIZE) { const batch = chunks.slice(i, i + BATCH_SIZE) const batchNumber = Math.floor(i / BATCH_SIZE) + 1 @@ -57,8 +53,7 @@ async function reEmbedKnowledgeBase() { ) try { - // Generate embeddings for this batch - const texts = batch.map((chunk: KnowledgeChunk) => chunk.content) + const texts = batch.map((c) => c.content) const embeddings = await embedBatch(texts) if (embeddings.length !== batch.length) { @@ -69,60 +64,55 @@ async function reEmbedKnowledgeBase() { continue } - // Update each chunk with its new embedding - const updatePromises = batch.map(async (chunk: KnowledgeChunk, index: number) => { - const embedding = embeddings[index] + await Promise.all( + batch.map(async (chunk, index) => { + const embedding = embeddings[index] - if (!embedding || embedding.length === 0) { - logger.warn( - `Skipping chunk ${chunk.id}: empty embedding generated`, - ) - failed++ - return - } - - if (embedding.length !== 1024) { - logger.warn( - `Chunk ${chunk.id}: embedding dimension is ${embedding.length}, expected 1024`, - ) - // Pad or truncate to 1024 - if (embedding.length < 1024) { - embedding.push(...new Array(1024 - embedding.length).fill(0)) - } else { - embedding.splice(1024) + if (!embedding || embedding.length === 0) { + logger.warn(`Skipping chunk ${chunk.id}: empty embedding`) + failed++ + return } - } - try { - await prisma.$executeRawUnsafe( - ` - UPDATE "KnowledgeChunk" - SET "embedding" = $1::vector(1024), - "updatedAt" = NOW() - WHERE "id" = $2 - `, - `[${embedding.join(",")}]`, - chunk.id, - ) - processed++ - } catch (error) { - logger.error(`Failed to update chunk ${chunk.id}:`, error) - failed++ - } - }) + if (embedding.length !== KB_EMBEDDING_DIMENSIONS) { + logger.error( + `Chunk ${chunk.id}: expected ${KB_EMBEDDING_DIMENSIONS} dims, got ${embedding.length}`, + ) + failed++ + return + } - await Promise.all(updatePromises) + const prevMeta = + chunk.metadata && + typeof chunk.metadata === "object" && + !Array.isArray(chunk.metadata) + ? (chunk.metadata as Record) + : {} + const nextMeta = { + ...prevMeta, + ...kbEmbeddingMetadata(), + } - logger.info( - `Batch ${batchNumber} completed. Processed: ${processed}, Failed: ${failed}`, + try { + await prisma.$executeRawUnsafe( + ` + UPDATE "KnowledgeChunk" + SET "embedding" = $1::vector, + "metadata" = $2::jsonb, + "updatedAt" = NOW() + WHERE "id" = $3 + `, + `[${embedding.join(",")}]`, + JSON.stringify(nextMeta), + chunk.id, + ) + processed++ + } catch (error) { + logger.error(`Failed to update chunk ${chunk.id}:`, error) + failed++ + } + }), ) - - // Add delay between batches to avoid rate limiting - // With 2s delay per request + batch processing, we need extra time between batches - if (i + BATCH_SIZE < chunks.length) { - // Wait 5 seconds between batches to ensure we stay under 2000 req/min limit - await new Promise((resolve) => setTimeout(resolve, 5000)) - } } catch (error) { logger.error(`Error processing batch ${batchNumber}:`, error) failed += batch.length @@ -140,7 +130,6 @@ async function reEmbedKnowledgeBase() { } } -// Run the script if (require.main === module) { reEmbedKnowledgeBase() .then(() => { diff --git a/src/app/api/stores/[storeId]/kb/documents/route.ts b/src/app/api/stores/[storeId]/kb/documents/route.ts index 16c5d23..86a5cac 100644 --- a/src/app/api/stores/[storeId]/kb/documents/route.ts +++ b/src/app/api/stores/[storeId]/kb/documents/route.ts @@ -40,6 +40,23 @@ export async function POST( return NextResponse.json({ message: "Store not found" }, { status: 404 }) } + if (store.knowledgeBaseMode === "STRUCTURED") { + return NextResponse.json( + { + message: + "Document upload is disabled while using structured knowledge. Switch to document-based knowledge in Settings → Knowledge base.", + }, + { status: 409 }, + ) + } + + if (store.knowledgeBaseMode === "UNSET") { + await prisma.store.update({ + where: { id: store.id }, + data: { knowledgeBaseMode: "DOCUMENTS" }, + }) + } + const formData = await request.formData() const file = formData.get("file") diff --git a/src/app/api/stores/[storeId]/kb/faqs/import/route.ts b/src/app/api/stores/[storeId]/kb/faqs/import/route.ts index ee90ce3..7ef589c 100644 --- a/src/app/api/stores/[storeId]/kb/faqs/import/route.ts +++ b/src/app/api/stores/[storeId]/kb/faqs/import/route.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" -import { embedBatch } from "@/lib/kb/bedrockEmbeddings" +import { embedBatch } from "@/lib/kb/openaiEmbeddings" +import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta" import type { FaqItem } from "@prisma/client" type FaqImportItem = { @@ -35,6 +36,16 @@ export async function POST( return NextResponse.json({ message: "Store not found" }, { status: 404 }) } + if (store.knowledgeBaseMode === "STRUCTURED") { + return NextResponse.json( + { + message: + "FAQ import is disabled while using structured knowledge. Switch to document-based knowledge in Settings.", + }, + { status: 409 }, + ) + } + let items: FaqImportItem[] = [] const contentType = request.headers.get("content-type") || "" @@ -135,6 +146,7 @@ export async function POST( { question: faq.question, answer: faq.answer, + ...kbEmbeddingMetadata(), } as unknown as object, ) }), diff --git a/src/app/api/stores/[storeId]/kb/faqs/route.ts b/src/app/api/stores/[storeId]/kb/faqs/route.ts index a8cb676..f91d089 100644 --- a/src/app/api/stores/[storeId]/kb/faqs/route.ts +++ b/src/app/api/stores/[storeId]/kb/faqs/route.ts @@ -2,7 +2,8 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" -import { embedBatch } from "@/lib/kb/bedrockEmbeddings" +import { embedBatch } from "@/lib/kb/openaiEmbeddings" +import { kbEmbeddingMetadata } from "@/lib/kb/embeddingMeta" export async function GET( _request: Request, @@ -67,6 +68,16 @@ export async function POST( return NextResponse.json({ message: "Store not found" }, { status: 404 }) } + if (store.knowledgeBaseMode === "STRUCTURED") { + return NextResponse.json( + { + message: + "Manual FAQs are disabled while using structured knowledge. Switch to document-based knowledge in Settings to add FAQs.", + }, + { status: 409 }, + ) + } + const body = await request.json() const { question, answer, tags } = body as { question?: string @@ -115,6 +126,7 @@ export async function POST( { question, answer, + ...kbEmbeddingMetadata(), } as unknown as object, ) } diff --git a/src/app/api/stores/[storeId]/kb/query/route.ts b/src/app/api/stores/[storeId]/kb/query/route.ts index e09de94..23ef0fc 100644 --- a/src/app/api/stores/[storeId]/kb/query/route.ts +++ b/src/app/api/stores/[storeId]/kb/query/route.ts @@ -2,20 +2,22 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" -import { embedBatch } from "@/lib/kb/bedrockEmbeddings" -import { chatCompletion } from "@/lib/kb/bedrockChat" +import { embedBatch } from "@/lib/kb/openaiEmbeddings" +import { KB_EMBEDDING_PROVIDER } from "@/lib/kb/embeddingMeta" +import { generateKbRagAnswer } from "@/lib/kb/kbAnswer" type QueryBody = { query?: string topK?: number includeFaq?: boolean includeDocuments?: boolean + includeStructured?: boolean } type KnowledgeChunkRow = { id: string content: string - sourceType: "DOCUMENT" | "FAQ" + sourceType: "DOCUMENT" | "FAQ" | "STRUCTURED" documentId: string | null faqItemId: string | null metadata: unknown @@ -58,10 +60,14 @@ export async function POST( const topK = Math.min(Math.max(body.topK ?? 8, 1), 20) const includeFaq = body.includeFaq ?? true const includeDocuments = body.includeDocuments ?? true + const includeStructured = body.includeStructured ?? true - if (!includeFaq && !includeDocuments) { + if (!includeFaq && !includeDocuments && !includeStructured) { return NextResponse.json( - { message: "At least one of FAQs or Documents must be included" }, + { + message: + "At least one of FAQs, documents, or structured knowledge must be included", + }, { status: 400 }, ) } @@ -74,14 +80,18 @@ export async function POST( ) } - const filters: string[] = ['"storeId" = $1'] - if (includeFaq && !includeDocuments) { - filters.push(`"sourceType" = 'FAQ'`) - } else if (!includeFaq && includeDocuments) { - filters.push(`"sourceType" = 'DOCUMENT'`) - } + const sourceTypes: string[] = [] + if (includeFaq) sourceTypes.push("'FAQ'") + if (includeDocuments) sourceTypes.push("'DOCUMENT'") + if (includeStructured) sourceTypes.push("'STRUCTURED'") - const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "" + const filters: string[] = [ + '"storeId" = $1', + `"sourceType" IN (${sourceTypes.join(", ")})`, + `COALESCE(("metadata"::jsonb->>'provider'), '') = '${KB_EMBEDDING_PROVIDER}'`, + ] + + const whereClause = `WHERE ${filters.join(" AND ")}` const chunks = await prisma.$queryRawUnsafe( ` @@ -113,7 +123,9 @@ export async function POST( const label = chunk.sourceType === "FAQ" ? `FAQ #${index + 1}` - : `Document chunk #${index + 1}` + : chunk.sourceType === "STRUCTURED" + ? `Store profile #${index + 1}` + : `Document chunk #${index + 1}` return `${label} (similarity: ${chunk.similarity.toFixed(2)}):\n${ chunk.content }` @@ -123,23 +135,21 @@ export async function POST( const systemPrompt = "You are an AI assistant helping answer customer and vendor questions for a specific store.\n" + - "Use ONLY the provided context from the store's documents and FAQs.\n" + + "Use ONLY the provided context from the store's documents, FAQs, and structured store profile.\n" + "If the answer is not clearly present in the context, say that you do not know.\n" + "Be concise and helpful.\n" const userMessage = `Context:\n${context}\n\nQuestion:\n${question}` - let answer: string - try { - answer = await chatCompletion(userMessage, { - systemPrompt, - maxTokens: 512, - }) - } catch (error) { - logger.error("Bedrock RAG answer error", error) + const { text: answer, source } = await generateKbRagAnswer(userMessage, { + systemPrompt, + maxTokens: 512, + }) + + if (source === "none") { return NextResponse.json( - { message: "Failed to generate answer from Bedrock" }, - { status: 500 }, + { message: "Failed to generate an answer" }, + { status: 502 }, ) } @@ -161,4 +171,3 @@ export async function POST( ) } } - diff --git a/src/app/api/stores/[storeId]/knowledge-template/route.ts b/src/app/api/stores/[storeId]/knowledge-template/route.ts new file mode 100644 index 0000000..1b99361 --- /dev/null +++ b/src/app/api/stores/[storeId]/knowledge-template/route.ts @@ -0,0 +1,214 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { + knowledgeTemplatePutSchema, + normalizeTemplateAnswers, + type KnowledgeTemplateStored, +} from "@/lib/kb/knowledgeTemplate" +import { replaceStructuredKnowledgeChunks } from "@/lib/kb/structuredTemplateChunks" +import { + deleteDocumentKbForStore, + deleteFaqKbForStore, + deleteStructuredKbForStore, +} from "@/lib/kb/storeKbCleanup" +import { Prisma, type KnowledgeBaseMode } from "@prisma/client" + +export async function GET( + _request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + const store = await prisma.store.findFirst({ + where: { id: storeId, userId: session.user.id }, + select: { + id: true, + knowledgeBaseMode: true, + knowledgeTemplate: true, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const [documentCount, faqCount, structuredChunkCount] = await Promise.all([ + prisma.knowledgeDocument.count({ where: { storeId: store.id } }), + prisma.faqItem.count({ where: { storeId: store.id } }), + prisma.knowledgeChunk.count({ + where: { storeId: store.id, sourceType: "STRUCTURED" }, + }), + ]) + + return NextResponse.json({ + knowledgeBaseMode: store.knowledgeBaseMode, + knowledgeTemplate: store.knowledgeTemplate, + counts: { + documents: documentCount, + faqs: faqCount, + structuredChunks: structuredChunkCount, + }, + }) + } catch (error) { + logger.error("GET knowledge-template error", error) + return NextResponse.json( + { message: "Failed to load knowledge template" }, + { status: 500 }, + ) + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + let store = await prisma.store.findFirst({ + where: { id: storeId, userId: session.user.id }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const json = await request.json().catch(() => null) + const parsed = knowledgeTemplatePutSchema.safeParse(json) + if (!parsed.success) { + return NextResponse.json( + { message: "Invalid body", issues: parsed.error.flatten() }, + { status: 400 }, + ) + } + + const body = parsed.data + const confirm = body.confirmDestructiveSwitch === true + + const docCount = await prisma.knowledgeDocument.count({ + where: { storeId: store.id }, + }) + const faqCount = await prisma.faqItem.count({ where: { storeId: store.id } }) + const structuredCount = await prisma.knowledgeChunk.count({ + where: { storeId: store.id, sourceType: "STRUCTURED" }, + }) + const hasTemplateJson = store.knowledgeTemplate != null + + if (body.knowledgeBaseMode !== undefined) { + const target = body.knowledgeBaseMode as KnowledgeBaseMode + if (target !== store.knowledgeBaseMode) { + if (target === "STRUCTURED") { + const destructive = docCount > 0 || faqCount > 0 + if (destructive && !confirm) { + return NextResponse.json( + { + message: + "Switching to structured knowledge will remove uploaded documents and FAQs. Send confirmDestructiveSwitch: true to proceed.", + code: "CONFIRM_REQUIRED", + }, + { status: 400 }, + ) + } + if (destructive) { + await deleteDocumentKbForStore(prisma, store.id) + await deleteFaqKbForStore(prisma, store.id) + } + await prisma.store.update({ + where: { id: store.id }, + data: { knowledgeBaseMode: "STRUCTURED" }, + }) + } else if (target === "DOCUMENTS") { + const destructive = structuredCount > 0 || hasTemplateJson + if (destructive && !confirm) { + return NextResponse.json( + { + message: + "Switching to document-based knowledge will remove your structured profile and its embeddings. Send confirmDestructiveSwitch: true to proceed.", + code: "CONFIRM_REQUIRED", + }, + { status: 400 }, + ) + } + await deleteStructuredKbForStore(prisma, store.id) + await prisma.store.update({ + where: { id: store.id }, + data: { + knowledgeBaseMode: "DOCUMENTS", + knowledgeTemplate: Prisma.JsonNull, + }, + }) + } + } + } + + store = await prisma.store.findFirstOrThrow({ + where: { id: store.id, userId: session.user.id }, + }) + + if (body.template !== undefined) { + if (store.knowledgeBaseMode === "DOCUMENTS") { + return NextResponse.json( + { + message: + "This store uses document-based knowledge. Switch to structured knowledge before editing the template.", + }, + { status: 409 }, + ) + } + + const answers = normalizeTemplateAnswers(body.template.answers) + const misc = body.template.misc?.trim() || undefined + + const stored: KnowledgeTemplateStored = { + version: 1, + answers, + misc, + updatedAt: new Date().toISOString(), + } + + const nextMode: KnowledgeBaseMode = + store.knowledgeBaseMode === "UNSET" ? "STRUCTURED" : store.knowledgeBaseMode + + await prisma.store.update({ + where: { id: store.id }, + data: { + knowledgeBaseMode: nextMode, + knowledgeTemplate: stored as object, + }, + }) + + await replaceStructuredKnowledgeChunks(store.id, stored) + } + + const refreshed = await prisma.store.findFirst({ + where: { id: store.id }, + select: { + knowledgeBaseMode: true, + knowledgeTemplate: true, + }, + }) + + return NextResponse.json({ + success: true, + knowledgeBaseMode: refreshed?.knowledgeBaseMode, + knowledgeTemplate: refreshed?.knowledgeTemplate, + }) + } catch (error) { + logger.error("PUT knowledge-template error", error) + return NextResponse.json( + { message: "Failed to save knowledge template" }, + { status: 500 }, + ) + } +} diff --git a/src/app/dashboard/juno-engine/knowledge-base/page.tsx b/src/app/dashboard/juno-engine/knowledge-base/page.tsx index ef9aa14..ec5f78a 100644 --- a/src/app/dashboard/juno-engine/knowledge-base/page.tsx +++ b/src/app/dashboard/juno-engine/knowledge-base/page.tsx @@ -1,138 +1,394 @@ "use client" -import { useEffect, useState } from "react" -import { DocumentsSection } from "@/components/kb/DocumentsSection" -import { FaqSection } from "@/components/kb/FaqSection" -import { AskSection } from "@/components/kb/AskSection" +import { useCallback, useEffect, useState } from "react" +import { DocumentsFaqKnowledgeModal } from "@/components/kb/DocumentsFaqKnowledgeModal" +import { StructuredKnowledgeModal } from "@/components/kb/StructuredKnowledgeModal" import { useTheme } from "@/components/ThemeProvider" import { cn } from "@/lib/utils" +import type { KnowledgeTemplateStored } from "@/lib/kb/knowledgeTemplate" +import { BookOpen, ChevronRight, FileStack, Loader2 } from "lucide-react" + +type KbCounts = { + documents: number + faqs: number + structuredChunks: number +} export default function KnowledgeBasePage() { const { theme } = useTheme() const isLight = theme === "light" const [activeStoreId, setActiveStoreId] = useState(null) + const [kbLoading, setKbLoading] = useState(true) + const [knowledgeBaseMode, setKnowledgeBaseMode] = useState< + "UNSET" | "STRUCTURED" | "DOCUMENTS" + >("UNSET") + const [structuredOpen, setStructuredOpen] = useState(false) + const [documentsModalOpen, setDocumentsModalOpen] = useState(false) + const [modeBusy, setModeBusy] = useState<"documents" | null>(null) + const [toast, setToast] = useState(null) + + const loadKb = useCallback(async (sid: string) => { + const res = await fetch(`/api/stores/${encodeURIComponent(sid)}/knowledge-template`) + if (!res.ok) return + const j = (await res.json()) as { + knowledgeBaseMode: "UNSET" | "STRUCTURED" | "DOCUMENTS" + knowledgeTemplate: KnowledgeTemplateStored | null + counts: KbCounts + } + setKnowledgeBaseMode(j.knowledgeBaseMode) + }, []) useEffect(() => { let cancelled = false - ; (async () => { try { const res = await fetch("/api/stores") if (!res.ok) return - const data = (await res.json()) as { stores?: { id: string; isActive: boolean }[] } const active = (data.stores || []).find((s) => s.isActive) - - if (!cancelled) { - setActiveStoreId(active?.id ?? null) - } + if (!cancelled) setActiveStoreId(active?.id ?? null) } catch { - if (!cancelled) { - setActiveStoreId(null) - } + if (!cancelled) setActiveStoreId(null) } })() - return () => { cancelled = true } }, []) - const [activeTab, setActiveTab] = useState<"documents" | "faqs" | "ask">( - "documents", - ) + useEffect(() => { + if (!activeStoreId) { + setKbLoading(false) + setKnowledgeBaseMode("UNSET") + return + } + let cancelled = false + ; (async () => { + try { + setKbLoading(true) + await loadKb(activeStoreId) + } finally { + if (!cancelled) setKbLoading(false) + } + })() + return () => { + cancelled = true + } + }, [activeStoreId, loadKb]) + + async function ensureDocumentsMode( + confirmDestructiveSwitch?: boolean, + ): Promise { + if (!activeStoreId) return false + if (knowledgeBaseMode === "DOCUMENTS") return true + + setModeBusy("documents") + setToast(null) + try { + const res = await fetch( + `/api/stores/${encodeURIComponent(activeStoreId)}/knowledge-template`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + knowledgeBaseMode: "DOCUMENTS", + ...(confirmDestructiveSwitch + ? { confirmDestructiveSwitch: true } + : {}), + }), + }, + ) + const j = await res.json().catch(() => ({})) + if (res.status === 400 && j.code === "CONFIRM_REQUIRED") { + const ok = window.confirm( + "This removes your structured profile and its embeddings. Continue?", + ) + if (!ok) return false + return await ensureDocumentsMode(true) + } + if (!res.ok) { + throw new Error(j.message || "Failed to update mode") + } + await loadKb(activeStoreId) + setToast("Using documents & FAQs for this store.") + return true + } catch (e) { + setToast(e instanceof Error ? e.message : "Could not switch mode") + return false + } finally { + setModeBusy(null) + } + } + + async function openDocumentsKnowledgePanel() { + if (!activeStoreId) return + const ok = await ensureDocumentsMode() + if (ok) setDocumentsModalOpen(true) + } const hasStore = Boolean(activeStoreId) + const structuredActive = knowledgeBaseMode === "STRUCTURED" + + const modeLabel = + knowledgeBaseMode === "STRUCTURED" + ? "Structured profile" + : knowledgeBaseMode === "DOCUMENTS" + ? "Documents & FAQs" + : "Not chosen yet" return ( -
-
-
-

- Knowledge Base -

-

- Centralize your store's documents and FAQs so JUNO can - answer customer and vendor questions. -

-
+
+
+

+ Knowledge base +

+

+ One source of truth per store for AI: either a guided profile or + uploaded docs & FAQs—not both. +

{!hasStore ? ( -
-
-

- No active store selected -

-

- Select a store from the top bar to manage its knowledge - base. -

-
+
+

+ Select an active store from the top bar to manage its knowledge + base. +

+
+ ) : kbLoading ? ( +
+ + Loading…
) : ( <> -
- - - +
+
+
+

+ Knowledge source +

+

+ {modeLabel} +

+
+
+ +
+ + + +
+ + {toast && ( +

+ {toast} +

+ )}
-
- {activeTab === "documents" && ( - - )} - {activeTab === "faqs" && ( - - )} - {activeTab === "ask" && ( - - )} -
+ {structuredActive && ( +

+ You're on a structured profile. Open{" "} + {" "} + to edit, or choose{" "} + {" "} + to switch (you'll confirm replacing structured data). +

+ )} + + { + await loadKb(activeStoreId!) + setToast("Structured profile saved and embedded.") + }} + /> + + )}
) } - diff --git a/src/app/dashboard/settings/knowledge-base/page.tsx b/src/app/dashboard/settings/knowledge-base/page.tsx new file mode 100644 index 0000000..85bb3eb --- /dev/null +++ b/src/app/dashboard/settings/knowledge-base/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from "next/navigation" + +/** Old location; knowledge base lives under JUNO Engine. */ +export default function KnowledgeBaseSettingsRedirectPage() { + redirect("/dashboard/juno-engine/knowledge-base") +} diff --git a/src/components/kb/AskSection.tsx b/src/components/kb/AskSection.tsx index 291d764..8419a00 100644 --- a/src/components/kb/AskSection.tsx +++ b/src/components/kb/AskSection.tsx @@ -10,7 +10,7 @@ type AskSectionProps = { type Source = { id: string - type: "DOCUMENT" | "FAQ" + type: "DOCUMENT" | "FAQ" | "STRUCTURED" documentId: string | null faqId: string | null similarity: number @@ -22,6 +22,7 @@ export function AskSection({ storeId }: AskSectionProps) { const [query, setQuery] = useState("") const [includeFaq, setIncludeFaq] = useState(true) const [includeDocuments, setIncludeDocuments] = useState(true) + const [includeStructured, setIncludeStructured] = useState(true) const [answer, setAnswer] = useState(null) const [sources, setSources] = useState([]) const [isLoading, setIsLoading] = useState(false) @@ -48,6 +49,7 @@ export function AskSection({ storeId }: AskSectionProps) { query: query.trim(), includeFaq, includeDocuments, + includeStructured, }), }, ) @@ -75,8 +77,8 @@ export function AskSection({ storeId }: AskSectionProps) {

Ask JUNO

- Ask questions across your store's documents and FAQs. The - AI will answer using only your knowledge base. + Ask questions across documents, FAQs, and your structured store profile. + The AI uses only your knowledge base.

@@ -105,7 +107,7 @@ export function AskSection({ storeId }: AskSectionProps) { )} />
-
+
+
+
, + document.body, + ) +} diff --git a/src/components/kb/DocumentsSection.tsx b/src/components/kb/DocumentsSection.tsx index 2e0b4e0..545c1f6 100644 --- a/src/components/kb/DocumentsSection.tsx +++ b/src/components/kb/DocumentsSection.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect, useState } from "react" +import Link from "next/link" import { useTheme } from "@/components/ThemeProvider" import { cn } from "@/lib/utils" @@ -17,9 +18,17 @@ type KnowledgeDocument = { type DocumentsSectionProps = { storeId: string + locked?: boolean + lockedMessage?: string + settingsHref?: string } -export function DocumentsSection({ storeId }: DocumentsSectionProps) { +export function DocumentsSection({ + storeId, + locked = false, + lockedMessage = "This store uses structured knowledge. Switch to Documents & FAQs above or edit your structured profile.", + settingsHref = "/dashboard/juno-engine/knowledge-base#kb-source", +}: DocumentsSectionProps) { const { theme } = useTheme() const isLight = theme === "light" const [documents, setDocuments] = useState([]) @@ -118,6 +127,27 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) { return (
+ {locked && ( +
+

{lockedMessage}

+ + Knowledge base + +
+ )}

@@ -127,14 +157,21 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) { Upload PDFs or DOCX files to power AI answers.

-
@@ -204,7 +241,8 @@ export function DocumentsSection({ storeId }: DocumentsSectionProps) { +
+ + {loading ? ( +
+ + Loading… +
+ ) : ( +
void handleSave(e)} + className="flex min-h-0 flex-1 flex-col" + > +
+ {error && ( +
+ {error} +
+ )} + + {KNOWLEDGE_TEMPLATE_FIELDS.map((field) => ( +
+ +

+ {field.description} +

+