ADR-040: K_Entry Content Format Normalization for RAG Ingestion

<!-- MADR 3.0 Template - Markdown Any Decision Records --> <!-- Reference: https://adr.github.io/madr/ -->

Status

Proposed

Date

2025-12-11

Deciders

Context and Problem Statement

The K_Entry table stores knowledge base articles in three different formats (K_EntryEditMode):

Currently, the KEntryIngestor extracts the TextMsg field as-is without format conversion. This creates problems for RAG (Retrieval-Augmented Generation) because:

  1. Editor.js JSON is not human-readable - AI embeddings work best with natural language text
  2. HTML contains markup noise - Tags and attributes reduce embedding quality
  3. Inconsistent format - Same semantic content has different representations
  4. Poor search results - JSON structure words (e.g., "blocks", "data", "type") pollute the search space

We need to normalize all content formats to a single, AI-friendly representation before embedding.

Decision Drivers

Considered Options

  1. Convert all formats to Markdown using LangChain4j DocumentParser + custom Editor.js converter
  2. Convert all formats to plain text (strip all formatting)
  3. Keep formats as-is, add format-specific metadata for filtering
  4. Use AI to convert formats on-the-fly during ingestion

Decision Outcome

Chosen option: "Convert all formats to Markdown using LangChain4j DocumentParser + custom Editor.js converter", because it:

Confirmation

The decision is confirmed when:

Pros and Cons of the Options

Option 1: Convert to Markdown (chosen)

Use LangChain4j DocumentParser for HTML, custom converter for Editor.js, pass-through for GFM.

Option 2: Convert to Plain Text

Strip all formatting and extract only text content.

Option 3: Keep Formats As-Is

Store content in original format, add metadata for filtering.

Option 4: AI-Powered Conversion

Use LLM to convert formats during ingestion.

Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│ K_ENTRY CONTENT NORMALIZATION PIPELINE                                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 1. QUERY K_Entry with K_EntryEditMode                                   │ │
│ │                                                                          │ │
│ │ SELECT k_entry_id, name, keywords, textmsg,                             │ │
│ │        k_entryeditmode,  -- BLK | GFM | HTM                             │ │
│ │        descriptionurl                                                    │ │
│ │ FROM k_entry WHERE ...                                                   │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│                                  │                                          │
│                                  ▼                                          │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 2. CONTENT FORMAT ROUTER                                                │ │
│ │                                                                          │ │
│ │    ┌──────────┐    ┌──────────┐    ┌──────────┐                         │ │
│ │    │   BLK    │    │   GFM    │    │   HTM    │                         │ │
│ │    │Editor.js │    │Markdown  │    │  HTML    │                         │ │
│ │    └────┬─────┘    └────┬─────┘    └────┬─────┘                         │ │
│ │         │               │               │                                │ │
│ │         ▼               ▼               ▼                                │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│           │               │               │                                  │
│           │               │               │                                  │
│           ▼               │               ▼                                  │
│ ┌──────────────────────┐  │    ┌──────────────────────┐                     │
│ │ EditorJsConverter    │  │    │ HtmlToMarkdown       │                     │
│ │                      │  │    │ (Apache Tika)        │                     │
│ │ Parse JSON:          │  │    │                      │                     │
│ │ {                    │  │    │ &lt;h2&gt;Title&lt;/h2&gt;       │                     │
│ │   &quot;blocks&quot;: [...]    │  │    │ &lt;p&gt;Text&lt;/p&gt;          │                     │
│ │ }                    │  │    │ &lt;ul&gt;&lt;li&gt;Item&lt;/li&gt;    │                     │
│ │                      │  │    │                      │                     │
│ │ Output Markdown:     │  │    │ Output Markdown:     │                     │
│ │ ## Header            │  │    │ ## Title             │                     │
│ │ Paragraph text       │  │    │ Text                 │                     │
│ │ - List item          │  │    │ - Item               │                     │
│ └────────┬─────────────┘  │    └────────┬─────────────┘                     │
│          │                │             │                                    │
│          │                │ (pass-through)                                   │
│          │                │             │                                    │
│          └────────────────┼─────────────┘                                    │
│                           ▼                                                  │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 3. UNIFIED MARKDOWN CONTENT                                             │ │
│ │                                                                          │ │
│ │ Path: Knowledge Base &gt; Topic &gt; Entry                                    │ │
│ │                                                                          │ │
│ │ # Entry Title                                                            │ │
│ │                                                                          │ │
│ │ Keywords: order, process, sales                                         │ │
│ │                                                                          │ │
│ │ ## Section Heading                                                       │ │
│ │                                                                          │ │
│ │ Paragraph text with **bold** and *italic* formatting.                   │ │
│ │                                                                          │ │
│ │ - List item 1                                                            │ │
│ │ - List item 2                                                            │ │
│ │                                                                          │ │
│ │ Reference: https://example.com/doc                                       │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│                           │                                                  │
│                           ▼                                                  │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 4. DOCUMENT SPLITTING                                                   │ │
│ │    (LangChain4j DocumentSplitters.recursive)                            │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│                           │                                                  │
│                           ▼                                                  │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ 5. EMBEDDING + STORAGE                                                  │ │
│ │    (BGE-small-en-q + PGVector)                                          │ │
│ │                                                                          │ │
│ │    Metadata:                                                             │ │
│ │    - source_type: &quot;k_entry&quot;                                             │ │
│ │    - source_id: &quot;12345_en_US&quot;                                           │ │
│ │    - original_format: &quot;BLK&quot; | &quot;GFM&quot; | &quot;HTM&quot;  &lt;-- New!                  │ │
│ │    - name: &quot;Entry Title&quot;                                                │ │
│ │    - knowledge_base: &quot;CloudEmpiere ERP&quot;                                 │ │
│ │    - breadcrumb: &quot;KB &gt; Topic &gt; Entry&quot;                                   │ │
│ │    - ad_language: &quot;en_US&quot;                                               │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Implementation Plan

Phase 1: Editor.js Converter (Core)

File: src/main/java/org/idempiere/cli/rag/converter/EditorJsConverter.java

@ApplicationScoped
public class EditorJsConverter {

    /**
     * Convert Editor.js JSON to Markdown.
     *
     * @param editorJsJson JSON string in Editor.js format
     * @return Markdown text
     */
    public String toMarkdown(String editorJsJson) {
        // Parse JSON
        JsonObject root = Json.createReader(new StringReader(editorJsJson)).readObject();
        JsonArray blocks = root.getJsonArray(&quot;blocks&quot;);

        StringBuilder markdown = new StringBuilder();

        for (JsonObject block : blocks.getValuesAs(JsonObject.class)) {
            String type = block.getString(&quot;type&quot;);
            JsonObject data = block.getJsonObject(&quot;data&quot;);

            switch (type) {
                case &quot;header&quot; -&gt; {
                    int level = data.getInt(&quot;level&quot;, 1);
                    String text = data.getString(&quot;text&quot;);
                    markdown.append(&quot;#&quot;.repeat(level)).append(&quot; &quot;)
                            .append(stripHtml(text)).append(&quot;\n\n&quot;);
                }
                case &quot;paragraph&quot; -&gt; {
                    String text = data.getString(&quot;text&quot;);
                    markdown.append(convertHtmlToMarkdown(text)).append(&quot;\n\n&quot;);
                }
                case &quot;list&quot; -&gt; {
                    JsonArray items = data.getJsonArray(&quot;items&quot;);
                    String style = data.getString(&quot;style&quot;, &quot;unordered&quot;);
                    for (int i = 0; i &lt; items.size(); i++) {
                        if (&quot;ordered&quot;.equals(style)) {
                            markdown.append(i + 1).append(&quot;. &quot;);
                        } else {
                            markdown.append(&quot;- &quot;);
                        }
                        markdown.append(stripHtml(items.getString(i))).append(&quot;\n&quot;);
                    }
                    markdown.append(&quot;\n&quot;);
                }
                case &quot;quote&quot; -&gt; {
                    String text = data.getString(&quot;text&quot;);
                    markdown.append(&quot;&gt; &quot;).append(stripHtml(text)).append(&quot;\n\n&quot;);
                }
                case &quot;code&quot; -&gt; {
                    String code = data.getString(&quot;code&quot;);
                    markdown.append(&quot;```\n&quot;).append(code).append(&quot;\n```\n\n&quot;);
                }
                case &quot;delimiter&quot; -&gt; {
                    markdown.append(&quot;---\n\n&quot;);
                }
                case &quot;warning&quot; -&gt; {
                    String title = data.getString(&quot;title&quot;, &quot;&quot;);
                    String message = data.getString(&quot;message&quot;, &quot;&quot;);
                    markdown.append(&quot;&gt; **⚠️ &quot;).append(title).append(&quot;**\n&gt; &quot;)
                            .append(message).append(&quot;\n\n&quot;);
                }
                case &quot;table&quot; -&gt; {
                    // Simple table conversion (Editor.js tables are complex)
                    markdown.append(convertTableToMarkdown(data)).append(&quot;\n\n&quot;);
                }
                default -&gt; {
                    // Unknown block type - extract text if available
                    if (data.containsKey(&quot;text&quot;)) {
                        markdown.append(stripHtml(data.getString(&quot;text&quot;))).append(&quot;\n\n&quot;);
                    }
                }
            }
        }

        return markdown.toString().trim();
    }

    /**
     * Convert inline HTML to Markdown (bold, italic, links).
     */
    private String convertHtmlToMarkdown(String html) {
        return html
            .replaceAll(&quot;&lt;b&gt;(.*?)&lt;/b&gt;&quot;, &quot;**$1**&quot;)
            .replaceAll(&quot;&lt;strong&gt;(.*?)&lt;/strong&gt;&quot;, &quot;**$1**&quot;)
            .replaceAll(&quot;&lt;i&gt;(.*?)&lt;/i&gt;&quot;, &quot;*$1*&quot;)
            .replaceAll(&quot;&lt;em&gt;(.*?)&lt;/em&gt;&quot;, &quot;*$1*&quot;)
            .replaceAll(&quot;&lt;a href=\&quot;(.*?)\&quot;&gt;(.*?)&lt;/a&gt;&quot;, &quot;[$2]($1)&quot;)
            .replaceAll(&quot;&lt;code&gt;(.*?)&lt;/code&gt;&quot;, &quot;`$1`&quot;)
            .replaceAll(&quot;&lt;[^&gt;]+&gt;&quot;, &quot;&quot;); // Strip remaining tags
    }

    private String stripHtml(String html) {
        return convertHtmlToMarkdown(html);
    }

    private String convertTableToMarkdown(JsonObject data) {
        // Simplified table conversion
        // Editor.js table format: { &quot;content&quot;: [[cell, cell], [cell, cell]] }
        JsonArray content = data.getJsonArray(&quot;content&quot;);
        StringBuilder table = new StringBuilder();

        // Header row
        JsonArray headerRow = content.getJsonArray(0);
        table.append(&quot;| &quot;);
        for (int i = 0; i &lt; headerRow.size(); i++) {
            table.append(stripHtml(headerRow.getString(i))).append(&quot; | &quot;);
        }
        table.append(&quot;\n&quot;);

        // Separator
        table.append(&quot;| &quot;);
        for (int i = 0; i &lt; headerRow.size(); i++) {
            table.append(&quot;--- | &quot;);
        }
        table.append(&quot;\n&quot;);

        // Data rows
        for (int r = 1; r &lt; content.size(); r++) {
            JsonArray row = content.getJsonArray(r);
            table.append(&quot;| &quot;);
            for (int c = 0; c &lt; row.size(); c++) {
                table.append(stripHtml(row.getString(c))).append(&quot; | &quot;);
            }
            table.append(&quot;\n&quot;);
        }

        return table.toString();
    }
}

Phase 2: HTML Converter (LangChain4j)

File: src/main/java/org/idempiere/cli/rag/converter/HtmlToMarkdownConverter.java

@ApplicationScoped
public class HtmlToMarkdownConverter {

    /**
     * Convert HTML to Markdown using Apache Tika.
     * LangChain4j provides Tika integration via DocumentParser.
     */
    public String toMarkdown(String html) {
        // Option 1: Use Tika directly
        try {
            InputStream stream = new ByteArrayInputStream(html.getBytes(StandardCharsets.UTF_8));
            BodyContentHandler handler = new BodyContentHandler();
            Metadata metadata = new Metadata();
            metadata.set(Metadata.CONTENT_TYPE, &quot;text/html&quot;);

            HtmlParser parser = new HtmlParser();
            parser.parse(stream, handler, metadata, new ParseContext());

            // Tika outputs plain text, need Markdown conversion
            // Use external library like flexmark or custom converter
            return convertPlainTextToMarkdown(handler.toString(), html);

        } catch (Exception e) {
            LOG.warn(&quot;Failed to parse HTML, falling back to plain text&quot;, e);
            return html.replaceAll(&quot;&lt;[^&gt;]+&gt;&quot;, &quot;&quot;); // Strip tags
        }
    }

    /**
     * Convert Tika plain text output to Markdown by analyzing original HTML.
     */
    private String convertPlainTextToMarkdown(String plainText, String originalHtml) {
        // Use jsoup for HTML parsing
        org.jsoup.nodes.Document doc = Jsoup.parse(originalHtml);
        StringBuilder markdown = new StringBuilder();

        // Convert heading tags
        for (org.jsoup.nodes.Element h : doc.select(&quot;h1, h2, h3, h4, h5, h6&quot;)) {
            int level = Integer.parseInt(h.tagName().substring(1));
            markdown.append(&quot;#&quot;.repeat(level)).append(&quot; &quot;).append(h.text()).append(&quot;\n\n&quot;);
        }

        // Convert paragraphs
        for (org.jsoup.nodes.Element p : doc.select(&quot;p&quot;)) {
            markdown.append(convertInlineElements(p)).append(&quot;\n\n&quot;);
        }

        // Convert lists
        for (org.jsoup.nodes.Element ul : doc.select(&quot;ul&quot;)) {
            for (org.jsoup.nodes.Element li : ul.select(&quot;li&quot;)) {
                markdown.append(&quot;- &quot;).append(li.text()).append(&quot;\n&quot;);
            }
            markdown.append(&quot;\n&quot;);
        }

        for (org.jsoup.nodes.Element ol : doc.select(&quot;ol&quot;)) {
            int index = 1;
            for (org.jsoup.nodes.Element li : ol.select(&quot;li&quot;)) {
                markdown.append(index++).append(&quot;. &quot;).append(li.text()).append(&quot;\n&quot;);
            }
            markdown.append(&quot;\n&quot;);
        }

        return markdown.toString();
    }

    private String convertInlineElements(org.jsoup.nodes.Element element) {
        String html = element.html();
        return html
            .replaceAll(&quot;&lt;strong&gt;(.*?)&lt;/strong&gt;&quot;, &quot;**$1**&quot;)
            .replaceAll(&quot;&lt;b&gt;(.*?)&lt;/b&gt;&quot;, &quot;**$1**&quot;)
            .replaceAll(&quot;&lt;em&gt;(.*?)&lt;/em&gt;&quot;, &quot;*$1*&quot;)
            .replaceAll(&quot;&lt;i&gt;(.*?)&lt;/i&gt;&quot;, &quot;*$1*&quot;)
            .replaceAll(&quot;&lt;a href=\&quot;(.*?)\&quot;&gt;(.*?)&lt;/a&gt;&quot;, &quot;[$2]($1)&quot;)
            .replaceAll(&quot;&lt;code&gt;(.*?)&lt;/code&gt;&quot;, &quot;`$1`&quot;)
            .replaceAll(&quot;&lt;[^&gt;]+&gt;&quot;, &quot;&quot;);
    }
}

Phase 3: Update KEntryIngestor

File: src/main/java/org/idempiere/cli/rag/ingest/KEntryIngestor.java

Update SQL query to include K_EntryEditMode:

private static final String K_ENTRY_SQL = &quot;&quot;&quot;
        WITH RECURSIVE entry_tree AS (
            -- ... existing CTE ...
        )
        SELECT
            et.k_entry_id,
            COALESCE(NULLIF(trl.name, &#39;&#39;), et.base_name) as name,
            et.issummary,
            COALESCE(NULLIF(trl.keywords, &#39;&#39;), et.base_keywords) as keywords,
            COALESCE(NULLIF(trl.textmsg, &#39;&#39;), et.base_textmsg) as textmsg,
            et.k_entryeditmode,  -- NEW: Get format type
            et.descriptionurl,
            et.created,
            et.updated,
            et.k_type_id,
            et.type_name,
            et.tree_level,
            et.breadcrumb
        FROM entry_tree et
        LEFT JOIN k_entry_trl trl ON et.k_entry_id = trl.k_entry_id
            AND trl.ad_language = ?
            AND trl.isactive = &#39;Y&#39;
        ORDER BY et.breadcrumb
        &quot;&quot;&quot;;

Update ingestion logic:

@Inject
EditorJsConverter editorJsConverter;

@Inject
HtmlToMarkdownConverter htmlConverter;

// Inside ResultSet loop:
while (rs.next()) {
    // ... existing field extraction ...
    String textMsg = rs.getString(&quot;textmsg&quot;);
    String editMode = rs.getString(&quot;k_entryeditmode&quot;); // BLK | GFM | HTM

    // Convert to Markdown based on format
    String markdownContent = switch (editMode) {
        case &quot;BLK&quot; -&gt; {
            // Editor.js JSON to Markdown
            try {
                yield editorJsConverter.toMarkdown(textMsg);
            } catch (Exception e) {
                LOG.warnf(&quot;Failed to convert Editor.js for K_Entry %d: %s&quot;,
                         kEntryId, e.getMessage());
                yield textMsg; // Fallback to raw content
            }
        }
        case &quot;HTM&quot; -&gt; {
            // HTML to Markdown
            try {
                yield htmlConverter.toMarkdown(textMsg);
            } catch (Exception e) {
                LOG.warnf(&quot;Failed to convert HTML for K_Entry %d: %s&quot;,
                         kEntryId, e.getMessage());
                yield textMsg; // Fallback to raw content
            }
        }
        case &quot;GFM&quot; -&gt; textMsg; // Already Markdown, pass-through
        default -&gt; {
            LOG.warnf(&quot;Unknown K_EntryEditMode &#39;%s&#39; for K_Entry %d, treating as plain text&quot;,
                     editMode, kEntryId);
            yield textMsg;
        }
    };

    // Build document content with hierarchical context
    StringBuilder content = new StringBuilder();

    // Add breadcrumb as context header
    if (breadcrumb != null &amp;&amp; !breadcrumb.isBlank()) {
        content.append(&quot;Path: &quot;).append(breadcrumb).append(&quot;\n\n&quot;);
    }

    if (name != null &amp;&amp; !name.isBlank()) {
        content.append(&quot;# &quot;).append(name).append(&quot;\n\n&quot;);
    }
    if (keywords != null &amp;&amp; !keywords.isBlank()) {
        content.append(&quot;Keywords: &quot;).append(keywords).append(&quot;\n\n&quot;);
    }
    if (markdownContent != null &amp;&amp; !markdownContent.isBlank()) {
        content.append(markdownContent);  // Use converted Markdown
    }
    if (descriptionUrl != null &amp;&amp; !descriptionUrl.isBlank()) {
        content.append(&quot;\n\nReference: &quot;).append(descriptionUrl);
    }

    // ... existing document creation and embedding ...

    // Add original_format to metadata
    doc.metadata().put(&quot;original_format&quot;, editMode);
}

Phase 4: Dependencies

Add to pom.xml:

&lt;!-- JSON Processing (Jakarta EE) --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;jakarta.json&lt;/groupId&gt;
    &lt;artifactId&gt;jakarta.json-api&lt;/artifactId&gt;
    &lt;version&gt;2.1.3&lt;/version&gt;
&lt;/dependency&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.glassfish&lt;/groupId&gt;
    &lt;artifactId&gt;jakarta.json&lt;/artifactId&gt;
    &lt;version&gt;2.0.1&lt;/version&gt;
&lt;/dependency&gt;

&lt;!-- HTML Parsing --&gt;
&lt;dependency&gt;
    &lt;groupId&gt;org.jsoup&lt;/groupId&gt;
    &lt;artifactId&gt;jsoup&lt;/artifactId&gt;
    &lt;version&gt;1.17.2&lt;/version&gt;
&lt;/dependency&gt;

&lt;!-- Apache Tika (already included via langchain4j-document-parser-apache-tika) --&gt;

Phase 5: Testing

File: src/test/java/org/idempiere/cli/rag/converter/EditorJsConverterTest.java

@QuarkusTest
class EditorJsConverterTest {

    @Inject
    EditorJsConverter converter;

    @Test
    void testHeaderConversion() {
        String json = &quot;&quot;&quot;
            {
              &quot;time&quot;: 1550476186479,
              &quot;blocks&quot;: [
                {
                  &quot;type&quot;: &quot;header&quot;,
                  &quot;data&quot;: {
                    &quot;text&quot;: &quot;Sample Heading&quot;,
                    &quot;level&quot;: 2
                  }
                }
              ],
              &quot;version&quot;: &quot;2.8.1&quot;
            }
            &quot;&quot;&quot;;

        String result = converter.toMarkdown(json);
        assertTrue(result.contains(&quot;## Sample Heading&quot;));
    }

    @Test
    void testParagraphWithHtml() {
        String json = &quot;&quot;&quot;
            {
              &quot;blocks&quot;: [
                {
                  &quot;type&quot;: &quot;paragraph&quot;,
                  &quot;data&quot;: {
                    &quot;text&quot;: &quot;Text with &lt;b&gt;bold&lt;/b&gt; and &lt;i&gt;italic&lt;/i&gt;&quot;
                  }
                }
              ]
            }
            &quot;&quot;&quot;;

        String result = converter.toMarkdown(json);
        assertTrue(result.contains(&quot;**bold**&quot;));
        assertTrue(result.contains(&quot;*italic*&quot;));
    }

    @Test
    void testListConversion() {
        String json = &quot;&quot;&quot;
            {
              &quot;blocks&quot;: [
                {
                  &quot;type&quot;: &quot;list&quot;,
                  &quot;data&quot;: {
                    &quot;style&quot;: &quot;unordered&quot;,
                    &quot;items&quot;: [&quot;Item 1&quot;, &quot;Item 2&quot;, &quot;Item 3&quot;]
                  }
                }
              ]
            }
            &quot;&quot;&quot;;

        String result = converter.toMarkdown(json);
        assertTrue(result.contains(&quot;- Item 1&quot;));
        assertTrue(result.contains(&quot;- Item 2&quot;));
        assertTrue(result.contains(&quot;- Item 3&quot;));
    }
}

Configuration

Add to application.properties:

# K_Entry Content Format Conversion
idempiere.cli.rag.k-entry.convert-to-markdown=true
idempiere.cli.rag.k-entry.fallback-on-error=true  # Use raw content if conversion fails

Migration Strategy

Backward Compatibility

Migration Steps

  1. Deploy new version with converters
  2. Run knowledge clear --source k_entry -y
  3. Run knowledge ingest --source k_entry
  4. Verify search quality improved

Rollback Plan

If conversion causes issues:

  1. Set idempiere.cli.rag.k-entry.convert-to-markdown=false
  2. Re-ingest using raw content

Performance Considerations

Expected Impact

Format Conversion Time Impact
GFM (Markdown) 0ms (pass-through) None
HTM (HTML) ~5ms per entry Low
BLK (Editor.js) ~2ms per entry Low

Total ingestion slowdown: ~5-10% (acceptable for batch operation)

Optimization Opportunities

  1. Parallel conversion - Convert multiple entries concurrently
  2. Caching - Cache converted content (keyed by source_id + hash)
  3. Lazy conversion - Convert on-demand instead of during ingestion

More Information

Editor.js Block Types

Common block types in CloudEmpiere:

Type Markdown Equivalent Notes
header ## Heading Level 1-6
paragraph Plain text May contain HTML
list - Item or 1. Item Ordered/unordered
quote &gt; Quote Blockquote
code ```code``` Code block
delimiter --- Horizontal rule
warning &gt; ⚠️ Warning Custom block
table Markdown table Complex format

References

Path: /docs/developers/architecture/idempiere-hub/040-k-entry-content-format-normalization