@hed-hog/core 0.0.362 → 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.
- package/dist/ai/ai.controller.d.ts +4 -4
- package/dist/ai/ai.module.d.ts.map +1 -1
- package/dist/ai/ai.module.js +2 -0
- package/dist/ai/ai.module.js.map +1 -1
- package/dist/ai/ai.service.d.ts +7 -5
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +63 -31
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-execution.service.d.ts +45 -0
- package/dist/ai-instruction/ai-execution.service.d.ts.map +1 -0
- package/dist/ai-instruction/ai-execution.service.js +203 -0
- package/dist/ai-instruction/ai-execution.service.js.map +1 -0
- package/dist/ai-instruction/ai-instruction.controller.d.ts +25 -0
- package/dist/ai-instruction/ai-instruction.controller.d.ts.map +1 -0
- package/dist/ai-instruction/ai-instruction.controller.js +117 -0
- package/dist/ai-instruction/ai-instruction.controller.js.map +1 -0
- package/dist/ai-instruction/ai-instruction.module.d.ts +3 -0
- package/dist/ai-instruction/ai-instruction.module.d.ts.map +1 -0
- package/dist/ai-instruction/ai-instruction.module.js +27 -0
- package/dist/ai-instruction/ai-instruction.module.js.map +1 -0
- package/dist/ai-instruction/ai-instruction.service.d.ts +38 -0
- package/dist/ai-instruction/ai-instruction.service.d.ts.map +1 -0
- package/dist/ai-instruction/ai-instruction.service.js +289 -0
- package/dist/ai-instruction/ai-instruction.service.js.map +1 -0
- package/dist/ai-instruction/ai-prompt-builder.service.d.ts +53 -0
- package/dist/ai-instruction/ai-prompt-builder.service.d.ts.map +1 -0
- package/dist/ai-instruction/ai-prompt-builder.service.js +132 -0
- package/dist/ai-instruction/ai-prompt-builder.service.js.map +1 -0
- package/dist/ai-instruction/dto/create-ai-instruction.dto.d.ts +12 -0
- package/dist/ai-instruction/dto/create-ai-instruction.dto.d.ts.map +1 -0
- package/dist/ai-instruction/dto/create-ai-instruction.dto.js +57 -0
- package/dist/ai-instruction/dto/create-ai-instruction.dto.js.map +1 -0
- package/dist/ai-instruction/dto/update-ai-instruction.dto.d.ts +10 -0
- package/dist/ai-instruction/dto/update-ai-instruction.dto.d.ts.map +1 -0
- package/dist/ai-instruction/dto/update-ai-instruction.dto.js +56 -0
- package/dist/ai-instruction/dto/update-ai-instruction.dto.js.map +1 -0
- package/dist/ai-instruction/types/ai-instruction.types.d.ts +78 -0
- package/dist/ai-instruction/types/ai-instruction.types.d.ts.map +1 -0
- package/dist/ai-instruction/types/ai-instruction.types.js +3 -0
- package/dist/ai-instruction/types/ai-instruction.types.js.map +1 -0
- package/dist/auth/auth.controller.d.ts +13 -5
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +39 -20
- package/dist/auth/auth.controller.js.map +1 -1
- package/dist/auth/auth.service.d.ts +18 -7
- package/dist/auth/auth.service.d.ts.map +1 -1
- package/dist/auth/auth.service.js +20 -16
- package/dist/auth/auth.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/file/file.service.d.ts +37 -0
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +94 -4
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/provider/abstract.provider.d.ts +6 -2
- package/dist/file/provider/abstract.provider.d.ts.map +1 -1
- package/dist/file/provider/abstract.provider.js.map +1 -1
- package/dist/file/provider/azure.provider.d.ts +4 -1
- package/dist/file/provider/azure.provider.d.ts.map +1 -1
- package/dist/file/provider/azure.provider.js +7 -2
- package/dist/file/provider/azure.provider.js.map +1 -1
- package/dist/file/provider/gcs.provider.d.ts +4 -1
- package/dist/file/provider/gcs.provider.d.ts.map +1 -1
- package/dist/file/provider/gcs.provider.js +7 -2
- package/dist/file/provider/gcs.provider.js.map +1 -1
- package/dist/file/provider/local.provider.d.ts +2 -2
- package/dist/file/provider/local.provider.d.ts.map +1 -1
- package/dist/file/provider/local.provider.js +7 -2
- package/dist/file/provider/local.provider.js.map +1 -1
- package/dist/file/provider/s3.provider.d.ts +4 -1
- package/dist/file/provider/s3.provider.d.ts.map +1 -1
- package/dist/file/provider/s3.provider.js +9 -6
- package/dist/file/provider/s3.provider.js.map +1 -1
- package/dist/file/provider/spaces.provider.d.ts +4 -1
- package/dist/file/provider/spaces.provider.d.ts.map +1 -1
- package/dist/file/provider/spaces.provider.js +9 -5
- package/dist/file/provider/spaces.provider.js.map +1 -1
- package/dist/integration-profile/integration-profile.controller.d.ts +3 -3
- package/dist/integration-profile/integration-profile.service.d.ts +3 -3
- package/dist/mcp-chat/mcp-chat.controller.d.ts +2 -2
- package/dist/mcp-chat/mcp-chat.module.d.ts.map +1 -1
- package/dist/mcp-chat/mcp-chat.module.js +2 -0
- package/dist/mcp-chat/mcp-chat.module.js.map +1 -1
- package/dist/mcp-chat/mcp-chat.service.d.ts +8 -3
- package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
- package/dist/mcp-chat/mcp-chat.service.js +100 -20
- package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
- package/dist/notification/notification.service.d.ts +1 -0
- package/dist/notification/notification.service.d.ts.map +1 -1
- package/dist/notification/notification.service.js +29 -1
- package/dist/notification/notification.service.js.map +1 -1
- package/dist/oauth/oauth.service.d.ts +1 -0
- package/dist/oauth/oauth.service.d.ts.map +1 -1
- package/dist/oauth/oauth.service.js +55 -1
- package/dist/oauth/oauth.service.js.map +1 -1
- package/dist/profile/events/profile-avatar-updated.event.d.ts +7 -0
- package/dist/profile/events/profile-avatar-updated.event.d.ts.map +1 -0
- package/dist/profile/events/profile-avatar-updated.event.js +12 -0
- package/dist/profile/events/profile-avatar-updated.event.js.map +1 -0
- package/dist/profile/events/profile-email-changed.event.d.ts +7 -0
- package/dist/profile/events/profile-email-changed.event.d.ts.map +1 -0
- package/dist/profile/events/profile-email-changed.event.js +12 -0
- package/dist/profile/events/profile-email-changed.event.js.map +1 -0
- package/dist/profile/events/profile-updated.event.d.ts +7 -0
- package/dist/profile/events/profile-updated.event.d.ts.map +1 -0
- package/dist/profile/events/profile-updated.event.js +12 -0
- package/dist/profile/events/profile-updated.event.js.map +1 -0
- package/dist/profile/profile.service.d.ts +3 -1
- package/dist/profile/profile.service.d.ts.map +1 -1
- package/dist/profile/profile.service.js +28 -17
- package/dist/profile/profile.service.js.map +1 -1
- package/dist/session/session.controller.d.ts +1 -0
- package/dist/session/session.controller.d.ts.map +1 -1
- package/dist/session/session.service.d.ts +9 -4
- package/dist/session/session.service.d.ts.map +1 -1
- package/dist/session/session.service.js +61 -51
- package/dist/session/session.service.js.map +1 -1
- package/dist/setting/setting.controller.d.ts +5 -5
- package/dist/setting/setting.service.d.ts +5 -5
- package/dist/setting/setting.service.d.ts.map +1 -1
- package/dist/setting/setting.service.js +2 -0
- package/dist/setting/setting.service.js.map +1 -1
- package/dist/task/task.service.d.ts +8 -11
- package/dist/task/task.service.d.ts.map +1 -1
- package/dist/task/task.service.js +10 -16
- package/dist/task/task.service.js.map +1 -1
- package/dist/user/user.controller.d.ts +1 -0
- package/dist/user/user.controller.d.ts.map +1 -1
- package/dist/user/user.service.d.ts +3 -0
- package/dist/user/user.service.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-command.registry.d.ts +9 -2
- package/dist/webhook-integration/webhook-command.registry.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-command.registry.js.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
- package/dist/webhook-integration/webhook-integration.service.js +13 -4
- package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
- package/hedhog/data/integration_provider.yaml +17 -0
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +86 -2
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +109 -0
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +6 -6
- package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +169 -14
- package/hedhog/frontend/messages/en.json +11 -2
- package/hedhog/frontend/messages/pt.json +11 -2
- package/hedhog/table/ai_execution.yaml +79 -0
- package/hedhog/table/ai_execution_step.yaml +65 -0
- package/hedhog/table/ai_instruction.yaml +43 -0
- package/hedhog/table/ai_instruction_version.yaml +30 -0
- package/hedhog/table/ai_model_pricing.yaml +35 -0
- package/hedhog/table/user_session.yaml +2 -0
- package/package.json +5 -4
- package/src/ai/ai.module.ts +2 -0
- package/src/ai/ai.service.ts +64 -31
- package/src/ai-instruction/ai-execution.service.ts +244 -0
- package/src/ai-instruction/ai-instruction.controller.ts +73 -0
- package/src/ai-instruction/ai-instruction.module.ts +14 -0
- package/src/ai-instruction/ai-instruction.service.ts +343 -0
- package/src/ai-instruction/ai-prompt-builder.service.ts +142 -0
- package/src/ai-instruction/dto/create-ai-instruction.dto.ts +36 -0
- package/src/ai-instruction/dto/update-ai-instruction.dto.ts +35 -0
- package/src/ai-instruction/types/ai-instruction.types.ts +90 -0
- package/src/auth/auth.controller.ts +23 -5
- package/src/auth/auth.service.ts +26 -15
- package/src/core.module.ts +3 -0
- package/src/file/file.service.ts +136 -4
- package/src/file/provider/abstract.provider.ts +4 -2
- package/src/file/provider/azure.provider.ts +6 -3
- package/src/file/provider/gcs.provider.ts +5 -3
- package/src/file/provider/local.provider.ts +7 -3
- package/src/file/provider/s3.provider.ts +8 -6
- package/src/file/provider/spaces.provider.ts +8 -7
- package/src/mcp-chat/mcp-chat.module.ts +19 -17
- package/src/mcp-chat/mcp-chat.service.ts +95 -9
- package/src/notification/notification.service.ts +32 -1
- package/src/oauth/oauth.service.ts +74 -1
- package/src/profile/events/profile-avatar-updated.event.ts +8 -0
- package/src/profile/events/profile-email-changed.event.ts +8 -0
- package/src/profile/events/profile-updated.event.ts +8 -0
- package/src/profile/profile.service.ts +25 -16
- package/src/session/session.service.ts +70 -52
- package/src/setting/setting.service.ts +10 -8
- package/src/task/task.service.ts +10 -16
- package/src/webhook-integration/webhook-command.registry.ts +10 -2
- 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
|
+
};
|