Skip to content

Commit 37fee2f

Browse files
Merge pull request #1811 from rocket-admin/backend_ai_table_schema_reponses
feat: enhance AI schema change handling with clarification responses and improved DTOs
2 parents 7a7ddf2 + c8b6fcc commit 37fee2f

22 files changed

Lines changed: 1125 additions & 38 deletions

backend/src/entities/table-schema/ai/run-schema-change-ai-loop.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,9 @@ export interface RunSchemaChangeAiLoopOptions {
3434
logger?: Logger;
3535
}
3636

37-
export interface SchemaChangeAiLoopResult {
38-
proposals: ProposeSchemaChangeArgs[];
39-
responseId: string | null;
40-
}
37+
export type SchemaChangeAiLoopResult =
38+
| { kind: 'proposals'; proposals: ProposeSchemaChangeArgs[]; responseId: string | null }
39+
| { kind: 'clarification'; assistantMessage: string; responseId: string | null };
4140

4241
export async function runSchemaChangeAiLoop(opts: RunSchemaChangeAiLoopOptions): Promise<SchemaChangeAiLoopResult> {
4342
const { aiCoreService, provider, dao, userEmail, logger } = opts;
@@ -77,15 +76,16 @@ export async function runSchemaChangeAiLoop(opts: RunSchemaChangeAiLoopOptions):
7776
const proposalCall = pendingToolCalls.find((tc) => TERMINAL_PROPOSAL_TOOL_NAMES.has(tc.name));
7877
if (proposalCall) {
7978
const proposals = coerceAndValidateProposals(proposalCall);
80-
return { proposals, responseId: lastResponseId };
79+
return { kind: 'proposals', proposals, responseId: lastResponseId };
8180
}
8281

8382
if (pendingToolCalls.length === 0) {
84-
const hint = accumulatedContent
85-
? `AI replied with text but no tool call: "${accumulatedContent.slice(0, 200)}"`
86-
: 'AI produced no tool calls and no text.';
83+
const trimmed = accumulatedContent.trim();
84+
if (trimmed.length > 0) {
85+
return { kind: 'clarification', assistantMessage: trimmed, responseId: lastResponseId };
86+
}
8787
throw new Error(
88-
`${hint} The model must call proposeSchemaChange (SQL), proposeMongoSchemaChange (Mongo), proposeDynamoDbSchemaChange (DynamoDB), or proposeElasticsearchSchemaChange (Elasticsearch) with structured arguments.`,
88+
'AI produced no tool calls and no text. The model must call proposeSchemaChange (SQL), proposeMongoSchemaChange (Mongo), proposeDynamoDbSchemaChange (DynamoDB), or proposeElasticsearchSchemaChange (Elasticsearch) with structured arguments, or ask a focused clarifying question in plain text.',
8989
);
9090
}
9191

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { Logger } from '@nestjs/common';
2+
import { getDataAccessObject } from '@rocketadmin/shared-code/dist/src/data-access-layer/shared/create-data-access-object.js';
3+
import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js';
4+
import { AIProviderType } from '../../../ai-core/interfaces/ai-service.interface.js';
5+
import { AICoreService } from '../../../ai-core/services/ai-core.service.js';
6+
import { MessageBuilder } from '../../../ai-core/utils/message-builder.js';
7+
import { ConnectionEntity } from '../../connection/connection.entity.js';
8+
import { SchemaChangeTypeEnum } from '../table-schema-change-enums.js';
9+
import { isDynamoDbDialect, isElasticsearchDialect, isMongoDialect } from '../utils/assert-dialect-supported.js';
10+
import { runSchemaChangeAiLoop } from './run-schema-change-ai-loop.js';
11+
import {
12+
createDynamoDbSchemaChangeTools,
13+
createElasticsearchSchemaChangeTools,
14+
createMongoSchemaChangeTools,
15+
createSchemaChangeTools,
16+
ProposeSchemaChangeArgs,
17+
} from './schema-change-tools.js';
18+
19+
export interface SchemaChangeFixAiLoopOptions {
20+
aiCoreService: AICoreService;
21+
provider: AIProviderType;
22+
connection: ConnectionEntity;
23+
connectionType: ConnectionTypesEnum;
24+
changeType: SchemaChangeTypeEnum;
25+
targetTableName: string;
26+
originalUserPrompt: string;
27+
failingSql: string;
28+
failingRollbackSql: string | null;
29+
executionError: string;
30+
logger?: Logger;
31+
}
32+
33+
export interface SchemaChangeFixResult {
34+
fixedProposal: ProposeSchemaChangeArgs;
35+
}
36+
37+
export async function runSchemaChangeFixAiLoop(
38+
opts: SchemaChangeFixAiLoopOptions,
39+
): Promise<SchemaChangeFixResult | null> {
40+
const {
41+
aiCoreService,
42+
provider,
43+
connection,
44+
connectionType,
45+
changeType,
46+
targetTableName,
47+
originalUserPrompt,
48+
failingSql,
49+
failingRollbackSql,
50+
executionError,
51+
logger,
52+
} = opts;
53+
54+
const systemPrompt = buildFixSystemPrompt(connectionType, changeType, targetTableName);
55+
const humanPrompt = buildFixHumanPrompt(originalUserPrompt, failingSql, failingRollbackSql, executionError);
56+
const messages = new MessageBuilder().system(systemPrompt).human(humanPrompt).build();
57+
58+
const tools = isMongoDialect(connectionType)
59+
? createMongoSchemaChangeTools()
60+
: isDynamoDbDialect(connectionType)
61+
? createDynamoDbSchemaChangeTools()
62+
: isElasticsearchDialect(connectionType)
63+
? createElasticsearchSchemaChangeTools()
64+
: createSchemaChangeTools();
65+
66+
const dao = getDataAccessObject(connection);
67+
68+
const result = await runSchemaChangeAiLoop({
69+
aiCoreService,
70+
provider,
71+
messages,
72+
tools,
73+
dao,
74+
userEmail: undefined,
75+
logger,
76+
maxDepth: 4,
77+
});
78+
79+
if (result.kind !== 'proposals') {
80+
logger?.warn(`AI fix loop returned ${result.kind} instead of proposals; treating as fix-failed.`);
81+
return null;
82+
}
83+
84+
if (result.proposals.length !== 1) {
85+
logger?.warn(
86+
`AI fix loop returned ${result.proposals.length} proposals; expected exactly 1. Treating as fix-failed.`,
87+
);
88+
return null;
89+
}
90+
91+
const proposal = result.proposals[0];
92+
93+
if (proposal.changeType !== changeType) {
94+
logger?.warn(
95+
`AI fix loop changed changeType from ${changeType} to ${proposal.changeType}; treating as fix-failed.`,
96+
);
97+
return null;
98+
}
99+
if (proposal.targetTableName !== targetTableName) {
100+
logger?.warn(
101+
`AI fix loop changed targetTableName from ${targetTableName} to ${proposal.targetTableName}; treating as fix-failed.`,
102+
);
103+
return null;
104+
}
105+
if (!proposal.forwardSql || proposal.forwardSql.trim() === failingSql.trim()) {
106+
logger?.warn('AI fix loop returned an identical or empty forwardSql; treating as fix-failed.');
107+
return null;
108+
}
109+
110+
return { fixedProposal: proposal };
111+
}
112+
113+
function buildFixSystemPrompt(
114+
connectionType: ConnectionTypesEnum,
115+
changeType: SchemaChangeTypeEnum,
116+
targetTableName: string,
117+
): string {
118+
return `You are a DDL repair assistant for ${connectionType}.
119+
120+
A previously proposed schema change failed when applied against the live database. Your single job is to repair the failing statement and emit a corrected proposal via the appropriate proposeSchemaChange tool.
121+
122+
Hard constraints (violations will be rejected and the fix discarded):
123+
- Emit EXACTLY ONE proposal.
124+
- The proposal MUST keep changeType = "${changeType}".
125+
- The proposal MUST keep targetTableName = "${targetTableName}".
126+
- The corrected forwardSql MUST be different from the failing forwardSql.
127+
- All previously documented rules for ${changeType} on ${connectionType} still apply (single statement, dialect-correct identifier quoting, no DML, etc.).
128+
129+
Workflow:
130+
1. Read the database error carefully. Identify the precise cause (missing dependency, wrong type, reserved word, conflicting name, etc.).
131+
2. If — and only if — the fix depends on inspecting the live structure of an existing table or collection, call getTableStructure first.
132+
3. Emit the corrected proposal via the appropriate proposeSchemaChange / proposeMongoSchemaChange / proposeDynamoDbSchemaChange / proposeElasticsearchSchemaChange tool.
133+
134+
Do NOT reply with free text. If you cannot produce a corrected statement, still emit a proposal with your best attempt and explain the residual risk in "reasoning".`;
135+
}
136+
137+
function buildFixHumanPrompt(
138+
originalUserPrompt: string,
139+
failingSql: string,
140+
failingRollbackSql: string | null,
141+
executionError: string,
142+
): string {
143+
return `Original user request:
144+
${originalUserPrompt}
145+
146+
Failing forwardSql:
147+
${failingSql}
148+
149+
Previously proposed rollbackSql:
150+
${failingRollbackSql ?? '(none)'}
151+
152+
Database error returned when applying forwardSql:
153+
${executionError}
154+
155+
Produce a corrected proposal that addresses the database error while still fulfilling the user's original request.`;
156+
}

backend/src/entities/table-schema/ai/schema-change-prompts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Your job: translate the user's natural-language request into ONE OR MORE DDL sta
3737
Workflow:
3838
1. If the user's request references an existing table, call getTableStructure FIRST to inspect it. You may call it multiple times for different tables.
3939
2. Once you have enough context, call proposeSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call.
40+
3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, tables, or fields hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2.
4041
4142
Multi-proposal rules:
4243
- Order proposals so that any change depending on another comes AFTER its dependency. Tables referenced by foreign keys must be created BEFORE the tables that reference them.
@@ -85,6 +86,7 @@ DynamoDB is a NoSQL key-value store with a fixed primary key schema per table an
8586
Workflow:
8687
1. If the user's request references an existing table, call getTableStructure FIRST to see its key schema and sampled attributes. You may call it multiple times.
8788
2. Once you have enough context, call proposeDynamoDbSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call.
89+
3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, tables, or attributes hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2.
8890
8991
Multi-proposal rules:
9092
- Order proposals so dependent changes come after their prerequisites.
@@ -168,6 +170,7 @@ Elasticsearch is a search engine where each "table" is an index with a mapping (
168170
Workflow:
169171
1. If the user's request references an existing index, call getTableStructure FIRST to inspect its sampled fields and inferred types. You may call it multiple times.
170172
2. Once you have enough context, call proposeElasticsearchSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call.
173+
3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, indices, or fields hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2.
171174
172175
Multi-proposal rules:
173176
- Order proposals so dependent changes come after their prerequisites.
@@ -225,6 +228,7 @@ MongoDB does not use SQL DDL. Instead, schema changes are structured JSON operat
225228
Workflow:
226229
1. If the user's request references an existing collection, call getTableStructure FIRST to inspect a sample of documents. You may call it multiple times for different collections.
227230
2. Once you have enough context, call proposeMongoSchemaChange EXACTLY ONCE with a "proposals" array containing every change required by the user's request. Do not write free-text explanations outside the tool call.
231+
3. If — and ONLY if — the user's request is genuinely too vague to translate into ANY concrete schema change (e.g. "draw a database", "make me something", with no domain, collections, or fields hinted), reply with a single short plain-text message containing ONE focused clarifying question (no tool call). Do not invent a domain. Once the user answers, proceed to step 2.
228232
229233
Multi-proposal rules:
230234
- Order proposals so dependent changes come after their prerequisites (e.g. createCollection before createIndex on that collection).

backend/src/entities/table-schema/application/data-transfer-objects/schema-change-batch-response.dto.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import { SchemaChangeResponseDto } from './schema-change-response.dto.js';
33

44
export class SchemaChangeBatchResponseDto {
55
@ApiProperty({
6+
required: false,
7+
nullable: true,
68
description:
7-
'Identifier shared by every change generated from a single user prompt. Use it to approve/reject/rollback the entire batch in one call.',
9+
'Identifier shared by every change generated from a single user prompt. Use it to approve/reject/rollback the entire batch in one call. Null when the AI did not propose any change (e.g. it returned a clarifying question instead — see assistantMessage).',
810
})
9-
batchId: string;
11+
batchId: string | null;
1012

1113
@ApiProperty({
1214
type: [SchemaChangeResponseDto],
13-
description: 'Generated changes ordered by orderInBatch (dependency order — parents first).',
15+
description:
16+
'Generated changes ordered by orderInBatch (dependency order — parents first). Empty when the AI returned a clarifying question instead of a proposal.',
1417
})
1518
changes: SchemaChangeResponseDto[];
1619

@@ -21,4 +24,12 @@ export class SchemaChangeBatchResponseDto {
2124
'Conversation thread ID. Present when the request used or created a chat thread. Pass it back as the threadId query param on subsequent generate calls to continue the conversation with full prior context.',
2225
})
2326
threadId?: string | null;
27+
28+
@ApiProperty({
29+
required: false,
30+
nullable: true,
31+
description:
32+
"Free-text reply from the AI when it could not propose any change and needs more information from the user (e.g. a clarifying question). When present, batchId is null and changes is empty; resubmit the user's answer with the same threadId to continue the conversation.",
33+
})
34+
assistantMessage?: string | null;
2435
}

backend/src/entities/table-schema/application/data-transfer-objects/schema-change-response.dto.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ export class SchemaChangeResponseDto {
5454
@ApiProperty()
5555
autoRollbackSucceeded: boolean;
5656

57+
@ApiProperty({
58+
description:
59+
'True when the original AI-proposed SQL failed at apply time and the AI was asked to repair it. When true, forwardSql/rollbackSql reflect the repaired statements and the originals are preserved in aiAutoFixOriginalForwardSql/aiAutoFixOriginalRollbackSql.',
60+
})
61+
aiAutoFixApplied: boolean;
62+
63+
@ApiProperty({ required: false, nullable: true })
64+
aiAutoFixOriginalForwardSql: string | null;
65+
66+
@ApiProperty({ required: false, nullable: true })
67+
aiAutoFixOriginalRollbackSql: string | null;
68+
69+
@ApiProperty({
70+
required: false,
71+
nullable: true,
72+
description: 'Database error returned when the original SQL was attempted, before the AI repaired it.',
73+
})
74+
aiAutoFixOriginalError: string | null;
75+
5776
@ApiProperty()
5877
userPrompt: string;
5978

backend/src/entities/table-schema/repository/custom-table-schema-change-repository-extension.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ export const customTableSchemaChangeRepositoryExtension: ITableSchemaChangeRepos
7777
if (meta.userModifiedSql !== undefined) {
7878
patch.userModifiedSql = meta.userModifiedSql;
7979
}
80+
if (meta.forwardSql !== undefined) {
81+
patch.forwardSql = meta.forwardSql;
82+
}
83+
if (meta.rollbackSql !== undefined) {
84+
patch.rollbackSql = meta.rollbackSql;
85+
}
86+
if (meta.aiAutoFixApplied !== undefined) {
87+
patch.aiAutoFixApplied = meta.aiAutoFixApplied;
88+
}
89+
if (meta.aiAutoFixOriginalForwardSql !== undefined) {
90+
patch.aiAutoFixOriginalForwardSql = meta.aiAutoFixOriginalForwardSql;
91+
}
92+
if (meta.aiAutoFixOriginalRollbackSql !== undefined) {
93+
patch.aiAutoFixOriginalRollbackSql = meta.aiAutoFixOriginalRollbackSql;
94+
}
95+
if (meta.aiAutoFixOriginalError !== undefined) {
96+
patch.aiAutoFixOriginalError = meta.aiAutoFixOriginalError;
97+
}
8098
}
8199
await this.update({ id }, patch);
82100
return await this.findOne({ where: { id } });

backend/src/entities/table-schema/repository/table-schema-change.repository.interface.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ export interface UpdateSchemaChangeStatusMeta {
99
autoRollbackAttempted?: boolean;
1010
autoRollbackSucceeded?: boolean;
1111
userModifiedSql?: string | null;
12+
forwardSql?: string;
13+
rollbackSql?: string | null;
14+
aiAutoFixApplied?: boolean;
15+
aiAutoFixOriginalForwardSql?: string | null;
16+
aiAutoFixOriginalRollbackSql?: string | null;
17+
aiAutoFixOriginalError?: string | null;
1218
}
1319

1420
export interface ITableSchemaChangeRepository {

backend/src/entities/table-schema/table-schema-change.entity.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ export class TableSchemaChangeEntity {
8888
@Column({ type: 'varchar', length: 128, nullable: true })
8989
aiModelUsed: string | null;
9090

91+
@Column({ type: 'boolean', default: false })
92+
aiAutoFixApplied: boolean;
93+
94+
@Column({ type: 'text', nullable: true })
95+
aiAutoFixOriginalForwardSql: string | null;
96+
97+
@Column({ type: 'text', nullable: true })
98+
aiAutoFixOriginalRollbackSql: string | null;
99+
100+
@Column({ type: 'text', nullable: true })
101+
aiAutoFixOriginalError: string | null;
102+
91103
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
92104
createdAt: Date;
93105

backend/src/entities/table-schema/table-schema.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class TableSchemaController {
7272

7373
@ApiOperation({
7474
summary:
75-
'Generate one or more schema changes from a natural-language prompt. The response is always a batch envelope; single-change prompts return a length-1 array. Pass an optional threadId in the body to continue an existing conversation; the response returns the threadId to use for follow-up turns.',
75+
"Generate one or more schema changes from a natural-language prompt. The response is always a batch envelope; single-change prompts return a length-1 array. Pass an optional threadId in the body to continue an existing conversation; the response returns the threadId to use for follow-up turns. If the AI needs more context (e.g. the request is too vague), the response carries an assistantMessage with a clarifying question, batchId is null, and changes is empty — resubmit the user's answer with the same threadId to continue.",
7676
})
7777
@ApiParam({ name: 'connectionId', type: String })
7878
@ApiBody({ type: GenerateSchemaChangeDto })

0 commit comments

Comments
 (0)