Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"name": "Forem DEVcontainer",
"dockerComposeFile": "../docker-compose.yml",
"service": "devcontainer",
"runServices": ["postgres", "redis"],
"runServices": [
"postgres",
"redis"
],
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"remoteEnv": {
"LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
Expand All @@ -28,6 +31,7 @@
"onAutoForward": "silent"
}
},
"privileged": true,
"postCreateCommand": ".devcontainer/postCreateCommand-init.sh",
"postAttachCommand": ".devcontainer/postAttachCommand-init.sh",
"customizations": {
Expand Down
2 changes: 1 addition & 1 deletion .ona/verify-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ fi
# Check if .env file exists
if [ -f .env ]; then
echo "✅ .env file exists"

# Check for Ona-specific configurations
if grep -q "app.ona.dev" .env; then
echo "✅ Ona domain configuration found"
Expand Down
143 changes: 137 additions & 6 deletions app/controllers/stories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -242,18 +242,18 @@ def redirect_if_view_param

def redirect_if_inactive_in_subforem_for_user
return unless @comments.none? &&
@pinned_stories.none? &&
@stories.none? &&
RequestStore.store[:subforem_id] != RequestStore.store[:default_subforem_id]
@pinned_stories.none? &&
@stories.none? &&
RequestStore.store[:subforem_id] != RequestStore.store[:default_subforem_id]

subforem = Subforem.find(RequestStore.store[:default_subforem_id])
redirect_to URL.url(@user.username, subforem), allow_other_host: true, status: :moved_permanently
end

def redirect_if_inactive_in_subforem_for_organization
return unless @stories.none? &&
RequestStore.store[:subforem_id] != RequestStore.store[:default_subforem_id]
RequestStore.store[:subforem_id] != RequestStore.store[:default_subforem_id]

subforem = Subforem.find(RequestStore.store[:default_subforem_id])
redirect_to URL.url(@organization.slug, subforem), allow_other_host: true, status: :moved_permanently
end
Expand Down Expand Up @@ -404,7 +404,13 @@ def set_user_json_ld
end

def set_article_json_ld
@article_json_ld = {
@article_json_ld = build_article_json_ld
end

private

def build_article_json_ld
json_ld = {
"@context": "http://schema.org",
"@type": "Article",
mainEntityOfPage: {
Expand Down Expand Up @@ -436,6 +442,98 @@ def set_article_json_ld
datePublished: @article.published_timestamp,
dateModified: @article.edited_at&.iso8601 || @article.published_timestamp
}

# Add discussion forum structured data if article has comments
return json_ld unless @article.comments_count.positive?

# Add main discussion forum posting for the article
json_ld[:mainEntity] = {
"@type": "DiscussionForumPosting",
"@id": "#article-discussion-#{@article.id}",
headline: @article.title,
text: @article.processed_html_final,
author: {
"@type": "Person",
name: @user.name,
url: URL.user(@user)
},
datePublished: @article.published_timestamp,
dateModified: @article.edited_at&.iso8601 || @article.published_timestamp,
url: URL.article(@article),
interactionStatistic: [
{
"@type": "InteractionCounter",
interactionType: "https://schema.org/CommentAction",
userInteractionCount: @article.comments_count
},
{
"@type": "InteractionCounter",
interactionType: "https://schema.org/LikeAction",
userInteractionCount: @article.public_reactions_count
},
]
}

# Add comment structured data
comments_data = fetch_comments_for_json_ld
return json_ld unless comments_data.any?

json_ld[:mainEntity][:comment] = comments_data
json_ld
end

private

def fetch_comments_for_json_ld
# Fetch top-level comments with their nested replies
comments_tree = Comments::Tree.for_commentable(@article, limit: 10, order: "top", include_negative: false)

comments_data = []
comments_tree.each do |root_comment, sub_comments|
comment_data = build_comment_json_ld(root_comment)

# Add nested replies
if sub_comments.any?
comment_data[:comment] = sub_comments.map { |comment, _| build_comment_json_ld(comment) }
end

comments_data << comment_data
end

comments_data
end

def build_comment_json_ld(comment)
comment_data = {
"@type": "Comment",
"@id": "#comment-#{comment.id}",
text: comment.processed_html_final,
author: {
"@type": "Person",
name: comment.user.name,
url: URL.user(comment.user)
},
datePublished: comment.created_at.iso8601,
dateModified: comment.edited_at&.iso8601 || comment.created_at.iso8601,
url: URL.comment(comment),
interactionStatistic: [
{
"@type": "InteractionCounter",
interactionType: "https://schema.org/LikeAction",
userInteractionCount: comment.public_reactions_count
},
]
}

# Add parent comment reference if this is a reply
if comment.ancestry.present?
comment_data[:parentItem] = {
"@type": "Comment",
"@id": "#comment-#{comment.parent.id}"
}
end

comment_data
end

def seo_optimized_images
Expand Down Expand Up @@ -479,4 +577,37 @@ def fetch_sort_order

"top"
end

def build_comment_json_ld(comment)
comment_data = {
"@type": "Comment",
"@id": "#comment-#{comment.id}",
text: comment.processed_html_final,
author: {
"@type": "Person",
name: comment.user.name,
url: URL.user(comment.user)
},
datePublished: comment.created_at.iso8601,
dateModified: comment.edited_at&.iso8601 || comment.created_at.iso8601,
url: URL.comment(comment),
interactionStatistic: [
{
"@type": "InteractionCounter",
interactionType: "https://schema.org/LikeAction",
userInteractionCount: comment.public_reactions_count
},
]
}

# Add parent comment reference if this is a reply
if comment.ancestry.present?
comment_data[:parentItem] = {
"@type": "Comment",
"@id": "#comment-#{comment.parent.id}"
}
end

comment_data
end
end
1 change: 1 addition & 0 deletions app/models/feed_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def create_slightly_modified_clone!
clone.language_match_weight = language_match_weight * rand(0.9..1.1)
clone.recent_subforem_weight = recent_subforem_weight * rand(0.9..1.1)
clone.subforem_follow_weight = subforem_follow_weight * rand(0.9..1.1)
clone.recent_page_views_shuffle_weight = recent_page_views_shuffle_weight * rand(0.9..1.1)
clone.recent_tag_count_min = [recent_tag_count_min + rand(-1..1), 0].max if recent_tag_count_min.positive?
clone.recent_tag_count_max = [recent_tag_count_max + rand(-1..1), 12].min if recent_tag_count_max.positive?
clone.recent_tag_count_max = clone.recent_tag_count_min if clone.recent_tag_count_max < clone.recent_tag_count_min
Expand Down
28 changes: 21 additions & 7 deletions app/services/ai/content_moderation_labeler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,30 @@ def initialize(article)

##
# Asks the AI to label the article and returns the label.
# Retries up to 2 times on error before falling back to default.
#
# @return [String] The moderation label for the article.
def label
prompt = build_prompt
response = @ai_client.call(prompt)
parse_response(response)
rescue StandardError => e
Rails.logger.error("Content Moderation Labeling failed: #{e}")
# Fallback to a safe default
"no_moderation_label"
attempt = 0
max_retries = 2

begin
attempt += 1
prompt = build_prompt
response = @ai_client.call(prompt)
parse_response(response)
rescue StandardError => e
Rails.logger.error("Content Moderation Labeling failed (attempt #{attempt}/#{max_retries + 1}): #{e}")

if attempt <= max_retries
Rails.logger.info("Retrying content moderation labeling (attempt #{attempt + 1}/#{max_retries + 1})")
retry
else
Rails.logger.error("Content Moderation Labeling failed after #{max_retries + 1} attempts, falling back to default")
# Fallback to a safe default after all retries exhausted
"no_moderation_label"
end
end
end

private
Expand Down
51 changes: 42 additions & 9 deletions app/services/articles/feeds/custom.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,48 @@ def shuffle_top_five_if_recent(articles)

return articles unless all_recent

# Split articles into top 5 and the rest
top_five = articles.first(5)
rest = articles[5..-1] || []

# Randomly shuffle only the top 5 articles
shuffled_top_five = top_five.shuffle

# Return shuffled top 5 + unshuffled rest
shuffled_top_five + rest
# Use dynamic shuffle count only if recent_page_views_shuffle_weight > 0
shuffle_count = if @feed_config.recent_page_views_shuffle_weight > 0.0
calculate_dynamic_shuffle_count
else
5 # Default behavior
end

# Split articles into top N (based on shuffle count) and the rest
top_articles = articles.first(shuffle_count)
rest = articles[shuffle_count..-1] || []

# Randomly shuffle the top articles
shuffled_top_articles = top_articles.shuffle

# Return shuffled top articles + unshuffled rest
shuffled_top_articles + rest
end

def calculate_dynamic_shuffle_count
return 5 unless @user&.user_activity&.recently_viewed_articles&.any?

# Get the most recent page view timestamp
most_recent_page_view = @user.user_activity.recently_viewed_articles.first
return 5 unless most_recent_page_view&.size >= 2

begin
most_recent_timestamp = most_recent_page_view[1].to_datetime
hours_since_last_view = ((Time.current - most_recent_timestamp) / 1.hour).ceil

# Calculate base shuffle count: 20 for within 1 hour, decreasing by 1 for each additional hour
# Minimum of 5 (original behavior)
base_shuffle_count = [21 - hours_since_last_view, 5].max

# Apply the weight multiplier
weighted_shuffle_count = (base_shuffle_count * @feed_config.recent_page_views_shuffle_weight).round

# Ensure minimum of 5 and maximum of 20
[[weighted_shuffle_count, 5].max, 20].min
rescue StandardError
# If there's any error parsing the timestamp, fall back to default
5
end
end

# Preserve the public interface
Expand Down
2 changes: 1 addition & 1 deletion app/views/articles/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<%= javascript_include_tag "webShare", "articlePage", "commentDropdowns", defer: true %>
<% end %>

<% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %>
<% cache("content-related-optional-scripts-#{@article.id}-#{@article.updated_at}-#{@article.last_comment_at}-#{internal_navigation?}-#{user_signed_in?}", expires_in: 30.hours) do %>
<% unless internal_navigation? || user_signed_in? %>
<script type="application/ld+json">
<%= @article_json_ld.to_json.html_safe %>
Expand Down
1 change: 1 addition & 0 deletions app/workers/reactions/bust_reactable_cache_worker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def perform(reaction_id)
path = "/reactions?commentable_id=#{reaction.reactable.commentable_id}&" \
"commentable_type=#{reaction.reactable.commentable_type}"
cache_bust.call(path)

end
end
end
Expand Down
4 changes: 3 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ services:
command: sleep infinity
tmpfs:
- /workspaces/forem/tmp/pids
network_mode: host
ports:
- '3000:3000'

Expand Down Expand Up @@ -100,6 +101,7 @@ services:
environment:
PSQL_HISTFILE: /usr/local/hist/.psql_history
POSTGRES_PASSWORD: postgres
network_mode: host
ports:
- 5432
healthcheck:
Expand All @@ -110,6 +112,7 @@ services:
image: redis:7.0-alpine
volumes:
- redis:/data
network_mode: host
ports:
- 6379
healthcheck:
Expand Down Expand Up @@ -160,4 +163,3 @@ volumes:
redis:
assets:
builds:

3 changes: 3 additions & 0 deletions spec/models/feed_config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@
feed_config.general_past_day_bonus_weight = 19.0
feed_config.recently_active_past_day_bonus_weight = 20.0
feed_config.subforem_follow_weight = 21.0 # Added new weight
feed_config.recent_page_views_shuffle_weight = 22.0
feed_config.recent_tag_count_min = 2
feed_config.recent_tag_count_max = 5
feed_config.all_time_tag_count_min = 3
Expand Down Expand Up @@ -367,6 +368,7 @@
expect(clone.general_past_day_bonus_weight).to eq(19.0 * 1.1)
expect(clone.recently_active_past_day_bonus_weight).to eq(20.0 * 1.1)
expect(clone.subforem_follow_weight).to eq(21.0 * 1.1) # Added expectation
expect(clone.recent_page_views_shuffle_weight).to eq(22.0 * 1.1)
end

it "does not modify the original feed_config" do
Expand All @@ -392,6 +394,7 @@
"compellingness_score_weight",
"language_match_weight",
"subforem_follow_weight", # Added new weight to check
"recent_page_views_shuffle_weight",
"recent_tag_count_min",
"recent_tag_count_max",
"all_time_tag_count_min",
Expand Down
Loading
Loading