From c6b7629c773652f666ea60a12eeb150167879021 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 19 Sep 2026 11:53:34 +0330 Subject: [PATCH] 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 --- src/products/dto/product-ai.dto.ts | 8 ++ src/products/product-ai.service.ts | 195 +++++++++++++++++++++++----- src/products/products.controller.ts | 17 ++- 3 files changed, 188 insertions(+), 32 deletions(-) diff --git a/src/products/dto/product-ai.dto.ts b/src/products/dto/product-ai.dto.ts index f17e371..243e72a 100644 --- a/src/products/dto/product-ai.dto.ts +++ b/src/products/dto/product-ai.dto.ts @@ -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; +} diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts index 470c594..25a964f 100644 --- a/src/products/product-ai.service.ts +++ b/src/products/product-ai.service.ts @@ -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) + : {}; + 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 }; + try { + parsed = JSON.parse(content) as { technicalValues?: Record }; + } 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, + fields: TechnicalFormField[], + ): Record { + const technicalValues: Record = {}; + + 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 = {}; - 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, + input.fields, + ); return { title, diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 158a583..dd30862 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -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(