Add multi_value technical fields and FA category AI forms.

Support duplicatable free-text lists, always append سایر ویژگی‌ها on AI category forms, and fill leftover product specs into that field.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-19 13:50:04 +03:30
co-authored by Cursor
parent ac04e51c8b
commit 00d0284454
8 changed files with 181 additions and 26 deletions
@@ -0,0 +1,6 @@
-- Free-text list field type for category technical forms (duplicatable values).
DO $$ BEGIN
ALTER TYPE technical_field_type ADD VALUE 'multi_value';
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+1
View File
@@ -1660,6 +1660,7 @@ enum TechnicalFieldType {
textarea
select
multi_select
multi_value
@@map("technical_field_type")
}
@@ -27,7 +27,22 @@ type AiTechnicalFormDraft = {
}[];
};
const ALLOWED_TYPES = new Set(['text', 'textarea', 'select', 'multi_select']);
const ALLOWED_TYPES = new Set([
'text',
'textarea',
'select',
'multi_select',
'multi_value',
]);
const OTHER_FEATURES_LABEL = 'سایر ویژگی‌ها';
const OTHER_FEATURES_LABELS = new Set([
'سایر ویژگی‌ها',
'سایر ویژگی ها',
'other features',
'other feature',
'additional features',
]);
@Injectable()
export class CategoryTechnicalFormAiService {
@@ -96,34 +111,35 @@ export class CategoryTechnicalFormAiService {
);
}
const systemPrompt = `You design technical specification forms for e-commerce product categories on an Iranian marketplace.
Return ONLY valid JSON with this shape:
const systemPrompt = `تو طراح فرم مشخصات فنی برای دسته‌بندی‌های فروشگاهی در یک مارکت‌پلیس ایرانی هستی.
فقط JSON معتبر با این شکل برگردان:
{
"fields": [
{
"label": "Field label",
"type": "text | textarea | select | multi_select",
"label": "برچسب فیلد",
"type": "text | textarea | select | multi_select | multi_value",
"isRequired": true,
"options": ["Option A", "Option B"]
"options": ["گزینه ۱", "گزینه ۲"]
}
]
}
Rules:
- Generate 5-12 practical fields that buyers need for products in the given category.
- Use a mix of text, textarea, select, and multi_select where appropriate.
- select and multi_select fields must include 3-8 realistic options.
- text fields are for short values (dimensions, weight, model year).
- textarea fields are for longer specs (features, care instructions).
- Mark important buyer-facing specs as required.
- Labels must be unique and human-readable.
- Write labels and option text in the same language as the user hint. If the hint is Persian (Farsi), use Persian labels and options; otherwise use English.
- Do not include price, stock, SKU, or warranty period fields.
- Prefer metric units common in Iran when relevant.
- When referencePages are provided, treat them as the source of truth: build fields that match the real specs / attributes shown on those pages (capacity, power, dimensions, materials, programs, colors, etc.).
- Prefer the main product specs near the product title; ignore conflicting related-product / upsell blocks.
- Do NOT invent field topics from memory when a reference page was provided — mirror what the page actually documents.
- Convert units carefully in labels when helpful (e.g. dimensions in cm if the page lists mm).`;
قوانین:
- بین ۵ تا ۱۱ فیلد کاربردی تولید کن که خریداران برای محصولات این دسته نیاز دارند (سیستم همیشه «سایر ویژگی‌ها» را اضافه می‌کند).
- از ترکیب text، textarea، select، multi_select و multi_value استفاده کن.
- فیلدهای select و multi_select باید ۳ تا ۸ گزینه واقعی داشته باشند.
- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل).
- textarea برای مشخصات طولانی‌تر (ویژگی‌ها، دستورالعمل نگهداری).
- مشخصات مهم خریدار را الزامی علامت بزن.
- برچسب‌ها باید یکتا و خوانا باشند.
- همیشه برچسب‌ها و گزینه‌ها را به فارسی بنویس.
- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز.
- در صورت مرتبط بودن از واحدهای متریک رایج در ایران استفاده کن.
- اگر referencePages داده شده، آن‌ها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات هم‌خوان باشد.
- مشخصات اصلی نزدیک عنوان محصول را ترجیح بده؛ بلوک‌های محصول مرتبط/آپسل را نادیده بگیر.
- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد.
- یک فیلد «سایر ویژگی‌ها» خودت نساز؛ سیستم آن را به‌صورت multi_value اضافه می‌کند.`;
const userPrompt = JSON.stringify({
category: input.categoryName,
@@ -174,7 +190,8 @@ Rules:
const label = String(raw.label ?? '').trim();
if (!label) continue;
const normalizedLabel = label.toLowerCase();
const normalizedLabel = label.toLowerCase().replace(/\s+/g, ' ').trim();
if (OTHER_FEATURES_LABELS.has(normalizedLabel)) continue;
if (labels.has(normalizedLabel)) continue;
labels.add(normalizedLabel);
@@ -198,9 +215,15 @@ Rules:
}
fields.push(field);
if (fields.length >= 12) break;
if (fields.length >= 11) break;
}
fields.push({
label: OTHER_FEATURES_LABEL,
type: 'multi_value',
isRequired: false,
});
if (!fields.length) {
throw new BadRequestException('AI did not return any valid technical form fields');
}
@@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto {
label!: string;
@IsString()
@IsIn(['text', 'textarea', 'select', 'multi_select'])
type!: 'text' | 'textarea' | 'select' | 'multi_select';
@IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value'])
type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value';
@IsOptional()
@IsBoolean()
+25
View File
@@ -0,0 +1,25 @@
/** Encode free-text list values for storage in text_value columns. */
export function encodeMultiValueText(values: string[]): string {
const cleaned = values.map((item) => String(item).trim()).filter(Boolean);
return JSON.stringify(cleaned);
}
/** Decode free-text list values from text_value storage. */
export function decodeMultiValueText(raw: string | null | undefined): string[] {
if (raw == null) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item).trim()).filter(Boolean);
}
} catch {
// fall through
}
}
return [trimmed];
}
+13
View File
@@ -336,6 +336,8 @@ 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 multi_value fields use an array of free-text strings.
- If a multi_value field is labeled «سایر ویژگی‌ها» (or similar “other features”), fill it with any remaining real product attributes/features that do not fit the other dedicated fields — one short string per leftover item (e.g. extra capabilities, included accessories, standards, certifications, special notes from the product page). Do not duplicate values already placed in other fields.
- For text/textarea fields use plain strings in the same language as the field labels / user prompt (usually Persian).
- When referencePages are provided, treat them as the source of truth. Copy specs from that page text (capacity, power, dimensions, weight, materials, programs, etc.).
- Convert units carefully (e.g. mm → cm for dimension fields labeled in cm: 279x300x464 mm → 27.9x30x46.4 cm).
@@ -415,6 +417,15 @@ Rules:
continue;
}
if (field.type === 'multi_value') {
const values = Array.isArray(raw) ? raw : [String(raw)];
const filtered = values.map((value) => String(value).trim()).filter(Boolean);
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);
@@ -476,6 +487,8 @@ Rules:
- If technical form fields are provided, fill technicalValues using exact field keys.
- For select fields use one allowed option value exactly.
- For multi_select fields use an array of allowed option values.
- For multi_value fields use an array of free-text strings.
- If a multi_value field is labeled «سایر ویژگی‌ها» (or similar “other features”), put any leftover real product attributes there that do not belong in the other fields — one short string per item; do not repeat values already used elsewhere.
- For text/textarea fields use plain strings.
- Prefer the input language for summary/description tone, but always return both title and nameFa.
- descriptionHtml should be 2-4 paragraphs with key specs.
+52 -1
View File
@@ -10,6 +10,10 @@ import { PermissionsService } from '../auth/permissions.service';
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
import { PrismaService } from '../prisma/prisma.service';
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
import {
decodeMultiValueText,
encodeMultiValueText,
} from '../common/technical-multi-value.util';
type FormField = {
id: string;
@@ -159,6 +163,22 @@ export class ProductTechnicalInfoService {
continue;
}
if (field.type === 'multi_value') {
const items = Array.isArray(entry.value)
? entry.value.map((item) => String(item).trim()).filter(Boolean)
: [];
if (!items.length) continue;
await tx.productTechnicalFieldValue.create({
data: {
businessId,
productId: product.id,
fieldId,
textValue: encodeMultiValueText(items),
},
});
continue;
}
if (field.type === 'text' || field.type === 'textarea') {
await tx.productTechnicalFieldValue.create({
data: {
@@ -185,6 +205,9 @@ export class ProductTechnicalInfoService {
}
const selectedValues = entry.value as string[];
if (!Array.isArray(selectedValues) || !selectedValues.length) {
continue;
}
const fieldValue = await tx.productTechnicalFieldValue.create({
data: {
businessId,
@@ -230,6 +253,24 @@ export class ProductTechnicalInfoService {
return;
}
if (field.type === 'multi_value') {
if (!Array.isArray(value)) {
throw new BadRequestException(
`Field "${field.label}" expects an array of text values`,
);
}
const items = value.map((item) => String(item).trim()).filter(Boolean);
if (!items.length) {
if (field.isRequired) {
throw new BadRequestException(
`Required field "${field.label}" cannot be empty`,
);
}
return;
}
return;
}
if (field.type === 'select') {
if (typeof value !== 'string') {
throw new BadRequestException(
@@ -303,7 +344,17 @@ export class ProductTechnicalInfoService {
fieldKey: field.key,
fieldLabel: field.label,
type: field.type,
value: field.type === 'multi_select' ? [] : null,
value:
field.type === 'multi_select' || field.type === 'multi_value' ? [] : null,
};
}
if (field.type === 'multi_value') {
return {
fieldKey: field.key,
fieldLabel: field.label,
type: field.type,
value: decodeMultiValueText(record.textValue),
};
}
@@ -19,6 +19,10 @@ import {
normalizeSeoMetaInput,
readSeoMetaFromMetadata,
} from '../common/seo-meta.util';
import {
decodeMultiValueText,
encodeMultiValueText,
} from '../common/technical-multi-value.util';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -1162,6 +1166,21 @@ export class UserProductsService {
return;
}
if (field.type === 'multi_value') {
if (entry.optionId || entry.optionIds?.length) {
throw new BadRequestException(
`Field "${field.label}" expects a text list value`,
);
}
const items = decodeMultiValueText(entry.textValue);
if (field.isRequired && !items.length) {
throw new BadRequestException(
`Required field "${field.label}" cannot be empty`,
);
}
return;
}
if (field.type === 'select') {
if (entry.textValue || entry.optionIds?.length) {
throw new BadRequestException(
@@ -1206,6 +1225,9 @@ export class UserProductsService {
if (field.type === 'text' || field.type === 'textarea') {
return Boolean(entry.textValue?.trim());
}
if (field.type === 'multi_value') {
return decodeMultiValueText(entry.textValue).length > 0;
}
if (field.type === 'select') {
return Boolean(entry.optionId);
}
@@ -1255,6 +1277,20 @@ export class UserProductsService {
continue;
}
if (field.type === 'multi_value') {
const items = decodeMultiValueText(entry.textValue);
if (!items.length) continue;
await tx.userProductTechnicalFieldValue.create({
data: {
businessId,
userProductId,
fieldId,
textValue: encodeMultiValueText(items),
},
});
continue;
}
if (field.type === 'select') {
await tx.userProductTechnicalFieldValue.create({
data: {