The data model behind SummarAIzeIT
In the first article, I wrote about the first product boundary in SummarAIzeIT: it is not a “summarize this URL” script. It is a Rails product that watches sources, captures content, creates summaries, and delivers digests.
This article goes one layer deeper: the data model.
The interesting part of the model is not that it has tables. Every Rails app has tables. The interesting part is where the boundaries landed.
SummarAIzeIT has to answer questions like:
- What is the user trying to follow?
- Where did this content come from?
- Was this summary generated from a page, a feed item, a YouTube transcript, or metadata fallback?
- Has this thing already been summarized?
- Did it already appear in a digest?
- Can the same YouTube video be summarized once and reused across projects?
- Can newsletters live in the same product experience without pretending they are web pages?
Those questions shaped the model more than the UI did.
The Core Shape
At a high level, the product is built around this flow:
Project
-> Source
-> Snapshot
-> Post
-> FetchRun
There are two important side paths:
Project
-> Newsletter
YouTubeVideoSummary
-> Post
That gives SummarAIzeIT a few clear layers:
Projectis the user’s topic or workspace.Sourceis the thing being watched.Snapshotis captured material.Postis the reader-facing summary.Newsletteris imported email content.FetchRunis a digest execution record.YoutubeVideoSummaryis a reusable video-level summary cache.
The model is intentionally not centered around the LLM call. The LLM call is one step inside a product loop.
Project: The User’s Context Boundary
Project is the first domain boundary.
A project groups sources, summaries, newsletters, digest settings, and schedule configuration. In product terms, this is what lets a user say “I care about this topic” instead of managing a flat list of URLs.
The association shape is straightforward:
class Project < ApplicationRecord
belongs_to :user
has_many :sources, dependent: :destroy
has_many :snapshots, dependent: :destroy
has_many :posts, dependent: :destroy
has_many :newsletters, dependent: :destroy
has_many :fetch_run_projects, dependent: :destroy
has_one :project_email_account, dependent: :destroy
has_one :fetch_schedule, dependent: :destroy
end
The important product decision is that the project owns the reading context. A source does not exist globally. A page, feed, video, channel, or inbox connection belongs to the project where it is useful.
That also makes user-facing features easier later:
- project dashboards,
- project-specific digest emails,
- project-level search,
- project-level automation,
- project-specific import windows.
This is one of those Rails decisions that looks simple, but keeps paying rent.
Source: Configuration, Not Content
Source is not the content. It is the instruction for where content should come from.
The model distinguishes source types explicitly:
enum :source_type, {
page: "PAGE",
feed: "FEED",
youtube: "YOUTUBE",
youtube_channel: "YOUTUBE_CHANNEL"
}, suffix: true
That may look like a small enum, but it keeps the system honest.
A normal web page needs HTML extraction and change detection. A feed needs item parsing, dates, links, and duplicate handling. A single YouTube video needs transcript and metadata handling. A YouTube channel needs video discovery, batch limits, shorts filtering, and fallback to the Data API when public feeds are not enough.
Those are not the same problem.
The sources table also stores fields that are source configuration, not generated output:
source_type
link
feed_url
video_id
channel_id
import_after
last_fetched
removed_at
source_title
source_description
source_image_url
Two fields matter a lot in practice.
import_after prevents a new source from flooding the project with old content. When a user adds a feed or channel, the product should not immediately summarize the entire history unless that is an explicit feature.
removed_at makes removal soft. A user can remove a source from the active set without forcing the app to forget every snapshot or post that came from it.
The public entry point stays intentionally small:
def fetch_updates
SourceFetchers::Factory.build(self).call
end
The source knows what it is. The strategy layer knows how to fetch it. That separation is the topic of the next article.
Snapshot: Provenance Before Summary
Snapshot is the layer that keeps SummarAIzeIT from becoming a pile of generated text.
class Snapshot < ApplicationRecord
belongs_to :project
belongs_to :source
has_many :posts, dependent: :destroy
validates :digest, :date, presence: true
end
A snapshot answers: what did the app capture before summarization?
The table stores fields like:
project_id
source_id
digest
date
newsletter_link
note
The name digest is a little overloaded here. In this layer it means captured or prepared source content, not the email digest sent to the user. If I were renaming things today, I might make that distinction sharper.
Still, the boundary is useful. A Post should not be the only record of what happened. The app needs an object between the source and the generated summary so it can compare, deduplicate, and explain where a summary came from.
The unique index on project_id and newsletter_link is another practical detail. It protects the system from creating multiple snapshots for the same item link inside the same project.
index_snapshots_on_project_id_and_newsletter_link
That kind of constraint is easy to skip during a prototype. In a digest product, it matters quickly.
Post: The Thing the User Reads
Post is the reader-facing summary.
class Post < ApplicationRecord
belongs_to :project
belongs_to :snapshot
belongs_to :youtube_video_summary, optional: true
has_one :source, through: :snapshot
enum :content_origin, {
transcript: "transcript",
metadata_fallback: "metadata_fallback"
}, suffix: true
end
The unique index is one of the most important details:
index_posts_on_project_id_and_snapshot_id
It says: for a given project and snapshot, create at most one post.
That is not just a database preference. It is product behavior. If a source fetch is retried, or a job runs twice, the product should not produce duplicate reader-facing summaries.
content_origin also deserves attention.
For YouTube summaries, the product needs to distinguish transcript-based summaries from metadata fallback summaries. A transcript summary and a metadata summary can both be useful, but they are not equally grounded. The data model should not pretend they are.
That small enum makes the fallback visible to the rest of the system.
YoutubeVideoSummary: A Cache With Product Meaning
YouTube videos are a special case because the same video can appear in multiple projects or be discovered through multiple sources.
If the app summarizes the same video every time it appears, it wastes API calls and creates inconsistent output. So SummarAIzeIT has a global video-level cache:
class YoutubeVideoSummary < ApplicationRecord
has_many :posts, dependent: :nullify
enum :content_origin, {
transcript: "transcript",
metadata_fallback: "metadata_fallback"
}, suffix: true
validates :video_id, presence: true, uniqueness: true
validates :summary, :raw_content, :content_origin, presence: true
end
The table stores:
video_id
canonical_url
title
summary
raw_content
content_origin
last_summarized_at
last_transcript_check_at
This model does more than optimize cost.
It also creates an upgrade path. A video might first be summarized from metadata fallback because no transcript was available. Later, if a transcript becomes available, the system has a place to check and replace or improve that summary.
That is a recurring pattern in AI products: cache results, but keep enough metadata to know when a cached result is weaker than it could be.
Newsletter: Similar Output, Different Input
Newsletters are not sources in the same sense as RSS feeds or pages.
They arrive through email import paths: Gmail, IMAP, or inbound forwarding. They have subjects, senders, message IDs, references, threading behavior, HTML bodies, plain text bodies, and summaries.
The table reflects that mess:
subject
body
html_body
summary
from
display_from
transport_from
message_id
thread_root_id
gmail_message_id
references
The model is intentionally separate:
class Newsletter < ApplicationRecord
belongs_to :project
scope :thread_roots, -> { where("message_id = thread_root_id") }
scope :thread_replies, ->(root_id) {
where(thread_root_id: root_id).where.not("message_id = thread_root_id")
}
end
This avoids a false abstraction.
A newsletter summary can appear in the same digest as a post summary, but the ingestion path is different enough that pretending it is a Source would make the source model worse.
The common layer is not ingestion. The common layer is the digest output.
FetchRun: Remember the Delivery
The final major boundary is the digest run.
FetchRun records a run for a user:
class FetchRun < ApplicationRecord
belongs_to :user
has_many :fetch_run_projects, dependent: :destroy
validates :projects_count, presence: true
validates :summaries_fetched, presence: true
end
FetchRunProject records the project-specific result:
class FetchRunProject < ApplicationRecord
belongs_to :fetch_run
belongs_to :project
has_many :fetch_run_project_summaries, dependent: :destroy
end
And FetchRunProjectSummary stores the digest-ready item:
title
excerpt
url
content_snapshot
summaryable_type
summaryable_id
That last pair is polymorphic:
belongs_to :summaryable, polymorphic: true, optional: true
This lets a digest include different kinds of summarized things without forcing every input path into the same model. A digest item can point back to a Post, a Newsletter, or another future summaryable object if the product grows in that direction.
It also preserves the email content at delivery time. If the underlying post changes later, the run still has the excerpt and snapshot that were sent.
That is important because delivery is part of the product history.
What This Model Buys
The data model buys a few practical things.
First, it gives the product provenance. A user-facing summary can be traced back to a project, source, and snapshot.
Second, it gives the product idempotency. Unique indexes and snapshot/post boundaries help retries avoid duplicate reader-facing output.
Third, it gives the product room for imperfect inputs. YouTube transcript summaries and metadata fallback summaries can both exist, but the system can distinguish them.
Fourth, it keeps ingestion paths honest. Pages, feeds, YouTube videos, YouTube channels, and newsletters do not have to pretend they are the same thing.
Finally, it gives delivery a memory. A digest run is not just an email side effect. It is an object the system can reason about.
Next
The next article will cover the ingestion architecture: how SummarAIzeIT routes each source type into a dedicated strategy instead of stuffing pages, feeds, YouTube videos, and channels into one giant service object.