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:
co-authored by
Cursor
parent
ac04e51c8b
commit
00d0284454
@@ -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 $$;
|
||||
@@ -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()
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user