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
|
textarea
|
||||||
select
|
select
|
||||||
multi_select
|
multi_select
|
||||||
|
multi_value
|
||||||
|
|
||||||
@@map("technical_field_type")
|
@@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()
|
@Injectable()
|
||||||
export class CategoryTechnicalFormAiService {
|
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.
|
const systemPrompt = `تو طراح فرم مشخصات فنی برای دستهبندیهای فروشگاهی در یک مارکتپلیس ایرانی هستی.
|
||||||
Return ONLY valid JSON with this shape:
|
فقط JSON معتبر با این شکل برگردان:
|
||||||
{
|
{
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"label": "Field label",
|
"label": "برچسب فیلد",
|
||||||
"type": "text | textarea | select | multi_select",
|
"type": "text | textarea | select | multi_select | multi_value",
|
||||||
"isRequired": true,
|
"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.
|
- از ترکیب text، textarea، select، multi_select و multi_value استفاده کن.
|
||||||
- select and multi_select fields must include 3-8 realistic options.
|
- فیلدهای select و multi_select باید ۳ تا ۸ گزینه واقعی داشته باشند.
|
||||||
- text fields are for short values (dimensions, weight, model year).
|
- multi_value یک لیست تکراری از مقادیر متنی آزاد است (بدون گزینه) برای مقادیر کوتاه چندتایی.
|
||||||
- textarea fields are for longer specs (features, care instructions).
|
- text برای مقادیر کوتاه (ابعاد، وزن، سال مدل).
|
||||||
- Mark important buyer-facing specs as required.
|
- textarea برای مشخصات طولانیتر (ویژگیها، دستورالعمل نگهداری).
|
||||||
- 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.
|
- فیلدهای قیمت، موجودی، SKU یا مدت گارانتی نساز.
|
||||||
- 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.
|
- اگر referencePages داده شده، آنها منبع حقیقت هستند: فیلدهایی بساز که با مشخصات واقعی همان صفحات همخوان باشد.
|
||||||
- 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).`;
|
- وقتی صفحه مرجع هست از حافظه فیلد اختراع نکن — همان چیزی را بازتاب بده که صفحه واقعاً دارد.
|
||||||
|
- یک فیلد «سایر ویژگیها» خودت نساز؛ سیستم آن را بهصورت multi_value اضافه میکند.`;
|
||||||
|
|
||||||
const userPrompt = JSON.stringify({
|
const userPrompt = JSON.stringify({
|
||||||
category: input.categoryName,
|
category: input.categoryName,
|
||||||
@@ -174,7 +190,8 @@ Rules:
|
|||||||
const label = String(raw.label ?? '').trim();
|
const label = String(raw.label ?? '').trim();
|
||||||
if (!label) continue;
|
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;
|
if (labels.has(normalizedLabel)) continue;
|
||||||
labels.add(normalizedLabel);
|
labels.add(normalizedLabel);
|
||||||
|
|
||||||
@@ -198,9 +215,15 @@ Rules:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fields.push(field);
|
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) {
|
if (!fields.length) {
|
||||||
throw new BadRequestException('AI did not return any valid technical form fields');
|
throw new BadRequestException('AI did not return any valid technical form fields');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export class TechnicalFormFieldInputDto {
|
|||||||
label!: string;
|
label!: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['text', 'textarea', 'select', 'multi_select'])
|
@IsIn(['text', 'textarea', 'select', 'multi_select', 'multi_value'])
|
||||||
type!: 'text' | 'textarea' | 'select' | 'multi_select';
|
type!: 'text' | 'textarea' | 'select' | 'multi_select' | 'multi_value';
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@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.
|
- Use exact field keys from the provided technicalFields list.
|
||||||
- For select fields use one allowed option "value" exactly (not the label).
|
- 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_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).
|
- 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.).
|
- 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).
|
- 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;
|
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') {
|
if (field.type === 'select') {
|
||||||
const value = String(raw).trim();
|
const value = String(raw).trim();
|
||||||
const allowed = field.options.some((option) => option.value === value);
|
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.
|
- If technical form fields are provided, fill technicalValues using exact field keys.
|
||||||
- For select fields use one allowed option value exactly.
|
- For select fields use one allowed option value exactly.
|
||||||
- For multi_select fields use an array of allowed option values.
|
- 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.
|
- For text/textarea fields use plain strings.
|
||||||
- Prefer the input language for summary/description tone, but always return both title and nameFa.
|
- Prefer the input language for summary/description tone, but always return both title and nameFa.
|
||||||
- descriptionHtml should be 2-4 paragraphs with key specs.
|
- 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 { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||||
|
import {
|
||||||
|
decodeMultiValueText,
|
||||||
|
encodeMultiValueText,
|
||||||
|
} from '../common/technical-multi-value.util';
|
||||||
|
|
||||||
type FormField = {
|
type FormField = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -159,6 +163,22 @@ export class ProductTechnicalInfoService {
|
|||||||
continue;
|
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') {
|
if (field.type === 'text' || field.type === 'textarea') {
|
||||||
await tx.productTechnicalFieldValue.create({
|
await tx.productTechnicalFieldValue.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -185,6 +205,9 @@ export class ProductTechnicalInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedValues = entry.value as string[];
|
const selectedValues = entry.value as string[];
|
||||||
|
if (!Array.isArray(selectedValues) || !selectedValues.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const fieldValue = await tx.productTechnicalFieldValue.create({
|
const fieldValue = await tx.productTechnicalFieldValue.create({
|
||||||
data: {
|
data: {
|
||||||
businessId,
|
businessId,
|
||||||
@@ -230,6 +253,24 @@ export class ProductTechnicalInfoService {
|
|||||||
return;
|
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 (field.type === 'select') {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -303,7 +344,17 @@ export class ProductTechnicalInfoService {
|
|||||||
fieldKey: field.key,
|
fieldKey: field.key,
|
||||||
fieldLabel: field.label,
|
fieldLabel: field.label,
|
||||||
type: field.type,
|
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,
|
normalizeSeoMetaInput,
|
||||||
readSeoMetaFromMetadata,
|
readSeoMetaFromMetadata,
|
||||||
} from '../common/seo-meta.util';
|
} from '../common/seo-meta.util';
|
||||||
|
import {
|
||||||
|
decodeMultiValueText,
|
||||||
|
encodeMultiValueText,
|
||||||
|
} from '../common/technical-multi-value.util';
|
||||||
import { MediaService } from '../media/media.service';
|
import { MediaService } from '../media/media.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -1162,6 +1166,21 @@ export class UserProductsService {
|
|||||||
return;
|
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 (field.type === 'select') {
|
||||||
if (entry.textValue || entry.optionIds?.length) {
|
if (entry.textValue || entry.optionIds?.length) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -1206,6 +1225,9 @@ export class UserProductsService {
|
|||||||
if (field.type === 'text' || field.type === 'textarea') {
|
if (field.type === 'text' || field.type === 'textarea') {
|
||||||
return Boolean(entry.textValue?.trim());
|
return Boolean(entry.textValue?.trim());
|
||||||
}
|
}
|
||||||
|
if (field.type === 'multi_value') {
|
||||||
|
return decodeMultiValueText(entry.textValue).length > 0;
|
||||||
|
}
|
||||||
if (field.type === 'select') {
|
if (field.type === 'select') {
|
||||||
return Boolean(entry.optionId);
|
return Boolean(entry.optionId);
|
||||||
}
|
}
|
||||||
@@ -1255,6 +1277,20 @@ export class UserProductsService {
|
|||||||
continue;
|
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') {
|
if (field.type === 'select') {
|
||||||
await tx.userProductTechnicalFieldValue.create({
|
await tx.userProductTechnicalFieldValue.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in New Issue
Block a user