@hed-hog/core 0.0.364 → 0.0.365

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 (185) hide show
  1. package/dist/ai/ai.controller.d.ts +4 -4
  2. package/dist/ai/ai.module.d.ts.map +1 -1
  3. package/dist/ai/ai.module.js +2 -0
  4. package/dist/ai/ai.module.js.map +1 -1
  5. package/dist/ai/ai.service.d.ts +7 -5
  6. package/dist/ai/ai.service.d.ts.map +1 -1
  7. package/dist/ai/ai.service.js +63 -31
  8. package/dist/ai/ai.service.js.map +1 -1
  9. package/dist/ai-instruction/ai-execution.service.d.ts +45 -0
  10. package/dist/ai-instruction/ai-execution.service.d.ts.map +1 -0
  11. package/dist/ai-instruction/ai-execution.service.js +203 -0
  12. package/dist/ai-instruction/ai-execution.service.js.map +1 -0
  13. package/dist/ai-instruction/ai-instruction.controller.d.ts +25 -0
  14. package/dist/ai-instruction/ai-instruction.controller.d.ts.map +1 -0
  15. package/dist/ai-instruction/ai-instruction.controller.js +117 -0
  16. package/dist/ai-instruction/ai-instruction.controller.js.map +1 -0
  17. package/dist/ai-instruction/ai-instruction.module.d.ts +3 -0
  18. package/dist/ai-instruction/ai-instruction.module.d.ts.map +1 -0
  19. package/dist/ai-instruction/ai-instruction.module.js +27 -0
  20. package/dist/ai-instruction/ai-instruction.module.js.map +1 -0
  21. package/dist/ai-instruction/ai-instruction.service.d.ts +38 -0
  22. package/dist/ai-instruction/ai-instruction.service.d.ts.map +1 -0
  23. package/dist/ai-instruction/ai-instruction.service.js +289 -0
  24. package/dist/ai-instruction/ai-instruction.service.js.map +1 -0
  25. package/dist/ai-instruction/ai-prompt-builder.service.d.ts +53 -0
  26. package/dist/ai-instruction/ai-prompt-builder.service.d.ts.map +1 -0
  27. package/dist/ai-instruction/ai-prompt-builder.service.js +132 -0
  28. package/dist/ai-instruction/ai-prompt-builder.service.js.map +1 -0
  29. package/dist/ai-instruction/dto/create-ai-instruction.dto.d.ts +12 -0
  30. package/dist/ai-instruction/dto/create-ai-instruction.dto.d.ts.map +1 -0
  31. package/dist/ai-instruction/dto/create-ai-instruction.dto.js +57 -0
  32. package/dist/ai-instruction/dto/create-ai-instruction.dto.js.map +1 -0
  33. package/dist/ai-instruction/dto/update-ai-instruction.dto.d.ts +10 -0
  34. package/dist/ai-instruction/dto/update-ai-instruction.dto.d.ts.map +1 -0
  35. package/dist/ai-instruction/dto/update-ai-instruction.dto.js +56 -0
  36. package/dist/ai-instruction/dto/update-ai-instruction.dto.js.map +1 -0
  37. package/dist/ai-instruction/types/ai-instruction.types.d.ts +78 -0
  38. package/dist/ai-instruction/types/ai-instruction.types.d.ts.map +1 -0
  39. package/dist/ai-instruction/types/ai-instruction.types.js +3 -0
  40. package/dist/ai-instruction/types/ai-instruction.types.js.map +1 -0
  41. package/dist/auth/auth.controller.d.ts +13 -5
  42. package/dist/auth/auth.controller.d.ts.map +1 -1
  43. package/dist/auth/auth.controller.js +39 -20
  44. package/dist/auth/auth.controller.js.map +1 -1
  45. package/dist/auth/auth.service.d.ts +18 -7
  46. package/dist/auth/auth.service.d.ts.map +1 -1
  47. package/dist/auth/auth.service.js +20 -16
  48. package/dist/auth/auth.service.js.map +1 -1
  49. package/dist/core.module.d.ts.map +1 -1
  50. package/dist/core.module.js +3 -0
  51. package/dist/core.module.js.map +1 -1
  52. package/dist/file/file.service.d.ts +37 -0
  53. package/dist/file/file.service.d.ts.map +1 -1
  54. package/dist/file/file.service.js +94 -4
  55. package/dist/file/file.service.js.map +1 -1
  56. package/dist/file/provider/abstract.provider.d.ts +6 -2
  57. package/dist/file/provider/abstract.provider.d.ts.map +1 -1
  58. package/dist/file/provider/abstract.provider.js.map +1 -1
  59. package/dist/file/provider/azure.provider.d.ts +4 -1
  60. package/dist/file/provider/azure.provider.d.ts.map +1 -1
  61. package/dist/file/provider/azure.provider.js +7 -2
  62. package/dist/file/provider/azure.provider.js.map +1 -1
  63. package/dist/file/provider/gcs.provider.d.ts +4 -1
  64. package/dist/file/provider/gcs.provider.d.ts.map +1 -1
  65. package/dist/file/provider/gcs.provider.js +7 -2
  66. package/dist/file/provider/gcs.provider.js.map +1 -1
  67. package/dist/file/provider/local.provider.d.ts +2 -2
  68. package/dist/file/provider/local.provider.d.ts.map +1 -1
  69. package/dist/file/provider/local.provider.js +7 -2
  70. package/dist/file/provider/local.provider.js.map +1 -1
  71. package/dist/file/provider/s3.provider.d.ts +4 -1
  72. package/dist/file/provider/s3.provider.d.ts.map +1 -1
  73. package/dist/file/provider/s3.provider.js +9 -6
  74. package/dist/file/provider/s3.provider.js.map +1 -1
  75. package/dist/file/provider/spaces.provider.d.ts +4 -1
  76. package/dist/file/provider/spaces.provider.d.ts.map +1 -1
  77. package/dist/file/provider/spaces.provider.js +9 -5
  78. package/dist/file/provider/spaces.provider.js.map +1 -1
  79. package/dist/integration-profile/integration-profile.controller.d.ts +3 -3
  80. package/dist/integration-profile/integration-profile.service.d.ts +3 -3
  81. package/dist/mcp-chat/mcp-chat.controller.d.ts +2 -2
  82. package/dist/mcp-chat/mcp-chat.module.d.ts.map +1 -1
  83. package/dist/mcp-chat/mcp-chat.module.js +2 -0
  84. package/dist/mcp-chat/mcp-chat.module.js.map +1 -1
  85. package/dist/mcp-chat/mcp-chat.service.d.ts +8 -3
  86. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  87. package/dist/mcp-chat/mcp-chat.service.js +100 -20
  88. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  89. package/dist/notification/notification.service.d.ts +1 -0
  90. package/dist/notification/notification.service.d.ts.map +1 -1
  91. package/dist/notification/notification.service.js +29 -1
  92. package/dist/notification/notification.service.js.map +1 -1
  93. package/dist/oauth/oauth.service.d.ts +1 -0
  94. package/dist/oauth/oauth.service.d.ts.map +1 -1
  95. package/dist/oauth/oauth.service.js +55 -1
  96. package/dist/oauth/oauth.service.js.map +1 -1
  97. package/dist/profile/events/profile-avatar-updated.event.d.ts +7 -0
  98. package/dist/profile/events/profile-avatar-updated.event.d.ts.map +1 -0
  99. package/dist/profile/events/profile-avatar-updated.event.js +12 -0
  100. package/dist/profile/events/profile-avatar-updated.event.js.map +1 -0
  101. package/dist/profile/events/profile-email-changed.event.d.ts +7 -0
  102. package/dist/profile/events/profile-email-changed.event.d.ts.map +1 -0
  103. package/dist/profile/events/profile-email-changed.event.js +12 -0
  104. package/dist/profile/events/profile-email-changed.event.js.map +1 -0
  105. package/dist/profile/events/profile-updated.event.d.ts +7 -0
  106. package/dist/profile/events/profile-updated.event.d.ts.map +1 -0
  107. package/dist/profile/events/profile-updated.event.js +12 -0
  108. package/dist/profile/events/profile-updated.event.js.map +1 -0
  109. package/dist/profile/profile.service.d.ts +3 -1
  110. package/dist/profile/profile.service.d.ts.map +1 -1
  111. package/dist/profile/profile.service.js +28 -17
  112. package/dist/profile/profile.service.js.map +1 -1
  113. package/dist/session/session.controller.d.ts +1 -0
  114. package/dist/session/session.controller.d.ts.map +1 -1
  115. package/dist/session/session.service.d.ts +9 -4
  116. package/dist/session/session.service.d.ts.map +1 -1
  117. package/dist/session/session.service.js +61 -51
  118. package/dist/session/session.service.js.map +1 -1
  119. package/dist/setting/setting.controller.d.ts +5 -5
  120. package/dist/setting/setting.service.d.ts +5 -5
  121. package/dist/setting/setting.service.d.ts.map +1 -1
  122. package/dist/setting/setting.service.js +2 -0
  123. package/dist/setting/setting.service.js.map +1 -1
  124. package/dist/task/task.service.d.ts +8 -11
  125. package/dist/task/task.service.d.ts.map +1 -1
  126. package/dist/task/task.service.js +10 -16
  127. package/dist/task/task.service.js.map +1 -1
  128. package/dist/user/user.controller.d.ts +1 -0
  129. package/dist/user/user.controller.d.ts.map +1 -1
  130. package/dist/user/user.service.d.ts +3 -0
  131. package/dist/user/user.service.d.ts.map +1 -1
  132. package/dist/webhook-integration/webhook-command.registry.d.ts +9 -2
  133. package/dist/webhook-integration/webhook-command.registry.d.ts.map +1 -1
  134. package/dist/webhook-integration/webhook-command.registry.js.map +1 -1
  135. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  136. package/dist/webhook-integration/webhook-integration.service.js +13 -4
  137. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  138. package/hedhog/data/integration_provider.yaml +17 -0
  139. package/hedhog/data/integration_type.yaml +6 -0
  140. package/hedhog/data/route.yaml +86 -2
  141. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +109 -0
  142. package/hedhog/frontend/app/configurations/layout.tsx.ejs +6 -6
  143. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +169 -14
  144. package/hedhog/frontend/messages/en.json +11 -2
  145. package/hedhog/frontend/messages/pt.json +11 -2
  146. package/hedhog/table/ai_execution.yaml +79 -0
  147. package/hedhog/table/ai_execution_step.yaml +65 -0
  148. package/hedhog/table/ai_instruction.yaml +43 -0
  149. package/hedhog/table/ai_instruction_version.yaml +30 -0
  150. package/hedhog/table/ai_model_pricing.yaml +35 -0
  151. package/hedhog/table/user_session.yaml +2 -0
  152. package/package.json +5 -4
  153. package/src/ai/ai.module.ts +2 -0
  154. package/src/ai/ai.service.ts +64 -31
  155. package/src/ai-instruction/ai-execution.service.ts +244 -0
  156. package/src/ai-instruction/ai-instruction.controller.ts +73 -0
  157. package/src/ai-instruction/ai-instruction.module.ts +14 -0
  158. package/src/ai-instruction/ai-instruction.service.ts +343 -0
  159. package/src/ai-instruction/ai-prompt-builder.service.ts +142 -0
  160. package/src/ai-instruction/dto/create-ai-instruction.dto.ts +36 -0
  161. package/src/ai-instruction/dto/update-ai-instruction.dto.ts +35 -0
  162. package/src/ai-instruction/types/ai-instruction.types.ts +90 -0
  163. package/src/auth/auth.controller.ts +23 -5
  164. package/src/auth/auth.service.ts +26 -15
  165. package/src/core.module.ts +3 -0
  166. package/src/file/file.service.ts +136 -4
  167. package/src/file/provider/abstract.provider.ts +4 -2
  168. package/src/file/provider/azure.provider.ts +6 -3
  169. package/src/file/provider/gcs.provider.ts +5 -3
  170. package/src/file/provider/local.provider.ts +7 -3
  171. package/src/file/provider/s3.provider.ts +8 -6
  172. package/src/file/provider/spaces.provider.ts +8 -7
  173. package/src/mcp-chat/mcp-chat.module.ts +19 -17
  174. package/src/mcp-chat/mcp-chat.service.ts +95 -9
  175. package/src/notification/notification.service.ts +32 -1
  176. package/src/oauth/oauth.service.ts +74 -1
  177. package/src/profile/events/profile-avatar-updated.event.ts +8 -0
  178. package/src/profile/events/profile-email-changed.event.ts +8 -0
  179. package/src/profile/events/profile-updated.event.ts +8 -0
  180. package/src/profile/profile.service.ts +25 -16
  181. package/src/session/session.service.ts +70 -52
  182. package/src/setting/setting.service.ts +10 -8
  183. package/src/task/task.service.ts +10 -16
  184. package/src/webhook-integration/webhook-command.registry.ts +10 -2
  185. package/src/webhook-integration/webhook-integration.service.ts +31 -4
@@ -0,0 +1,343 @@
1
+ import { PaginationDTO } from '@hed-hog/api-pagination';
2
+ import { Prisma, PrismaService } from '@hed-hog/api-prisma';
3
+ import {
4
+ forwardRef,
5
+ Inject,
6
+ Injectable,
7
+ Logger,
8
+ NotFoundException,
9
+ OnModuleInit,
10
+ } from '@nestjs/common';
11
+ import { DeleteDTO } from '../dto/delete.dto';
12
+ import { CreateAiInstructionDTO } from './dto/create-ai-instruction.dto';
13
+ import { UpdateAiInstructionDTO } from './dto/update-ai-instruction.dto';
14
+ import type {
15
+ AiInstructionLayer,
16
+ AiInstructionRecord,
17
+ } from './types/ai-instruction.types';
18
+
19
+ type CacheEntry = { content: string; fetchedAt: number };
20
+
21
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
22
+
23
+ const FALLBACK_SYSTEM_PROMPT =
24
+ 'You are a helpful assistant with access to tools from the HedHog system. ' +
25
+ "Use the available tools to answer the user's questions accurately. " +
26
+ 'Always respond in the same language the user writes in.';
27
+
28
+ @Injectable()
29
+ export class AiInstructionService implements OnModuleInit {
30
+ private readonly logger = new Logger(AiInstructionService.name);
31
+ private readonly cache = new Map<string, CacheEntry>();
32
+
33
+ constructor(
34
+ @Inject(forwardRef(() => PrismaService))
35
+ private readonly prisma: PrismaService,
36
+ ) {}
37
+
38
+ async onModuleInit() {
39
+ // Warm up global and product layers on startup
40
+ try {
41
+ await this.getLayer('system');
42
+ await this.getLayer('product');
43
+ } catch {
44
+ // DB may not be ready yet; cache will fill on first request
45
+ }
46
+ }
47
+
48
+ // ── Public layer accessors (cached) ──────────────────────────────────────
49
+
50
+ async getLayer(layer: AiInstructionLayer, moduleSlug?: string): Promise<string> {
51
+ const cacheKey = moduleSlug ? `${layer}:${moduleSlug}` : layer;
52
+
53
+ const cached = this.cache.get(cacheKey);
54
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
55
+ return cached.content;
56
+ }
57
+
58
+ const instruction = await this.findActiveByLayer(layer, moduleSlug);
59
+ if (!instruction) {
60
+ if (layer === 'system') {
61
+ return FALLBACK_SYSTEM_PROMPT;
62
+ }
63
+ return '';
64
+ }
65
+
66
+ this.cache.set(cacheKey, { content: instruction.content, fetchedAt: Date.now() });
67
+ return instruction.content;
68
+ }
69
+
70
+ async getAgentInstruction(agentSlug: string): Promise<string> {
71
+ const cacheKey = `agent:${agentSlug}`;
72
+ const cached = this.cache.get(cacheKey);
73
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
74
+ return cached.content;
75
+ }
76
+
77
+ const instruction = await this.findActiveBySlug(`agent.${agentSlug}`);
78
+ if (!instruction) return '';
79
+
80
+ this.cache.set(cacheKey, { content: instruction.content, fetchedAt: Date.now() });
81
+ return instruction.content;
82
+ }
83
+
84
+ invalidateCache(slug?: string) {
85
+ if (slug) {
86
+ // Cache keys use format "layer" or "layer:identifier"
87
+ // Slugs use format "layer.identifier" (e.g., "module.lms", "agent.admin-support")
88
+ const dotIndex = slug.indexOf('.');
89
+ if (dotIndex === -1) {
90
+ this.cache.delete(slug);
91
+ } else {
92
+ const layer = slug.slice(0, dotIndex);
93
+ const identifier = slug.slice(dotIndex + 1);
94
+ this.cache.delete(layer);
95
+ this.cache.delete(`${layer}:${identifier}`);
96
+ }
97
+ } else {
98
+ this.cache.clear();
99
+ }
100
+ }
101
+
102
+ // ── CRUD ─────────────────────────────────────────────────────────────────
103
+
104
+ async list(paginationParams: PaginationDTO) {
105
+ const page = Math.max(Number(paginationParams?.page || 1), 1);
106
+ const pageSize = Math.min(Math.max(Number(paginationParams?.pageSize || 20), 1), 100);
107
+ const offset = (page - 1) * pageSize;
108
+ const search = String(paginationParams?.search || '').trim();
109
+
110
+ const whereClause = search
111
+ ? Prisma.sql`WHERE (LOWER(slug) LIKE LOWER(${`%${search}%`}) OR LOWER(name) LIKE LOWER(${`%${search}%`}))`
112
+ : Prisma.empty;
113
+
114
+ const data = await this.prisma.$queryRaw<AiInstructionRecord[]>(Prisma.sql`
115
+ SELECT id, slug, layer, name, content, variables_schema, locale, module_slug,
116
+ is_active, version, created_at, updated_at
117
+ FROM ai_instruction
118
+ ${whereClause}
119
+ ORDER BY layer, slug
120
+ LIMIT ${pageSize}
121
+ OFFSET ${offset}
122
+ `);
123
+
124
+ const totalResult = await this.prisma.$queryRaw<Array<{ total: bigint }>>(Prisma.sql`
125
+ SELECT COUNT(*) AS total FROM ai_instruction ${whereClause}
126
+ `);
127
+
128
+ const total = Number(totalResult?.[0]?.total || 0);
129
+
130
+ return {
131
+ data,
132
+ total,
133
+ page,
134
+ pageSize,
135
+ totalPages: Math.ceil(total / pageSize),
136
+ };
137
+ }
138
+
139
+ async getById(id: number): Promise<AiInstructionRecord> {
140
+ const record = await this.findById(id);
141
+ if (!record) throw new NotFoundException(`AI instruction ${id} not found.`);
142
+ return record;
143
+ }
144
+
145
+ async getBySlug(slug: string): Promise<AiInstructionRecord> {
146
+ const record = await this.findActiveBySlug(slug);
147
+ if (!record) throw new NotFoundException(`AI instruction "${slug}" not found.`);
148
+ return record;
149
+ }
150
+
151
+ async create(dto: CreateAiInstructionDTO, createdById?: number) {
152
+ const locale = dto.locale || 'en';
153
+ const isActive = dto.is_active ?? true;
154
+
155
+ await this.prisma.$executeRaw`
156
+ INSERT INTO ai_instruction (slug, layer, name, content, variables_schema, locale, module_slug, is_active, version)
157
+ VALUES (
158
+ ${dto.slug},
159
+ CAST(${dto.layer} AS ai_instruction_layer_enum),
160
+ ${dto.name},
161
+ ${dto.content},
162
+ ${dto.variables_schema ? JSON.stringify(dto.variables_schema) : null}::jsonb,
163
+ ${locale},
164
+ ${dto.module_slug || null},
165
+ ${isActive},
166
+ 1
167
+ )
168
+ `;
169
+
170
+ const created = await this.findActiveBySlug(dto.slug);
171
+
172
+ // Record version 1
173
+ if (created) {
174
+ await this.saveVersion(created.id, 1, dto.content, 'Initial version', createdById);
175
+ }
176
+
177
+ return created;
178
+ }
179
+
180
+ async update(id: number, dto: UpdateAiInstructionDTO, updatedById?: number) {
181
+ const current = await this.getById(id);
182
+
183
+ const newContent = dto.content ?? current.content;
184
+ const newVersion = dto.content && dto.content !== current.content
185
+ ? current.version + 1
186
+ : current.version;
187
+
188
+ await this.prisma.$executeRaw`
189
+ UPDATE ai_instruction
190
+ SET
191
+ name = ${dto.name ?? current.name},
192
+ content = ${newContent},
193
+ variables_schema = ${dto.variables_schema !== undefined ? JSON.stringify(dto.variables_schema) : current.variables_schema ? JSON.stringify(current.variables_schema) : null}::jsonb,
194
+ locale = ${dto.locale ?? current.locale},
195
+ module_slug = ${dto.module_slug !== undefined ? dto.module_slug : current.module_slug},
196
+ is_active = ${dto.is_active ?? current.is_active},
197
+ version = ${newVersion},
198
+ updated_at = NOW()
199
+ WHERE id = ${id}
200
+ `;
201
+
202
+ // Save new version if content changed
203
+ if (dto.content && dto.content !== current.content) {
204
+ await this.saveVersion(id, newVersion, newContent, dto.change_note, updatedById);
205
+ }
206
+
207
+ this.invalidateCache(current.slug);
208
+
209
+ return this.getById(id);
210
+ }
211
+
212
+ async delete({ ids }: DeleteDTO) {
213
+ const existing = await this.prisma.$queryRaw<Array<{ id: number; slug: string }>>(Prisma.sql`
214
+ SELECT id, slug FROM ai_instruction WHERE id IN (${Prisma.join(ids)})
215
+ `);
216
+
217
+ if (existing.length !== ids.length) {
218
+ const missing = ids.filter((id) => !existing.map((r) => r.id).includes(id));
219
+ throw new NotFoundException(`AI instructions ${missing.join(', ')} not found.`);
220
+ }
221
+
222
+ await this.prisma.$executeRaw`
223
+ DELETE FROM ai_instruction WHERE id IN (${Prisma.join(ids)})
224
+ `;
225
+
226
+ existing.forEach((r) => this.invalidateCache(r.slug));
227
+
228
+ return { count: ids.length };
229
+ }
230
+
231
+ async listVersions(id: number) {
232
+ await this.getById(id);
233
+ return this.prisma.$queryRaw`
234
+ SELECT v.id, v.version, v.content, v.change_note, v.created_at,
235
+ u.name AS created_by_name
236
+ FROM ai_instruction_version v
237
+ LEFT JOIN "user" u ON u.id = v.created_by_id
238
+ WHERE v.instruction_id = ${id}
239
+ ORDER BY v.version DESC
240
+ `;
241
+ }
242
+
243
+ async restoreVersion(id: number, version: number, restoredById?: number) {
244
+ const versions = await this.prisma.$queryRaw<Array<{ version: number; content: string }>>`
245
+ SELECT version, content FROM ai_instruction_version
246
+ WHERE instruction_id = ${id} AND version = ${version}
247
+ LIMIT 1
248
+ `;
249
+
250
+ if (!versions.length) {
251
+ throw new NotFoundException(`Version ${version} not found for instruction ${id}.`);
252
+ }
253
+
254
+ const target = versions[0];
255
+ const current = await this.getById(id);
256
+ const newVersion = current.version + 1;
257
+
258
+ await this.prisma.$executeRaw`
259
+ UPDATE ai_instruction
260
+ SET content = ${target.content}, version = ${newVersion}, updated_at = NOW()
261
+ WHERE id = ${id}
262
+ `;
263
+
264
+ await this.saveVersion(
265
+ id,
266
+ newVersion,
267
+ target.content,
268
+ `Restored from version ${version}`,
269
+ restoredById,
270
+ );
271
+
272
+ this.invalidateCache(current.slug);
273
+
274
+ return this.getById(id);
275
+ }
276
+
277
+ // ── Private helpers ───────────────────────────────────────────────────────
278
+
279
+ private async findById(id: number): Promise<AiInstructionRecord | null> {
280
+ const rows = await this.prisma.$queryRaw<AiInstructionRecord[]>`
281
+ SELECT id, slug, layer, name, content, variables_schema, locale, module_slug,
282
+ is_active, version, created_at, updated_at
283
+ FROM ai_instruction WHERE id = ${id} LIMIT 1
284
+ `;
285
+ return rows?.[0] || null;
286
+ }
287
+
288
+ private async findActiveBySlug(slug: string): Promise<AiInstructionRecord | null> {
289
+ const rows = await this.prisma.$queryRaw<AiInstructionRecord[]>`
290
+ SELECT id, slug, layer, name, content, variables_schema, locale, module_slug,
291
+ is_active, version, created_at, updated_at
292
+ FROM ai_instruction WHERE slug = ${slug} AND is_active = TRUE LIMIT 1
293
+ `;
294
+ return rows?.[0] || null;
295
+ }
296
+
297
+ private async findActiveByLayer(
298
+ layer: AiInstructionLayer,
299
+ moduleSlug?: string,
300
+ ): Promise<AiInstructionRecord | null> {
301
+ if (moduleSlug) {
302
+ const rows = await this.prisma.$queryRaw<AiInstructionRecord[]>`
303
+ SELECT id, slug, layer, name, content, variables_schema, locale, module_slug,
304
+ is_active, version, created_at, updated_at
305
+ FROM ai_instruction
306
+ WHERE layer = CAST(${layer} AS ai_instruction_layer_enum)
307
+ AND module_slug = ${moduleSlug}
308
+ AND is_active = TRUE
309
+ LIMIT 1
310
+ `;
311
+ if (rows?.[0]) return rows[0];
312
+ }
313
+
314
+ const rows = await this.prisma.$queryRaw<AiInstructionRecord[]>`
315
+ SELECT id, slug, layer, name, content, variables_schema, locale, module_slug,
316
+ is_active, version, created_at, updated_at
317
+ FROM ai_instruction
318
+ WHERE layer = CAST(${layer} AS ai_instruction_layer_enum)
319
+ AND module_slug IS NULL
320
+ AND is_active = TRUE
321
+ LIMIT 1
322
+ `;
323
+ return rows?.[0] || null;
324
+ }
325
+
326
+ private async saveVersion(
327
+ instructionId: number,
328
+ version: number,
329
+ content: string,
330
+ changeNote?: string,
331
+ createdById?: number,
332
+ ) {
333
+ try {
334
+ await this.prisma.$executeRaw`
335
+ INSERT INTO ai_instruction_version (instruction_id, version, content, change_note, created_by_id)
336
+ VALUES (${instructionId}, ${version}, ${content}, ${changeNote || null}, ${createdById || null})
337
+ ON CONFLICT (instruction_id, version) DO NOTHING
338
+ `;
339
+ } catch (e) {
340
+ this.logger.warn(`Failed to save version for instruction ${instructionId}: ${e}`);
341
+ }
342
+ }
343
+ }
@@ -0,0 +1,142 @@
1
+ import { forwardRef, Inject, Injectable } from '@nestjs/common';
2
+ import { AiInstructionService } from './ai-instruction.service';
3
+ import type { AiPromptBuildContext } from './types/ai-instruction.types';
4
+
5
+ export type BuiltPrompt = {
6
+ systemPrompt: string;
7
+ layers: string[];
8
+ layerNames: string[];
9
+ };
10
+
11
+ @Injectable()
12
+ export class AiPromptBuilderService {
13
+ constructor(
14
+ @Inject(forwardRef(() => AiInstructionService))
15
+ private readonly instructionService: AiInstructionService,
16
+ ) {}
17
+
18
+ /**
19
+ * Composes the system prompt from active layers in priority order.
20
+ * Only includes non-empty layers to minimize tokens.
21
+ *
22
+ * Layers sent:
23
+ * L1 system — always
24
+ * L2 product — always (if configured)
25
+ * L3 module — when moduleSlug is provided
26
+ * L4 agent — when agentSlug is provided
27
+ * runtime — injected by caller at send time (not stored here)
28
+ */
29
+ async build(ctx: AiPromptBuildContext): Promise<BuiltPrompt> {
30
+ const parts: string[] = [];
31
+ const names: string[] = [];
32
+
33
+ // L1 — system (always; has hardcoded fallback)
34
+ const system = await this.instructionService.getLayer('system');
35
+ if (system) {
36
+ parts.push(system);
37
+ names.push('system');
38
+ }
39
+
40
+ // L2 — product (optional, no fallback)
41
+ const product = await this.instructionService.getLayer('product');
42
+ if (product) {
43
+ parts.push(product);
44
+ names.push('product');
45
+ }
46
+
47
+ // L3 — module (only when moduleSlug provided)
48
+ if (ctx.moduleSlug) {
49
+ const module = await this.instructionService.getLayer('module', ctx.moduleSlug);
50
+ if (module) {
51
+ parts.push(module);
52
+ names.push(`module:${ctx.moduleSlug}`);
53
+ }
54
+ }
55
+
56
+ // L4 — agent (only when agentSlug provided)
57
+ if (ctx.agentSlug) {
58
+ const agent = await this.instructionService.getAgentInstruction(ctx.agentSlug);
59
+ if (agent) {
60
+ parts.push(agent);
61
+ names.push(`agent:${ctx.agentSlug}`);
62
+ }
63
+ }
64
+
65
+ const systemPrompt = parts.join('\n\n---\n\n');
66
+
67
+ return { systemPrompt, layers: parts, layerNames: names };
68
+ }
69
+
70
+ /**
71
+ * Builds a compact runtime context block (L6) to append to the system prompt.
72
+ * This is NEVER cached — it is generated fresh per request.
73
+ */
74
+ buildRuntimeContext(params: {
75
+ userId?: number;
76
+ locale?: string;
77
+ userRoles?: string[];
78
+ currentDate?: string;
79
+ extraContext?: Record<string, string>;
80
+ }): string {
81
+ const lines: string[] = ['[Runtime context]'];
82
+
83
+ if (params.userId) lines.push(`user_id: ${params.userId}`);
84
+ if (params.locale) lines.push(`locale: ${params.locale}`);
85
+ if (params.userRoles?.length) lines.push(`roles: ${params.userRoles.join(', ')}`);
86
+ if (params.currentDate) lines.push(`date: ${params.currentDate}`);
87
+
88
+ if (params.extraContext) {
89
+ for (const [k, v] of Object.entries(params.extraContext)) {
90
+ lines.push(`${k}: ${v}`);
91
+ }
92
+ }
93
+
94
+ return lines.join('\n');
95
+ }
96
+
97
+ /**
98
+ * Builds a compact history summary for older messages to avoid
99
+ * sending full history on every turn.
100
+ *
101
+ * @param messages All conversation messages
102
+ * @param keepLast How many recent messages to keep verbatim
103
+ */
104
+ summarizeHistory(
105
+ messages: Array<{ role: string; content: string }>,
106
+ keepLast = 6,
107
+ ): Array<{ role: string; content: string }> {
108
+ if (messages.length <= keepLast) return messages;
109
+
110
+ const older = messages.slice(0, messages.length - keepLast);
111
+ const recent = messages.slice(messages.length - keepLast);
112
+
113
+ // Simple extractive summary: keep first user turn + count of exchanges
114
+ const userMessages = older.filter((m) => m.role === 'user');
115
+ const toolCount = older.filter((m) => m.role === 'tool_call').length;
116
+
117
+ const summaryLines: string[] = [
118
+ `[Earlier conversation summary: ${older.length} messages]`,
119
+ ];
120
+
121
+ if (userMessages[0]) {
122
+ summaryLines.push(`First question: "${userMessages[0].content.slice(0, 120)}"`);
123
+ }
124
+ if (toolCount > 0) {
125
+ summaryLines.push(`Tools called: ${toolCount}`);
126
+ }
127
+
128
+ const summaryMessage = {
129
+ role: 'user',
130
+ content: summaryLines.join('\n'),
131
+ };
132
+
133
+ return [summaryMessage, ...recent];
134
+ }
135
+
136
+ /**
137
+ * Interpolates {{variable}} placeholders in a template string.
138
+ */
139
+ interpolate(template: string, variables: Record<string, string>): string {
140
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? `{{${key}}}`);
141
+ }
142
+ }
@@ -0,0 +1,36 @@
1
+ import { IsBoolean, IsEnum, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
2
+ import { AiInstructionLayer } from '../types/ai-instruction.types';
3
+
4
+ export class CreateAiInstructionDTO {
5
+ @IsString()
6
+ @MaxLength(255)
7
+ slug: string;
8
+
9
+ @IsEnum(['system', 'product', 'module', 'agent', 'tool', 'output'])
10
+ layer: AiInstructionLayer;
11
+
12
+ @IsString()
13
+ @MaxLength(200)
14
+ name: string;
15
+
16
+ @IsString()
17
+ content: string;
18
+
19
+ @IsOptional()
20
+ @IsObject()
21
+ variables_schema?: Record<string, any>;
22
+
23
+ @IsOptional()
24
+ @IsString()
25
+ @MaxLength(10)
26
+ locale?: string;
27
+
28
+ @IsOptional()
29
+ @IsString()
30
+ @MaxLength(255)
31
+ module_slug?: string;
32
+
33
+ @IsOptional()
34
+ @IsBoolean()
35
+ is_active?: boolean;
36
+ }
@@ -0,0 +1,35 @@
1
+ import { IsBoolean, IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
2
+
3
+ export class UpdateAiInstructionDTO {
4
+ @IsOptional()
5
+ @IsString()
6
+ @MaxLength(200)
7
+ name?: string;
8
+
9
+ @IsOptional()
10
+ @IsString()
11
+ content?: string;
12
+
13
+ @IsOptional()
14
+ @IsObject()
15
+ variables_schema?: Record<string, any>;
16
+
17
+ @IsOptional()
18
+ @IsString()
19
+ @MaxLength(10)
20
+ locale?: string;
21
+
22
+ @IsOptional()
23
+ @IsString()
24
+ @MaxLength(255)
25
+ module_slug?: string;
26
+
27
+ @IsOptional()
28
+ @IsBoolean()
29
+ is_active?: boolean;
30
+
31
+ @IsOptional()
32
+ @IsString()
33
+ @MaxLength(500)
34
+ change_note?: string;
35
+ }
@@ -0,0 +1,90 @@
1
+ export type AiInstructionLayer =
2
+ | 'system'
3
+ | 'product'
4
+ | 'module'
5
+ | 'agent'
6
+ | 'tool'
7
+ | 'output';
8
+
9
+ export type AiInstructionRecord = {
10
+ id: number;
11
+ slug: string;
12
+ layer: AiInstructionLayer;
13
+ name: string;
14
+ content: string;
15
+ variables_schema: Record<string, any> | null;
16
+ locale: string;
17
+ module_slug: string | null;
18
+ is_active: boolean;
19
+ version: number;
20
+ created_at: Date;
21
+ updated_at: Date;
22
+ };
23
+
24
+ export type AiExecutionTrigger = 'manual' | 'webhook' | 'schedule' | 'tool' | 'api';
25
+ export type AiExecutionStatus = 'running' | 'completed' | 'failed' | 'cancelled';
26
+ export type AiExecutionStepType = 'prompt' | 'tool_call' | 'tool_result' | 'output' | 'error';
27
+
28
+ export type AiExecutionRecord = {
29
+ id: number;
30
+ user_id: number | null;
31
+ conversation_id: number | null;
32
+ agent_id: number | null;
33
+ trigger: AiExecutionTrigger;
34
+ status: AiExecutionStatus;
35
+ context_slug: string | null;
36
+ tokens_input: number;
37
+ tokens_output: number;
38
+ tokens_total: number;
39
+ cost_usd: number;
40
+ started_at: Date;
41
+ finished_at: Date | null;
42
+ error: string | null;
43
+ created_at: Date;
44
+ updated_at: Date;
45
+ };
46
+
47
+ export type AiExecutionStepRecord = {
48
+ id: number;
49
+ execution_id: number;
50
+ step_order: number;
51
+ type: AiExecutionStepType;
52
+ provider: string | null;
53
+ model: string | null;
54
+ tool_name: string | null;
55
+ tokens_input: number;
56
+ tokens_output: number;
57
+ cost_usd: number;
58
+ duration_ms: number;
59
+ input_summary: string | null;
60
+ output_summary: string | null;
61
+ success: boolean;
62
+ error: string | null;
63
+ created_at: Date;
64
+ };
65
+
66
+ export type AiModelPricingRecord = {
67
+ id: number;
68
+ provider: string;
69
+ model: string;
70
+ price_input_per_million: number;
71
+ price_output_per_million: number;
72
+ price_cached_input_per_million: number | null;
73
+ is_active: boolean;
74
+ valid_from: Date;
75
+ };
76
+
77
+ export type AiPromptBuildContext = {
78
+ moduleSlug?: string;
79
+ agentSlug?: string;
80
+ locale?: string;
81
+ userId?: number;
82
+ extraVariables?: Record<string, string>;
83
+ };
84
+
85
+ export type AiTokenUsage = {
86
+ input: number;
87
+ output: number;
88
+ total: number;
89
+ cached?: number;
90
+ };