@pixygon/chatbot-server 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -42
- package/dist/index.js +33 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,82 +1,151 @@
|
|
|
1
1
|
# @pixygon/chatbot-server
|
|
2
2
|
|
|
3
|
-
RAG chatbot + analytics for Node +
|
|
3
|
+
Drop-in RAG chatbot + knowledge base + analytics for **Node 22 + Express 5 + Mongoose 8**.
|
|
4
|
+
Multi-tenant by construction. Public anonymous surface included.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
+--------------------+ +-------------------------+
|
|
8
|
+
| Host Express app |----->| chatbot.routes.private |
|
|
9
|
+
| (your auth here) | | chatbot.routes.public |
|
|
10
|
+
+--------------------+ +-------------------------+
|
|
11
|
+
| |
|
|
12
|
+
v v
|
|
13
|
+
Host's mongoose chatbot.{rag, analytics}
|
|
14
|
+
(models register here) services
|
|
15
|
+
|
|
|
16
|
+
v
|
|
17
|
+
KnowledgeDocument · KnowledgeChunk · ChatConversation
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What it does
|
|
23
|
+
|
|
24
|
+
- **Knowledge base.** Operators paste docs (text/url/file). The service chunks
|
|
25
|
+
to ~2 kB paragraphs, embeds via OpenAI `text-embedding-3-small` (1536-dim),
|
|
26
|
+
stores chunks per tenant.
|
|
27
|
+
- **Chat (RAG).** User asks a question → cosine-sim top-K=5 chunks → renders a
|
|
28
|
+
system prompt → forwards to the Pixygon AI gateway (configurable model) →
|
|
29
|
+
returns text + cited sources.
|
|
30
|
+
- **Analytics.** 8 endpoints: overview KPIs, top questions, keyword frequency,
|
|
31
|
+
cost timeseries, knowledge gaps, document usage, conversation drill-down,
|
|
32
|
+
semantic clusters. All tenant-scoped.
|
|
33
|
+
- **Public surface.** Anonymous `/public/chat/:tenantSlug` for embedding on
|
|
34
|
+
marketing/help-center sites. IP rate-limited.
|
|
35
|
+
- **Cost cap.** Per-tenant monthly USD ceiling. New messages refused with a
|
|
36
|
+
503 `CHAT_BUDGET_EXCEEDED` once exceeded.
|
|
37
|
+
|
|
38
|
+
---
|
|
4
39
|
|
|
5
40
|
## Install
|
|
6
41
|
|
|
7
42
|
```bash
|
|
8
43
|
npm install @pixygon/chatbot-server
|
|
9
|
-
# Peer deps the host already has:
|
|
10
|
-
# - express ≥5
|
|
11
|
-
# - mongoose ≥8
|
|
12
44
|
```
|
|
13
45
|
|
|
46
|
+
Peer expectations (host already has these):
|
|
47
|
+
|
|
48
|
+
- `express` ≥ 5
|
|
49
|
+
- `mongoose` ≥ 8
|
|
50
|
+
- Node ≥ 22
|
|
51
|
+
|
|
52
|
+
Env vars consumed by `createChatbot`:
|
|
53
|
+
|
|
54
|
+
| Var | Required | What it's for |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `PIXYGON_API_KEY` | yes | Chat completions via the Pixygon AI gateway |
|
|
57
|
+
| `OPENAI_API_KEY` | yes | Embeddings (`text-embedding-3-small`) |
|
|
58
|
+
| `PIXYGON_API_URL` | no | Override gateway base; default `https://api.pixygon.com/v1` |
|
|
59
|
+
| `PIXYGON_CHAT_INPUT_USD_PER_1K` | no | Cost-cap input pricing |
|
|
60
|
+
| `PIXYGON_CHAT_OUTPUT_USD_PER_1K` | no | Cost-cap output pricing |
|
|
61
|
+
| `OPENAI_EMBED_USD_PER_1K` | no | Cost-cap embeddings pricing |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
14
65
|
## Usage
|
|
15
66
|
|
|
16
67
|
```ts
|
|
17
|
-
import { createChatbot } from "@pixygon/chatbot-server";
|
|
18
68
|
import mongoose from "mongoose";
|
|
69
|
+
import { createChatbot } from "@pixygon/chatbot-server";
|
|
19
70
|
import { Tenant } from "./models/Tenant.js";
|
|
20
71
|
import { withTenantScope } from "./middleware/requestContext.js";
|
|
21
72
|
import { tenantScopedPlugin } from "./models/_plugins/tenantScoped.js";
|
|
73
|
+
import { auditLogPlugin } from "./models/_plugins/auditLog.js";
|
|
22
74
|
|
|
23
|
-
const chatbot = createChatbot({
|
|
75
|
+
export const chatbot = createChatbot({
|
|
24
76
|
mongoose,
|
|
25
|
-
tenantParamName: "tenantId",
|
|
26
|
-
|
|
77
|
+
tenantParamName: "tenantId", // path param: /tenants/:tenantId/...
|
|
78
|
+
tenantField: "tenantId", // the field name on documents
|
|
79
|
+
tenantRefName: "Tenant", // mongoose ref name for population
|
|
80
|
+
|
|
27
81
|
ai: {
|
|
28
82
|
pixygonApiKey: process.env.PIXYGON_API_KEY!,
|
|
29
83
|
openaiApiKey: process.env.OPENAI_API_KEY!,
|
|
30
84
|
},
|
|
31
|
-
|
|
85
|
+
|
|
86
|
+
// Optional host plugins applied to every chatbot model.
|
|
87
|
+
// Use this for tenant-scoped query enforcement, audit log, etc.
|
|
88
|
+
plugins: [
|
|
89
|
+
(schema, label) =>
|
|
90
|
+
schema.plugin(tenantScopedPlugin, { tenantField: "tenantId", label }),
|
|
91
|
+
(schema, label) =>
|
|
92
|
+
schema.plugin(auditLogPlugin, { entityType: label }),
|
|
93
|
+
],
|
|
94
|
+
|
|
32
95
|
hooks: {
|
|
33
96
|
getTenantName: async (id) =>
|
|
34
|
-
(await Tenant.findById(id).select("name").lean())?.name,
|
|
35
|
-
|
|
36
|
-
|
|
97
|
+
(await Tenant.findById(id).select("name").lean())?.name ?? null,
|
|
98
|
+
|
|
99
|
+
getTenantBySlug: async (slug) => {
|
|
100
|
+
const t = await Tenant.findOne({ slug, status: "active" })
|
|
101
|
+
.select("_id name slug").lean();
|
|
102
|
+
return t ? { _id: t._id, name: t.name, slug: t.slug } : null;
|
|
103
|
+
},
|
|
104
|
+
|
|
37
105
|
getCostCap: async (id) =>
|
|
38
|
-
(await Tenant.findById(id).select("chatCostCapUsdMonthly").lean())
|
|
39
|
-
|
|
106
|
+
(await Tenant.findById(id).select("chatCostCapUsdMonthly").lean())
|
|
107
|
+
?.chatCostCapUsdMonthly ?? null,
|
|
108
|
+
|
|
109
|
+
withTenantScope: (tenantId, fn) => withTenantScope(tenantId, fn),
|
|
110
|
+
|
|
40
111
|
systemPromptBuilder: (tenantName, contextBlocks) => {
|
|
41
112
|
const sources = contextBlocks.length === 0
|
|
42
|
-
? "(no relevant sources)"
|
|
43
|
-
: contextBlocks.map((
|
|
44
|
-
return `You are the ${tenantName} assistant. Use ONLY the sources below
|
|
113
|
+
? "(no relevant sources matched)"
|
|
114
|
+
: contextBlocks.map((c, i) => `[Source ${i + 1}]\n${c}`).join("\n\n");
|
|
115
|
+
return `You are the ${tenantName} assistant. Use ONLY the sources below as factual basis. If unsure, say so.
|
|
116
|
+
|
|
117
|
+
=== Sources ===
|
|
118
|
+
${sources}
|
|
119
|
+
=== End ===`;
|
|
45
120
|
},
|
|
46
121
|
},
|
|
47
122
|
});
|
|
48
123
|
|
|
49
|
-
// Mount under whatever
|
|
124
|
+
// Mount under whatever shape the host uses.
|
|
50
125
|
app.use("/v1/tenants/:tenantId", verifyToken, tenantAccess, chatbot.routes.private);
|
|
51
126
|
app.use("/v1/public/chat", chatbot.routes.public);
|
|
52
127
|
```
|
|
53
128
|
|
|
54
|
-
|
|
129
|
+
A host that uses `companyId` instead of `tenantId` swaps both `tenantParamName`
|
|
130
|
+
and `tenantField` to `"companyId"`. The package adapts.
|
|
55
131
|
|
|
56
|
-
|
|
57
|
-
`KnowledgeChunk`, `ChatConversation`.
|
|
58
|
-
- **Services**: `chatbot.rag.respond({tenantId, sessionId, message})`,
|
|
59
|
-
`chatbot.rag.processDocument(docId)`, `chatbot.rag.currentMonthCost(tenantId)`,
|
|
60
|
-
`chatbot.analytics.*` (8 methods).
|
|
61
|
-
- **Routers**: `chatbot.routes.private` (auth required, mounted under a
|
|
62
|
-
tenant-scoped path) and `chatbot.routes.public` (anonymous, IP rate-limited,
|
|
63
|
-
slug-based lookup).
|
|
132
|
+
---
|
|
64
133
|
|
|
65
134
|
## API surface
|
|
66
135
|
|
|
67
|
-
Private routes (mounted under `/v1
|
|
136
|
+
**Private routes** (mounted under `/v1/<tenants>/:<id>`):
|
|
68
137
|
|
|
69
138
|
```
|
|
70
139
|
GET /knowledge
|
|
71
|
-
POST /knowledge
|
|
140
|
+
POST /knowledge { title, sourceType, sourceText? | url? }
|
|
72
141
|
GET /knowledge/:documentId
|
|
73
|
-
PUT /knowledge/:documentId
|
|
142
|
+
PUT /knowledge/:documentId { title?, sourceText? }
|
|
74
143
|
DELETE /knowledge/:documentId
|
|
75
144
|
|
|
76
|
-
POST /chat
|
|
145
|
+
POST /chat { sessionId, message }
|
|
77
146
|
GET /chat/:sessionId
|
|
78
147
|
GET /conversations?limit=50
|
|
79
|
-
POST /chat/rate
|
|
148
|
+
POST /chat/rate { sessionId, turnIndex, rating } // 1 | -1
|
|
80
149
|
|
|
81
150
|
GET /chat-analytics/overview
|
|
82
151
|
GET /chat-analytics/top-questions?limit=20
|
|
@@ -84,24 +153,99 @@ GET /chat-analytics/keywords?limit=30
|
|
|
84
153
|
GET /chat-analytics/cost-timeseries?days=30
|
|
85
154
|
GET /chat-analytics/knowledge-gaps?limit=15
|
|
86
155
|
GET /chat-analytics/document-usage
|
|
87
|
-
GET /chat-analytics/conversations?normalized
|
|
156
|
+
GET /chat-analytics/conversations?normalized=<q>&limit=50
|
|
88
157
|
GET /chat-analytics/semantic-clusters?limit=15
|
|
89
158
|
```
|
|
90
159
|
|
|
91
|
-
Public routes (mounted under `/v1/public/chat`):
|
|
160
|
+
**Public routes** (mounted under `/v1/public/chat`):
|
|
92
161
|
|
|
93
162
|
```
|
|
94
|
-
POST /:tenantSlug
|
|
163
|
+
POST /:tenantSlug { sessionId, message }
|
|
95
164
|
GET /:tenantSlug/:sessionId
|
|
96
|
-
POST /:tenantSlug/rate
|
|
165
|
+
POST /:tenantSlug/rate { sessionId, turnIndex, rating }
|
|
97
166
|
```
|
|
98
167
|
|
|
99
|
-
|
|
100
|
-
`createPublicRouter(chatbot, { rateLimitConfig: { windowMs, max } })`
|
|
168
|
+
Default IP rate limit: 20 req/min/IP. Override via
|
|
169
|
+
`createPublicRouter(chatbot, { rateLimitConfig: { windowMs, max } })` if you
|
|
170
|
+
need to wire a custom limiter — or use the exported `rateLimit` helper.
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Hooks reference
|
|
175
|
+
|
|
176
|
+
| Hook | Required | Purpose |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `getTenantName(id)` | yes | System prompt — "You are the X assistant" |
|
|
179
|
+
| `getTenantBySlug(slug)` | yes (for public surface) | Resolves slug to tenant for anonymous chat |
|
|
180
|
+
| `getCostCap(id)` | yes | Returns monthly USD cap; `null` = no cap |
|
|
181
|
+
| `withTenantScope(id, fn)` | yes | AsyncLocalStorage wrapper for tenant context |
|
|
182
|
+
| `systemPromptBuilder(name, blocks)` | yes | Builds the LLM system prompt with citations |
|
|
183
|
+
|
|
184
|
+
`plugins` is an array of `(schema, label) => void` — applied to every chatbot
|
|
185
|
+
model schema. Use this to attach your host's tenant-scope enforcement, audit
|
|
186
|
+
log, soft-delete, or whatever else every model needs.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Direct service calls
|
|
191
|
+
|
|
192
|
+
The router is convenient but you can call the services directly if needed:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const { text, citations, usage } = await chatbot.rag.respond({
|
|
196
|
+
tenantId, sessionId, message: "How do I export SAF-T?",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
await chatbot.rag.processDocument(documentId); // background embedding
|
|
200
|
+
const spend = await chatbot.rag.currentMonthCost(tenantId);
|
|
201
|
+
|
|
202
|
+
const kpis = await chatbot.analytics.overview(tenantId);
|
|
203
|
+
const gaps = await chatbot.analytics.knowledgeGaps(tenantId, 10);
|
|
204
|
+
const clusters = await chatbot.analytics.semanticClusters(tenantId, 15);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
---
|
|
101
208
|
|
|
102
209
|
## Cost-cap enforcement
|
|
103
210
|
|
|
104
|
-
When `hooks.getCostCap` returns a number > 0, `rag.respond()`
|
|
105
|
-
the current
|
|
106
|
-
`CHAT_BUDGET_EXCEEDED`.
|
|
107
|
-
with a numeric `.status` to the response.
|
|
211
|
+
When `hooks.getCostCap(tenantId)` returns a number > 0, `rag.respond()`
|
|
212
|
+
pre-flights the current month's spend. Over the cap → throws
|
|
213
|
+
`{ status: 503, code: "CHAT_BUDGET_EXCEEDED" }`. The host's error handler
|
|
214
|
+
should map application errors with a numeric `.status` to the response.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
// somewhere in your error middleware
|
|
218
|
+
app.use((err, req, res, next) => {
|
|
219
|
+
if (err.status) return res.status(err.status).json({ error: err.code || err.message });
|
|
220
|
+
next(err);
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Exports
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import {
|
|
230
|
+
createChatbot, // main factory
|
|
231
|
+
chunkText, // 2 kB paragraph chunker
|
|
232
|
+
cosineSimilarity, // dot-product over unit vectors
|
|
233
|
+
rateLimit, // express middleware factory
|
|
234
|
+
type ChatbotConfig,
|
|
235
|
+
type ChatbotHooks,
|
|
236
|
+
type Chatbot,
|
|
237
|
+
type ChatMessage,
|
|
238
|
+
type Citation,
|
|
239
|
+
type RespondArgs,
|
|
240
|
+
type RespondResult,
|
|
241
|
+
type AnalyticsService,
|
|
242
|
+
} from "@pixygon/chatbot-server";
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Companion package
|
|
248
|
+
|
|
249
|
+
`@pixygon/chatbot-react` ships matching MUI + RTK Query pages
|
|
250
|
+
(KnowledgePage / ChatPage / ChatAnalyticsPage / EmbedChatPage /
|
|
251
|
+
ChatbotSettings). See its README for the React/Vite wire-up.
|
package/dist/index.js
CHANGED
|
@@ -134,12 +134,41 @@ function createAiClient(cfg) {
|
|
|
134
134
|
return { content, model: `${model}/${version}`, tokensInput, tokensOutput, costUsd };
|
|
135
135
|
},
|
|
136
136
|
async embed(text, opts = {}) {
|
|
137
|
+
const model = opts.model || "text-embedding-3-small";
|
|
137
138
|
if (!cfg.openaiApiKey) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
if (!cfg.pixygonApiKey) {
|
|
140
|
+
const err = new Error("PIXYGON_API_KEY (or OPENAI_API_KEY) required for embeddings");
|
|
141
|
+
err.code = "EMBED_UNCONFIGURED";
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
const formData = new FormData();
|
|
145
|
+
formData.append("type", "embedding");
|
|
146
|
+
formData.append("model", "openai");
|
|
147
|
+
formData.append("version", model);
|
|
148
|
+
formData.append("prompt", text);
|
|
149
|
+
const res2 = await fetch(PIXYGON_AI_URL, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "x-api-key": cfg.pixygonApiKey },
|
|
152
|
+
body: formData
|
|
153
|
+
});
|
|
154
|
+
if (!res2.ok) {
|
|
155
|
+
const body = await res2.text().catch(() => "");
|
|
156
|
+
const err = new Error(`Pixygon embed failed: ${res2.status} \u2014 ${body.slice(0, 300)}`);
|
|
157
|
+
err.code = "PIXYGON_EMBED_FAILED";
|
|
158
|
+
err.status = res2.status;
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
const payload2 = await res2.json();
|
|
162
|
+
const vector = payload2?.embedding;
|
|
163
|
+
if (!Array.isArray(vector) || vector.length === 0) {
|
|
164
|
+
const err = new Error("Pixygon embed response had no vector");
|
|
165
|
+
err.code = "PIXYGON_EMBED_EMPTY";
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
const tokens2 = Number(payload2?.tokens ?? approxTokens(text));
|
|
169
|
+
const costUsd2 = tokens2 / 1e3 * EMBED_RATE;
|
|
170
|
+
return { embedding: vector, tokens: tokens2, costUsd: costUsd2, dimensions: vector.length };
|
|
141
171
|
}
|
|
142
|
-
const model = opts.model || "text-embedding-3-small";
|
|
143
172
|
const res = await fetch(`${OPENAI_API_URL}/embeddings`, {
|
|
144
173
|
method: "POST",
|
|
145
174
|
headers: {
|