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:
co-authored by
Cursor
parent
57800634ca
commit
c6b7629c77
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user