Class: Assistant::ResponseFormatter

Inherits:
Object
  • Object
show all
Includes:
ActionView::Helpers::DateHelper, ActionView::Helpers::NumberHelper, ActionView::Helpers::UrlHelper
Defined in:
app/services/assistant/response_formatter.rb

Overview

Formats AI chat responses for display

Handles:

  • Markdown to HTML conversion using Kramdown (GFM-compatible)
  • SQL code blocks in collapsible accordions
  • Table formatting with proper number/date/currency formatting
  • Entity linking (customers, employees, products, orders)
  • Copy to clipboard buttons

Usage:
formatter = Assistant::ResponseFormatter.new(content, view_context: view_context)
html = formatter.format

Constant Summary collapse

CURRENCY_COLUMNS =

Fallback heuristics for AI-generated column headers that have no manifest metadata.
These are only used when the column wasn't found in @column_metadata (i.e. the AI
aliased or computed the column, or the table isn't from a SQL tool result).
For SQL tool results, manifest-backed column_hints take precedence (see format_cell).

/revenue|amount|value|price|cost|msrp/i
DATE_COLUMNS =

Date columns.

/date|created_at|updated_at|shipped|ordered/i
PERCENTAGE_COLUMNS =

Percentage columns.

/rate|percent|pct|ratio/i
COUNT_COLUMNS =

Count columns.

/count|orders|units|quantity|calls|activities|stock|inventory|qty/i
PRODUCT_LINE_COLUMNS =

Product line columns.

/product_line|product line|productline/i
LANGUAGE_DISPLAY_NAMES =

Human-friendly names for code block language identifiers

{
  'html' => 'HTML', 'css' => 'CSS', 'scss' => 'SCSS', 'sass' => 'Sass',
  'javascript' => 'JavaScript', 'js' => 'JavaScript',
  'typescript' => 'TypeScript', 'ts' => 'TypeScript',
  'ruby' => 'Ruby', 'rb' => 'Ruby', 'erb' => 'ERB',
  'sql' => 'SQL', 'json' => 'JSON', 'yaml' => 'YAML', 'yml' => 'YAML',
  'python' => 'Python', 'py' => 'Python',
  'bash' => 'Bash', 'sh' => 'Shell', 'shell' => 'Shell', 'zsh' => 'Shell',
  'xml' => 'XML', 'svg' => 'SVG',
  'markdown' => 'Markdown', 'md' => 'Markdown',
  'text' => 'Plain Text', 'plaintext' => 'Plain Text',
  'csv' => 'CSV', 'diff' => 'Diff', 'go' => 'Go', 'rust' => 'Rust',
  'java' => 'Java', 'swift' => 'Swift', 'kotlin' => 'Kotlin',
  'php' => 'PHP', 'c' => 'C', 'cpp' => 'C++', 'csharp' => 'C#'
}.freeze
GFM_QUIRKS =

GFM parser quirks passed to Kramdown. :paragraph_end is the parser's own
default (preserved here); :no_auto_typographic disables the substitution of
-- → en-dash "–", --- → em-dash "—", and ... → ellipsis "…" in running
text. Without it, a double-dash in a query value the AI echoes as a bare URL
(e.g. utm_campaign=spring--sale) is silently rewritten and the link breaks.
Smart quotes (curly ' and ") are a separate option and stay on for prose.
See https://kramdown.gettalong.org/parser/gfm.html

%i[paragraph_end no_auto_typographic].freeze
SANITIZE_CONFIG =

Sanitize LLM HTML to strip XSS vectors while keeping safe formatting.
Runs BEFORE our post-processing (tables, SQL blocks) which adds safe data-* attributes.
Based on Sanitize::Config::RELAXED minus tags and inline style attributes.

Sanitize::Config.merge(
  Sanitize::Config::RELAXED,
  elements: Sanitize::Config::RELAXED[:elements] - ['style'],
  attributes: Sanitize::Config.merge(
    Sanitize::Config::RELAXED[:attributes],
    all: (Sanitize::Config::RELAXED[:attributes][:all] || Set.new) - Set['style']
  )
).freeze

Instance Method Summary collapse

Constructor Details

#initialize(content, view_context: nil, column_metadata: {}) ⇒ ResponseFormatter

Returns a new instance of ResponseFormatter.

Parameters:

  • content (String)

    Raw markdown content from the AI

  • view_context (ActionView::Base, nil) (defaults to: nil)

    For URL helpers (entity linking)

  • column_metadata (Hash) (defaults to: {})

    Column formatting hints from SqlBroker
    e.g. { "qty_on_hand" => "integer", "unit_cogs" => "currency", "gl_date" => "date" }
    Hint values: 'currency', 'integer', 'number', 'percentage', 'date', 'boolean', 'text'



83
84
85
86
87
88
89
# File 'app/services/assistant/response_formatter.rb', line 83

def initialize(content, view_context: nil, column_metadata: {})
  @content = content.to_s
  @view_context = view_context
  @column_metadata =  || {}
  @sql_counter = 0
  @product_line_cache = nil # Lazy-loaded lookup hash
end

Instance Method Details

#formatObject

Format the content with all transformations



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'app/services/assistant/response_formatter.rb', line 92

def format
  # Pre-load product lines to avoid N+1 queries
  preload_product_lines

  # Extract ALL fenced code blocks before markdown processing.
  # Each block records its language and raw source for copy/preview buttons.
  content_with_placeholders, code_blocks = extract_code_blocks(@content)

  # Protect supported LaTeX delimiters before Kramdown. Kramdown strips
  # \(...\) / \[...\] escapes and rewrites $$...$$, so the fragments must
  # survive Markdown as opaque placeholders. Inline code is excluded.
  latex_formatter = LatexFragmentFormatter.new(content_with_placeholders)
  content_with_placeholders = latex_formatter.extract

  # Neutralize bracket-style source citations before Kramdown processes them.
  # Kramdown interprets `- [Text]: URL` as a link reference definition and hides the line,
  # leaving empty <li> bullets. Replace `[X]` at the start of list items with `X ·`.
  content_with_placeholders = neutralize_bracket_sources(content_with_placeholders)

  # Convert markdown to HTML using Kramdown with GFM + Rouge syntax highlighting
  html = render_markdown(content_with_placeholders)

  # Sanitize HTML to prevent XSS from LLM-generated content.
  # LLMs can hallucinate <script> tags or malicious attributes.
  html = sanitize_html(html)

  # Link plain URLs in general response content before code panels are restored.
  # Code blocks are still placeholders at this stage, so URLs in code stay untouched.
  html = auto_link_urls(html)

  # Restore code blocks with rich panels (language header, copy, preview for HTML)
  html = restore_code_blocks(html, code_blocks)

  # Format tables with proper cell formatting
  html = format_tables(html)

  # Link entities to CRM pages
  html = link_entities(html)

  # Style the Sources section with a distinctive panel
  html = format_sources_section(html)

  # Restore safe, explicit hooks for the on-demand KaTeX controller only
  # after all HTML parsing/linkification is complete. The raw formula stays
  # visible as a progressive fallback until KaTeX loads.
  html = latex_formatter.restore(html)

  html.html_safe
end