FEATURE: New sentiment analysis visualization report (#1109)

## 🔍 Overview
This update adds a new report page at `admin/reports/sentiment_analysis` where admins can see a sentiment analysis report for the forum grouped by either category or tags. 

##  More details
The report can breakdown either category or tags into positive/negative/neutral sentiments based on the grouping (category/tag). Clicking on the doughnut visualization will bring up a post list of all the posts that were involved in that classification with further sentiment classifications by post. 

The report can additionally be sorted in alphabetical order or by size, as well as be filtered by either category/tag based on the grouping.

## 👨🏽‍💻 Technical Details
The new admin report is registered via the pluginAPi with `api.registerReportModeComponent` to register the custom sentiment doughnut report. However, when each doughnut visualization is clicked, a new endpoint found at: `/discourse-ai/sentiment/posts` is fetched to showcase posts classified by sentiments based on the respective params.


## 📸 Screenshots
![Screenshot 2025-02-14 at 11 11 35](https://github.com/user-attachments/assets/a63b5ab8-4fb2-477d-bd29-92545f44ff09)
This commit is contained in:
Keegan George 2025-02-20 09:14:10 -08:00 committed by GitHub
parent 1f9f330ce2
commit 24f0e1262d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 787 additions and 2 deletions

View File

@ -0,0 +1,79 @@
# frozen_string_literal: true
module DiscourseAi
module Sentiment
class SentimentController < ::Admin::StaffController
include Constants
requires_plugin ::DiscourseAi::PLUGIN_NAME
def posts
group_by = params.required(:group_by)&.to_sym
group_value = params.required(:group_value).presence
start_date = params[:start_date].presence
end_date = params[:end_date]
threshold = SENTIMENT_THRESHOLD
raise Discourse::InvalidParameters if %i[category tag].exclude?(group_by)
case group_by
when :category
grouping_clause = "c.name"
grouping_join = "INNER JOIN categories c ON c.id = t.category_id"
when :tag
grouping_clause = "tags.name"
grouping_join =
"INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id INNER JOIN tags ON tags.id = tt.tag_id"
end
posts =
DB.query(
<<~SQL,
SELECT
p.id AS post_id,
p.topic_id,
t.title AS topic_title,
p.cooked as post_cooked,
p.user_id,
p.post_number,
u.username,
u.name,
u.uploaded_avatar_id,
(CASE
WHEN (cr.classification::jsonb->'positive')::float > :threshold THEN 'positive'
WHEN (cr.classification::jsonb->'negative')::float > :threshold THEN 'negative'
ELSE 'neutral'
END) AS sentiment
FROM posts p
INNER JOIN topics t ON t.id = p.topic_id
INNER JOIN classification_results cr ON cr.target_id = p.id AND cr.target_type = 'Post'
LEFT JOIN users u ON u.id = p.user_id
#{grouping_join}
WHERE
#{grouping_clause} = :group_value AND
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND
((:start_date IS NULL OR p.created_at > :start_date) AND (:end_date IS NULL OR p.created_at < :end_date))
AND p.deleted_at IS NULL
ORDER BY p.created_at DESC
SQL
group_value: group_value,
start_date: start_date,
end_date: end_date,
threshold: threshold,
)
render_json_dump(
serialize_data(
posts,
AiSentimentPostSerializer,
scope: guardian,
add_raw: true,
add_excerpt: true,
add_title: true,
),
)
end
end
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
class AiSentimentPostSerializer < ApplicationSerializer
attributes :post_id,
:topic_id,
:topic_title,
:post_number,
:username,
:name,
:avatar_template,
:excerpt,
:sentiment,
:truncated
def avatar_template
User.avatar_template(object.username, object.uploaded_avatar_id)
end
def excerpt
Post.excerpt(object.post_cooked)
end
def truncated
true
end
end

View File

@ -0,0 +1,184 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import { action, get } from "@ember/object";
import PostList from "discourse/components/post-list";
import dIcon from "discourse/helpers/d-icon";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Post from "discourse/models/post";
import closeOnClickOutside from "discourse/modifiers/close-on-click-outside";
import { i18n } from "discourse-i18n";
import DoughnutChart from "discourse/plugins/discourse-ai/discourse/components/doughnut-chart";
export default class AdminReportSentimentAnalysis extends Component {
@tracked selectedChart = null;
@tracked posts = null;
get colors() {
return ["#2ecc71", "#95a5a6", "#e74c3c"];
}
calculateNeutralScore(data) {
return data.total_count - (data.positive_count + data.negative_count);
}
get currentGroupFilter() {
return this.args.model.available_filters.find(
(filter) => filter.id === "group_by"
).default;
}
get currentSortFilter() {
return this.args.model.available_filters.find(
(filter) => filter.id === "sort_by"
).default;
}
get transformedData() {
return this.args.model.data.map((data) => {
return {
title: data.category_name || data.tag_name,
scores: [
data.positive_count,
this.calculateNeutralScore(data),
data.negative_count,
],
total_score: data.total_count,
};
});
}
@action
async showDetails(data) {
this.selectedChart = data;
try {
const posts = await ajax(`/discourse-ai/sentiment/posts`, {
data: {
group_by: this.currentGroupFilter,
group_value: data.title,
start_date: this.args.model.start_date,
end_date: this.args.model.end_date,
},
});
this.posts = posts.map((post) => Post.create(post));
} catch (e) {
popupAjaxError(e);
}
}
sentimentMapping(sentiment) {
switch (sentiment) {
case "positive":
return {
id: "positive",
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.score_types.positive"
),
icon: "face-smile",
};
case "neutral":
return {
id: "neutral",
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.score_types.neutral"
),
icon: "face-meh",
};
case "negative":
return {
id: "negative",
text: i18n(
"discourse_ai.sentiments.sentiment_analysis.score_types.negative"
),
icon: "face-angry",
};
}
}
doughnutTitle(data) {
if (data?.total_score) {
return `${data.title} (${data.total_score})`;
} else {
return data.title;
}
}
<template>
<div class="admin-report-sentiment-analysis">
{{#each this.transformedData as |data|}}
<div
class="admin-report-sentiment-analysis__chart-wrapper"
role="button"
{{on "click" (fn this.showDetails data)}}
{{closeOnClickOutside
(fn (mut this.selectedChart) null)
(hash
targetSelector=".admin-report-sentiment-analysis-details"
secondaryTargetSelector=".admin-report-sentiment-analysis"
)
}}
>
<DoughnutChart
@labels={{@model.labels}}
@colors={{this.colors}}
@data={{data.scores}}
@doughnutTitle={{this.doughnutTitle data}}
/>
</div>
{{/each}}
</div>
{{#if this.selectedChart}}
<div class="admin-report-sentiment-analysis-details">
<h3 class="admin-report-sentiment-analysis-details__title">
{{this.selectedChart.title}}
</h3>
<ul class="admin-report-sentiment-analysis-details__scores">
<li>
{{dIcon "face-smile" style="color: #2ecc71"}}
{{i18n
"discourse_ai.sentiments.sentiment_analysis.score_types.positive"
}}:
{{get this.selectedChart.scores 0}}</li>
<li>
{{dIcon "face-meh"}}
{{i18n
"discourse_ai.sentiments.sentiment_analysis.score_types.neutral"
}}:
{{get this.selectedChart.scores 1}}</li>
<li>
{{dIcon "face-angry"}}
{{i18n
"discourse_ai.sentiments.sentiment_analysis.score_types.negative"
}}:
{{get this.selectedChart.scores 2}}</li>
</ul>
<PostList
@posts={{this.posts}}
@urlPath="url"
@idPath="post_id"
@titlePath="topic_title"
@usernamePath="username"
class="admin-report-sentiment-analysis-details__post-list"
>
<:abovePostItemExcerpt as |post|>
{{#let (this.sentimentMapping post.sentiment) as |sentiment|}}
<span
class="admin-report-sentiment-analysis-details__post-score"
data-sentiment-score={{sentiment.id}}
>
{{dIcon sentiment.icon}}
{{sentiment.text}}
</span>
{{/let}}
</:abovePostItemExcerpt>
</PostList>
</div>
{{/if}}
</template>
}

View File

@ -0,0 +1,67 @@
import Component from "@glimmer/component";
import Chart from "admin/components/chart";
export default class DoughnutChart extends Component {
get config() {
const doughnutTitle = this.args.doughnutTitle || "";
return {
type: "doughnut",
data: {
labels: this.args.labels,
datasets: [
{
data: this.args.data,
backgroundColor: this.args.colors,
},
],
},
options: {
responsive: true,
plugins: {
legend: {
position: this.args.legendPosition || "bottom",
},
},
},
plugins: [
{
id: "centerText",
afterDraw: function (chart) {
const cssVarColor =
getComputedStyle(document.documentElement).getPropertyValue(
"--primary"
) || "#000";
const cssFontSize =
getComputedStyle(document.documentElement).getPropertyValue(
"--font-down-2"
) || "1.3em";
const cssFontFamily =
getComputedStyle(document.documentElement).getPropertyValue(
"--font-family"
) || "sans-serif";
const { ctx, chartArea } = chart;
const centerX = (chartArea.left + chartArea.right) / 2;
const centerY = (chartArea.top + chartArea.bottom) / 2;
ctx.restore();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = cssVarColor.trim();
ctx.font = `${cssFontSize.trim()} ${cssFontFamily.trim()}`;
ctx.fillText(doughnutTitle, centerX, centerY);
ctx.save();
},
},
],
};
}
<template>
{{#if this.config}}
<Chart @chartConfig={{this.config}} class="admin-report-doughnut" />
{{/if}}
</template>
}

View File

@ -10,8 +10,18 @@ export default {
return; return;
} }
withPluginApi("2.0.1", (api) => { // We need to import dynamically with CommonJS require because
// using ESM import in an initializer would cause the component to be imported globally
// and cause errors for non-admin users since the component is only available to admins
const AdminReportSentimentAnalysis =
require("discourse/plugins/discourse-ai/discourse/components/admin-report-sentiment-analysis").default;
withPluginApi((api) => {
api.registerReportModeComponent("emotion", AdminReportEmotion); api.registerReportModeComponent("emotion", AdminReportEmotion);
api.registerReportModeComponent(
"sentiment_analysis",
AdminReportSentimentAnalysis
);
}); });
}, },
}; };

View File

@ -9,3 +9,158 @@
} }
} }
} }
@mixin report-container-box() {
border: 1px solid var(--primary-low);
border-radius: var(--d-border-radius);
padding: 1rem;
}
.admin-report.sentiment-analysis .body {
display: flex;
flex-flow: row wrap;
gap: 1rem;
.filters {
@include report-container-box();
order: 1;
width: 100%;
margin-left: 0;
flex-flow: row wrap;
align-items: flex-start;
justify-content: flex-start;
gap: 0.5rem;
.control {
min-width: 200px;
}
.control:nth-of-type(-n + 4) {
flex: 1;
}
.control:nth-of-type(n + 6) {
flex-basis: 49%;
align-self: flex-end;
}
// Hides tag selector when showing subcategories selector
.control:nth-of-type(6):nth-last-of-type(3) {
display: none;
}
}
.main {
flex: 100%;
display: flex;
order: 2;
gap: 1rem;
align-items: flex-start;
max-height: 100vh;
}
}
.admin-report-sentiment-analysis {
@include report-container-box();
flex: 2;
display: flex;
flex-flow: row wrap;
gap: 3rem;
.admin-report-doughnut {
max-width: 300px;
max-height: 300px;
padding: 0.25rem;
}
&__chart-wrapper {
transition: transform 0.25s ease, box-shadow 0.25s ease;
border-radius: var(--d-border-radius);
&:hover {
transform: translateY(-1rem);
box-shadow: var(--shadow-card);
cursor: pointer;
}
}
}
:root {
--d-sentiment-report-positive-rgb: 46, 204, 112;
--d-sentiment-report-neutral-rgb: 149, 166, 167;
--d-sentiment-report-negative-rgb: 231, 77, 60;
}
.admin-report-sentiment-analysis-details {
@include report-container-box();
flex: 1;
display: flex;
flex-flow: column nowrap;
overflow-y: auto;
height: 100%;
&__title {
font-size: var(--font-up-2);
}
&__scores {
display: flex;
flex-flow: column wrap;
align-items: flex-start;
justify-content: flex-start;
gap: 0.25rem;
list-style: none;
margin-left: 0;
background: var(--primary-very-low);
padding: 1rem;
border-radius: var(--d-border-radius);
.d-icon-face-smile {
color: rgb(var(--d-sentiment-report-positive-rgb));
}
.d-icon-face-meh {
color: rgb(var(--d-sentiment-report-neutral-rgb));
}
.d-icon-face-angry {
color: rgb(var(--d-sentiment-report-negative-rgb));
}
}
&__post-score {
border-radius: var(--d-border-radius);
background: var(--primary-very-low);
margin-top: 0.5rem;
padding: 0.25rem;
font-size: var(--font-down-1);
display: inline-block;
&[data-sentiment-score="positive"] {
color: rgb(var(--d-sentiment-report-positive-rgb));
background: rgba(var(--d-sentiment-report-positive-rgb), 0.1);
}
&[data-sentiment-score="neutral"] {
color: rgb(var(--d-sentiment-report-neutral-rgb));
background: rgba(var(--d-sentiment-report-neutral-rgb), 0.1);
}
&[data-sentiment-score="negative"] {
color: rgb(var(--d-sentiment-report-negative-rgb));
background: rgba(var(--d-sentiment-report-negative-rgb), 0.1);
}
}
&__post-list {
.avatar-wrapper,
.avatar-link {
width: calc(48px * 0.75);
height: calc(48px * 0.75);
}
img.avatar {
width: 100%;
height: 100%;
}
}
}

View File

@ -15,6 +15,15 @@ en:
emotion: emotion:
title: "Emotion" title: "Emotion"
description: "The table lists a count of posts classified with a determined emotion. Classified with the model 'SamLowe/roberta-base-go_emotions'." description: "The table lists a count of posts classified with a determined emotion. Classified with the model 'SamLowe/roberta-base-go_emotions'."
reports:
filters:
group_by:
label: "Group by"
sort_by:
label: "Sort by"
tag:
label: "Tag"
js: js:
discourse_automation: discourse_automation:
scriptables: scriptables:
@ -641,6 +650,11 @@ en:
sentiments: sentiments:
dashboard: dashboard:
title: "Sentiment" title: "Sentiment"
sentiment_analysis:
score_types:
positive: "Positive"
neutral: "Neutral"
negative: "Negative"
summarization: summarization:
chat: chat:

View File

@ -104,6 +104,9 @@ en:
flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW. flagged_by_nsfw: The AI plugin flagged this after classifying at least one of the attached images as NSFW.
reports: reports:
sentiment_analysis:
title: "Sentiment analysis"
description: "This report provides sentiment analysis for posts, grouped by category, with positive, negative, and neutral scores for each post and category."
overall_sentiment: overall_sentiment:
title: "Overall sentiment" title: "Overall sentiment"
description: 'The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Personal messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"' description: 'The chart compares the number of posts classified as either positive or negative. These are calculated when positive or negative scores > the set threshold score. This means neutral posts are not shown. Personal messages (PMs) are also excluded. Classified with "cardiffnlp/twitter-roberta-base-sentiment-latest"'
@ -431,6 +434,10 @@ en:
anger: "Anger 😡" anger: "Anger 😡"
joy: "Joy 😀" joy: "Joy 😀"
disgust: "Disgust 🤢" disgust: "Disgust 🤢"
sentiment_analysis:
positive: "Positive"
negative: "Negative"
neutral: "Neutral"
llm: llm:
configuration: configuration:

View File

@ -43,6 +43,10 @@ DiscourseAi::Engine.routes.draw do
get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ } get "/t/:topic_id" => "summary#show", :constraints => { topic_id: /\d+/ }
get "/channels/:channel_id" => "chat_summary#show" get "/channels/:channel_id" => "chat_summary#show"
end end
scope module: :sentiment, path: "/sentiment", defaults: { format: :json } do
get "/posts" => "sentiment#posts", :constraints => StaffConstraint.new
end
end end
Discourse::Application.routes.draw do Discourse::Application.routes.draw do

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module DiscourseAi
module Sentiment
module Constants
SENTIMENT_THRESHOLD = 0.6
end
end
end

View File

@ -14,9 +14,13 @@ module DiscourseAi
plugin.on(:post_created, &sentiment_analysis_cb) plugin.on(:post_created, &sentiment_analysis_cb)
plugin.on(:post_edited, &sentiment_analysis_cb) plugin.on(:post_edited, &sentiment_analysis_cb)
additional_icons = %w[face-smile face-meh face-angry]
additional_icons.each { |icon| plugin.register_svg_icon(icon) }
EmotionFilterOrder.register!(plugin) EmotionFilterOrder.register!(plugin)
EmotionDashboardReport.register!(plugin) EmotionDashboardReport.register!(plugin)
SentimentDashboardReport.register!(plugin) SentimentDashboardReport.register!(plugin)
SentimentAnalysisReport.register!(plugin)
end end
end end
end end

View File

@ -0,0 +1,165 @@
# frozen_string_literal: true
module DiscourseAi
module Sentiment
class SentimentAnalysisReport
include Constants
GROUP_BY_FILTER_DEFAULT = :category
SORT_BY_FILTER_DEFAULT = :size
def self.register!(plugin)
plugin.add_report("sentiment_analysis") do |report|
report.modes = [:sentiment_analysis]
group_by_filter = report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT
report.add_filter(
"group_by",
type: "list",
default: group_by_filter,
choices: [{ id: "category", name: "Category" }, { id: "tag", name: "Tag" }],
allow_any: false,
auto_insert_none_item: false,
)
size_filter = report.filters.dig(:sort_by) || SORT_BY_FILTER_DEFAULT
report.add_filter(
"sort_by",
type: "list",
default: size_filter,
choices: [{ id: "size", name: "Size" }, { id: "alphabetical", name: "Alphabetical" }],
allow_any: false,
auto_insert_none_item: false,
)
report.add_category_filter(disabled: group_by_filter.to_sym == :tag)
tag_filter = report.filters.dig(:tag) || "any"
tag_choices =
Tag
.all
.map { |tag| { id: tag.name, name: tag.name } }
.unshift({ id: "any", name: "Any" })
report.add_filter(
"tag",
type: "list",
default: tag_filter,
choices: tag_choices,
allow_any: false,
auto_insert_none_item: false,
disabled: group_by_filter.to_sym == :category,
)
sentiment_data = DiscourseAi::Sentiment::SentimentAnalysisReport.fetch_data(report)
report.data = sentiment_data
report.labels = [
I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.positive"),
I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.neutral"),
I18n.t("discourse_ai.sentiment.reports.sentiment_analysis.negative"),
]
end
end
def self.fetch_data(report)
threshold = SENTIMENT_THRESHOLD
grouping = (report.filters.dig(:group_by) || GROUP_BY_FILTER_DEFAULT).to_sym
sorting = (report.filters.dig(:sort_by) || SORT_BY_FILTER_DEFAULT).to_sym
category_filter = report.filters.dig(:category)
tag_filter = report.filters.dig(:tag)
sentiment_count_sql = Proc.new { |sentiment| <<~SQL }
COUNT(
CASE WHEN (cr.classification::jsonb->'#{sentiment}')::float > :threshold THEN 1 ELSE NULL END
)
SQL
grouping_clause =
case grouping
when :category
<<~SQL
c.name AS category_name,
SQL
when :tag
<<~SQL
tags.name AS tag_name,
SQL
else
raise Discourse::InvalidParameters
end
grouping_join =
case grouping
when :category
<<~SQL
INNER JOIN categories c ON c.id = t.category_id
SQL
when :tag
<<~SQL
INNER JOIN topic_tags tt ON tt.topic_id = p.topic_id
INNER JOIN tags ON tags.id = tt.tag_id
SQL
else
raise Discourse::InvalidParameters
end
order_by_clause =
case sorting
when :size
"ORDER BY total_count DESC"
when :alphabetical
"ORDER BY 1 ASC"
else
raise Discourse::InvalidParameters
end
where_clause =
case grouping
when :category
if category_filter.nil?
""
else
"AND c.id = :category_filter"
end
when :tag
if tag_filter.nil? || tag_filter == "any"
""
else
"AND tags.name = :tag_filter"
end
end
grouped_sentiments =
DB.query(
<<~SQL,
SELECT
#{grouping_clause}
#{sentiment_count_sql.call("positive")} AS positive_count,
#{sentiment_count_sql.call("negative")} AS negative_count,
COUNT(*) AS total_count
FROM
classification_results AS cr
INNER JOIN posts p ON p.id = cr.target_id AND cr.target_type = 'Post'
INNER JOIN topics t ON t.id = p.topic_id
#{grouping_join}
WHERE
t.archetype = 'regular' AND
p.user_id > 0 AND
cr.model_used = 'cardiffnlp/twitter-roberta-base-sentiment-latest' AND
(p.created_at > :report_start AND p.created_at < :report_end)
#{where_clause}
GROUP BY 1
#{order_by_clause}
SQL
report_start: report.start_date,
report_end: report.end_date,
threshold: threshold,
category_filter: category_filter,
tag_filter: tag_filter,
)
grouped_sentiments
end
end
end
end

View File

@ -3,10 +3,12 @@
module DiscourseAi module DiscourseAi
module Sentiment module Sentiment
class SentimentDashboardReport class SentimentDashboardReport
include Constants
def self.register!(plugin) def self.register!(plugin)
plugin.add_report("overall_sentiment") do |report| plugin.add_report("overall_sentiment") do |report|
report.modes = [:stacked_chart] report.modes = [:stacked_chart]
threshold = 0.6 threshold = SENTIMENT_THRESHOLD
sentiment_count_sql = Proc.new { |sentiment| <<~SQL } sentiment_count_sql = Proc.new { |sentiment| <<~SQL }
COUNT( COUNT(

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::Sentiment::SentimentAnalysisReport do
fab!(:admin)
fab!(:category)
fab!(:topic) { Fabricate(:topic, category: category) }
fab!(:post) { Fabricate(:post, user: admin, topic: topic) }
fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) }
fab!(:classification_result) { Fabricate(:classification_result, target: post) }
before { SiteSetting.ai_sentiment_enabled = true }
it "contains the correct filters" do
report = Report.find("sentiment_analysis")
expect(report.available_filters).to include("group_by", "sort_by", "category", "tag")
end
it "contains the correct labels" do
report = Report.find("sentiment_analysis")
expect(report.labels).to eq(%w[Positive Neutral Negative])
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
RSpec.describe DiscourseAi::Sentiment::SentimentController do
describe "#posts" do
fab!(:admin)
fab!(:category)
fab!(:topic) { Fabricate(:topic, category: category) }
fab!(:post) { Fabricate(:post, user: admin, topic: topic) }
fab!(:post_2) { Fabricate(:post, user: admin, topic: topic) }
fab!(:classification_result) { Fabricate(:classification_result, target: post) }
before do
SiteSetting.ai_sentiment_enabled = true
sign_in(admin)
end
it "returns a posts based on params" do
post.reload
classification_result.reload
get "/discourse-ai/sentiment/posts",
params: {
group_by: "category",
group_value: category.name,
threshold: 0.0,
}
expect(response).to be_successful
posts = JSON.parse(response.body)
posts.each do |post|
expect(post).to have_key("sentiment")
expect(post["sentiment"]).to match(/positive|negative|neutral/)
end
end
end
end