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()
|
@IsString()
|
||||||
categoryId?: string;
|
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 { AuthUser } from '../auth/auth.types';
|
||||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import {
|
import {
|
||||||
@@ -251,6 +257,159 @@ Aim for 2–4 short paragraphs and optionally a bullet list.`;
|
|||||||
return { field: ProductAiFillField.description, html };
|
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: {
|
private async generateDraft(input: {
|
||||||
categoryName: string;
|
categoryName: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
@@ -350,36 +509,10 @@ Rules:
|
|||||||
throw new BadRequestException('AI draft is missing a Persian product name');
|
throw new BadRequestException('AI draft is missing a Persian product name');
|
||||||
}
|
}
|
||||||
|
|
||||||
const technicalValues: Record<string, string | string[]> = {};
|
const technicalValues = this.normalizeTechnicalValues(
|
||||||
const rawTechnical = draft.technicalValues ?? {};
|
(draft.technicalValues ?? {}) as Record<string, unknown>,
|
||||||
|
input.fields,
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import { RequireBusinessPermission } from '../auth/decorators/require-business-p
|
|||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
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 { TranslationEntityType } from '@prisma/client';
|
||||||
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
|
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
|
||||||
import { TranslationsService } from '../translations/translations.service';
|
import { TranslationsService } from '../translations/translations.service';
|
||||||
@@ -208,6 +212,17 @@ export class ProductsController {
|
|||||||
return this.technicalInfoService.getForProduct(businessId, productId, user);
|
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')
|
@Put(':productId/technical-info')
|
||||||
@RequireBusinessPermission('products.update')
|
@RequireBusinessPermission('products.update')
|
||||||
replaceTechnicalInfo(
|
replaceTechnicalInfo(
|
||||||
|
|||||||
Reference in New Issue
Block a user