Class: RailsMarkup::Annotation

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
app/models/rails_markup/annotation.rb

Defined Under Namespace

Classes: RevisionConflict

Constant Summary collapse

INTENTS =
%w[fix change question approve].freeze
SEVERITIES =
%w[suggestion important blocking].freeze
STATUSES =
%w[pending acknowledged resolved dismissed].freeze
BROWSER_ATTRIBUTES =
%w[content intent severity selected_text target page_url].freeze
BROWSER_METADATA_KEYS =
%w[tool url localId sessionId screenshot].freeze
CLIENT_UUID_PATTERN =
/\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
CLIENT_UUID_INPUT_PATTERN =
/\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
LEGACY_SESSION_PATTERN =
/\Arm-[0-9a-f]{16}\z/i
LEGACY_CLIENT_ID_LIMIT =
256
LEGACY_UUID_NAMESPACE =
"265e7cf0-8be6-5e21-8f31-a582cfde8646"
MAX_THREAD_ENTRIES =

Bound thread growth so large or many reply/summary/reason messages (incl. via the MCP tools) can't grow a row without limit.

500
MAX_THREAD_MESSAGE =
5000

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.distinct_authorsObject



104
105
106
107
108
109
110
# File 'app/models/rails_markup/annotation.rb', line 104

def self.distinct_authors
  if connection.adapter_name.downcase.include?("postgres")
    where("metadata->>'author' IS NOT NULL").distinct.pluck(Arel.sql("metadata->>'author'")).compact.sort
  else
    all.filter_map(&:author_name).uniq.sort
  end
end

.legacy_client_uuid(session_id:, legacy_client_id:) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'app/models/rails_markup/annotation.rb', line 62

def self.legacy_client_uuid(session_id:, legacy_client_id:)
  session_id = session_id.to_s
  legacy_client_id = legacy_client_id.to_s
  unless LEGACY_SESSION_PATTERN.match?(session_id) && legacy_client_id.present? && legacy_client_id.bytesize <= LEGACY_CLIENT_ID_LIMIT
    raise ArgumentError, "legacy client identity requires a valid session and client id"
  end

  namespace = [LEGACY_UUID_NAMESPACE.delete("-")].pack("H*")
  bytes = Digest::SHA1.digest(namespace + "#{session_id}\0#{legacy_client_id}").bytes.first(16)
  bytes[6] = (bytes[6] & 0x0f) | 0x50
  bytes[8] = (bytes[8] & 0x3f) | 0x80
  hex = bytes.pack("C*").unpack1("H*")
  "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
end

.normalize_client_uuid(value) ⇒ Object



58
59
60
# File 'app/models/rails_markup/annotation.rb', line 58

def self.normalize_client_uuid(value)
  value.downcase if value.is_a?(String) && CLIENT_UUID_INPUT_PATTERN.match?(value)
end

.valid_client_uuid?(value) ⇒ Boolean

Returns:

  • (Boolean)


54
55
56
# File 'app/models/rails_markup/annotation.rb', line 54

def self.valid_client_uuid?(value)
  value.is_a?(String) && CLIENT_UUID_PATTERN.match?(value)
end

Instance Method Details

#acknowledge!Object



132
133
134
135
136
137
138
139
140
141
142
143
# File 'app/models/rails_markup/annotation.rb', line 132

def acknowledge!
  # Lock + reload so a concurrent resolve!/dismiss! can't be clobbered:
  # without it, an acknowledge validated against a stale "pending" could
  # write "acknowledged" over an already-resolved record.
  with_lock do
    return self if status == "acknowledged" # idempotent — re-acknowledging is a no-op
    raise "Cannot acknowledge a #{status} annotation" unless status == "pending"

    update!(status: "acknowledged", revision: revision + 1)
  end
  self
end

#add_reply!(message:, role: "agent") ⇒ Object



169
170
171
172
173
174
175
176
# File 'app/models/rails_markup/annotation.rb', line 169

def add_reply!(message:, role: "agent")
  with_lock do
    add_thread_entry(role: role, message: message)
    self.revision += 1
    save!
  end
  self
end

#apply_browser_state(attributes, dirty_fields:, base_revision:) ⇒ Object

Raises:



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'app/models/rails_markup/annotation.rb', line 116

def apply_browser_state(attributes, dirty_fields:, base_revision:)
  raise RevisionConflict, self unless base_revision == revision

  dirty_fields.each do |field|
    if BROWSER_ATTRIBUTES.include?(field)
      public_send("#{field}=", attributes[field]) if attributes.key?(field)
    elsif field == "metadata" && attributes.key?("metadata")
      self. = ( || {}).merge(attributes["metadata"].slice(*BROWSER_METADATA_KEYS))
    elsif field == "status" && attributes.key?("status")
      self.status = attributes["status"]
    end
  end
  self.revision += 1 if changed?
  self
end

#as_api_jsonObject



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'app/models/rails_markup/annotation.rb', line 178

def as_api_json
  {
    id: id.to_s,
    clientId: client_uuid,
    userId: user_id,
    authorName: author_name,
    content: content,
    intent: intent,
    severity: severity,
    status: status,
    selectedText: selected_text,
    pageUrl: page_url,
    target: target,
    metadata: ,
    thread: thread,
    createdAt: created_at&.iso8601,
    updatedAt: updated_at&.iso8601,
    revision: revision
  }
end

#author_nameObject



112
113
114
# File 'app/models/rails_markup/annotation.rb', line 112

def author_name
  &.dig("author")
end

#dismiss!(reason: nil) ⇒ Object



158
159
160
161
162
163
164
165
166
167
# File 'app/models/rails_markup/annotation.rb', line 158

def dismiss!(reason: nil)
  with_lock do
    return self if status == "dismissed" # idempotent — re-dismissing is a no-op
    raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])

    add_thread_entry(role: "agent", message: reason) if reason.present?
    update!(status: "dismissed", revision: revision + 1)
  end
  self
end

#resolve!(summary: nil) ⇒ Object



145
146
147
148
149
150
151
152
153
154
155
156
# File 'app/models/rails_markup/annotation.rb', line 145

def resolve!(summary: nil)
  # with_lock reloads under a row lock so a concurrent reply/resolve can't
  # read a stale thread and silently drop the other write on save.
  with_lock do
    return self if status == "resolved" # idempotent — re-resolving is a no-op
    raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])

    add_thread_entry(role: "agent", message: summary) if summary.present?
    update!(status: "resolved", revision: revision + 1)
  end
  self
end