PostgreSQL Examples: Blog & Content Management¶
Examples for building blog platforms and content management systems with SteadyText.
Blog Platform¶
Schema Design¶
-- Create blog schema
CREATE SCHEMA IF NOT EXISTS blog;
-- Authors table
CREATE TABLE blog.authors (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
bio TEXT,
bio_embedding vector(1024),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Posts table with AI fields
CREATE TABLE blog.posts (
id SERIAL PRIMARY KEY,
author_id INTEGER REFERENCES blog.authors(id),
title VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
content TEXT NOT NULL,
summary TEXT,
embedding vector(1024),
tags TEXT[],
status VARCHAR(20) DEFAULT 'draft',
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Comments with sentiment analysis
CREATE TABLE blog.comments (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES blog.posts(id),
author_name VARCHAR(100),
content TEXT NOT NULL,
sentiment FLOAT,
embedding vector(1024),
is_spam BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Categories with embeddings
CREATE TABLE blog.categories (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT,
embedding vector(1024)
);
-- Post categories junction
CREATE TABLE blog.post_categories (
post_id INTEGER REFERENCES blog.posts(id),
category_id INTEGER REFERENCES blog.categories(id),
PRIMARY KEY (post_id, category_id)
);
Content Generation Functions¶
-- Generate blog post summary
CREATE OR REPLACE FUNCTION blog.generate_post_summary(
p_content TEXT,
p_max_words INTEGER DEFAULT 50
) RETURNS TEXT AS $$
DECLARE
v_prompt TEXT;
v_summary TEXT;
BEGIN
v_prompt := format(
'Summarize this blog post in approximately %s words: %s',
p_max_words,
substring(p_content, 1, 2000)
);
v_summary := steadytext_generate(v_prompt, 150);
IF v_summary IS NULL THEN
-- Fallback to simple extraction
v_summary := substring(p_content, 1, 200) || '...';
END IF;
RETURN v_summary;
END;
$$ LANGUAGE plpgsql;
-- Generate SEO-friendly slug
CREATE OR REPLACE FUNCTION blog.generate_slug(
p_title TEXT
) RETURNS TEXT AS $$
DECLARE
v_prompt TEXT;
v_slug TEXT;
BEGIN
v_prompt := format(
'Convert this title to a URL-friendly slug (lowercase, hyphens, no special chars): "%s"',
p_title
);
v_slug := steadytext_generate(v_prompt, 50);
IF v_slug IS NULL THEN
-- Fallback to regex-based conversion
v_slug := lower(p_title);
v_slug := regexp_replace(v_slug, '[^a-z0-9]+', '-', 'g');
v_slug := trim(both '-' from v_slug);
END IF;
RETURN v_slug;
END;
$$ LANGUAGE plpgsql;
-- Generate tags for post
CREATE OR REPLACE FUNCTION blog.generate_tags(
p_content TEXT,
p_max_tags INTEGER DEFAULT 5
) RETURNS TEXT[] AS $$
DECLARE
v_prompt TEXT;
v_tags_text TEXT;
v_tags TEXT[];
BEGIN
v_prompt := format(
'Extract %s relevant tags from this content as a comma-separated list: %s',
p_max_tags,
substring(p_content, 1, 1000)
);
v_tags_text := steadytext_generate(v_prompt, 100);
IF v_tags_text IS NOT NULL THEN
v_tags := string_to_array(
regexp_replace(v_tags_text, '[\s,]+', ',', 'g'),
','
);
ELSE
v_tags := ARRAY[]::TEXT[];
END IF;
RETURN v_tags;
END;
$$ LANGUAGE plpgsql;
Automated Content Processing¶
-- Trigger to auto-generate content on insert/update
CREATE OR REPLACE FUNCTION blog.process_post()
RETURNS TRIGGER AS $$
BEGIN
-- Generate summary if not provided
IF NEW.summary IS NULL OR NEW.summary = '' THEN
NEW.summary := blog.generate_post_summary(NEW.content);
END IF;
-- Generate slug if not provided
IF NEW.slug IS NULL OR NEW.slug = '' THEN
NEW.slug := blog.generate_slug(NEW.title);
-- Ensure uniqueness
WHILE EXISTS (SELECT 1 FROM blog.posts WHERE slug = NEW.slug AND id != COALESCE(NEW.id, -1)) LOOP
NEW.slug := NEW.slug || '-' || floor(random() * 1000)::text;
END LOOP;
END IF;
-- Generate embedding
NEW.embedding := steadytext_embed(
NEW.title || ' ' || COALESCE(NEW.summary, '') || ' ' || substring(NEW.content, 1, 1000)
);
-- Generate tags if empty
IF array_length(NEW.tags, 1) IS NULL THEN
NEW.tags := blog.generate_tags(NEW.content);
END IF;
-- Update timestamp
NEW.updated_at := CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER blog_post_process
BEFORE INSERT OR UPDATE ON blog.posts
FOR EACH ROW
EXECUTE FUNCTION blog.process_post();
Content Recommendation System¶
-- Find related posts
CREATE OR REPLACE FUNCTION blog.find_related_posts(
p_post_id INTEGER,
p_limit INTEGER DEFAULT 5
) RETURNS TABLE(
post_id INTEGER,
title VARCHAR(200),
similarity FLOAT,
reason TEXT
) AS $$
BEGIN
RETURN QUERY
WITH current_post AS (
SELECT embedding, tags
FROM blog.posts
WHERE id = p_post_id
)
SELECT
p.id as post_id,
p.title,
1 - (p.embedding <-> cp.embedding) as similarity,
CASE
WHEN array_length(array_intersect(p.tags, cp.tags), 1) > 0
THEN 'Similar tags: ' || array_to_string(array_intersect(p.tags, cp.tags), ', ')
ELSE 'Similar content'
END as reason
FROM blog.posts p, current_post cp
WHERE p.id != p_post_id
AND p.status = 'published'
ORDER BY similarity DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
-- Generate content recommendations
CREATE OR REPLACE FUNCTION blog.generate_recommendations(
p_user_id INTEGER,
p_limit INTEGER DEFAULT 10
) RETURNS TABLE(
post_id INTEGER,
title VARCHAR(200),
score FLOAT,
recommendation_reason TEXT
) AS $$
BEGIN
RETURN QUERY
WITH user_interests AS (
-- Get user's reading history
SELECT
p.embedding,
p.tags,
COUNT(*) as read_count
FROM blog.posts p
JOIN blog.post_views pv ON p.id = pv.post_id
WHERE pv.user_id = p_user_id
GROUP BY p.id
),
interest_profile AS (
-- Create aggregate interest profile
SELECT
pg_stat_avg_vector(embedding) as avg_embedding,
array_agg(DISTINCT tag) as all_tags
FROM user_interests, unnest(tags) as tag
)
SELECT
p.id as post_id,
p.title,
(
0.7 * (1 - (p.embedding <-> ip.avg_embedding)) +
0.3 * (array_length(array_intersect(p.tags, ip.all_tags), 1)::float / array_length(p.tags, 1))
) as score,
'Based on your reading history' as recommendation_reason
FROM blog.posts p, interest_profile ip
WHERE p.status = 'published'
AND p.id NOT IN (
SELECT post_id FROM blog.post_views WHERE user_id = p_user_id
)
ORDER BY score DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
Comment Moderation¶
-- Analyze comment sentiment and spam detection
CREATE OR REPLACE FUNCTION blog.analyze_comment(
p_content TEXT
) RETURNS TABLE(
sentiment FLOAT,
is_spam BOOLEAN,
reason TEXT
) AS $$
DECLARE
v_sentiment_prompt TEXT;
v_spam_prompt TEXT;
v_sentiment_result TEXT;
v_spam_result TEXT;
BEGIN
-- Sentiment analysis
v_sentiment_prompt := format(
'Rate the sentiment of this comment from -1 (very negative) to 1 (very positive), return only the number: %s',
p_content
);
v_sentiment_result := steadytext_generate(v_sentiment_prompt, 10);
-- Spam detection
v_spam_prompt := format(
'Is this comment spam? Reply only "yes" or "no": %s',
p_content
);
v_spam_result := steadytext_generate_choice(
v_spam_prompt,
ARRAY['yes', 'no']
);
RETURN QUERY
SELECT
CASE
WHEN v_sentiment_result ~ '^-?[0-9]*\.?[0-9]+$'
THEN v_sentiment_result::FLOAT
ELSE 0.0
END,
v_spam_result = 'yes',
CASE
WHEN v_spam_result = 'yes' THEN 'Detected as spam'
WHEN v_sentiment_result::FLOAT < -0.5 THEN 'Very negative sentiment'
ELSE 'Approved'
END;
END;
$$ LANGUAGE plpgsql;
-- Auto-moderate comments
CREATE OR REPLACE FUNCTION blog.moderate_comment()
RETURNS TRIGGER AS $$
DECLARE
v_analysis RECORD;
BEGIN
-- Analyze comment
SELECT * INTO v_analysis
FROM blog.analyze_comment(NEW.content);
NEW.sentiment := v_analysis.sentiment;
NEW.is_spam := v_analysis.is_spam;
-- Generate embedding for similarity search
NEW.embedding := steadytext_embed(NEW.content);
-- Auto-approve or flag for review
IF v_analysis.is_spam OR v_analysis.sentiment < -0.7 THEN
-- Could implement notification system here
RAISE NOTICE 'Comment flagged for review: %', v_analysis.reason;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER comment_moderation
BEFORE INSERT ON blog.comments
FOR EACH ROW
EXECUTE FUNCTION blog.moderate_comment();
Content Analytics¶
-- Analyze content performance
CREATE OR REPLACE FUNCTION blog.analyze_post_performance(
p_days INTEGER DEFAULT 30
) RETURNS TABLE(
post_id INTEGER,
title VARCHAR(200),
views BIGINT,
avg_time_on_page INTERVAL,
bounce_rate FLOAT,
sentiment_score FLOAT,
engagement_score FLOAT
) AS $$
BEGIN
RETURN QUERY
WITH post_metrics AS (
SELECT
pv.post_id,
COUNT(*) as view_count,
AVG(pv.time_spent) as avg_time,
SUM(CASE WHEN pv.bounced THEN 1 ELSE 0 END)::FLOAT / COUNT(*) as bounce,
AVG(c.sentiment) as avg_sentiment,
COUNT(DISTINCT c.id) as comment_count
FROM blog.post_views pv
LEFT JOIN blog.comments c ON c.post_id = pv.post_id
WHERE pv.viewed_at > CURRENT_TIMESTAMP - INTERVAL '1 day' * p_days
GROUP BY pv.post_id
)
SELECT
p.id,
p.title,
pm.view_count,
pm.avg_time,
pm.bounce,
COALESCE(pm.avg_sentiment, 0),
(
0.4 * (pm.view_count::FLOAT / NULLIF(MAX(pm.view_count) OVER (), 0)) +
0.3 * (extract(epoch from pm.avg_time) / NULLIF(MAX(extract(epoch from pm.avg_time)) OVER (), 0)) +
0.2 * (1 - pm.bounce) +
0.1 * ((pm.avg_sentiment + 1) / 2)
) as engagement_score
FROM blog.posts p
JOIN post_metrics pm ON p.id = pm.post_id
ORDER BY engagement_score DESC;
END;
$$ LANGUAGE plpgsql;
-- Generate content insights
CREATE OR REPLACE FUNCTION blog.generate_content_insights(
p_post_id INTEGER
) RETURNS TEXT AS $$
DECLARE
v_metrics RECORD;
v_prompt TEXT;
v_insights TEXT;
BEGIN
-- Get post metrics
SELECT * INTO v_metrics
FROM blog.analyze_post_performance(30)
WHERE post_id = p_post_id;
-- Generate insights
v_prompt := format(
'Analyze these blog post metrics and provide insights: Views: %s, Avg time: %s, Bounce rate: %s%%, Sentiment: %s. What does this suggest about the content?',
v_metrics.views,
v_metrics.avg_time_on_page,
round(v_metrics.bounce_rate * 100),
round(v_metrics.sentiment_score::numeric, 2)
);
v_insights := steadytext_generate(v_prompt, 200);
RETURN COALESCE(v_insights, 'Insufficient data for insights.');
END;
$$ LANGUAGE plpgsql;
RSS Feed Generation¶
-- Generate RSS feed with AI summaries
CREATE OR REPLACE FUNCTION blog.generate_rss_feed(
p_limit INTEGER DEFAULT 20
) RETURNS XML AS $$
DECLARE
v_feed XML;
BEGIN
v_feed := xmlelement(
name rss,
xmlattributes('2.0' as version),
xmlelement(
name channel,
xmlelement(name title, 'My Blog'),
xmlelement(name link, 'https://myblog.com'),
xmlelement(name description, 'Latest posts from My Blog'),
xmlelement(name language, 'en-us'),
xmlelement(name lastBuildDate, to_char(CURRENT_TIMESTAMP, 'Dy, DD Mon YYYY HH24:MI:SS TZ')),
(
SELECT xmlagg(
xmlelement(
name item,
xmlelement(name title, p.title),
xmlelement(name link, 'https://myblog.com/posts/' || p.slug),
xmlelement(name description, xmlcdata(p.summary)),
xmlelement(name pubDate, to_char(p.published_at, 'Dy, DD Mon YYYY HH24:MI:SS TZ')),
xmlelement(name guid, 'https://myblog.com/posts/' || p.id),
(
SELECT xmlagg(
xmlelement(name category, tag)
)
FROM unnest(p.tags) as tag
)
)
)
FROM blog.posts p
WHERE p.status = 'published'
ORDER BY p.published_at DESC
LIMIT p_limit
)
)
);
RETURN v_feed;
END;
$$ LANGUAGE plpgsql;
Content Versioning¶
-- Version control for posts
CREATE TABLE blog.post_versions (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES blog.posts(id),
version_number INTEGER NOT NULL,
title VARCHAR(200),
content TEXT,
summary TEXT,
change_summary TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
UNIQUE(post_id, version_number)
);
-- Auto-generate change summaries
CREATE OR REPLACE FUNCTION blog.create_post_version()
RETURNS TRIGGER AS $$
DECLARE
v_version_number INTEGER;
v_change_summary TEXT;
v_old_content TEXT;
BEGIN
-- Skip if no actual changes
IF OLD.content = NEW.content AND OLD.title = NEW.title THEN
RETURN NEW;
END IF;
-- Get next version number
SELECT COALESCE(MAX(version_number), 0) + 1
INTO v_version_number
FROM blog.post_versions
WHERE post_id = NEW.id;
-- Generate change summary
v_change_summary := steadytext_generate(
format(
'Summarize the changes between these versions in one sentence: OLD: %s NEW: %s',
substring(OLD.title || ' ' || OLD.content, 1, 500),
substring(NEW.title || ' ' || NEW.content, 1, 500)
),
50
);
-- Insert version record
INSERT INTO blog.post_versions (
post_id, version_number, title, content,
summary, change_summary
) VALUES (
NEW.id, v_version_number, OLD.title, OLD.content,
OLD.summary, COALESCE(v_change_summary, 'Content updated')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER post_versioning
BEFORE UPDATE ON blog.posts
FOR EACH ROW
WHEN (OLD.content IS DISTINCT FROM NEW.content OR OLD.title IS DISTINCT FROM NEW.title)
EXECUTE FUNCTION blog.create_post_version();