Class: Protege::Message
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Protege::Message
- Includes:
- BroadcastableMessage
- Defined in:
- app/models/protege/message.rb
Overview
Persisted record of a single email — inbound or outbound — in the Protege pipeline.
Message is the application's source of truth for everything the engine reasons about: the web
UI, the inbox, and the ThreadHistoryResolver resolver all read from this model. The underlying
ActionMailbox::InboundEmail is retained only as a raw delivery-pipeline artifact; application
logic never queries it directly. Every message belongs to exactly one EmailThread and one
Persona, and inbound messages link back to the InboundEmail they were ingested from.
Lifecycle (inbound)
pending → received, InferenceJob not yet queued
processing → InferenceJob is running
processed → InferenceJob completed successfully
failed → InferenceJob raised; error captured in +last_processing_error+
bounced → no matching persona; handled in AgentMailbox before a job is queued
Lifecycle (outbound)
Outbound messages are persisted by MessageObserver once delivery succeeds and carry no
processing_status — they are terminal records of what the agent sent.
Class Method Summary collapse
-
.build_from_mail(mail:, direction:, thread:, persona:, inbound_email: nil, attach_from_mail: true) ⇒ Protege::Message
Build (but do not save) a
Messagefrom aMail::Messageobject. -
.ingest!(inbound_email:, persona:) ⇒ Protege::Message
Ingest an
ActionMailbox::InboundEmailinto a persisted, processingMessage.
Instance Method Summary collapse
-
#assign_from_mail(mail:, thread:, persona:, inbound_email: nil, attach_from_mail: true) ⇒ void
Copy every RFC 2822-derived attribute from a
Mail::Messageonto this record. -
#attachment_parts_with_note ⇒ Array(Array<Object>, Protege::TextPart)
Walk this message's attachments once, returning
[[vision_parts], note_part]— each viewable file rendered as its vision part (via StoredFile#to_part) and a single trailing TextPart note naming every attachment (unviewable ones included) with its blob id. -
#content_header ⇒ String
The standard email-style header block for this message: From / To / Cc / Date / Subject, one line each, in that fixed order.
-
#content_parts ⇒ String, Array<Object>
This message expressed as provider content — the reply path's opening user turn.
-
#mark_failed!(error) ⇒ void
Transition the message to
failedand capture the error for later inspection. -
#mark_processed! ⇒ void
Transition the message to
processedand record the completion time. -
#readable_body ⇒ String?
The message's best plain-text content for replay or display.
-
#start_processing! ⇒ void
Mark this message as in-progress and persist it.
-
#to_llm_text ⇒ String
The canonical plain-text serialization of this message for the model: the standard #content_header block, the readable body, and — when the message has attachments — the note the app already builds for them (see #attachment_parts_with_note).
Class Method Details
.build_from_mail(mail:, direction:, thread:, persona:, inbound_email: nil, attach_from_mail: true) ⇒ Protege::Message
Build (but do not save) a Message from a Mail::Message object.
Used for outbound messages constructed outside the mailbox ingress path. The caller owns
any further field assignment and the eventual save!.
119 120 121 122 123 124 |
# File 'app/models/protege/message.rb', line 119 def build_from_mail(mail:, direction:, thread:, persona:, inbound_email: nil, attach_from_mail: true) new.tap do |record| record.assign_from_mail(mail:, thread:, persona:, inbound_email:, attach_from_mail:) record.direction = direction end end |
.ingest!(inbound_email:, persona:) ⇒ Protege::Message
Ingest an ActionMailbox::InboundEmail into a persisted, processing Message.
Finds or creates the owning EmailThread, copies all mail-derived attributes onto the
record, and transitions it to processing. Keyed on inbound_email_id, so calling it
again for the same inbound email updates the existing record rather than duplicating it —
making it safe to invoke on job retry.
96 97 98 99 100 101 102 103 104 |
# File 'app/models/protege/message.rb', line 96 def ingest!(inbound_email:, persona:) mail = inbound_email.mail thread = EmailThread.find_or_create_for(mail:, persona:) find_or_initialize_by(inbound_email_id: inbound_email.id).tap do |record| record.assign_from_mail(mail:, thread:, persona:, inbound_email:) record.start_processing! end end |
Instance Method Details
#assign_from_mail(mail:, thread:, persona:, inbound_email: nil, attach_from_mail: true) ⇒ void
This method returns an undefined value.
Copy every RFC 2822-derived attribute from a Mail::Message onto this record.
Shared by both ingest! and build_from_mail. Sets the thread/persona associations and then delegates the header, body, and threading field groups to private helpers so the work stays within Active Record's metrics budget. Does not save.
251 252 253 254 255 256 257 258 259 260 |
# File 'app/models/protege/message.rb', line 251 def assign_from_mail(mail:, thread:, persona:, inbound_email: nil, attach_from_mail: true) self.email_thread = thread self.persona = persona self.inbound_email = inbound_email assign_envelope_from(mail) assign_threading_from(mail) assign_bodies_from(mail) (mail) if attach_from_mail end |
#attachment_parts_with_note ⇒ Array(Array<Object>, Protege::TextPart)
Walk this message's attachments once, returning [[vision_parts], note_part] — each viewable file
rendered as its vision part (via StoredFile#to_part) and a single trailing
TextPart note naming every attachment (unviewable ones included) with its blob id.
Callers flatten both into content, or take just the note part (e.g. thread-history replay).
225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'app/models/protege/message.rb', line 225 def vision = [] entries = [] .each do || file = Protege::StoredFile.new(.blob) vision << file.to_part if file.viewable? entries << file.summary end [vision, Protege::TextPart.new(text: (entries))] end |
#content_header ⇒ String
The standard email-style header block for this message: From / To / Cc / Date / Subject, one line
each, in that fixed order. Every line is always rendered — a field with no value is shown blank
(e.g. Cc:), never omitted — so every turn has the same predictable shape for the model. Folded
into #to_llm_text as plain text, deliberately kept in the content string rather than added as
new message metadata.
183 184 185 186 187 188 189 190 191 |
# File 'app/models/protege/message.rb', line 183 def content_header [ "From: #{from_address}", "To: #{Array(to_addresses).join(', ')}", "Cc: #{Array(cc_addresses).join(', ')}", "Date: #{sent_at&.utc&.strftime('%Y-%m-%d %H:%M UTC')}", "Subject: #{subject}" ].join("\n") end |
#content_parts ⇒ String, Array<Object>
This message expressed as provider content — the reply path's opening user turn. The text is #to_llm_text (header + body + attachment note); when the message carries viewable attachments (images/PDFs) their vision parts are appended so a multimodal model sees the files themselves. Returns a plain string when there are no attachments.
212 213 214 215 216 217 |
# File 'app/models/protege/message.rb', line 212 def content_parts return to_llm_text unless .attached? vision, note = [Protege::TextPart.new(text: compose_llm_text(note: note.text)), *vision] end |
#mark_failed!(error) ⇒ void
This method returns an undefined value.
Transition the message to failed and capture the error for later inspection.
Stores the error class, message, and the first five backtrace lines in
last_processing_error so a failed job can be triaged without re-running it.
295 296 297 298 299 300 |
# File 'app/models/protege/message.rb', line 295 def mark_failed!(error) update!( processing_status: :failed, last_processing_error: "#{error.class}: #{error.}\n#{error.backtrace.first(5).join("\n")}" ) end |
#mark_processed! ⇒ void
This method returns an undefined value.
Transition the message to processed and record the completion time.
283 284 285 |
# File 'app/models/protege/message.rb', line 283 def mark_processed! update!(processing_status: :processed, processing_completed_at: Time.current) end |
#readable_body ⇒ String?
The message's best plain-text content for replay or display.
Prefers the plain-text body; when that is blank, falls back to the HTML body with its tags
stripped, so an HTML-only email still yields readable text. Returns nil when the message
carries neither — letting callers (e.g. the ThreadHistoryResolver resolver) skip empty turns.
168 169 170 171 172 173 174 |
# File 'app/models/protege/message.rb', line 168 def readable_body text = text_body.to_s.strip return text unless text.empty? html = ActionController::Base.helpers.(html_body.to_s).strip html unless html.empty? end |
#start_processing! ⇒ void
This method returns an undefined value.
Mark this message as in-progress and persist it.
Called at the start of inference (by InferenceJob via ingest!, and by
ConsoleInferenceJob directly). Defaults the direction to :inbound, stamps the start time,
increments the attempt counter, and captures the current correlation ID for tracing.
270 271 272 273 274 275 276 277 |
# File 'app/models/protege/message.rb', line 270 def start_processing! self.direction = direction || :inbound self.processing_status = :processing self.processing_started_at = Time.current self.processing_attempts = processing_attempts.to_i + 1 self.correlation_id = Protege::Current.correlation_id save! end |
#to_llm_text ⇒ String
The canonical plain-text serialization of this message for the model: the standard
#content_header block, the readable body, and — when the message has attachments — the note the
app already builds for them (see #attachment_parts_with_note). This is the single "message as
text" artifact both the reply opening turn and the ThreadHistoryResolver replay, so a turn is
self-describing. Always includes the header; callers deciding whether to skip an empty turn test
the message's body/attachments, not this.
201 202 203 204 |
# File 'app/models/protege/message.rb', line 201 def to_llm_text note = .last.text if .attached? compose_llm_text(note:) end |