Add product technical-info AI fill endpoint.

Lets the dashboard suggest technical form values from a user prompt without auto-saving.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-19 11:53:34 +03:30
co-authored by Cursor
parent 57800634ca
commit c6b7629c77
3 changed files with 188 additions and 32 deletions
+8
View File
@@ -52,3 +52,11 @@ export class FillProductFieldByAiDto {
@IsString()
categoryId?: string;
}
export class FillProductTechnicalInfoByAiDto {
/** Editable user prompt / research brief from the dashboard modal. */
@IsString()
@MinLength(10)
@MaxLength(8000)
prompt!: string;
}
+164 -31
View File
@@ -7,7 +7,13 @@ import { ContentStatus, TechnicalFieldType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductByAiDto, FillProductFieldByAiDto, ProductAiFillField, ProductAiLanguage } from './dto/product-ai.dto';
import {
CreateProductByAiDto,
FillProductFieldByAiDto,
FillProductTechnicalInfoByAiDto,
ProductAiFillField,
ProductAiLanguage,
} from './dto/product-ai.dto';
import { ProductTechnicalInfoService } from './product-technical-info.service';
import { ProductsService } from './products.service';
import {
@@ -251,6 +257,159 @@ Aim for 2–4 short paragraphs and optionally a bullet list.`;
return { field: ProductAiFillField.description, html };
}
/**
* Suggest technical form values for an existing product. Does not persist —
* the dashboard applies the map and the user saves.
*/
async fillTechnicalInfo(
businessIdRaw: string,
productIdRaw: string,
dto: FillProductTechnicalInfoByAiDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const productId = BigInt(productIdRaw);
const prompt = dto.prompt.trim();
const info = await this.technicalInfo.getForProduct(
businessIdRaw,
productIdRaw,
actor,
);
if (!info.form || info.form.fields.length === 0) {
throw new BadRequestException(
'No technical form is defined for this product category',
);
}
const product = await this.prisma.product.findFirst({
where: { id: productId, businessId },
select: { title: true, content: true },
});
if (!product) {
throw new BadRequestException('Product not found');
}
const productContent =
product.content && typeof product.content === 'object' && !Array.isArray(product.content)
? (product.content as Record<string, unknown>)
: {};
const nameFa = String(productContent.nameFa ?? '').trim();
const fields: TechnicalFormField[] = info.form.fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
isRequired: field.isRequired,
options: field.options.map((option) => ({
value: option.value,
label: option.label,
})),
}));
const technicalSchema = fields.map((field) => ({
key: field.key,
label: field.label,
type: field.type,
required: field.isRequired,
options:
field.type === 'select' || field.type === 'multi_select'
? field.options.map((option) => ({
value: option.value,
label: option.label,
}))
: undefined,
}));
const provider = resolveAiProvider(this.config);
const systemPrompt = `You fill e-commerce product technical specification forms for Iranian stores.
Return ONLY valid JSON with this shape:
{ "technicalValues": { "fieldKey": "value" } }
Rules:
- Use exact field keys from the provided technicalFields list.
- For select fields use one allowed option "value" exactly (not the label).
- For multi_select fields use an array of allowed option values.
- For text/textarea fields use plain strings in the same language as the field labels / user prompt (usually Persian).
- Prefer factual values for the identified product. Do not invent unrealistic specs.
- Leave a field out if you cannot determine a reasonable value.
- Follow the user's research brief / instructions when provided.`;
const userPrompt = JSON.stringify({
instructions: prompt,
product: {
title: product.title,
nameFa: nameFa || null,
},
technicalFields: technicalSchema,
});
let content: string;
try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.4);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message);
}
let parsed: { technicalValues?: Record<string, unknown> };
try {
parsed = JSON.parse(content) as { technicalValues?: Record<string, unknown> };
} catch {
throw new BadRequestException('AI returned invalid JSON');
}
const technicalValues = this.normalizeTechnicalValues(
parsed.technicalValues ?? {},
fields,
);
if (Object.keys(technicalValues).length === 0) {
throw new BadRequestException('AI returned no usable technical values');
}
return { values: technicalValues };
}
private normalizeTechnicalValues(
rawTechnical: Record<string, unknown>,
fields: TechnicalFormField[],
): Record<string, string | string[]> {
const technicalValues: Record<string, string | string[]> = {};
for (const field of fields) {
const raw = rawTechnical[field.key];
if (raw === undefined || raw === null || raw === '') continue;
if (field.type === 'multi_select') {
const values = Array.isArray(raw) ? raw : [String(raw)];
const allowed = new Set(field.options.map((option) => option.value));
const filtered = values
.map((value) => String(value).trim())
.filter((value) => allowed.has(value));
if (filtered.length > 0) {
technicalValues[field.key] = filtered;
}
continue;
}
if (field.type === 'select') {
const value = String(raw).trim();
const allowed = field.options.some((option) => option.value === value);
if (allowed) {
technicalValues[field.key] = value;
}
continue;
}
technicalValues[field.key] = String(raw).trim();
}
return technicalValues;
}
private async generateDraft(input: {
categoryName: string;
productName: string;
@@ -350,36 +509,10 @@ Rules:
throw new BadRequestException('AI draft is missing a Persian product name');
}
const technicalValues: Record<string, string | string[]> = {};
const rawTechnical = draft.technicalValues ?? {};
for (const field of input.fields) {
const raw = rawTechnical[field.key];
if (raw === undefined || raw === null || raw === '') continue;
if (field.type === 'multi_select') {
const values = Array.isArray(raw) ? raw : [String(raw)];
const allowed = new Set(field.options.map((option) => option.value));
const filtered = values
.map((value) => String(value).trim())
.filter((value) => allowed.has(value));
if (filtered.length > 0) {
technicalValues[field.key] = filtered;
}
continue;
}
if (field.type === 'select') {
const value = String(raw).trim();
const allowed = field.options.some((option) => option.value === value);
if (allowed) {
technicalValues[field.key] = value;
}
continue;
}
technicalValues[field.key] = String(raw).trim();
}
const technicalValues = this.normalizeTechnicalValues(
(draft.technicalValues ?? {}) as Record<string, unknown>,
input.fields,
);
return {
title,
+16 -1
View File
@@ -17,7 +17,11 @@ import { RequireBusinessPermission } from '../auth/decorators/require-business-p
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
import {
CreateProductByAiDto,
FillProductFieldByAiDto,
FillProductTechnicalInfoByAiDto,
} from './dto/product-ai.dto';
import { TranslationEntityType } from '@prisma/client';
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
import { TranslationsService } from '../translations/translations.service';
@@ -208,6 +212,17 @@ export class ProductsController {
return this.technicalInfoService.getForProduct(businessId, productId, user);
}
@Post(':productId/technical-info/ai-fill')
@RequireBusinessPermission('products.update')
fillTechnicalInfoByAi(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Body() dto: FillProductTechnicalInfoByAiDto,
@CurrentUser() user: AuthUser,
) {
return this.aiService.fillTechnicalInfo(businessId, productId, dto, user);
}
@Put(':productId/technical-info')
@RequireBusinessPermission('products.update')
replaceTechnicalInfo(