rails-image-post-solution 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +8 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE.txt +21 -0
- data/README.md +264 -0
- data/Rakefile +8 -0
- data/app/controllers/admin/frozen_posts_controller.rb +79 -0
- data/app/controllers/admin/image_reports_controller.rb +81 -0
- data/app/controllers/admin/users_controller.rb +100 -0
- data/app/controllers/rails_image_post_solution/admin/image_reports_controller.rb +92 -0
- data/app/controllers/rails_image_post_solution/image_reports_controller.rb +87 -0
- data/app/jobs/rails_image_post_solution/image_moderation_job.rb +97 -0
- data/app/models/rails_image_post_solution/image_report.rb +57 -0
- data/app/services/rails_image_post_solution/openai_vision_service.rb +128 -0
- data/app/views/admin/frozen_posts/index.html.erb +128 -0
- data/app/views/admin/image_reports/index.html.erb +94 -0
- data/app/views/admin/image_reports/show.html.erb +138 -0
- data/app/views/admin/users/index.html.erb +93 -0
- data/app/views/admin/users/show.html.erb +198 -0
- data/app/views/rails_image_post_solution/admin/image_reports/index.html.erb +100 -0
- data/app/views/rails_image_post_solution/admin/image_reports/show.html.erb +154 -0
- data/app/views/shared/_image_report_button.html.erb +26 -0
- data/app/views/shared/_image_report_modal.html.erb +45 -0
- data/config/locales/en.yml +260 -0
- data/config/locales/ja.yml +259 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20250101000001_create_rails_image_post_solution_image_reports.rb +20 -0
- data/db/migrate/20250101000002_add_ai_moderation_fields_to_image_reports.rb +12 -0
- data/lib/generators/rails_image_post_solution/install/README +27 -0
- data/lib/generators/rails_image_post_solution/install/install_generator.rb +47 -0
- data/lib/generators/rails_image_post_solution/install/templates/add_ai_moderation_fields.rb.erb +12 -0
- data/lib/generators/rails_image_post_solution/install/templates/create_image_reports.rb.erb +19 -0
- data/lib/generators/rails_image_post_solution/install/templates/rails_image_post_solution.rb +15 -0
- data/lib/rails_image_post_solution/engine.rb +22 -0
- data/lib/rails_image_post_solution/version.rb +5 -0
- data/lib/rails_image_post_solution.rb +28 -0
- metadata +150 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsImagePostSolution
|
|
4
|
+
module Admin
|
|
5
|
+
class ImageReportsController < ApplicationController
|
|
6
|
+
before_action :require_login
|
|
7
|
+
before_action :require_admin
|
|
8
|
+
before_action :set_report, only: %i[show confirm dismiss]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@status_filter = params[:status] || "all"
|
|
12
|
+
|
|
13
|
+
@reports = ImageReport.includes(:user, :active_storage_attachment, :reviewed_by)
|
|
14
|
+
.recent
|
|
15
|
+
|
|
16
|
+
# Filter by status
|
|
17
|
+
case @status_filter
|
|
18
|
+
when "pending"
|
|
19
|
+
@reports = @reports.pending
|
|
20
|
+
when "confirmed"
|
|
21
|
+
@reports = @reports.confirmed
|
|
22
|
+
when "dismissed"
|
|
23
|
+
@reports = @reports.dismissed
|
|
24
|
+
when "reviewed"
|
|
25
|
+
@reports = @reports.reviewed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@reports = @reports.limit(100)
|
|
29
|
+
|
|
30
|
+
# Statistics
|
|
31
|
+
@stats = {
|
|
32
|
+
total: ImageReport.count,
|
|
33
|
+
pending: ImageReport.pending.count,
|
|
34
|
+
confirmed: ImageReport.confirmed.count,
|
|
35
|
+
dismissed: ImageReport.dismissed.count,
|
|
36
|
+
reviewed: ImageReport.reviewed.count
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def show
|
|
41
|
+
@attachment = @report.active_storage_attachment
|
|
42
|
+
@reported_user = @attachment.record.user if @attachment.record.respond_to?(:user)
|
|
43
|
+
@all_reports = ImageReport.where(active_storage_attachment_id: @attachment.id)
|
|
44
|
+
.includes(:user)
|
|
45
|
+
.recent
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def confirm
|
|
49
|
+
@report.update!(
|
|
50
|
+
status: ImageReport::STATUSES[:confirmed],
|
|
51
|
+
reviewed_by: current_user,
|
|
52
|
+
reviewed_at: Time.current
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
redirect_to admin_image_reports_path, notice: I18n.t("rails_image_post_solution.flash.admin.report_confirmed")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def dismiss
|
|
59
|
+
@report.update!(
|
|
60
|
+
status: ImageReport::STATUSES[:dismissed],
|
|
61
|
+
reviewed_by: current_user,
|
|
62
|
+
reviewed_at: Time.current
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
redirect_to admin_image_reports_path, notice: I18n.t("rails_image_post_solution.flash.admin.report_dismissed")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def set_report
|
|
71
|
+
@report = ImageReport.find(params[:id])
|
|
72
|
+
rescue ActiveRecord::RecordNotFound
|
|
73
|
+
redirect_to admin_image_reports_path, alert: I18n.t("rails_image_post_solution.flash.admin.report_not_found")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def require_admin
|
|
77
|
+
admin_check = RailsImagePostSolution.configuration&.admin_check_method || :admin?
|
|
78
|
+
|
|
79
|
+
return if current_user.respond_to?(admin_check) && current_user.public_send(admin_check)
|
|
80
|
+
|
|
81
|
+
redirect_to main_app.root_path, alert: I18n.t("rails_image_post_solution.flash.admin.admin_access_only")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def require_login
|
|
85
|
+
# Call authentication method implemented in host application
|
|
86
|
+
return if respond_to?(:current_user) && current_user
|
|
87
|
+
|
|
88
|
+
redirect_to main_app.root_path, alert: I18n.t("rails_image_post_solution.flash.admin.login_required")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsImagePostSolution
|
|
4
|
+
class ImageReportsController < ApplicationController
|
|
5
|
+
before_action :require_login
|
|
6
|
+
before_action :set_attachment, only: :create
|
|
7
|
+
|
|
8
|
+
def create
|
|
9
|
+
# Check if already reported
|
|
10
|
+
existing_report = ImageReport.find_by(
|
|
11
|
+
active_storage_attachment_id: @attachment.id,
|
|
12
|
+
user_id: current_user.id
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if existing_report
|
|
16
|
+
respond_to do |format|
|
|
17
|
+
format.json do
|
|
18
|
+
render json: { error: I18n.t("rails_image_post_solution.flash.already_reported") },
|
|
19
|
+
status: :unprocessable_entity
|
|
20
|
+
end
|
|
21
|
+
format.html do
|
|
22
|
+
redirect_back fallback_location: root_path,
|
|
23
|
+
alert: I18n.t("rails_image_post_solution.flash.already_reported")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
return
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@report = ImageReport.new(
|
|
30
|
+
active_storage_attachment_id: @attachment.id,
|
|
31
|
+
user_id: current_user.id,
|
|
32
|
+
reason: params[:reason],
|
|
33
|
+
status: ImageReport::STATUSES[:pending]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if @report.save
|
|
37
|
+
# Run image moderation only on first report
|
|
38
|
+
if ImageReport.where(active_storage_attachment_id: @attachment.id).count == 1
|
|
39
|
+
ImageModerationJob.perform_later(@attachment.id)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
respond_to do |format|
|
|
43
|
+
format.json do
|
|
44
|
+
render json: { success: true, message: I18n.t("rails_image_post_solution.flash.report_received") },
|
|
45
|
+
status: :created
|
|
46
|
+
end
|
|
47
|
+
format.html do
|
|
48
|
+
redirect_back fallback_location: root_path,
|
|
49
|
+
notice: I18n.t("rails_image_post_solution.flash.report_received")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
else
|
|
53
|
+
respond_to do |format|
|
|
54
|
+
format.json { render json: { error: @report.errors.full_messages.join(", ") }, status: :unprocessable_entity }
|
|
55
|
+
format.html { redirect_back fallback_location: root_path, alert: @report.errors.full_messages.join(", ") }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def set_attachment
|
|
63
|
+
@attachment = ActiveStorage::Attachment.find(params[:attachment_id])
|
|
64
|
+
rescue ActiveRecord::RecordNotFound
|
|
65
|
+
respond_to do |format|
|
|
66
|
+
format.json do
|
|
67
|
+
render json: { error: I18n.t("rails_image_post_solution.flash.image_not_found") }, status: :not_found
|
|
68
|
+
end
|
|
69
|
+
format.html do
|
|
70
|
+
redirect_back fallback_location: root_path, alert: I18n.t("rails_image_post_solution.flash.image_not_found")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def require_login
|
|
76
|
+
# Call authentication method implemented in host application
|
|
77
|
+
return if respond_to?(:current_user) && current_user
|
|
78
|
+
|
|
79
|
+
respond_to do |format|
|
|
80
|
+
format.json do
|
|
81
|
+
render json: { error: I18n.t("rails_image_post_solution.flash.login_required") }, status: :unauthorized
|
|
82
|
+
end
|
|
83
|
+
format.html { redirect_to main_app.root_path, alert: I18n.t("rails_image_post_solution.flash.login_required") }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsImagePostSolution
|
|
4
|
+
class ImageModerationJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
# Job to perform automatic image moderation
|
|
8
|
+
# Uses OpenAI Vision API to detect R18/R18G content
|
|
9
|
+
def perform(attachment_id)
|
|
10
|
+
attachment = ActiveStorage::Attachment.find_by(id: attachment_id)
|
|
11
|
+
return unless attachment&.blob
|
|
12
|
+
|
|
13
|
+
# Analyze image using OpenAI Vision Service
|
|
14
|
+
result = OpenaiVisionService.new.moderate_image(attachment)
|
|
15
|
+
|
|
16
|
+
# Process results
|
|
17
|
+
if result[:flagged]
|
|
18
|
+
# Create automatic report when inappropriate content is detected
|
|
19
|
+
create_auto_report(attachment, result)
|
|
20
|
+
|
|
21
|
+
# Temporarily freeze post (if enabled in config)
|
|
22
|
+
freeze_post(attachment, result) if auto_freeze_enabled?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Rails.logger.info "Image moderation completed for attachment ##{attachment_id}: #{result[:flagged] ? 'FLAGGED' : 'OK'}"
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Rails.logger.error "Image moderation failed for attachment ##{attachment_id}: #{e.message}"
|
|
28
|
+
# Don't fail the job on error (leave for manual review)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def auto_freeze_enabled?
|
|
34
|
+
RailsImagePostSolution.configuration&.auto_freeze_on_flag != false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create_auto_report(attachment, result)
|
|
38
|
+
# Create automatic report as system user (nil user_id)
|
|
39
|
+
# Skip if automatic report already exists
|
|
40
|
+
existing_report = ImageReport.find_by(
|
|
41
|
+
active_storage_attachment_id: attachment.id,
|
|
42
|
+
user_id: nil # Automatic report by system
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return if existing_report
|
|
46
|
+
|
|
47
|
+
ImageReport.create!(
|
|
48
|
+
active_storage_attachment: attachment,
|
|
49
|
+
user_id: nil, # Automatic report by system
|
|
50
|
+
reason: build_auto_report_reason(result),
|
|
51
|
+
status: ImageReport::STATUSES[:confirmed], # Automatically set to confirmed (inappropriate)
|
|
52
|
+
reviewed_at: Time.current,
|
|
53
|
+
ai_flagged: result[:flagged],
|
|
54
|
+
ai_confidence: result[:confidence],
|
|
55
|
+
ai_categories: result[:categories].to_json,
|
|
56
|
+
ai_detected_at: Time.current
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_auto_report_reason(result)
|
|
61
|
+
reasons = []
|
|
62
|
+
reasons << "Auto-detected: Inappropriate content detected"
|
|
63
|
+
|
|
64
|
+
if result[:categories]
|
|
65
|
+
flagged_categories = result[:categories].select { |_, flagged| flagged }
|
|
66
|
+
if flagged_categories.any?
|
|
67
|
+
reasons << "\nDetected categories:"
|
|
68
|
+
flagged_categories.each_key do |category|
|
|
69
|
+
reasons << " - #{category}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
reasons << "\nConfidence: #{(result[:confidence] * 100).round(1)}%" if result[:confidence]
|
|
75
|
+
|
|
76
|
+
reasons.join("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Temporarily freeze post
|
|
80
|
+
# Only works if host application implements freeze_post! method
|
|
81
|
+
def freeze_post(attachment, result)
|
|
82
|
+
record = attachment.record
|
|
83
|
+
return unless record
|
|
84
|
+
return unless record.respond_to?(:freeze_post!)
|
|
85
|
+
|
|
86
|
+
# Skip if already frozen
|
|
87
|
+
return if record.respond_to?(:frozen?) && record.frozen?
|
|
88
|
+
|
|
89
|
+
reason = "AI Auto-moderation: Post has been temporarily frozen due to inappropriate content detection.\n#{build_auto_report_reason(result)}"
|
|
90
|
+
|
|
91
|
+
record.freeze_post!(type: :temporary, reason: reason)
|
|
92
|
+
Rails.logger.info "Post #{record.class.name}##{record.id} has been temporarily frozen due to inappropriate content"
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Rails.logger.error "Failed to freeze post for attachment ##{attachment.id}: #{e.message}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsImagePostSolution
|
|
4
|
+
class ImageReport < ApplicationRecord
|
|
5
|
+
self.table_name = "image_reports"
|
|
6
|
+
|
|
7
|
+
# Status constants
|
|
8
|
+
STATUSES = {
|
|
9
|
+
pending: "pending", # Not reviewed yet
|
|
10
|
+
reviewed: "reviewed", # Reviewed (no action needed)
|
|
11
|
+
confirmed: "confirmed", # Confirmed (flagged as inappropriate)
|
|
12
|
+
dismissed: "dismissed" # Dismissed (no issues found)
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# Report reason category keys (translations via I18n.t("rails_image_post_solution.image_report.categories.#{key}"))
|
|
16
|
+
REASON_CATEGORIES = %i[r18 r18g copyright spam harassment other].freeze
|
|
17
|
+
|
|
18
|
+
# Get category translation text
|
|
19
|
+
def self.category_text(key)
|
|
20
|
+
I18n.t("rails_image_post_solution.image_report.categories.#{key}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get category list (for use in views)
|
|
24
|
+
def self.categories_for_select
|
|
25
|
+
REASON_CATEGORIES.map { |key| [ key, category_text(key) ] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Associations
|
|
29
|
+
belongs_to :active_storage_attachment, class_name: "ActiveStorage::Attachment"
|
|
30
|
+
belongs_to :user
|
|
31
|
+
belongs_to :reviewed_by, class_name: "User", optional: true
|
|
32
|
+
|
|
33
|
+
# Validations
|
|
34
|
+
validates :status, presence: true, inclusion: { in: STATUSES.values }
|
|
35
|
+
validates :user_id, uniqueness: { scope: :active_storage_attachment_id,
|
|
36
|
+
message: "has already reported this image" }
|
|
37
|
+
|
|
38
|
+
# Scopes
|
|
39
|
+
scope :pending, -> { where(status: STATUSES[:pending]) }
|
|
40
|
+
scope :reviewed, -> { where(status: STATUSES[:reviewed]) }
|
|
41
|
+
scope :confirmed, -> { where(status: STATUSES[:confirmed]) }
|
|
42
|
+
scope :dismissed, -> { where(status: STATUSES[:dismissed]) }
|
|
43
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
44
|
+
|
|
45
|
+
# Check if image has been reported as inappropriate
|
|
46
|
+
def self.image_reported?(attachment_id)
|
|
47
|
+
where(active_storage_attachment_id: attachment_id)
|
|
48
|
+
.where(status: [ STATUSES[:pending], STATUSES[:confirmed] ])
|
|
49
|
+
.exists?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get report count for image
|
|
53
|
+
def self.report_count(attachment_id)
|
|
54
|
+
where(active_storage_attachment_id: attachment_id).count
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsImagePostSolution
|
|
4
|
+
class OpenaiVisionService
|
|
5
|
+
# Perform image content moderation using OpenAI Vision API
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
api_key = RailsImagePostSolution.configuration&.openai_api_key || ENV["OPENAI_API_KEY"]
|
|
9
|
+
@client = OpenAI::Client.new(access_token: api_key) if api_key.present?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Analyze image to detect R18/R18G content
|
|
13
|
+
# @param attachment [ActiveStorage::Attachment] Image to analyze
|
|
14
|
+
# @return [Hash] Analysis result { flagged: Boolean, categories: Hash, confidence: Float, raw_response: Hash }
|
|
15
|
+
def moderate_image(attachment)
|
|
16
|
+
# Skip if OpenAI API key is not configured
|
|
17
|
+
return safe_result unless @client
|
|
18
|
+
|
|
19
|
+
# Get image URL (generate temporary URL)
|
|
20
|
+
image_url = get_image_url(attachment)
|
|
21
|
+
return safe_result unless image_url
|
|
22
|
+
|
|
23
|
+
# Call OpenAI Vision API
|
|
24
|
+
response = call_vision_api(image_url)
|
|
25
|
+
|
|
26
|
+
# Parse response
|
|
27
|
+
parse_response(response)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Rails.logger.error "OpenAI Vision API error: #{e.message}"
|
|
30
|
+
safe_result # Fail safe on error (no flag)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def get_image_url(attachment)
|
|
36
|
+
# Rails.application.routes.url_helpers.rails_blob_url(attachment, only_path: false)
|
|
37
|
+
# Use Base64 encoding as local URLs don't work in development environment
|
|
38
|
+
return nil unless attachment.blob
|
|
39
|
+
|
|
40
|
+
# Encode image as Base64
|
|
41
|
+
image_data = attachment.blob.download
|
|
42
|
+
base64_image = Base64.strict_encode64(image_data)
|
|
43
|
+
"data:#{attachment.blob.content_type};base64,#{base64_image}"
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Rails.logger.error "Failed to get image data: #{e.message}"
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call_vision_api(image_url)
|
|
50
|
+
@client.chat(
|
|
51
|
+
parameters: {
|
|
52
|
+
model: "gpt-4o-mini", # Model capable of image analysis
|
|
53
|
+
messages: [
|
|
54
|
+
{
|
|
55
|
+
role: "user",
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text",
|
|
59
|
+
text: moderation_prompt
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "image_url",
|
|
63
|
+
image_url: {
|
|
64
|
+
url: image_url
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
max_tokens: 500
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def moderation_prompt
|
|
76
|
+
<<~PROMPT
|
|
77
|
+
Analyze this image and determine if it falls into any of the following categories:
|
|
78
|
+
|
|
79
|
+
1. R18 (Adult Content): Contains sexual expression or nudity
|
|
80
|
+
2. R18G (Grotesque Content): Contains violent, cruel, or gory imagery
|
|
81
|
+
3. Illegal Content: Illegal drugs, inappropriate use of weapons, etc.
|
|
82
|
+
|
|
83
|
+
Please respond in the following JSON format:
|
|
84
|
+
{
|
|
85
|
+
"flagged": true/false,
|
|
86
|
+
"categories": {
|
|
87
|
+
"r18": true/false,
|
|
88
|
+
"r18g": true/false,
|
|
89
|
+
"illegal": true/false
|
|
90
|
+
},
|
|
91
|
+
"confidence": 0.0-1.0,
|
|
92
|
+
"reason": "Brief explanation of the determination"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
**Important**: Respond only in JSON format, do not include any other text.
|
|
96
|
+
PROMPT
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_response(response)
|
|
100
|
+
content = response.dig("choices", 0, "message", "content")
|
|
101
|
+
return safe_result unless content
|
|
102
|
+
|
|
103
|
+
# Parse JSON response
|
|
104
|
+
result = JSON.parse(content)
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
flagged: result["flagged"] || false,
|
|
108
|
+
categories: result["categories"] || {},
|
|
109
|
+
confidence: result["confidence"] || 0.0,
|
|
110
|
+
reason: result["reason"],
|
|
111
|
+
raw_response: response
|
|
112
|
+
}
|
|
113
|
+
rescue JSON::ParserError => e
|
|
114
|
+
Rails.logger.error "Failed to parse OpenAI response: #{e.message}, Content: #{content}"
|
|
115
|
+
safe_result
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def safe_result
|
|
119
|
+
{
|
|
120
|
+
flagged: false,
|
|
121
|
+
categories: {},
|
|
122
|
+
confidence: 0.0,
|
|
123
|
+
reason: nil,
|
|
124
|
+
raw_response: nil
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<div class="admin-container">
|
|
2
|
+
<h1 class="admin-header"><%= t('admin.frozen_posts.index.title') %></h1>
|
|
3
|
+
|
|
4
|
+
<!-- Statistics -->
|
|
5
|
+
<div class="admin-stats">
|
|
6
|
+
<div class="stat-card">
|
|
7
|
+
<div class="stat-label"><%= t('admin.frozen_posts.index.stats.total') %></div>
|
|
8
|
+
<div class="stat-value"><%= @stats[:total] %></div>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="stat-card temporary">
|
|
11
|
+
<div class="stat-label"><%= t('admin.frozen_posts.index.stats.temporary') %></div>
|
|
12
|
+
<div class="stat-value"><%= @stats[:temporary] %></div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="stat-card permanent">
|
|
15
|
+
<div class="stat-label"><%= t('admin.frozen_posts.index.stats.permanent') %></div>
|
|
16
|
+
<div class="stat-value"><%= @stats[:permanent] %></div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- Filters -->
|
|
21
|
+
<div class="admin-filters">
|
|
22
|
+
<%= link_to t('admin.frozen_posts.index.filters.all'), admin_frozen_posts_path, class: "filter-btn #{'active' if @filter == 'all'}" %>
|
|
23
|
+
<%= link_to t('admin.frozen_posts.index.filters.temporary'), admin_frozen_posts_path(filter: "temporary"), class: "filter-btn #{'active' if @filter == 'temporary'}" %>
|
|
24
|
+
<%= link_to t('admin.frozen_posts.index.filters.permanent'), admin_frozen_posts_path(filter: "permanent"), class: "filter-btn #{'active' if @filter == 'permanent'}" %>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<!-- Frozen Posts List -->
|
|
28
|
+
<div class="admin-reports-list">
|
|
29
|
+
<% if @frozen_posts.any? %>
|
|
30
|
+
<table class="admin-table">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr>
|
|
33
|
+
<th><%= t('admin.frozen_posts.index.table.type') %></th>
|
|
34
|
+
<th><%= t('admin.frozen_posts.index.table.content') %></th>
|
|
35
|
+
<th><%= t('admin.frozen_posts.index.table.poster') %></th>
|
|
36
|
+
<th><%= t('admin.frozen_posts.index.table.freeze_reason') %></th>
|
|
37
|
+
<th><%= t('admin.frozen_posts.index.table.status') %></th>
|
|
38
|
+
<th><%= t('admin.frozen_posts.index.table.frozen_at') %></th>
|
|
39
|
+
<th><%= t('admin.frozen_posts.index.table.actions') %></th>
|
|
40
|
+
</tr>
|
|
41
|
+
</thead>
|
|
42
|
+
<tbody>
|
|
43
|
+
<% @frozen_posts.each do |post| %>
|
|
44
|
+
<tr class="report-row status-<%= post.frozen_type %>">
|
|
45
|
+
<td>
|
|
46
|
+
<% if post.is_a?(Stage) %>
|
|
47
|
+
<span class="post-type-badge stage"><%= t('admin.frozen_posts.index.post_types.stage') %></span>
|
|
48
|
+
<% else %>
|
|
49
|
+
<span class="post-type-badge comment"><%= t('admin.frozen_posts.index.post_types.comment') %></span>
|
|
50
|
+
<% end %>
|
|
51
|
+
</td>
|
|
52
|
+
<td class="content-cell">
|
|
53
|
+
<% if post.is_a?(Stage) %>
|
|
54
|
+
<div class="post-title"><%= link_to post.title, stage_path(post), target: "_blank", class: "user-link" %></div>
|
|
55
|
+
<div class="post-excerpt"><%= truncate(post.description, length: 80) %></div>
|
|
56
|
+
<% else %>
|
|
57
|
+
<div class="post-title">
|
|
58
|
+
<%= link_to "#{t('admin.frozen_posts.index.comment_prefix')}: #{post.multiplay_recruitment.title}", multiplay_recruitment_path(post.multiplay_recruitment), target: "_blank", class: "user-link" %>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="post-excerpt"><%= truncate(post.content, length: 80) %></div>
|
|
61
|
+
<% end %>
|
|
62
|
+
</td>
|
|
63
|
+
<td>
|
|
64
|
+
<%= link_to post.user.display_name, admin_user_path(post.user), class: "user-link" %>
|
|
65
|
+
</td>
|
|
66
|
+
<td class="reason-cell">
|
|
67
|
+
<%= truncate(post.frozen_reason, length: 100) %>
|
|
68
|
+
</td>
|
|
69
|
+
<td>
|
|
70
|
+
<% if post.temporarily_frozen? %>
|
|
71
|
+
<span class="status-badge status-pending"><%= t('admin.frozen_posts.index.status_badges.temporary') %></span>
|
|
72
|
+
<% elsif post.permanently_frozen? %>
|
|
73
|
+
<span class="status-badge status-confirmed"><%= t('admin.frozen_posts.index.status_badges.permanent') %></span>
|
|
74
|
+
<% end %>
|
|
75
|
+
</td>
|
|
76
|
+
<td>
|
|
77
|
+
<%= l(post.frozen_at, format: :long) %>
|
|
78
|
+
</td>
|
|
79
|
+
<td class="actions-cell">
|
|
80
|
+
<div class="action-buttons">
|
|
81
|
+
<% if post.is_a?(Stage) %>
|
|
82
|
+
<% unless post.permanently_frozen? %>
|
|
83
|
+
<%= button_to t('admin.frozen_posts.index.actions.permanent_freeze'), admin_frozen_posts_permanent_freeze_stage_path(post),
|
|
84
|
+
method: :patch,
|
|
85
|
+
class: "btn-action btn-warning",
|
|
86
|
+
data: { confirm: t('admin.frozen_posts.index.confirm.permanent_freeze') } %>
|
|
87
|
+
<% end %>
|
|
88
|
+
<%= button_to t('admin.frozen_posts.index.actions.unfreeze'), admin_frozen_posts_unfreeze_stage_path(post),
|
|
89
|
+
method: :patch,
|
|
90
|
+
class: "btn-action btn-success",
|
|
91
|
+
data: { confirm: t('admin.frozen_posts.index.confirm.unfreeze') } %>
|
|
92
|
+
<%= button_to t('admin.frozen_posts.index.actions.delete'), admin_frozen_posts_destroy_stage_path(post),
|
|
93
|
+
method: :delete,
|
|
94
|
+
class: "btn-action btn-danger",
|
|
95
|
+
data: { confirm: t('admin.frozen_posts.index.confirm.delete') } %>
|
|
96
|
+
<% else %>
|
|
97
|
+
<% unless post.permanently_frozen? %>
|
|
98
|
+
<%= button_to t('admin.frozen_posts.index.actions.permanent_freeze'), admin_frozen_posts_permanent_freeze_comment_path(post),
|
|
99
|
+
method: :patch,
|
|
100
|
+
class: "btn-action btn-warning",
|
|
101
|
+
data: { confirm: t('admin.frozen_posts.index.confirm.permanent_freeze') } %>
|
|
102
|
+
<% end %>
|
|
103
|
+
<%= button_to t('admin.frozen_posts.index.actions.unfreeze'), admin_frozen_posts_unfreeze_comment_path(post),
|
|
104
|
+
method: :patch,
|
|
105
|
+
class: "btn-action btn-success",
|
|
106
|
+
data: { confirm: t('admin.frozen_posts.index.confirm.unfreeze') } %>
|
|
107
|
+
<%= button_to t('admin.frozen_posts.index.actions.delete'), admin_frozen_posts_destroy_comment_path(post),
|
|
108
|
+
method: :delete,
|
|
109
|
+
class: "btn-action btn-danger",
|
|
110
|
+
data: { confirm: t('admin.frozen_posts.index.confirm.delete') } %>
|
|
111
|
+
<% end %>
|
|
112
|
+
</div>
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
<% end %>
|
|
116
|
+
</tbody>
|
|
117
|
+
</table>
|
|
118
|
+
<% else %>
|
|
119
|
+
<div class="empty-state">
|
|
120
|
+
<p><%= t('admin.frozen_posts.index.no_frozen_posts') %></p>
|
|
121
|
+
</div>
|
|
122
|
+
<% end %>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div class="admin-actions">
|
|
126
|
+
<%= link_to t('admin.frozen_posts.index.back'), root_path, class: "btn btn-secondary" %>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|