@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.
Files changed (65) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +163 -0
  3. package/dist/declarations/src/AiRagService.d.ts +50 -0
  4. package/dist/declarations/src/AiRagService.d.ts.map +1 -0
  5. package/dist/declarations/src/admin-page.d.ts +29 -0
  6. package/dist/declarations/src/admin-page.d.ts.map +1 -0
  7. package/dist/declarations/src/chunking.d.ts +8 -0
  8. package/dist/declarations/src/chunking.d.ts.map +1 -0
  9. package/dist/declarations/src/collection.d.ts +18 -0
  10. package/dist/declarations/src/collection.d.ts.map +1 -0
  11. package/dist/declarations/src/express.d.ts +36 -0
  12. package/dist/declarations/src/express.d.ts.map +1 -0
  13. package/dist/declarations/src/graphql.d.ts +23 -0
  14. package/dist/declarations/src/graphql.d.ts.map +1 -0
  15. package/dist/declarations/src/index.d.ts +39 -0
  16. package/dist/declarations/src/index.d.ts.map +1 -0
  17. package/dist/declarations/src/plugin.d.ts +53 -0
  18. package/dist/declarations/src/plugin.d.ts.map +1 -0
  19. package/dist/declarations/src/prompt.d.ts +14 -0
  20. package/dist/declarations/src/prompt.d.ts.map +1 -0
  21. package/dist/declarations/src/providers/AnthropicRagProvider.d.ts +16 -0
  22. package/dist/declarations/src/providers/AnthropicRagProvider.d.ts.map +1 -0
  23. package/dist/declarations/src/providers/GeminiRagProvider.d.ts +19 -0
  24. package/dist/declarations/src/providers/GeminiRagProvider.d.ts.map +1 -0
  25. package/dist/declarations/src/providers/OllamaRagProvider.d.ts +23 -0
  26. package/dist/declarations/src/providers/OllamaRagProvider.d.ts.map +1 -0
  27. package/dist/declarations/src/providers/OpenAiRagProvider.d.ts +17 -0
  28. package/dist/declarations/src/providers/OpenAiRagProvider.d.ts.map +1 -0
  29. package/dist/declarations/src/providers/ServiceRagProvider.d.ts +17 -0
  30. package/dist/declarations/src/providers/ServiceRagProvider.d.ts.map +1 -0
  31. package/dist/declarations/src/providers/index.d.ts +14 -0
  32. package/dist/declarations/src/providers/index.d.ts.map +1 -0
  33. package/dist/declarations/src/providers/types.d.ts +45 -0
  34. package/dist/declarations/src/providers/types.d.ts.map +1 -0
  35. package/dist/declarations/src/similarity.d.ts +12 -0
  36. package/dist/declarations/src/similarity.d.ts.map +1 -0
  37. package/dist/declarations/src/types.d.ts +319 -0
  38. package/dist/declarations/src/types.d.ts.map +1 -0
  39. package/dist/declarations/src/vector-store.d.ts +34 -0
  40. package/dist/declarations/src/vector-store.d.ts.map +1 -0
  41. package/dist/nixxie-cms-ai-rag.cjs.d.ts +2 -0
  42. package/dist/nixxie-cms-ai-rag.cjs.js +2507 -0
  43. package/dist/nixxie-cms-ai-rag.esm.js +2481 -0
  44. package/package.json +37 -0
  45. package/src/AiRagService.ts +640 -0
  46. package/src/admin-page.ts +135 -0
  47. package/src/chunking.ts +78 -0
  48. package/src/collection.ts +79 -0
  49. package/src/express.ts +212 -0
  50. package/src/graphql.ts +196 -0
  51. package/src/guard.ts +75 -0
  52. package/src/index.ts +102 -0
  53. package/src/plugin.ts +162 -0
  54. package/src/prompt.ts +62 -0
  55. package/src/providers/AnthropicRagProvider.ts +91 -0
  56. package/src/providers/GeminiRagProvider.ts +147 -0
  57. package/src/providers/OllamaRagProvider.ts +157 -0
  58. package/src/providers/OpenAiRagProvider.ts +108 -0
  59. package/src/providers/ServiceRagProvider.ts +44 -0
  60. package/src/providers/index.ts +67 -0
  61. package/src/providers/types.ts +44 -0
  62. package/src/semaphore.ts +26 -0
  63. package/src/similarity.ts +31 -0
  64. package/src/types.ts +346 -0
  65. 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
+ }