Skip to content

Commit 8d8f6fc

Browse files
Merge pull request #393 from geturbackend/fix/ai-collection-creator-improvements
feat(ai): comprehensive AI Collection Creator improvements, rate-limiting fixes, and interactive Schema Canvas visualize
2 parents d57daff + 6312400 commit 8d8f6fc

6 files changed

Lines changed: 941 additions & 177 deletions

File tree

‎apps/dashboard-api/src/controllers/ai.controller.js‎

Lines changed: 114 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const mongoose = require('mongoose');
12
const { Project, Developer } = require('@urbackend/common/src/models');
23
const { forwardToPythonService } = require('../utils/internalPythonClient');
34
const { AppError, ApiResponse, getProjectAccessQuery, resolveEffectivePlan } = require('@urbackend/common');
@@ -13,7 +14,6 @@ const queryBuilder = async (req, res, next) => {
1314
const { projectId } = req.params;
1415
const { collectionName, prompt } = req.body;
1516

16-
const mongoose = require('mongoose');
1717
if (!mongoose.Types.ObjectId.isValid(projectId)) {
1818
throw new AppError(400, "Invalid project ID");
1919
}
@@ -36,11 +36,11 @@ const queryBuilder = async (req, res, next) => {
3636
throw new AppError(403, "Cannot query the users collection via AI");
3737
}
3838

39-
// 1. Load collection and project details
39+
// 1. Load collection and project details (including BYOK fields in a single query)
4040
const project = await Project.findOne({
4141
_id: projectId,
4242
...getProjectAccessQuery(req.user._id)
43-
});
43+
}).select('+byok.groqKey.encrypted +byok.groqKey.iv +byok.groqKey.tag');
4444

4545
if (!project) {
4646
throw new AppError(404, "Project not found or access denied");
@@ -80,20 +80,20 @@ const queryBuilder = async (req, res, next) => {
8080
let resolvedKey = null;
8181

8282
// 1. Check project-level BYOK
83-
const projectByok = await Project.findOne({
84-
_id: projectId,
85-
...getProjectAccessQuery(req.user._id)
86-
})
87-
.select('+byok.groqKey.encrypted +byok.groqKey.iv +byok.groqKey.tag')
88-
.lean();
89-
if (projectByok?.byok?.groqKey?.encrypted) {
90-
resolvedKey = decrypt(projectByok.byok.groqKey);
83+
if (project.byok?.groqKey?.encrypted) {
84+
resolvedKey = decrypt(project.byok.groqKey);
85+
if (!resolvedKey) {
86+
console.warn(`[QueryBuilder] Project-level BYOK key decryption failed for project ${projectId}`);
87+
}
9188
}
9289

9390
// 2. Fallback to developer-level BYOK
9491
const dev = await Developer.findById(req.user._id).select('+byok');
9592
if (!resolvedKey && dev?.byok?.groqKey?.encrypted) {
9693
resolvedKey = decrypt(dev.byok.groqKey);
94+
if (!resolvedKey) {
95+
console.warn(`[QueryBuilder] Developer-level BYOK key decryption failed for developer ${req.user._id}`);
96+
}
9797
}
9898

9999
// 3. Encrypt for secure transit to Python
@@ -166,7 +166,33 @@ const ccSessionKey = (projectId, developerId) =>
166166
const ccIterationsKey = (projectId, developerId) =>
167167
`ai:cc:iterations:${projectId}:${developerId}`;
168168

169+
const LUA_RESERVE_ITERATION = `
170+
local sessionExists = redis.call('EXISTS', KEYS[2])
171+
local current = redis.call('GET', KEYS[1])
172+
local count = tonumber(current) or 0
173+
174+
if sessionExists == 0 and count > 0 then
175+
count = 0
176+
redis.call('SET', KEYS[1], '0')
177+
end
178+
179+
if count >= tonumber(ARGV[1]) then
180+
return {0, count}
181+
end
182+
183+
local nextVal = redis.call('INCR', KEYS[1])
184+
redis.call('EXPIRE', KEYS[1], ARGV[2])
185+
if sessionExists == 1 then
186+
redis.call('EXPIRE', KEYS[2], ARGV[2])
187+
end
188+
return {1, nextVal}
189+
`;
190+
169191
const collectionCreator = async (req, res, next) => {
192+
let reservedIteration = false;
193+
let iterKey = null;
194+
let hasByok = false;
195+
170196
try {
171197
const { projectId } = req.params;
172198
const { userMessage } = req.body;
@@ -179,7 +205,6 @@ const collectionCreator = async (req, res, next) => {
179205
}
180206
const safeUserMessage = userMessage.trim();
181207

182-
const mongoose = require('mongoose');
183208
if (!mongoose.Types.ObjectId.isValid(projectId)) {
184209
throw new AppError(400, "Invalid project ID");
185210
}
@@ -196,38 +221,54 @@ const collectionCreator = async (req, res, next) => {
196221

197222
// 2. Load Redis session
198223
const sessionKey = ccSessionKey(projectId, req.user._id);
199-
const iterKey = ccIterationsKey(projectId, req.user._id);
200-
const [sessionStr, iterStr] = await Promise.all([
201-
redis.get(sessionKey),
202-
redis.get(iterKey)
203-
]);
224+
iterKey = ccIterationsKey(projectId, req.user._id);
225+
const sessionStr = await redis.get(sessionKey);
204226

205227
let session = { messages: [], hasByok: false };
206228
try {
207229
if (sessionStr) session = JSON.parse(sessionStr);
208230
} catch (e) {
209231
console.warn("Invalid session data in Redis, resetting chat transcript");
210232
}
211-
let iterations = iterStr ? parseInt(iterStr, 10) : 0;
212233

213234
// 3. BYOK resolution
214235
let resolvedKey = null;
215236
if (project.byok?.groqKey?.encrypted) {
216237
resolvedKey = decrypt(project.byok.groqKey);
238+
if (!resolvedKey) {
239+
console.warn(`[CollectionCreator] Project-level BYOK key decryption failed for project ${projectId}`);
240+
}
217241
}
218242

219243
const dev = await Developer.findById(req.user._id).select('+byok +plan +trialEndsAt +subscriptionId');
220244
if (!resolvedKey && dev?.byok?.groqKey?.encrypted) {
221245
resolvedKey = decrypt(dev.byok.groqKey);
246+
if (!resolvedKey) {
247+
console.warn(`[CollectionCreator] Developer-level BYOK key decryption failed for developer ${req.user._id}`);
248+
}
222249
}
223250

224251
const encryptedByok = resolvedKey ? { groqKey: encryptForTransit(resolvedKey) } : null;
225-
const hasByok = !!encryptedByok;
252+
hasByok = !!encryptedByok;
226253

227-
// 4. Iteration limit check
254+
// 4. Atomic iteration reservation before calling Python service
228255
const PLATFORM_ITERATION_LIMIT = 3;
229-
if (!hasByok && iterations >= PLATFORM_ITERATION_LIMIT) {
230-
throw new AppError(403, "AI iteration limit reached (3/3 on platform key). Add your Groq API key in Settings for unlimited usage.");
256+
let iterations = 0;
257+
258+
if (!hasByok) {
259+
const [allowed, newCount] = await redis.eval(
260+
LUA_RESERVE_ITERATION,
261+
2,
262+
iterKey,
263+
sessionKey,
264+
PLATFORM_ITERATION_LIMIT,
265+
7200
266+
);
267+
if (allowed === 0) {
268+
throw new AppError(403, "AI iteration limit reached (3/3 on platform key). Add your Groq API key in Settings for unlimited usage.");
269+
}
270+
reservedIteration = true;
271+
iterations = newCount;
231272
}
232273

233274
// 5. Update session
@@ -251,46 +292,67 @@ const collectionCreator = async (req, res, next) => {
251292
let safeSchema = null;
252293

253294
if (['schema', 'complete'].includes(aiResponse.type) && Array.isArray(aiResponse.schema)) {
295+
const sanitizeFields = (fields) => {
296+
return (fields || []).filter(f => f.name && allowedTypes.has(f.type)).map(f => {
297+
const fieldDef = {
298+
name: f.name,
299+
type: f.type,
300+
required: !!f.required
301+
};
302+
if (f.type === 'Ref') {
303+
fieldDef.ref = f.ref || 'users';
304+
} else if (f.type === 'Array') {
305+
if (f.items && typeof f.items === 'object') {
306+
const itemDef = { type: f.items.type || 'String' };
307+
if (f.items.type === 'Ref') {
308+
itemDef.ref = f.items.ref || 'users';
309+
} else if (f.items.type === 'Object' && Array.isArray(f.items.fields) && f.items.fields.length > 0) {
310+
itemDef.fields = sanitizeFields(f.items.fields);
311+
}
312+
fieldDef.items = itemDef;
313+
} else {
314+
fieldDef.items = { type: typeof f.items === 'string' ? f.items : 'String' };
315+
}
316+
} else if (f.type === 'Object' && Array.isArray(f.fields) && f.fields.length > 0) {
317+
fieldDef.fields = sanitizeFields(f.fields);
318+
}
319+
if (f.unique !== undefined && !['Array', 'Object', 'Ref'].includes(f.type)) {
320+
fieldDef.unique = !!f.unique;
321+
}
322+
return fieldDef;
323+
});
324+
};
325+
254326
safeSchema = aiResponse.schema
255327
.filter(c => c.collection && c.collection.toLowerCase() !== 'users')
256-
.map(c => {
257-
const sanitizedFields = (c.fields || []).filter(f => f.name && allowedTypes.has(f.type)).map(f => {
258-
const fieldDef = {
259-
name: f.name,
260-
type: f.type,
261-
required: !!f.required
262-
};
263-
if (f.type === 'Ref') {
264-
fieldDef.ref = f.ref || 'users';
265-
} else if (f.type === 'Array') {
266-
fieldDef.items = f.items || { type: 'String' };
267-
}
268-
return fieldDef;
269-
});
270-
return {
271-
collection: c.collection,
272-
fields: sanitizedFields
273-
};
274-
})
328+
.map(c => ({
329+
collection: c.collection,
330+
fields: sanitizeFields(c.fields)
331+
}))
275332
.filter(c => c.fields.length > 0);
276333
}
277334

278-
// 7. Save session
335+
// 7. Save session with TTL
279336
session.messages.push({ role: "assistant", content: aiResponse.message });
280-
if (!hasByok) {
281-
iterations += 1;
282-
await redis.set(iterKey, iterations.toString());
283-
}
284337
await redis.set(sessionKey, JSON.stringify(session), 'EX', 7200);
285338

286339
return new ApiResponse({
287340
type: aiResponse.type,
288341
message: aiResponse.message,
289342
schema: safeSchema,
290-
iterationsLeft: hasByok ? null : Math.max(0, PLATFORM_ITERATION_LIMIT - iterations)
343+
iterationsLeft: hasByok ? null : Math.max(0, PLATFORM_ITERATION_LIMIT - iterations),
344+
iterationLimit: hasByok ? null : PLATFORM_ITERATION_LIMIT
291345
}, "Agent responded successfully").send(res);
292346

293347
} catch (error) {
348+
// Rollback reserved iteration on error if platform key was used
349+
if (!hasByok && reservedIteration && iterKey) {
350+
try {
351+
await redis.decr(iterKey);
352+
} catch (revertErr) {
353+
console.warn("[CollectionCreator] Failed to rollback reserved iteration counter", revertErr);
354+
}
355+
}
294356
if (error instanceof AppError) {
295357
return next(error);
296358
}
@@ -318,7 +380,10 @@ const collectionCreator = async (req, res, next) => {
318380
const clearCollectionCreatorSession = async (req, res, next) => {
319381
try {
320382
const { projectId } = req.params;
321-
await redis.del(ccSessionKey(projectId, req.user._id));
383+
await Promise.all([
384+
redis.del(ccSessionKey(projectId, req.user._id)),
385+
redis.del(ccIterationsKey(projectId, req.user._id))
386+
]);
322387
return new ApiResponse({}, "Session cleared").send(res);
323388
} catch(err) {
324389
console.error("clearCollectionCreatorSession error", err);
@@ -331,3 +396,4 @@ module.exports = {
331396
collectionCreator,
332397
clearCollectionCreatorSession
333398
};
399+

‎apps/python-service/routers/ai.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ class CollectionField(BaseModel):
4545
type: str
4646
required: bool = False
4747
ref: str | None = None
48+
unique: bool = False
49+
items: dict | None = None
50+
fields: list["CollectionField"] | None = None
51+
52+
@model_validator(mode='after')
53+
def validate_object_fields(self):
54+
if self.type == "Object":
55+
if self.fields is None or len(self.fields) == 0:
56+
raise ValueError("fields must be present and non-empty for Object type")
57+
return self
4858

4959
class CollectionSchema(BaseModel):
5060
collection: str
@@ -171,7 +181,7 @@ async def collection_creator(request: CollectionCreatorRequest, req: Request):
171181
Rules:
172182
1. If the description is vague, ask 2-3 specific clarifying questions and set type: "clarify". You MUST set "schema": null. NEVER ask more than 3 at once.
173183
2. Once you have enough context, propose a schema and set type: "schema".
174-
3. Only use these field types: String, Number, Boolean, Date, Ref, Array. (For Ref, ALWAYS set 'ref' to the referenced collection name e.g. 'users'. Avoid empty Object types).
184+
3. Only use these field types: String, Number, Boolean, Date, Ref, Array, Object. For Ref, ALWAYS set 'ref' to the referenced collection name e.g. 'users'. For Array, set 'items' to describe item type e.g. {"type": "String"}. For Object, set 'fields' as a list of sub-fields. Do NOT use Object as a lazy catch-all — prefer specific flat fields when possible.
175185
4. ALWAYS include "createdAt" (type: Date, required: true) in EVERY collection.
176186
5. Suggest Ref to "users" where ownership applies (ownerId, authorId, userId, etc.) with 'ref': 'users'.
177187
6. NEVER generate a collection named "users" — it is reserved for auth.

‎apps/python-service/tests/test_byok.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async def test_byok_key_decrypted_and_used(mock_decrypt):
4646
mock_decrypt.assert_called_once_with(encrypted_byok["groqKey"])
4747
MockGroq.assert_called_once_with(
4848
api_key="gsk_test_byok_key_12345",
49-
model_name="llama-3.1-8b-instant",
49+
model_name="llama-3.3-70b-versatile",
5050
temperature=0,
5151
)
5252

@@ -73,7 +73,7 @@ async def test_free_tier_under_limit(mock_redis):
7373

7474
MockGroq.assert_called_once_with(
7575
api_key=settings.GROQ_API_KEY,
76-
model_name="llama-3.1-8b-instant",
76+
model_name="llama-3.3-70b-versatile",
7777
temperature=0,
7878
)
7979

@@ -100,7 +100,7 @@ async def test_pro_tier_under_limit(mock_redis):
100100

101101
MockGroq.assert_called_with(
102102
api_key=settings.GROQ_API_KEY,
103-
model_name="llama-3.1-8b-instant",
103+
model_name="llama-3.3-70b-versatile",
104104
temperature=0,
105105
)
106106

@@ -111,7 +111,7 @@ async def test_pro_tier_under_limit(mock_redis):
111111
client = await resolve_ai_client("dev123", "pro", None)
112112
MockGroq.assert_called_with(
113113
api_key=settings.GROQ_API_KEY,
114-
model_name="llama-3.1-8b-instant",
114+
model_name="llama-3.3-70b-versatile",
115115
temperature=0,
116116
)
117117

‎apps/python-service/tests/test_collection_creator.py‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ async def test_collection_creator_schema_validation_rejection():
4646
headers={"X-Internal-Signature": "fake_sig"}
4747
)
4848

49-
# Pydantic validation errors from Langchain result in an unhandled exception (500)
50-
assert response.status_code == 500
49+
# Pydantic validation errors from Langchain result in 400 Bad Request
50+
assert response.status_code == 400
5151

5252
@pytest.mark.asyncio
5353
async def test_collection_creator_success():
@@ -126,3 +126,39 @@ async def delayed_invoke(*args, **kwargs):
126126
)
127127

128128
assert response.status_code == 504
129+
130+
def test_collection_field_object_validation():
131+
from routers.ai import CollectionField
132+
from pydantic import ValidationError
133+
134+
# 1. Reject missing fields on Object
135+
with pytest.raises(ValidationError) as exc:
136+
CollectionField(name="profile", type="Object")
137+
assert "fields must be present and non-empty for Object type" in str(exc.value)
138+
139+
# 2. Reject None fields on Object
140+
with pytest.raises(ValidationError) as exc:
141+
CollectionField(name="profile", type="Object", fields=None)
142+
assert "fields must be present and non-empty for Object type" in str(exc.value)
143+
144+
# 3. Reject empty fields list on Object
145+
with pytest.raises(ValidationError) as exc:
146+
CollectionField(name="profile", type="Object", fields=[])
147+
assert "fields must be present and non-empty for Object type" in str(exc.value)
148+
149+
# 4. Valid Object with non-empty fields
150+
valid_field = CollectionField(
151+
name="profile",
152+
type="Object",
153+
fields=[CollectionField(name="bio", type="String", required=True)]
154+
)
155+
assert valid_field.name == "profile"
156+
assert valid_field.type == "Object"
157+
assert len(valid_field.fields) == 1
158+
assert valid_field.fields[0].name == "bio"
159+
160+
# 5. Non-Object fields do not require fields
161+
valid_string = CollectionField(name="title", type="String")
162+
assert valid_string.name == "title"
163+
assert valid_string.fields is None
164+

0 commit comments

Comments
 (0)