Class: AssistantMessageLinkifyHandler

Inherits:
Object
  • Object
show all
Defined in:
app/subscribers/assistant_message_linkify_handler.rb

Overview

Post-processes assistant LLM responses to ensure CRM reference numbers
(CN#, ON#, SO#, SQ#) are correctly linked.

Two responsibilities:

  1. Linkify plain-text references the LLM left unlinked
  2. Validate LLM-generated CRM reference links — if the link text is a
    known reference but the URL is wrong (e.g. /searches/...), fix the URL

Canonical URL patterns:
CN12345 → CN12345 (CN embeds customer id)
ON… → ON… via Crm::OpportunityLinkPath
SO12345 → SO12345
SQ12345 → SQ12345

Non-CRM links (blog posts, product pages, source citations) are never touched.

Constant Summary collapse

/\[([^\]]*)\]\(([^)]*)\)/
CRM_REF =

Crm ref.

/\A(CN|ON|SO|SQ)(\d+)\z/i
CORRECT_PATH_SUFFIX =

Correct path suffix.

{
  'CN' => ->(id) { "/customers/#{id}" },
  'SO' => ->(id) { "/orders/SO#{id}" },
  'SQ' => ->(id) { "/quotes/SQ#{id}" }
}.freeze

Instance Method Summary collapse

Instance Method Details

#call(event) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'app/subscribers/assistant_message_linkify_handler.rb', line 31

def call(event)
  msg = AssistantMessage.find_by(id: event.data[:assistant_message_id])
  return if msg&.content.blank?

  base = CRM_URL
  placeholders = []

  # Mask fenced code blocks and inline code so CRM refs inside them aren't rewritten.
  masked = msg.content.gsub(/(`{3,})[^`]*?\1|`[^`\n]+`/m) do |match|
    placeholders << match
    "\x00LINK#{placeholders.size - 1}\x00"
  end

  # Mask all markdown links. CRM reference links get validated; others pass through.
  masked.gsub!(MARKDOWN_LINK) do
    text = ::Regexp.last_match(1)
    url  = ::Regexp.last_match(2)

    corrected = validate_crm_link(text, url, base)
    placeholders << corrected
    "\x00LINK#{placeholders.size - 1}\x00"
  end

  # Linkify plain-text references the LLM left unlinked.
  masked.gsub!(/\bCN(\d+)\b/i) { "[CN#{::Regexp.last_match(1)}](#{base}/customers/#{::Regexp.last_match(1)})" }
  masked.gsub!(/\b(ON\d+)\b/i) do |full|
    digits = full[/ON(\d+)/i, 1]
    "[#{full}](#{base}#{Crm::OpportunityLinkPath.path_for_on_digits(digits)})"
  end
  masked.gsub!(/\b(SO\d+)\b/i) { "[#{::Regexp.last_match(1)}](#{base}/orders/#{::Regexp.last_match(1)})" }
  masked.gsub!(/\b(SQ\d+)\b/i) { "[#{::Regexp.last_match(1)}](#{base}/quotes/#{::Regexp.last_match(1)})" }

  # Restore all masked content (code blocks, validated links, untouched non-CRM
  # links). Loop until stable: a placeholder can itself contain a sentinel when
  # inline code sits inside a link's text — the code is masked first, then the
  # link placeholder embeds the code's sentinel. A single gsub pass doesn't
  # rescan its own replacement, so the inner sentinel's \x00 would survive and
  # PostgreSQL rejects null bytes on write ("string contains null byte").
  # Nesting depth is at most 2 (code-inside-link), so this converges immediately;
  # the bound is just a guard against a pathological/cyclic placeholder. (AppSignal #6079)
  linked = masked
  10.times do
    break unless linked.match?(/\x00LINK\d+\x00/)

    linked = linked.gsub(/\x00LINK(\d+)\x00/) { placeholders[::Regexp.last_match(1).to_i] }
  end
  # Belt-and-suspenders: strip any residual NUL the bounded loop couldn't resolve
  # so update_column can never raise on a null byte.
  linked = linked.delete("\x00")

  msg.update_column(:content, linked) if linked != msg.content
end