diff --git a/src/common/ai-html.util.ts b/src/common/ai-html.util.ts
new file mode 100644
index 0000000..f755c51
--- /dev/null
+++ b/src/common/ai-html.util.ts
@@ -0,0 +1,153 @@
+const ALLOWED_TAGS = new Set([
+ 'p',
+ 'br',
+ 'ul',
+ 'ol',
+ 'li',
+ 'strong',
+ 'em',
+ 'b',
+ 'i',
+]);
+
+function decodeBasicEntities(value: string): string {
+ return value
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/"/gi, '"')
+ .replace(/'/g, "'")
+ .replace(/&/gi, '&');
+}
+
+function stripCodeFences(value: string): string {
+ const trimmed = value.trim();
+ const fenced = trimmed.match(/^```(?:html)?\s*([\s\S]*?)\s*```$/i);
+ return fenced ? fenced[1].trim() : trimmed;
+}
+
+function escapeText(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+function plainTextToHtml(value: string): string {
+ const normalized = value.replace(/\r\n/g, '\n').trim();
+ if (!normalized) return '';
+
+ const blocks = normalized.split(/\n{2,}/);
+ const htmlParts: string[] = [];
+
+ for (const block of blocks) {
+ const lines = block
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean);
+ if (lines.length === 0) continue;
+
+ const bulletLines = lines.filter((line) => /^([-*•]|\d+[.)])\s+/.test(line));
+ if (bulletLines.length === lines.length && lines.length > 1) {
+ const items = lines
+ .map((line) => line.replace(/^([-*•]|\d+[.)])\s+/, ''))
+ .map((item) => `
${escapeText(item)}`)
+ .join('');
+ htmlParts.push(``);
+ continue;
+ }
+
+ htmlParts.push(`${escapeText(lines.join(' '))}
`);
+ }
+
+ return htmlParts.join('') || `${escapeText(normalized)}
`;
+}
+
+function tokenizeHtml(value: string): Array<{ type: 'tag' | 'text'; value: string }> {
+ const tokens: Array<{ type: 'tag' | 'text'; value: string }> = [];
+ const pattern = /<\/?([a-zA-Z][\w:-]*)\b[^>]*>/g;
+ let lastIndex = 0;
+ let match: RegExpExecArray | null;
+
+ while ((match = pattern.exec(value)) !== null) {
+ if (match.index > lastIndex) {
+ tokens.push({ type: 'text', value: value.slice(lastIndex, match.index) });
+ }
+ tokens.push({ type: 'tag', value: match[0] });
+ lastIndex = pattern.lastIndex;
+ }
+
+ if (lastIndex < value.length) {
+ tokens.push({ type: 'text', value: value.slice(lastIndex) });
+ }
+
+ return tokens;
+}
+
+function sanitizeHtmlFragment(value: string): string {
+ // Drop script/style blocks entirely (including their text content).
+ const withoutBlocks = value.replace(
+ /<\s*(script|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi,
+ '',
+ );
+ const tokens = tokenizeHtml(withoutBlocks);
+ let output = '';
+
+ for (const token of tokens) {
+ if (token.type === 'text') {
+ output += token.value;
+ continue;
+ }
+
+ const tagMatch = token.value.match(/^<\/?([a-zA-Z][\w:-]*)\b[^>]*>$/);
+ if (!tagMatch) continue;
+
+ const tagName = tagMatch[1].toLowerCase();
+ if (!ALLOWED_TAGS.has(tagName)) {
+ continue;
+ }
+
+ if (tagName === 'br') {
+ output += '
';
+ continue;
+ }
+
+ const isClosing = token.value.startsWith('');
+ output += isClosing ? `${tagName}>` : `<${tagName}>`;
+ }
+
+ return output.trim();
+}
+
+/**
+ * Normalize model output into a safe HTML fragment for the rich text editor.
+ * Prevents markdown/plain text and escaped/malformed markup from landing outside
+ * or rendering as raw source in the advanced description field.
+ */
+export function normalizeAiDescriptionHtml(raw: string): string {
+ let value = String(raw ?? '').trim();
+ if (!value) return '';
+
+ value = stripCodeFences(value);
+
+ // Models sometimes return entity-escaped HTML inside the JSON string.
+ if (/<\/?(p|ul|ol|li|strong|em|br)\b/i.test(value)) {
+ value = decodeBasicEntities(value);
+ }
+
+ const hasHtmlTags = /<\/?[a-zA-Z][\w:-]*\b[^>]*>/.test(value);
+ if (!hasHtmlTags) {
+ return plainTextToHtml(value);
+ }
+
+ const sanitized = sanitizeHtmlFragment(value);
+ if (!sanitized) {
+ return plainTextToHtml(value.replace(/<[^>]+>/g, ' '));
+ }
+
+ // If sanitization stripped everything useful to plain text, wrap it.
+ if (!/<\/?[a-zA-Z]/.test(sanitized)) {
+ return plainTextToHtml(sanitized);
+ }
+
+ return sanitized;
+}
diff --git a/src/products/product-ai.service.ts b/src/products/product-ai.service.ts
index 3e5e8ca..4ffa88d 100644
--- a/src/products/product-ai.service.ts
+++ b/src/products/product-ai.service.ts
@@ -14,6 +14,7 @@ import {
requestAiJsonCompletion,
resolveAiProvider,
} from '../common/ai-provider.util';
+import { normalizeAiDescriptionHtml } from '../common/ai-html.util';
type TechnicalFormField = {
key: string;
@@ -150,13 +151,15 @@ Follow the user's instructions for language and tone.
Keep the summary to 1–3 sentences. No HTML. No markdown.
Prefer a technical, informative style: what the product is and why its traits matter.
Never write sales CTAs, discounts, "buy now", or vague praise like "excellent choice" / "amazing experience".`
- : `You write e-commerce product descriptions as HTML for Iranian stores.
+ : `You write e-commerce product descriptions as HTML for an advanced rich-text editor in Iranian stores.
Return ONLY valid JSON: { "html": "..." }
+The "html" value must be a raw HTML fragment (real tags, not escaped entities, not markdown, not code fences).
Allowed tags only: p, ul, ol, li, strong, em, br.
+Do not wrap in html/body/div. Do not include scripts, styles, classes, or attributes.
Follow the user's instructions for language and tone.
Prefer a technical, informative structure: what it is, key traits/composition, typical use.
Never write sales CTAs, discounts, or vague marketing slogans.
-Aim for 2–4 short paragraphs and optionally a bullet list. No scripts, styles, or markdown.`;
+Aim for 2–4 short paragraphs and optionally a bullet list.`;
const userPrompt = JSON.stringify({
instructions: dto.prompt.trim(),
@@ -190,7 +193,7 @@ Aim for 2–4 short paragraphs and optionally a bullet list. No scripts, styles,
return { field: ProductAiFillField.summary, text };
}
- const html = String(parsed.html ?? '').trim();
+ const html = normalizeAiDescriptionHtml(String(parsed.html ?? ''));
if (!html) {
throw new BadRequestException('AI returned an empty description');
}
@@ -284,7 +287,9 @@ Rules:
: String(draft.nameFa ?? '').trim();
const summary = String(draft.summary ?? '').trim();
- const descriptionHtml = String(draft.descriptionHtml ?? '').trim();
+ const descriptionHtml = normalizeAiDescriptionHtml(
+ String(draft.descriptionHtml ?? ''),
+ );
const tags = Array.isArray(draft.tags)
? draft.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 12)
: [];