Class: RailsMarkup::Annotation
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- RailsMarkup::Annotation
- 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
- .distinct_authors ⇒ Object
- .legacy_client_uuid(session_id:, legacy_client_id:) ⇒ Object
- .normalize_client_uuid(value) ⇒ Object
- .valid_client_uuid?(value) ⇒ Boolean
Instance Method Summary collapse
- #acknowledge! ⇒ Object
- #add_reply!(message:, role: "agent") ⇒ Object
- #apply_browser_state(attributes, dirty_fields:, base_revision:) ⇒ Object
- #as_api_json ⇒ Object
- #author_name ⇒ Object
- #dismiss!(reason: nil) ⇒ Object
- #resolve!(summary: nil) ⇒ Object
Class Method Details
.distinct_authors ⇒ Object
104 105 106 107 108 109 110 |
# File 'app/models/rails_markup/annotation.rb', line 104 def self. 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
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: ) self.revision += 1 save! end self end |
#apply_browser_state(attributes, dirty_fields:, base_revision:) ⇒ Object
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_json ⇒ Object
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: , 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_name ⇒ Object
112 113 114 |
# File 'app/models/rails_markup/annotation.rb', line 112 def &.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 |