@memberjunction/query-gen 0.0.1 → 2.126.1
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/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +34 -0
- package/COORDINATOR.md +768 -0
- package/IMPLEMENTATION_PLAN.md +1753 -0
- package/LLM_ENTITY_GROUPING_PLAN.md +977 -0
- package/README.md +675 -29
- package/dist/cli/commands/export.d.ts +15 -0
- package/dist/cli/commands/export.d.ts.map +1 -0
- package/dist/cli/commands/export.js +178 -0
- package/dist/cli/commands/export.js.map +1 -0
- package/dist/cli/commands/generate.d.ts +19 -0
- package/dist/cli/commands/generate.d.ts.map +1 -0
- package/dist/cli/commands/generate.js +282 -0
- package/dist/cli/commands/generate.js.map +1 -0
- package/dist/cli/commands/validate.d.ts +17 -0
- package/dist/cli/commands/validate.d.ts.map +1 -0
- package/dist/cli/commands/validate.js +193 -0
- package/dist/cli/commands/validate.js.map +1 -0
- package/dist/cli/config.d.ts +51 -0
- package/dist/cli/config.d.ts.map +1 -0
- package/dist/cli/config.js +142 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/index.d.ts +13 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +57 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/EntityGrouper.d.ts +74 -0
- package/dist/core/EntityGrouper.d.ts.map +1 -0
- package/dist/core/EntityGrouper.js +246 -0
- package/dist/core/EntityGrouper.js.map +1 -0
- package/dist/core/MetadataExporter.d.ts +59 -0
- package/dist/core/MetadataExporter.d.ts.map +1 -0
- package/dist/core/MetadataExporter.js +151 -0
- package/dist/core/MetadataExporter.js.map +1 -0
- package/dist/core/QueryDatabaseWriter.d.ts +50 -0
- package/dist/core/QueryDatabaseWriter.d.ts.map +1 -0
- package/dist/core/QueryDatabaseWriter.js +152 -0
- package/dist/core/QueryDatabaseWriter.js.map +1 -0
- package/dist/core/QueryFixer.d.ts +48 -0
- package/dist/core/QueryFixer.d.ts.map +1 -0
- package/dist/core/QueryFixer.js +115 -0
- package/dist/core/QueryFixer.js.map +1 -0
- package/dist/core/QueryRefiner.d.ts +94 -0
- package/dist/core/QueryRefiner.d.ts.map +1 -0
- package/dist/core/QueryRefiner.js +267 -0
- package/dist/core/QueryRefiner.js.map +1 -0
- package/dist/core/QueryTester.d.ts +70 -0
- package/dist/core/QueryTester.d.ts.map +1 -0
- package/dist/core/QueryTester.js +243 -0
- package/dist/core/QueryTester.js.map +1 -0
- package/dist/core/QueryWriter.d.ts +57 -0
- package/dist/core/QueryWriter.d.ts.map +1 -0
- package/dist/core/QueryWriter.js +184 -0
- package/dist/core/QueryWriter.js.map +1 -0
- package/dist/core/QuestionGenerator.d.ts +58 -0
- package/dist/core/QuestionGenerator.d.ts.map +1 -0
- package/dist/core/QuestionGenerator.js +145 -0
- package/dist/core/QuestionGenerator.js.map +1 -0
- package/dist/data/schema.d.ts +230 -0
- package/dist/data/schema.d.ts.map +1 -0
- package/dist/data/schema.js +6 -0
- package/dist/data/schema.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/prompts/PromptNames.d.ts +32 -0
- package/dist/prompts/PromptNames.d.ts.map +1 -0
- package/dist/prompts/PromptNames.js +35 -0
- package/dist/prompts/PromptNames.js.map +1 -0
- package/dist/utils/category-builder.d.ts +28 -0
- package/dist/utils/category-builder.d.ts.map +1 -0
- package/dist/utils/category-builder.js +90 -0
- package/dist/utils/category-builder.js.map +1 -0
- package/dist/utils/entity-helpers.d.ts +49 -0
- package/dist/utils/entity-helpers.d.ts.map +1 -0
- package/dist/utils/entity-helpers.js +189 -0
- package/dist/utils/entity-helpers.js.map +1 -0
- package/dist/utils/error-handlers.d.ts +19 -0
- package/dist/utils/error-handlers.d.ts.map +1 -0
- package/dist/utils/error-handlers.js +41 -0
- package/dist/utils/error-handlers.js.map +1 -0
- package/dist/utils/graph-helpers.d.ts +51 -0
- package/dist/utils/graph-helpers.d.ts.map +1 -0
- package/dist/utils/graph-helpers.js +82 -0
- package/dist/utils/graph-helpers.js.map +1 -0
- package/dist/utils/prompt-helpers.d.ts +25 -0
- package/dist/utils/prompt-helpers.d.ts.map +1 -0
- package/dist/utils/prompt-helpers.js +66 -0
- package/dist/utils/prompt-helpers.js.map +1 -0
- package/dist/utils/query-helpers.d.ts +23 -0
- package/dist/utils/query-helpers.d.ts.map +1 -0
- package/dist/utils/query-helpers.js +34 -0
- package/dist/utils/query-helpers.js.map +1 -0
- package/dist/utils/user-helpers.d.ts +15 -0
- package/dist/utils/user-helpers.d.ts.map +1 -0
- package/dist/utils/user-helpers.js +32 -0
- package/dist/utils/user-helpers.js.map +1 -0
- package/dist/vectors/EmbeddingService.d.ts +58 -0
- package/dist/vectors/EmbeddingService.d.ts.map +1 -0
- package/dist/vectors/EmbeddingService.js +90 -0
- package/dist/vectors/EmbeddingService.js.map +1 -0
- package/dist/vectors/SimilaritySearch.d.ts +51 -0
- package/dist/vectors/SimilaritySearch.d.ts.map +1 -0
- package/dist/vectors/SimilaritySearch.js +85 -0
- package/dist/vectors/SimilaritySearch.js.map +1 -0
- package/docs/API.md +1040 -0
- package/docs/ARCHITECTURE.md +1120 -0
- package/examples/advanced-usage.ts +401 -0
- package/examples/basic-usage.ts +285 -0
- package/package.json +48 -6
- package/src/cli/commands/export.ts +173 -0
- package/src/cli/commands/generate.ts +330 -0
- package/src/cli/commands/validate.ts +185 -0
- package/src/cli/config.ts +203 -0
- package/src/cli/index.ts +63 -0
- package/src/core/EntityGrouper.ts +318 -0
- package/src/core/MetadataExporter.ts +148 -0
- package/src/core/QueryDatabaseWriter.ts +187 -0
- package/src/core/QueryFixer.ts +153 -0
- package/src/core/QueryRefiner.ts +382 -0
- package/src/core/QueryTester.ts +264 -0
- package/src/core/QueryWriter.ts +239 -0
- package/src/core/QuestionGenerator.ts +199 -0
- package/src/data/golden-queries.json +1371 -0
- package/src/data/schema.ts +252 -0
- package/src/index.ts +49 -0
- package/src/prompts/PromptNames.ts +36 -0
- package/src/utils/category-builder.ts +97 -0
- package/src/utils/entity-helpers.ts +203 -0
- package/src/utils/error-handlers.ts +41 -0
- package/src/utils/graph-helpers.ts +99 -0
- package/src/utils/prompt-helpers.ts +79 -0
- package/src/utils/query-helpers.ts +32 -0
- package/src/utils/user-helpers.ts +39 -0
- package/src/vectors/EmbeddingService.ts +109 -0
- package/src/vectors/SimilaritySearch.ts +108 -0
- package/tsconfig.json +39 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM-based Entity Grouper
|
|
3
|
+
*
|
|
4
|
+
* Uses AI to generate semantically meaningful entity groupings based on
|
|
5
|
+
* business context and schema understanding, replacing the deterministic
|
|
6
|
+
* hub-and-spoke algorithm with intelligent semantic analysis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EntityInfo, EntityRelationshipInfo, UserInfo, LogStatus } from '@memberjunction/core';
|
|
10
|
+
import { AIEngine } from '@memberjunction/aiengine';
|
|
11
|
+
import { EntityGroup } from '../data/schema';
|
|
12
|
+
import { QueryGenConfig } from '../cli/config';
|
|
13
|
+
import { generateRelationshipGraph, formatEntitiesForPrompt } from '../utils/graph-helpers';
|
|
14
|
+
import { extractErrorMessage } from '../utils/error-handlers';
|
|
15
|
+
import { executePromptWithOverrides } from '../utils/prompt-helpers';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* LLM response format from Entity Group Generator prompt
|
|
19
|
+
*/
|
|
20
|
+
interface LLMEntityGroupResponse {
|
|
21
|
+
groups: Array<{
|
|
22
|
+
entities: string[];
|
|
23
|
+
primaryEntity: string;
|
|
24
|
+
businessDomain: string;
|
|
25
|
+
businessRationale: string;
|
|
26
|
+
relationshipType: 'single' | 'parent-child' | 'many-to-many';
|
|
27
|
+
expectedQuestionTypes: string[];
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generates entity groups using LLM-based semantic analysis
|
|
33
|
+
*
|
|
34
|
+
* This class replaces the deterministic hub-and-spoke algorithm with an
|
|
35
|
+
* intelligent approach that understands business context and generates
|
|
36
|
+
* meaningful entity combinations for query generation.
|
|
37
|
+
*/
|
|
38
|
+
export class EntityGrouper {
|
|
39
|
+
private readonly promptName = 'Entity Group Generator';
|
|
40
|
+
private readonly config: QueryGenConfig;
|
|
41
|
+
|
|
42
|
+
constructor(config: QueryGenConfig) {
|
|
43
|
+
this.config = config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate semantically meaningful entity groups using LLM analysis
|
|
48
|
+
*
|
|
49
|
+
* Note: The LLM determines the optimal number and size of entity groups based on
|
|
50
|
+
* business domain understanding. Makes separate LLM calls for each schema to
|
|
51
|
+
* avoid mixing entities from different databases.
|
|
52
|
+
*
|
|
53
|
+
* @param entities - All entities to analyze
|
|
54
|
+
* @param contextUser - User context for server-side operations
|
|
55
|
+
* @returns Array of validated entity groups with business context
|
|
56
|
+
*/
|
|
57
|
+
async generateEntityGroups(
|
|
58
|
+
entities: EntityInfo[],
|
|
59
|
+
contextUser: UserInfo
|
|
60
|
+
): Promise<EntityGroup[]> {
|
|
61
|
+
try {
|
|
62
|
+
// 1. Group entities by schema
|
|
63
|
+
const entitiesBySchema = this.groupEntitiesBySchema(entities);
|
|
64
|
+
|
|
65
|
+
// 2. Process each schema separately
|
|
66
|
+
const allGroups: EntityGroup[] = [];
|
|
67
|
+
let schemaIndex = 0;
|
|
68
|
+
const totalSchemas = entitiesBySchema.size;
|
|
69
|
+
|
|
70
|
+
for (const [schemaName, schemaEntities] of entitiesBySchema.entries()) {
|
|
71
|
+
schemaIndex++;
|
|
72
|
+
|
|
73
|
+
if (this.config.verbose && totalSchemas > 1) {
|
|
74
|
+
LogStatus(`[${schemaIndex}/${totalSchemas}] Processing schema: ${schemaName} (${schemaEntities.length} entities)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2a. Prepare schema data for LLM
|
|
78
|
+
const schemaData = this.prepareSchemaData(schemaEntities, schemaName);
|
|
79
|
+
|
|
80
|
+
// 2b. Call LLM to generate groups for this schema
|
|
81
|
+
const llmResponse = await this.callLLMForGrouping(schemaData, contextUser);
|
|
82
|
+
|
|
83
|
+
// 2c. Validate and convert to EntityGroup objects
|
|
84
|
+
const validatedGroups = this.validateAndConvertGroups(llmResponse, schemaEntities);
|
|
85
|
+
|
|
86
|
+
if (this.config.verbose && totalSchemas > 1) {
|
|
87
|
+
LogStatus(`[${schemaIndex}/${totalSchemas}] Generated ${validatedGroups.length} groups for schema: ${schemaName}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
allGroups.push(...validatedGroups);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Deduplicate any similar groups across all schemas
|
|
94
|
+
const deduplicatedGroups = this.deduplicateGroups(allGroups);
|
|
95
|
+
|
|
96
|
+
return deduplicatedGroups;
|
|
97
|
+
} catch (error: unknown) {
|
|
98
|
+
throw new Error(extractErrorMessage(error, 'EntityGrouper.generateEntityGroups'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Group entities by schema name
|
|
104
|
+
*/
|
|
105
|
+
private groupEntitiesBySchema(entities: EntityInfo[]): Map<string, EntityInfo[]> {
|
|
106
|
+
const schemaMap = new Map<string, EntityInfo[]>();
|
|
107
|
+
|
|
108
|
+
for (const entity of entities) {
|
|
109
|
+
const schemaName = entity.SchemaName || 'Unknown';
|
|
110
|
+
const schemaEntities = schemaMap.get(schemaName) || [];
|
|
111
|
+
schemaEntities.push(entity);
|
|
112
|
+
schemaMap.set(schemaName, schemaEntities);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return schemaMap;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Prepare schema data for LLM prompt
|
|
120
|
+
*
|
|
121
|
+
* Note: The LLM will determine appropriate group count based on business
|
|
122
|
+
* domain understanding and schema complexity. We provide the full entity
|
|
123
|
+
* schema and relationship graph for intelligent analysis.
|
|
124
|
+
*
|
|
125
|
+
* @param entities - Entities within a single schema
|
|
126
|
+
* @param schemaName - Name of the schema being processed
|
|
127
|
+
*/
|
|
128
|
+
private prepareSchemaData(entities: EntityInfo[], schemaName: string): Record<string, unknown> {
|
|
129
|
+
const formattedEntities = formatEntitiesForPrompt(entities);
|
|
130
|
+
const relationshipGraph = generateRelationshipGraph(entities);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
schemaName,
|
|
134
|
+
entities: formattedEntities,
|
|
135
|
+
relationshipGraph,
|
|
136
|
+
minGroupSize: this.config.minGroupSize,
|
|
137
|
+
maxGroupSize: this.config.maxGroupSize
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Call LLM via AIPromptRunner to generate entity groups
|
|
143
|
+
*/
|
|
144
|
+
private async callLLMForGrouping(
|
|
145
|
+
schemaData: Record<string, unknown>,
|
|
146
|
+
contextUser: UserInfo
|
|
147
|
+
): Promise<LLMEntityGroupResponse> {
|
|
148
|
+
// Get prompt entity from AIEngine
|
|
149
|
+
const prompt = AIEngine.Instance.Prompts.find(p => p.Name === this.promptName);
|
|
150
|
+
if (!prompt) {
|
|
151
|
+
throw new Error(`Prompt "${this.promptName}" not found. Ensure metadata has been synced to database.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Execute with model/vendor overrides if specified in config
|
|
155
|
+
const result = await executePromptWithOverrides<LLMEntityGroupResponse>(
|
|
156
|
+
prompt,
|
|
157
|
+
schemaData,
|
|
158
|
+
contextUser,
|
|
159
|
+
this.config
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
if (!result.success) {
|
|
163
|
+
throw new Error(`LLM grouping failed: ${result.errorMessage || 'Unknown error'}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!result.result) {
|
|
167
|
+
throw new Error('LLM did not return structured data');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result.result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validate LLM output and convert to EntityGroup objects
|
|
175
|
+
*/
|
|
176
|
+
private validateAndConvertGroups(
|
|
177
|
+
llmResponse: LLMEntityGroupResponse,
|
|
178
|
+
entities: EntityInfo[]
|
|
179
|
+
): EntityGroup[] {
|
|
180
|
+
const entityMap = new Map(entities.map(e => [e.Name, e]));
|
|
181
|
+
const validGroups: EntityGroup[] = [];
|
|
182
|
+
|
|
183
|
+
for (const llmGroup of llmResponse.groups) {
|
|
184
|
+
try {
|
|
185
|
+
// Validate all entity names exist
|
|
186
|
+
const groupEntities = llmGroup.entities
|
|
187
|
+
.map(name => entityMap.get(name))
|
|
188
|
+
.filter((e): e is EntityInfo => e !== undefined);
|
|
189
|
+
|
|
190
|
+
if (groupEntities.length !== llmGroup.entities.length) {
|
|
191
|
+
// Skip logging - verbose debug info that clutters output
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Validate primary entity exists
|
|
196
|
+
const primaryEntity = entityMap.get(llmGroup.primaryEntity);
|
|
197
|
+
if (!primaryEntity) {
|
|
198
|
+
// Skip logging - verbose debug info that clutters output
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Build relationships array (collect all relationships between entities in the group)
|
|
203
|
+
const relationships = this.extractRelationships(groupEntities);
|
|
204
|
+
|
|
205
|
+
// Validate connectivity (all entities must be reachable from primary)
|
|
206
|
+
if (groupEntities.length > 1 && !this.isConnected(groupEntities, relationships)) {
|
|
207
|
+
// Skip logging - verbose debug info that clutters output
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Create EntityGroup with LLM metadata
|
|
212
|
+
validGroups.push({
|
|
213
|
+
entities: groupEntities,
|
|
214
|
+
relationships,
|
|
215
|
+
primaryEntity,
|
|
216
|
+
relationshipType: llmGroup.relationshipType,
|
|
217
|
+
businessDomain: llmGroup.businessDomain,
|
|
218
|
+
businessRationale: llmGroup.businessRationale,
|
|
219
|
+
expectedQuestionTypes: llmGroup.expectedQuestionTypes
|
|
220
|
+
});
|
|
221
|
+
} catch (error: unknown) {
|
|
222
|
+
// Skip logging - verbose debug info that clutters output
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (validGroups.length === 0) {
|
|
227
|
+
throw new Error('No valid entity groups generated by LLM');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return validGroups;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Extract relationships between entities in a group
|
|
235
|
+
*/
|
|
236
|
+
private extractRelationships(entities: EntityInfo[]): EntityRelationshipInfo[] {
|
|
237
|
+
const entityNames = new Set(entities.map(e => e.Name));
|
|
238
|
+
const relationships: EntityRelationshipInfo[] = [];
|
|
239
|
+
|
|
240
|
+
for (const entity of entities) {
|
|
241
|
+
for (const rel of entity.RelatedEntities) {
|
|
242
|
+
if (entityNames.has(rel.RelatedEntity)) {
|
|
243
|
+
relationships.push(rel);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return relationships;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Check if all entities in a group are connected by relationships
|
|
253
|
+
*
|
|
254
|
+
* Uses BFS to verify all entities are reachable from the first entity
|
|
255
|
+
*/
|
|
256
|
+
private isConnected(entities: EntityInfo[], relationships: EntityRelationshipInfo[]): boolean {
|
|
257
|
+
if (entities.length <= 1) return true;
|
|
258
|
+
|
|
259
|
+
// Build adjacency map
|
|
260
|
+
const adjacency = new Map<string, Set<string>>();
|
|
261
|
+
for (const entity of entities) {
|
|
262
|
+
adjacency.set(entity.Name, new Set());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const rel of relationships) {
|
|
266
|
+
const entityName = entities.find(e =>
|
|
267
|
+
e.RelatedEntities.includes(rel)
|
|
268
|
+
)?.Name;
|
|
269
|
+
|
|
270
|
+
if (entityName) {
|
|
271
|
+
adjacency.get(entityName)?.add(rel.RelatedEntity);
|
|
272
|
+
adjacency.get(rel.RelatedEntity)?.add(entityName); // Bidirectional
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// BFS from first entity
|
|
277
|
+
const visited = new Set<string>();
|
|
278
|
+
const queue = [entities[0].Name];
|
|
279
|
+
visited.add(entities[0].Name);
|
|
280
|
+
|
|
281
|
+
while (queue.length > 0) {
|
|
282
|
+
const current = queue.shift()!;
|
|
283
|
+
const neighbors = adjacency.get(current) || new Set();
|
|
284
|
+
|
|
285
|
+
for (const neighbor of neighbors) {
|
|
286
|
+
if (!visited.has(neighbor)) {
|
|
287
|
+
visited.add(neighbor);
|
|
288
|
+
queue.push(neighbor);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// All entities should be visited
|
|
294
|
+
return visited.size === entities.length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Remove duplicate or highly similar groups
|
|
299
|
+
*
|
|
300
|
+
* Groups are considered duplicates if they contain the exact same set of entities
|
|
301
|
+
*/
|
|
302
|
+
private deduplicateGroups(groups: EntityGroup[]): EntityGroup[] {
|
|
303
|
+
const seen = new Set<string>();
|
|
304
|
+
const unique: EntityGroup[] = [];
|
|
305
|
+
|
|
306
|
+
for (const group of groups) {
|
|
307
|
+
// Create normalized key (sorted entity names)
|
|
308
|
+
const key = group.entities.map(e => e.Name).sort().join('|');
|
|
309
|
+
|
|
310
|
+
if (!seen.has(key)) {
|
|
311
|
+
seen.add(key);
|
|
312
|
+
unique.push(group);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return unique;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetadataExporter - Exports validated queries to MJ metadata format
|
|
3
|
+
*
|
|
4
|
+
* Transforms validated queries into MemberJunction metadata JSON files
|
|
5
|
+
* that can be synced to the database using mj-sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { ValidatedQuery, ExportResult, QueryMetadataRecord, QueryCategoryInfo } from '../data/schema';
|
|
11
|
+
import { generateQueryName } from '../utils/query-helpers';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* MetadataExporter class
|
|
15
|
+
* Exports validated queries to MJ metadata JSON format
|
|
16
|
+
*/
|
|
17
|
+
export class MetadataExporter {
|
|
18
|
+
/**
|
|
19
|
+
* Export queries to metadata JSON files
|
|
20
|
+
*
|
|
21
|
+
* Transforms validated queries into MemberJunction metadata format
|
|
22
|
+
* and writes them to timestamped JSON files:
|
|
23
|
+
* - .query-categories-{timestamp}.json for the categories
|
|
24
|
+
* - .queries-{timestamp}.json for the queries
|
|
25
|
+
*
|
|
26
|
+
* @param validatedQueries - Array of validated queries to export
|
|
27
|
+
* @param uniqueCategories - Pre-built unique categories for all queries
|
|
28
|
+
* @param outputDirectory - Directory to write the queries file
|
|
29
|
+
* @param outputCategoryDirectory - Optional directory for categories file (defaults to outputDirectory)
|
|
30
|
+
* @returns Export result with file path and query count
|
|
31
|
+
*/
|
|
32
|
+
async exportQueries(
|
|
33
|
+
validatedQueries: ValidatedQuery[],
|
|
34
|
+
uniqueCategories: QueryCategoryInfo[],
|
|
35
|
+
outputDirectory: string,
|
|
36
|
+
outputCategoryDirectory?: string
|
|
37
|
+
): Promise<ExportResult> {
|
|
38
|
+
// Generate shared timestamp for both files
|
|
39
|
+
const timestamp = Date.now();
|
|
40
|
+
|
|
41
|
+
// Use category directory if provided, otherwise use queries directory
|
|
42
|
+
const categoryDir = outputCategoryDirectory || outputDirectory;
|
|
43
|
+
|
|
44
|
+
// 1. Transform to MJ metadata format
|
|
45
|
+
const queryMetadata = validatedQueries.map(q => this.toQueryMetadata(q));
|
|
46
|
+
const categoryMetadata = uniqueCategories.map(c => this.toCategoryMetadata(c));
|
|
47
|
+
|
|
48
|
+
// 2. Ensure output directories exist
|
|
49
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
50
|
+
if (categoryDir !== outputDirectory) {
|
|
51
|
+
await fs.mkdir(categoryDir, { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Write categories file (if categories exist)
|
|
55
|
+
if (categoryMetadata.length > 0) {
|
|
56
|
+
const categoriesPath = path.join(categoryDir, `.query-categories-${timestamp}.json`);
|
|
57
|
+
await fs.writeFile(
|
|
58
|
+
categoriesPath,
|
|
59
|
+
JSON.stringify(categoryMetadata, null, 2),
|
|
60
|
+
'utf-8'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Write queries file
|
|
65
|
+
const outputPath = path.join(outputDirectory, `.queries-${timestamp}.json`);
|
|
66
|
+
await fs.writeFile(
|
|
67
|
+
outputPath,
|
|
68
|
+
JSON.stringify(queryMetadata, null, 2),
|
|
69
|
+
'utf-8'
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
success: true,
|
|
74
|
+
outputPath,
|
|
75
|
+
queryCount: queryMetadata.length
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Transform a validated query into MJ metadata format
|
|
81
|
+
*
|
|
82
|
+
* Note: This method only creates the Query record.
|
|
83
|
+
* QueryFields and QueryParameters are automatically extracted
|
|
84
|
+
* by QueryEntity.server.ts using AI analysis of the SQL template.
|
|
85
|
+
* This happens asynchronously during the Save() operation.
|
|
86
|
+
*
|
|
87
|
+
* @param query - Validated query to transform
|
|
88
|
+
* @returns Query metadata record
|
|
89
|
+
*/
|
|
90
|
+
private toQueryMetadata(query: ValidatedQuery): QueryMetadataRecord {
|
|
91
|
+
// Build category lookup path (e.g., "Golden-Queries/Members" becomes lookup filter)
|
|
92
|
+
const categoryLookup = this.buildCategoryLookup(query.category);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
fields: {
|
|
96
|
+
Name: generateQueryName(query.businessQuestion),
|
|
97
|
+
CategoryID: categoryLookup,
|
|
98
|
+
UserQuestion: query.businessQuestion.userQuestion,
|
|
99
|
+
Description: query.businessQuestion.description,
|
|
100
|
+
TechnicalDescription: query.businessQuestion.technicalDescription,
|
|
101
|
+
SQL: query.query.sql,
|
|
102
|
+
OriginalSQL: query.query.sql,
|
|
103
|
+
UsesTemplate: true,
|
|
104
|
+
Status: 'Pending'
|
|
105
|
+
}
|
|
106
|
+
// relatedEntities removed - QueryEntity.server.ts handles extraction automatically
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Transform a QueryCategoryInfo into MJ metadata format
|
|
112
|
+
*
|
|
113
|
+
* @param category - Category information
|
|
114
|
+
* @returns Category metadata record
|
|
115
|
+
*/
|
|
116
|
+
private toCategoryMetadata(category: QueryCategoryInfo) {
|
|
117
|
+
return {
|
|
118
|
+
fields: {
|
|
119
|
+
Name: category.name,
|
|
120
|
+
ParentID: category.parentName
|
|
121
|
+
? `@lookup:Query Categories.Name=${category.parentName}`
|
|
122
|
+
: null,
|
|
123
|
+
Description: category.description,
|
|
124
|
+
UserID: '@lookup:Users.Name=System'
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build category lookup string for metadata
|
|
131
|
+
* Uses multi-field lookup to uniquely identify categories in hierarchies
|
|
132
|
+
*
|
|
133
|
+
* For root categories: Name=X&ParentID=null
|
|
134
|
+
* For child categories: Name=X&Parent=Y (where Parent is the parent category name)
|
|
135
|
+
*
|
|
136
|
+
* @param category - Category information
|
|
137
|
+
* @returns Lookup string for CategoryID field
|
|
138
|
+
*/
|
|
139
|
+
private buildCategoryLookup(category: QueryCategoryInfo): string {
|
|
140
|
+
if (category.parentName) {
|
|
141
|
+
// Child category - use Name and Parent field (view field showing parent name)
|
|
142
|
+
return `@lookup:Query Categories.Name=${category.name}&Parent=${category.parentName}`;
|
|
143
|
+
} else {
|
|
144
|
+
// Root category - use Name and ParentID=null
|
|
145
|
+
return `@lookup:Query Categories.Name=${category.name}&ParentID=null`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QueryDatabaseWriter - Write validated queries directly to the database
|
|
3
|
+
*
|
|
4
|
+
* Creates Query entities in the database. QueryFields and QueryParameters
|
|
5
|
+
* are automatically extracted by QueryEntity.server.ts using AI analysis
|
|
6
|
+
* of the SQL template during the Save() operation.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Metadata, RunView, UserInfo } from '@memberjunction/core';
|
|
10
|
+
import {
|
|
11
|
+
QueryEntity,
|
|
12
|
+
QueryCategoryEntity
|
|
13
|
+
} from '@memberjunction/core-entities';
|
|
14
|
+
import { ValidatedQuery, WriteResult } from '../data/schema';
|
|
15
|
+
import { extractErrorMessage } from '../utils/error-handlers';
|
|
16
|
+
import { generateQueryName } from '../utils/query-helpers';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* QueryDatabaseWriter class
|
|
20
|
+
* Writes validated queries directly to the database
|
|
21
|
+
*/
|
|
22
|
+
export class QueryDatabaseWriter {
|
|
23
|
+
private categoryCache: Map<string, string> = new Map();
|
|
24
|
+
|
|
25
|
+
constructor() {
|
|
26
|
+
// No config needed - category info comes from ValidatedQuery
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Write validated queries to the database
|
|
30
|
+
*
|
|
31
|
+
* Creates Query entities. QueryFields and QueryParameters are automatically
|
|
32
|
+
* extracted by QueryEntity.server.ts using AI analysis of the SQL template.
|
|
33
|
+
* This happens asynchronously during the Save() operation.
|
|
34
|
+
*
|
|
35
|
+
* Errors for individual queries are logged but don't stop the batch process.
|
|
36
|
+
*
|
|
37
|
+
* @param validatedQueries - Array of validated queries to write
|
|
38
|
+
* @param contextUser - User context for entity operations
|
|
39
|
+
* @returns Write result with success status and per-query results
|
|
40
|
+
*/
|
|
41
|
+
async writeQueriesToDatabase(
|
|
42
|
+
validatedQueries: ValidatedQuery[],
|
|
43
|
+
contextUser: UserInfo
|
|
44
|
+
): Promise<WriteResult> {
|
|
45
|
+
const md = new Metadata();
|
|
46
|
+
const results: string[] = [];
|
|
47
|
+
|
|
48
|
+
// Process each query
|
|
49
|
+
for (const vq of validatedQueries) {
|
|
50
|
+
try {
|
|
51
|
+
// Get or create the category for this query (with caching)
|
|
52
|
+
const categoryId = await this.getCategoryId(vq.category, contextUser);
|
|
53
|
+
|
|
54
|
+
// Create Query entity ONLY (NO manual fields/params creation)
|
|
55
|
+
// QueryEntity.server.ts will automatically:
|
|
56
|
+
// - Detect Nunjucks syntax
|
|
57
|
+
// - Extract parameters using AI
|
|
58
|
+
// - Create QueryParameter records
|
|
59
|
+
// - Create QueryField records
|
|
60
|
+
// - Set UsesTemplate flag
|
|
61
|
+
const query = await md.GetEntityObject<QueryEntity>('Queries', contextUser);
|
|
62
|
+
query.NewRecord();
|
|
63
|
+
query.Name = generateQueryName(vq.businessQuestion);
|
|
64
|
+
query.CategoryID = categoryId;
|
|
65
|
+
query.UserQuestion = vq.businessQuestion.userQuestion;
|
|
66
|
+
query.Description = vq.businessQuestion.description;
|
|
67
|
+
query.TechnicalDescription = vq.businessQuestion.technicalDescription;
|
|
68
|
+
query.SQL = vq.query.sql;
|
|
69
|
+
query.OriginalSQL = vq.query.sql;
|
|
70
|
+
query.Status = 'Pending';
|
|
71
|
+
|
|
72
|
+
const saved = await query.Save();
|
|
73
|
+
if (!saved) {
|
|
74
|
+
throw new Error(`Failed to save query: ${query.LatestResult?.Message}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
results.push(`✓ ${query.Name} (ID: ${query.ID}) - AI extraction queued`);
|
|
78
|
+
|
|
79
|
+
} catch (error: unknown) {
|
|
80
|
+
results.push(`✗ ${vq.businessQuestion.userQuestion}: ${extractErrorMessage(error, 'Database Write')}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
success: true,
|
|
86
|
+
results
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get or create category ID from QueryCategoryInfo
|
|
92
|
+
* Uses caching to avoid repeated lookups/creations
|
|
93
|
+
* Ensures parent categories exist before creating children
|
|
94
|
+
*/
|
|
95
|
+
private async getCategoryId(
|
|
96
|
+
category: { name: string; parentName: string | null; description: string; path: string },
|
|
97
|
+
contextUser: UserInfo
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
// Check cache first (by path for uniqueness)
|
|
100
|
+
if (this.categoryCache.has(category.path)) {
|
|
101
|
+
return this.categoryCache.get(category.path)!;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If this category has a parent, ensure parent exists first
|
|
105
|
+
let parentId: string | null = null;
|
|
106
|
+
if (category.parentName) {
|
|
107
|
+
// Create parent category info (it's a root category)
|
|
108
|
+
const parentCategory = {
|
|
109
|
+
name: category.parentName,
|
|
110
|
+
parentName: null,
|
|
111
|
+
description: 'Automatically generated queries from query-gen tool',
|
|
112
|
+
path: category.parentName
|
|
113
|
+
};
|
|
114
|
+
parentId = await this.getCategoryId(parentCategory, contextUser);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Find or create this category
|
|
118
|
+
const categoryId = await this.findOrCreateCategory(
|
|
119
|
+
category.name,
|
|
120
|
+
parentId,
|
|
121
|
+
category.description,
|
|
122
|
+
contextUser
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Cache for future use
|
|
126
|
+
this.categoryCache.set(category.path, categoryId);
|
|
127
|
+
return categoryId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find or create a query category
|
|
132
|
+
*
|
|
133
|
+
* Searches for an existing category with the given name and parent, or creates it if not found.
|
|
134
|
+
*
|
|
135
|
+
* @param categoryName - Name of the category to find or create
|
|
136
|
+
* @param parentCategoryId - Parent category ID (null for root categories)
|
|
137
|
+
* @param description - Description for new categories
|
|
138
|
+
* @param contextUser - User context for entity operations
|
|
139
|
+
* @returns Category ID
|
|
140
|
+
*/
|
|
141
|
+
private async findOrCreateCategory(
|
|
142
|
+
categoryName: string,
|
|
143
|
+
parentCategoryId: string | null,
|
|
144
|
+
description: string,
|
|
145
|
+
contextUser: UserInfo
|
|
146
|
+
): Promise<string> {
|
|
147
|
+
const rv = new RunView();
|
|
148
|
+
|
|
149
|
+
// Build filter to match both name and parent
|
|
150
|
+
let filter = `Name='${categoryName.replace(/'/g, "''")}'`;
|
|
151
|
+
if (parentCategoryId) {
|
|
152
|
+
filter += ` AND ParentID='${parentCategoryId}'`;
|
|
153
|
+
} else {
|
|
154
|
+
filter += ` AND ParentID IS NULL`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const result = await rv.RunView<QueryCategoryEntity>({
|
|
158
|
+
EntityName: 'Query Categories',
|
|
159
|
+
ExtraFilter: filter,
|
|
160
|
+
ResultType: 'entity_object'
|
|
161
|
+
}, contextUser);
|
|
162
|
+
|
|
163
|
+
if (!result.Success) {
|
|
164
|
+
throw new Error(`Failed to search for category: ${result.ErrorMessage}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// If found, return existing category ID
|
|
168
|
+
if (result.Results && result.Results.length > 0) {
|
|
169
|
+
return result.Results[0].ID;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Category doesn't exist, create it
|
|
173
|
+
const md = new Metadata();
|
|
174
|
+
const category = await md.GetEntityObject<QueryCategoryEntity>('Query Categories', contextUser);
|
|
175
|
+
category.NewRecord();
|
|
176
|
+
category.Name = categoryName;
|
|
177
|
+
category.ParentID = parentCategoryId;
|
|
178
|
+
category.Description = description;
|
|
179
|
+
|
|
180
|
+
const saved = await category.Save();
|
|
181
|
+
if (!saved) {
|
|
182
|
+
throw new Error(`Failed to create category: ${category.LatestResult?.Message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return category.ID;
|
|
186
|
+
}
|
|
187
|
+
}
|