@nixxie-cms/ai-rag 1.0.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/LICENSE +23 -0
- package/README.md +163 -0
- package/dist/declarations/src/AiRagService.d.ts +50 -0
- package/dist/declarations/src/AiRagService.d.ts.map +1 -0
- package/dist/declarations/src/admin-page.d.ts +29 -0
- package/dist/declarations/src/admin-page.d.ts.map +1 -0
- package/dist/declarations/src/chunking.d.ts +8 -0
- package/dist/declarations/src/chunking.d.ts.map +1 -0
- package/dist/declarations/src/collection.d.ts +18 -0
- package/dist/declarations/src/collection.d.ts.map +1 -0
- package/dist/declarations/src/express.d.ts +36 -0
- package/dist/declarations/src/express.d.ts.map +1 -0
- package/dist/declarations/src/graphql.d.ts +23 -0
- package/dist/declarations/src/graphql.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +39 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/plugin.d.ts +53 -0
- package/dist/declarations/src/plugin.d.ts.map +1 -0
- package/dist/declarations/src/prompt.d.ts +14 -0
- package/dist/declarations/src/prompt.d.ts.map +1 -0
- package/dist/declarations/src/providers/AnthropicRagProvider.d.ts +16 -0
- package/dist/declarations/src/providers/AnthropicRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/GeminiRagProvider.d.ts +19 -0
- package/dist/declarations/src/providers/GeminiRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/OllamaRagProvider.d.ts +23 -0
- package/dist/declarations/src/providers/OllamaRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/OpenAiRagProvider.d.ts +17 -0
- package/dist/declarations/src/providers/OpenAiRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/ServiceRagProvider.d.ts +17 -0
- package/dist/declarations/src/providers/ServiceRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/index.d.ts +14 -0
- package/dist/declarations/src/providers/index.d.ts.map +1 -0
- package/dist/declarations/src/providers/types.d.ts +45 -0
- package/dist/declarations/src/providers/types.d.ts.map +1 -0
- package/dist/declarations/src/similarity.d.ts +12 -0
- package/dist/declarations/src/similarity.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +319 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/declarations/src/vector-store.d.ts +34 -0
- package/dist/declarations/src/vector-store.d.ts.map +1 -0
- package/dist/nixxie-cms-ai-rag.cjs.d.ts +2 -0
- package/dist/nixxie-cms-ai-rag.cjs.js +2507 -0
- package/dist/nixxie-cms-ai-rag.esm.js +2481 -0
- package/package.json +37 -0
- package/src/AiRagService.ts +640 -0
- package/src/admin-page.ts +135 -0
- package/src/chunking.ts +78 -0
- package/src/collection.ts +79 -0
- package/src/express.ts +212 -0
- package/src/graphql.ts +196 -0
- package/src/guard.ts +75 -0
- package/src/index.ts +102 -0
- package/src/plugin.ts +162 -0
- package/src/prompt.ts +62 -0
- package/src/providers/AnthropicRagProvider.ts +91 -0
- package/src/providers/GeminiRagProvider.ts +147 -0
- package/src/providers/OllamaRagProvider.ts +157 -0
- package/src/providers/OpenAiRagProvider.ts +108 -0
- package/src/providers/ServiceRagProvider.ts +44 -0
- package/src/providers/index.ts +67 -0
- package/src/providers/types.ts +44 -0
- package/src/semaphore.ts +26 -0
- package/src/similarity.ts +31 -0
- package/src/types.ts +346 -0
- package/src/vector-store.ts +136 -0
package/src/graphql.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { g } from '@nixxie-cms/core'
|
|
2
|
+
import type {
|
|
3
|
+
NixxieAiRagService,
|
|
4
|
+
NixxieContext,
|
|
5
|
+
NixxieRagAnswer,
|
|
6
|
+
NixxieRagChunk,
|
|
7
|
+
NixxieRagCitation,
|
|
8
|
+
NixxieRagDocument,
|
|
9
|
+
NixxieRagIndexStats,
|
|
10
|
+
} from '@nixxie-cms/core'
|
|
11
|
+
|
|
12
|
+
/** How the GraphQL resolvers obtain the service (defaults to `context.services.aiRag`). */
|
|
13
|
+
export type RagGraphqlOptions = {
|
|
14
|
+
/** Override the service lookup. By default reads `context.services.aiRag`. */
|
|
15
|
+
getService?: (context: NixxieContext) => NixxieAiRagService | null | undefined
|
|
16
|
+
/**
|
|
17
|
+
* Gate the mutating operations (ingest/remove/reindex). Returning false rejects the call.
|
|
18
|
+
* Reads (`ragAsk`, `ragRetrieve`) are always allowed — guard them with collection access if needed.
|
|
19
|
+
* @default requires a session
|
|
20
|
+
*/
|
|
21
|
+
isAuthorized?: (context: NixxieContext) => boolean | Promise<boolean>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function serviceFrom(
|
|
25
|
+
context: NixxieContext,
|
|
26
|
+
options: RagGraphqlOptions
|
|
27
|
+
): NixxieAiRagService {
|
|
28
|
+
const svc = (options.getService ?? (c => c.services.aiRag))(context)
|
|
29
|
+
if (!svc) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'[@nixxie-cms/ai-rag] No aiRag service is configured. Pass it to `config({ aiRag })` or `ragPlugin({ service })`.'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return svc
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function assertAuthorized(context: NixxieContext, options: RagGraphqlOptions): Promise<void> {
|
|
38
|
+
const check = options.isAuthorized ?? ((c: NixxieContext) => !!c.session)
|
|
39
|
+
if (!(await check(context))) {
|
|
40
|
+
throw new Error('[@nixxie-cms/ai-rag] Not authorized.')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the `extendGraphqlSchema` function that adds the assistant's queries and mutations:
|
|
46
|
+
*
|
|
47
|
+
* - `ragAsk(question, topK?, tags?)` → grounded answer with citations
|
|
48
|
+
* - `ragRetrieve(query, topK?, tags?)` → raw retrieved chunks
|
|
49
|
+
* - `ragAddDocument(...)` / `ragRemoveDocument(id)` / `ragReindex(force?)`
|
|
50
|
+
*
|
|
51
|
+
* `ragPlugin()` wires this automatically; use it directly only if you compose the schema yourself.
|
|
52
|
+
*/
|
|
53
|
+
export function ragGraphqlExtension(options: RagGraphqlOptions = {}) {
|
|
54
|
+
return g.extend(() => {
|
|
55
|
+
const Citation = g.object<NixxieRagCitation>()({
|
|
56
|
+
name: 'RagCitation',
|
|
57
|
+
fields: {
|
|
58
|
+
documentId: g.field({ type: g.nonNull(g.String), resolve: s => s.documentId }),
|
|
59
|
+
chunkId: g.field({ type: g.nonNull(g.String), resolve: s => s.chunkId }),
|
|
60
|
+
title: g.field({ type: g.String, resolve: s => s.title ?? null }),
|
|
61
|
+
source: g.field({ type: g.String, resolve: s => s.source ?? null }),
|
|
62
|
+
score: g.field({ type: g.nonNull(g.Float), resolve: s => s.score }),
|
|
63
|
+
snippet: g.field({ type: g.nonNull(g.String), resolve: s => s.snippet }),
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const Chunk = g.object<NixxieRagChunk>()({
|
|
68
|
+
name: 'RagChunk',
|
|
69
|
+
fields: {
|
|
70
|
+
id: g.field({ type: g.nonNull(g.String), resolve: s => s.id }),
|
|
71
|
+
documentId: g.field({ type: g.nonNull(g.String), resolve: s => s.documentId }),
|
|
72
|
+
title: g.field({ type: g.String, resolve: s => s.title ?? null }),
|
|
73
|
+
source: g.field({ type: g.String, resolve: s => s.source ?? null }),
|
|
74
|
+
content: g.field({ type: g.nonNull(g.String), resolve: s => s.content }),
|
|
75
|
+
score: g.field({ type: g.nonNull(g.Float), resolve: s => s.score }),
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const Answer = g.object<NixxieRagAnswer>()({
|
|
80
|
+
name: 'RagAnswer',
|
|
81
|
+
fields: {
|
|
82
|
+
text: g.field({ type: g.nonNull(g.String), resolve: s => s.text }),
|
|
83
|
+
grounded: g.field({ type: g.nonNull(g.Boolean), resolve: s => s.grounded }),
|
|
84
|
+
refused: g.field({ type: g.nonNull(g.Boolean), resolve: s => s.refused }),
|
|
85
|
+
model: g.field({ type: g.nonNull(g.String), resolve: s => s.model }),
|
|
86
|
+
sources: g.field({
|
|
87
|
+
type: g.nonNull(g.list(g.nonNull(Citation))),
|
|
88
|
+
resolve: s => s.sources,
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const Document = g.object<NixxieRagDocument>()({
|
|
94
|
+
name: 'RagDocument',
|
|
95
|
+
fields: {
|
|
96
|
+
id: g.field({ type: g.nonNull(g.String), resolve: s => s.id }),
|
|
97
|
+
title: g.field({ type: g.String, resolve: s => s.title ?? null }),
|
|
98
|
+
content: g.field({ type: g.nonNull(g.String), resolve: s => s.content }),
|
|
99
|
+
source: g.field({ type: g.String, resolve: s => s.source ?? null }),
|
|
100
|
+
tags: g.field({
|
|
101
|
+
type: g.list(g.nonNull(g.String)),
|
|
102
|
+
resolve: s => s.tags ?? null,
|
|
103
|
+
}),
|
|
104
|
+
status: g.field({ type: g.nonNull(g.String), resolve: s => s.status }),
|
|
105
|
+
chunkCount: g.field({ type: g.nonNull(g.Int), resolve: s => s.chunkCount }),
|
|
106
|
+
error: g.field({ type: g.String, resolve: s => s.error ?? null }),
|
|
107
|
+
indexedAt: g.field({
|
|
108
|
+
type: g.String,
|
|
109
|
+
resolve: s => s.indexedAt?.toISOString() ?? null,
|
|
110
|
+
}),
|
|
111
|
+
createdAt: g.field({
|
|
112
|
+
type: g.nonNull(g.String),
|
|
113
|
+
resolve: s => s.createdAt.toISOString(),
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const IndexStats = g.object<NixxieRagIndexStats>()({
|
|
119
|
+
name: 'RagIndexStats',
|
|
120
|
+
fields: {
|
|
121
|
+
documents: g.field({ type: g.nonNull(g.Int), resolve: s => s.documents }),
|
|
122
|
+
chunks: g.field({ type: g.nonNull(g.Int), resolve: s => s.chunks }),
|
|
123
|
+
errors: g.field({ type: g.nonNull(g.Int), resolve: s => s.errors }),
|
|
124
|
+
durationMs: g.field({ type: g.nonNull(g.Int), resolve: s => s.durationMs }),
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
query: {
|
|
130
|
+
ragAsk: g.field({
|
|
131
|
+
type: g.nonNull(Answer),
|
|
132
|
+
args: {
|
|
133
|
+
question: g.arg({ type: g.nonNull(g.String) }),
|
|
134
|
+
topK: g.arg({ type: g.Int }),
|
|
135
|
+
tags: g.arg({ type: g.list(g.nonNull(g.String)) }),
|
|
136
|
+
},
|
|
137
|
+
resolve: async (_s, args, context) =>
|
|
138
|
+
serviceFrom(context, options).ask(args.question, {
|
|
139
|
+
topK: args.topK ?? undefined,
|
|
140
|
+
tags: (args.tags ?? undefined) as string[] | undefined,
|
|
141
|
+
}),
|
|
142
|
+
}),
|
|
143
|
+
ragRetrieve: g.field({
|
|
144
|
+
type: g.nonNull(g.list(g.nonNull(Chunk))),
|
|
145
|
+
args: {
|
|
146
|
+
query: g.arg({ type: g.nonNull(g.String) }),
|
|
147
|
+
topK: g.arg({ type: g.Int }),
|
|
148
|
+
tags: g.arg({ type: g.list(g.nonNull(g.String)) }),
|
|
149
|
+
},
|
|
150
|
+
resolve: async (_s, args, context) =>
|
|
151
|
+
serviceFrom(context, options).retrieve(args.query, {
|
|
152
|
+
topK: args.topK ?? undefined,
|
|
153
|
+
tags: (args.tags ?? undefined) as string[] | undefined,
|
|
154
|
+
}),
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
mutation: {
|
|
158
|
+
ragAddDocument: g.field({
|
|
159
|
+
type: g.nonNull(Document),
|
|
160
|
+
args: {
|
|
161
|
+
content: g.arg({ type: g.nonNull(g.String) }),
|
|
162
|
+
title: g.arg({ type: g.String }),
|
|
163
|
+
source: g.arg({ type: g.String }),
|
|
164
|
+
tags: g.arg({ type: g.list(g.nonNull(g.String)) }),
|
|
165
|
+
},
|
|
166
|
+
resolve: async (_s, args, context) => {
|
|
167
|
+
await assertAuthorized(context, options)
|
|
168
|
+
return serviceFrom(context, options).addDocument({
|
|
169
|
+
content: args.content,
|
|
170
|
+
title: args.title ?? undefined,
|
|
171
|
+
source: args.source ?? undefined,
|
|
172
|
+
tags: (args.tags ?? undefined) as string[] | undefined,
|
|
173
|
+
})
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
ragRemoveDocument: g.field({
|
|
177
|
+
type: g.nonNull(g.Boolean),
|
|
178
|
+
args: { id: g.arg({ type: g.nonNull(g.String) }) },
|
|
179
|
+
resolve: async (_s, args, context) => {
|
|
180
|
+
await assertAuthorized(context, options)
|
|
181
|
+
await serviceFrom(context, options).removeDocument(args.id)
|
|
182
|
+
return true
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
ragReindex: g.field({
|
|
186
|
+
type: g.nonNull(IndexStats),
|
|
187
|
+
args: { force: g.arg({ type: g.Boolean }) },
|
|
188
|
+
resolve: async (_s, args, context) => {
|
|
189
|
+
await assertAuthorized(context, options)
|
|
190
|
+
return serviceFrom(context, options).reindex({ force: args.force ?? false })
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
}
|
package/src/guard.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { NixxieRagChunk } from '@nixxie-cms/core'
|
|
2
|
+
import type { GenerationProvider } from './providers/types'
|
|
3
|
+
import type { RagGuardConfig } from './types'
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_REFUSAL =
|
|
6
|
+
"I don't have enough information in my knowledge base to answer that."
|
|
7
|
+
|
|
8
|
+
/** Resolve guard config against defaults. */
|
|
9
|
+
export function resolveGuard(config: RagGuardConfig = {}) {
|
|
10
|
+
const enabled = config.enabled ?? true
|
|
11
|
+
return {
|
|
12
|
+
enabled,
|
|
13
|
+
refuseWhenNoContext: enabled && (config.refuseWhenNoContext ?? true),
|
|
14
|
+
refusal: config.refusal ?? DEFAULT_REFUSAL,
|
|
15
|
+
requireCitations: enabled && (config.requireCitations ?? true),
|
|
16
|
+
groundingCheck: enabled && (config.groundingCheck ?? false),
|
|
17
|
+
groundingModel: config.groundingModel,
|
|
18
|
+
refuseWhenUngrounded: config.refuseWhenUngrounded ?? true,
|
|
19
|
+
allowModelKnowledge: config.allowModelKnowledge ?? false,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ResolvedGuard = ReturnType<typeof resolveGuard>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decide whether retrieval found enough relevant context to answer. When nothing clears the
|
|
27
|
+
* relevance bar and `refuseWhenNoContext` is on (and model knowledge isn't allowed), the
|
|
28
|
+
* assistant should refuse instead of guessing.
|
|
29
|
+
*/
|
|
30
|
+
export function shouldRefuseForNoContext(
|
|
31
|
+
chunks: NixxieRagChunk[],
|
|
32
|
+
guard: ResolvedGuard,
|
|
33
|
+
minScore: number
|
|
34
|
+
): boolean {
|
|
35
|
+
if (!guard.refuseWhenNoContext || guard.allowModelKnowledge) return false
|
|
36
|
+
const best = chunks[0]?.score ?? 0
|
|
37
|
+
return chunks.length === 0 || best < minScore
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Post-hoc grounding check: ask a (cheap) model whether the drafted answer is fully
|
|
42
|
+
* supported by the retrieved context. Returns whether it's grounded plus an optional reason.
|
|
43
|
+
* Best-effort — any failure is treated as "grounded" so the guard never hard-fails a request.
|
|
44
|
+
*/
|
|
45
|
+
export async function checkGrounding(
|
|
46
|
+
provider: GenerationProvider,
|
|
47
|
+
answer: string,
|
|
48
|
+
chunks: NixxieRagChunk[],
|
|
49
|
+
model: string | undefined
|
|
50
|
+
): Promise<{ grounded: boolean; reason?: string }> {
|
|
51
|
+
if (!answer.trim() || chunks.length === 0) return { grounded: true }
|
|
52
|
+
const context = chunks
|
|
53
|
+
.map((c, i) => `[${i + 1}] ${c.content}`)
|
|
54
|
+
.join('\n\n')
|
|
55
|
+
.slice(0, 8000)
|
|
56
|
+
|
|
57
|
+
const system =
|
|
58
|
+
'You are a strict fact-checker. Decide if the ANSWER is fully supported by the CONTEXT. ' +
|
|
59
|
+
'Reply with a single JSON object: {"grounded": boolean, "reason": string}. ' +
|
|
60
|
+
'An answer that adds facts not present in the context is NOT grounded. A refusal or ' +
|
|
61
|
+
'"I don\'t know" counts as grounded.'
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const res = await provider.generate(
|
|
65
|
+
[{ role: 'user', content: `CONTEXT:\n${context}\n\nANSWER:\n${answer}` }],
|
|
66
|
+
{ system, model, temperature: 0, maxTokens: 200 }
|
|
67
|
+
)
|
|
68
|
+
const match = res.text.match(/\{[\s\S]*\}/)
|
|
69
|
+
if (!match) return { grounded: true }
|
|
70
|
+
const parsed = JSON.parse(match[0]) as { grounded?: boolean; reason?: string }
|
|
71
|
+
return { grounded: parsed.grounded !== false, reason: parsed.reason }
|
|
72
|
+
} catch {
|
|
73
|
+
return { grounded: true }
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AiRagService } from './AiRagService'
|
|
2
|
+
import type { AiRagConfig } from './types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a custom, RAG-trained assistant backed by a knowledge-base collection.
|
|
6
|
+
*
|
|
7
|
+
* Register it with `ragPlugin()` so the knowledge-base collections, HTTP + GraphQL surfaces
|
|
8
|
+
* and scheduled indexing are wired automatically:
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { createAiRag, ragPlugin } from '@nixxie-cms/ai-rag'
|
|
12
|
+
* const aiRag = createAiRag({
|
|
13
|
+
* generation: { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY! },
|
|
14
|
+
* embedding: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY! },
|
|
15
|
+
* retrieval: { topK: 6, minScore: 0.25 },
|
|
16
|
+
* guard: { groundingCheck: true },
|
|
17
|
+
* indexing: { schedule: '0 3 * * *' },
|
|
18
|
+
* })
|
|
19
|
+
* export default config({ db: { ... }, plugins: [ragPlugin({ service: aiRag })] })
|
|
20
|
+
*
|
|
21
|
+
* Then anywhere you have context:
|
|
22
|
+
* await context.services.aiRag!.addDocument({ title, content })
|
|
23
|
+
* const answer = await context.services.aiRag!.ask('How do refunds work?')
|
|
24
|
+
*/
|
|
25
|
+
export function createAiRag(config: AiRagConfig = {}): AiRagService {
|
|
26
|
+
return new AiRagService(config)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { AiRagService } from './AiRagService'
|
|
30
|
+
|
|
31
|
+
// Plugin + surfaces
|
|
32
|
+
export { ragPlugin, type RagPluginOptions } from './plugin'
|
|
33
|
+
export { installAiRagRoutes, type AiRagRoutesOptions } from './express'
|
|
34
|
+
export { ragGraphqlExtension, type RagGraphqlOptions } from './graphql'
|
|
35
|
+
export { ragAdminPage, type RagAdminPageOptions } from './admin-page'
|
|
36
|
+
|
|
37
|
+
// Collections
|
|
38
|
+
export { knowledgeBaseCollection, knowledgeChunkCollection } from './collection'
|
|
39
|
+
|
|
40
|
+
// Providers (advanced / custom wiring)
|
|
41
|
+
export {
|
|
42
|
+
resolveGenerationProvider,
|
|
43
|
+
resolveEmbeddingProvider,
|
|
44
|
+
AnthropicRagProvider,
|
|
45
|
+
OpenAiRagProvider,
|
|
46
|
+
GeminiRagProvider,
|
|
47
|
+
OllamaRagProvider,
|
|
48
|
+
ServiceRagProvider,
|
|
49
|
+
} from './providers'
|
|
50
|
+
export type {
|
|
51
|
+
GenerationProvider,
|
|
52
|
+
EmbeddingProvider,
|
|
53
|
+
RagMessage,
|
|
54
|
+
RagGenerateOptions,
|
|
55
|
+
RagGenerateResult,
|
|
56
|
+
RagStreamChunk,
|
|
57
|
+
} from './providers'
|
|
58
|
+
|
|
59
|
+
// Vector stores
|
|
60
|
+
export { SqlVectorStore, InMemoryVectorStore } from './vector-store'
|
|
61
|
+
|
|
62
|
+
// Retrieval primitives
|
|
63
|
+
export { chunkText } from './chunking'
|
|
64
|
+
export { cosineSimilarity, normalize } from './similarity'
|
|
65
|
+
export { buildRagPrompt, renderContext, DEFAULT_SYSTEM_PROMPT } from './prompt'
|
|
66
|
+
|
|
67
|
+
// Config + value types
|
|
68
|
+
export type {
|
|
69
|
+
AiRagConfig,
|
|
70
|
+
RagProviderName,
|
|
71
|
+
RagProviderConfig,
|
|
72
|
+
RagGenerationConfig,
|
|
73
|
+
RagEmbeddingConfig,
|
|
74
|
+
RagRetrievalConfig,
|
|
75
|
+
RagChunkingConfig,
|
|
76
|
+
RagChunkingStrategy,
|
|
77
|
+
RagIndexingConfig,
|
|
78
|
+
RagGuardConfig,
|
|
79
|
+
RagChatConfig,
|
|
80
|
+
RagLimitsConfig,
|
|
81
|
+
RagCollectionsConfig,
|
|
82
|
+
VectorStore,
|
|
83
|
+
VectorRecord,
|
|
84
|
+
VectorQuery,
|
|
85
|
+
PromptBuildArgs,
|
|
86
|
+
PromptBuildResult,
|
|
87
|
+
} from './types'
|
|
88
|
+
|
|
89
|
+
// Re-exported service types from core
|
|
90
|
+
export type {
|
|
91
|
+
NixxieAiRagService,
|
|
92
|
+
NixxieRagDocument,
|
|
93
|
+
NixxieRagDocumentInput,
|
|
94
|
+
NixxieRagDocumentQuery,
|
|
95
|
+
NixxieRagChunk,
|
|
96
|
+
NixxieRagCitation,
|
|
97
|
+
NixxieRagAnswer,
|
|
98
|
+
NixxieRagAskOptions,
|
|
99
|
+
NixxieRagRetrieveOptions,
|
|
100
|
+
NixxieRagStreamEvent,
|
|
101
|
+
NixxieRagIndexStats,
|
|
102
|
+
} from '@nixxie-cms/core'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { NixxieContext, NixxiePlugin } from '@nixxie-cms/core/types'
|
|
2
|
+
import type { AiRagService } from './AiRagService'
|
|
3
|
+
import { knowledgeBaseCollection, knowledgeChunkCollection } from './collection'
|
|
4
|
+
import { installAiRagRoutes, type AiRagRoutesOptions } from './express'
|
|
5
|
+
import { ragGraphqlExtension, type RagGraphqlOptions } from './graphql'
|
|
6
|
+
|
|
7
|
+
export type RagPluginOptions = {
|
|
8
|
+
/** The service created with `createAiRag()`. */
|
|
9
|
+
service: AiRagService
|
|
10
|
+
/**
|
|
11
|
+
* Add the knowledge-base + chunk collections to the schema.
|
|
12
|
+
* Disable if you register them yourself (e.g. to customise access control).
|
|
13
|
+
* @default true
|
|
14
|
+
*/
|
|
15
|
+
collections?: boolean
|
|
16
|
+
/**
|
|
17
|
+
* Re-index a knowledge-base row whenever it's created/updated through the CMS, and drop
|
|
18
|
+
* its chunks when it's deleted. Overrides the service's `indexing.auto` for CMS writes.
|
|
19
|
+
* @default the service's `indexing.auto`
|
|
20
|
+
*/
|
|
21
|
+
autoIndex?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* Mount the HTTP routes (chat + streaming + document CRUD). Pass `false` to skip, or an
|
|
24
|
+
* options object to configure the path / auth.
|
|
25
|
+
* @default true (mounted at /api/ai-rag)
|
|
26
|
+
*/
|
|
27
|
+
routes?: boolean | Omit<AiRagRoutesOptions, 'service'>
|
|
28
|
+
/**
|
|
29
|
+
* Add the GraphQL queries/mutations (ragAsk, ragRetrieve, ragAddDocument, …).
|
|
30
|
+
* Pass `false` to skip, or options to configure auth.
|
|
31
|
+
* @default true
|
|
32
|
+
*/
|
|
33
|
+
graphql?: boolean | RagGraphqlOptions
|
|
34
|
+
/** Name of the scheduled reindex job registered with the jobs service. @default 'ai-rag-reindex' */
|
|
35
|
+
jobName?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Re-index on CMS create/update, purge chunks on delete. Skips the service's own status writes. */
|
|
39
|
+
function autoIndexHooks(service: AiRagService) {
|
|
40
|
+
return {
|
|
41
|
+
afterOperation: async (args: any) => {
|
|
42
|
+
try {
|
|
43
|
+
if (args.operation === 'delete') {
|
|
44
|
+
const id = args.originalItem?.id
|
|
45
|
+
if (id != null) await service.purgeChunks(String(id))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const item = args.item
|
|
49
|
+
if (!item?.id) return
|
|
50
|
+
// Only (re)index when indexable content actually changed — the service's own
|
|
51
|
+
// status/chunkCount writes go through raw Prisma and never reach this hook, but
|
|
52
|
+
// this also avoids needless work on unrelated field edits.
|
|
53
|
+
const changed =
|
|
54
|
+
args.operation === 'create' ||
|
|
55
|
+
item.content !== args.originalItem?.content ||
|
|
56
|
+
item.title !== args.originalItem?.title
|
|
57
|
+
if (changed) await service.index(String(item.id))
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('[@nixxie-cms/ai-rag] Auto-index hook failed:', err)
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function composeExtendExpress(
|
|
66
|
+
previous: ((app: any, context: NixxieContext) => void | Promise<void>) | undefined,
|
|
67
|
+
service: AiRagService,
|
|
68
|
+
routes: RagPluginOptions['routes']
|
|
69
|
+
): (app: any, context: NixxieContext) => Promise<void> {
|
|
70
|
+
const routeOptions = (typeof routes === 'object' ? routes : {}) as AiRagRoutesOptions
|
|
71
|
+
return async (app: any, context: NixxieContext) => {
|
|
72
|
+
await previous?.(app, context)
|
|
73
|
+
if (routes !== false) installAiRagRoutes(app, context, { ...routeOptions, service })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function composeExtendGraphql(
|
|
78
|
+
previous: ((schema: any) => any) | undefined,
|
|
79
|
+
graphqlOption: RagPluginOptions['graphql']
|
|
80
|
+
) {
|
|
81
|
+
if (graphqlOption === false) return previous
|
|
82
|
+
const ext = ragGraphqlExtension(typeof graphqlOption === 'object' ? graphqlOption : {})
|
|
83
|
+
return (schema: any) => ext(previous ? previous(schema) : schema)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One-call wiring for the RAG assistant. Registers the service, adds the knowledge-base
|
|
88
|
+
* collections (with auto-indexing hooks), mounts the HTTP + GraphQL surfaces and schedules
|
|
89
|
+
* re-indexing.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* import { createAiRag, ragPlugin } from '@nixxie-cms/ai-rag'
|
|
93
|
+
* const aiRag = createAiRag({
|
|
94
|
+
* generation: { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY! },
|
|
95
|
+
* embedding: { provider: 'openai', apiKey: process.env.OPENAI_API_KEY! },
|
|
96
|
+
* indexing: { schedule: '0 3 * * *' },
|
|
97
|
+
* })
|
|
98
|
+
* export default config({
|
|
99
|
+
* db: { ... },
|
|
100
|
+
* plugins: [ragPlugin({ service: aiRag })],
|
|
101
|
+
* })
|
|
102
|
+
*/
|
|
103
|
+
export function ragPlugin(options: RagPluginOptions): NixxiePlugin {
|
|
104
|
+
const { service } = options
|
|
105
|
+
const { documents, chunks } = service.collections
|
|
106
|
+
const autoIndex = options.autoIndex ?? true
|
|
107
|
+
const jobName = options.jobName ?? 'ai-rag-reindex'
|
|
108
|
+
|
|
109
|
+
const collections =
|
|
110
|
+
options.collections === false
|
|
111
|
+
? undefined
|
|
112
|
+
: {
|
|
113
|
+
[documents]: autoIndex
|
|
114
|
+
? { ...knowledgeBaseCollection(), hooks: autoIndexHooks(service) }
|
|
115
|
+
: knowledgeBaseCollection(),
|
|
116
|
+
[chunks]: knowledgeChunkCollection(),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
name: 'nixxie-ai-rag',
|
|
121
|
+
collections,
|
|
122
|
+
extendConfig: config => ({
|
|
123
|
+
...config,
|
|
124
|
+
// Register the service so core initialises it and it's reachable via context.services.aiRag.
|
|
125
|
+
aiRag: config.aiRag ?? service,
|
|
126
|
+
graphql: {
|
|
127
|
+
...config.graphql,
|
|
128
|
+
extendGraphqlSchema: composeExtendGraphql(
|
|
129
|
+
config.graphql?.extendGraphqlSchema,
|
|
130
|
+
options.graphql
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
server: {
|
|
134
|
+
...config.server,
|
|
135
|
+
extendExpressApp: composeExtendExpress(
|
|
136
|
+
config.server?.extendExpressApp,
|
|
137
|
+
service,
|
|
138
|
+
options.routes
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
onConnect: async context => {
|
|
143
|
+
// The service itself is initialised by core's service-init loop (because we set
|
|
144
|
+
// config.aiRag above). Here we only schedule the periodic full reindex, if requested.
|
|
145
|
+
const schedule = service.indexingSchedule
|
|
146
|
+
if (schedule && context.services.jobs) {
|
|
147
|
+
context.services.jobs.define({
|
|
148
|
+
name: jobName,
|
|
149
|
+
schedule,
|
|
150
|
+
handler: async () => {
|
|
151
|
+
await service.reindex()
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
} else if (schedule && !context.services.jobs) {
|
|
155
|
+
console.warn(
|
|
156
|
+
`[@nixxie-cms/ai-rag] indexing.schedule is set but no jobs service is configured — ` +
|
|
157
|
+
`add \`jobs: createJobs()\` from @nixxie-cms/jobs to enable scheduled re-indexing.`
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { NixxieRagChunk } from '@nixxie-cms/core'
|
|
2
|
+
import type { PromptBuildArgs, PromptBuildResult } from './types'
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SYSTEM_PROMPT =
|
|
5
|
+
'You are a helpful assistant that answers questions strictly using the provided knowledge ' +
|
|
6
|
+
'base context. Be accurate and concise. If the context does not contain the answer, say you ' +
|
|
7
|
+
"don't know rather than guessing."
|
|
8
|
+
|
|
9
|
+
export const ALLOW_KNOWLEDGE_SYSTEM_PROMPT =
|
|
10
|
+
'You are a helpful assistant. Prefer the provided knowledge base context when answering. If ' +
|
|
11
|
+
'the context is insufficient you may use your general knowledge, but make clear which parts ' +
|
|
12
|
+
'are not from the knowledge base.'
|
|
13
|
+
|
|
14
|
+
/** Render retrieved chunks as a numbered, citable source block. */
|
|
15
|
+
export function renderContext(chunks: NixxieRagChunk[], maxChars: number): string {
|
|
16
|
+
const lines: string[] = []
|
|
17
|
+
let used = 0
|
|
18
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
19
|
+
const c = chunks[i]!
|
|
20
|
+
const header = `[${i + 1}]${c.title ? ` ${c.title}` : ''}${c.source ? ` (${c.source})` : ''}`
|
|
21
|
+
const body = c.content.trim()
|
|
22
|
+
const block = `${header}\n${body}`
|
|
23
|
+
if (used + block.length > maxChars && lines.length > 0) break
|
|
24
|
+
lines.push(block)
|
|
25
|
+
used += block.length
|
|
26
|
+
}
|
|
27
|
+
return lines.join('\n\n')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Default prompt assembly: a system prompt carrying the numbered context + citation rules,
|
|
32
|
+
* the trimmed history, and the user's question. Overridable via `generation.buildPrompt`.
|
|
33
|
+
*/
|
|
34
|
+
export function buildRagPrompt(
|
|
35
|
+
args: PromptBuildArgs,
|
|
36
|
+
options: { maxContextChars: number }
|
|
37
|
+
): PromptBuildResult {
|
|
38
|
+
const contextBlock = renderContext(args.context, options.maxContextChars)
|
|
39
|
+
const rules: string[] = []
|
|
40
|
+
if (args.context.length > 0) {
|
|
41
|
+
rules.push('Answer using ONLY the numbered context below.')
|
|
42
|
+
if (args.requireCitations) {
|
|
43
|
+
rules.push('Cite the sources you used inline as [n] matching the context numbers.')
|
|
44
|
+
}
|
|
45
|
+
rules.push("If the context does not contain the answer, say you don't have that information.")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const system = [
|
|
49
|
+
args.systemPrompt,
|
|
50
|
+
rules.length ? `\nRules:\n- ${rules.join('\n- ')}` : '',
|
|
51
|
+
contextBlock ? `\nContext:\n${contextBlock}` : '\nContext: (none found)',
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join('\n')
|
|
55
|
+
|
|
56
|
+
const messages: PromptBuildResult['messages'] = [
|
|
57
|
+
...args.history,
|
|
58
|
+
{ role: 'user', content: args.question },
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
return { system, messages }
|
|
62
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RagProviderConfig } from '../types'
|
|
2
|
+
import type {
|
|
3
|
+
GenerationProvider,
|
|
4
|
+
RagGenerateOptions,
|
|
5
|
+
RagGenerateResult,
|
|
6
|
+
RagMessage,
|
|
7
|
+
RagStreamChunk,
|
|
8
|
+
} from './types'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MODEL = 'claude-opus-4-8'
|
|
11
|
+
|
|
12
|
+
function loadAnthropic(): any {
|
|
13
|
+
try {
|
|
14
|
+
return require('@anthropic-ai/sdk').default ?? require('@anthropic-ai/sdk')
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'[@nixxie-cms/ai-rag] The Anthropic provider requires @anthropic-ai/sdk. Run: npm install @anthropic-ai/sdk'
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Anthropic (Claude) generation provider with native streaming. */
|
|
23
|
+
export class AnthropicRagProvider implements GenerationProvider {
|
|
24
|
+
readonly name = 'anthropic'
|
|
25
|
+
readonly defaultModel = DEFAULT_MODEL
|
|
26
|
+
private client: any
|
|
27
|
+
private model: string
|
|
28
|
+
private maxTokens: number
|
|
29
|
+
private extra: Record<string, unknown>
|
|
30
|
+
|
|
31
|
+
constructor(config: RagProviderConfig) {
|
|
32
|
+
if (!config.apiKey) throw new Error('[@nixxie-cms/ai-rag] Anthropic generation requires `apiKey`.')
|
|
33
|
+
const Anthropic = loadAnthropic()
|
|
34
|
+
this.client = new Anthropic({ apiKey: config.apiKey, baseURL: config.baseUrl })
|
|
35
|
+
this.model = config.model ?? DEFAULT_MODEL
|
|
36
|
+
this.maxTokens = 1024
|
|
37
|
+
this.extra = config.extra ?? {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private buildBody(messages: RagMessage[], options?: RagGenerateOptions) {
|
|
41
|
+
return {
|
|
42
|
+
model: options?.model ?? this.model,
|
|
43
|
+
max_tokens: options?.maxTokens ?? this.maxTokens,
|
|
44
|
+
system: options?.system || undefined,
|
|
45
|
+
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
|
46
|
+
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
47
|
+
...(options?.topP !== undefined ? { top_p: options.topP } : {}),
|
|
48
|
+
...this.extra,
|
|
49
|
+
...(options?.extra ?? {}),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async generate(messages: RagMessage[], options?: RagGenerateOptions): Promise<RagGenerateResult> {
|
|
54
|
+
const res = await this.client.messages.create(this.buildBody(messages, options))
|
|
55
|
+
const text = (res.content ?? [])
|
|
56
|
+
.filter((b: any) => b.type === 'text')
|
|
57
|
+
.map((b: any) => b.text)
|
|
58
|
+
.join('')
|
|
59
|
+
return {
|
|
60
|
+
text,
|
|
61
|
+
model: res.model ?? options?.model ?? this.model,
|
|
62
|
+
usage: { inputTokens: res.usage?.input_tokens, outputTokens: res.usage?.output_tokens },
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async *stream(
|
|
67
|
+
messages: RagMessage[],
|
|
68
|
+
options?: RagGenerateOptions
|
|
69
|
+
): AsyncIterable<RagStreamChunk> {
|
|
70
|
+
const stream = await this.client.messages.create({
|
|
71
|
+
...this.buildBody(messages, options),
|
|
72
|
+
stream: true,
|
|
73
|
+
})
|
|
74
|
+
let input = 0
|
|
75
|
+
let output = 0
|
|
76
|
+
for await (const event of stream) {
|
|
77
|
+
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
78
|
+
yield { delta: event.delta.text }
|
|
79
|
+
} else if (event.type === 'message_start') {
|
|
80
|
+
input = event.message?.usage?.input_tokens ?? input
|
|
81
|
+
} else if (event.type === 'message_delta') {
|
|
82
|
+
output = event.usage?.output_tokens ?? output
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
yield {
|
|
86
|
+
done: true,
|
|
87
|
+
model: options?.model ?? this.model,
|
|
88
|
+
usage: { inputTokens: input, outputTokens: output },
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|