Class: Blog::ContentRules

Inherits:
Object
  • Object
show all
Defined in:
app/services/blog/content_rules.rb

Overview

Stage 4 of the Sunny blog editor fix plan: server-enforced content rules.

Brain prompts that say "never put X in Y" are advisory only — the LLM
routinely ignores them (Julia's example: "Remove the Operating cost
calculator widget for the FAQ" → Sunny re-inserted it a few turns later).
This registry turns enforceable rules into hard server validators that
reject offending content at save time, with the rule name surfaced in
the error so Sunny knows what to fix.

Rules are scoped — a single piece of HTML can be validated against
only the rules that apply to its scope (:post_body, :faq_answer, etc).

Usage:
Blog::ContentRules.register(
Blog::ContentRules::Rule.new(
name: :no_widgets_in_faq_answers,
applies_to: :faq_answer,
validate: ->(html) { ... returns nil or violation message ... }
)
)

violations = Blog::ContentRules.validate(scope: :faq_answer, html: faq_html)

=> [{ rule: :no_widgets_in_faq_answers, message: "..." }, ...]

Defined Under Namespace

Modules: Helpers Classes: Rule

Class Method Summary collapse

Class Method Details

.allObject



69
70
71
# File 'app/services/blog/content_rules.rb', line 69

def all
  @mutex.synchronize { @rules.dup }
end

.format_error(violations, scope:) ⇒ String?

Format violations into a single error message for tool error responses.
Multiple violations are concatenated with rule names so Sunny can
address each one specifically.

Parameters:

  • violations (Array<Hash>)

    from #validate

  • scope (Symbol)

    for the user-facing prefix

Returns:

  • (String, nil)

    formatted error or nil if no violations



80
81
82
83
84
85
86
87
88
89
90
91
# File 'app/services/blog/content_rules.rb', line 80

def format_error(violations, scope:)
  return nil if violations.empty?

  scope_label = scope.to_s.tr('_', ' ').capitalize
  if violations.size == 1
    violation = violations.first
    "#{scope_label} rejected by rule :#{violation[:rule]}#{violation[:message]}"
  else
    parts = violations.map { |violation| ":#{violation[:rule]}#{violation[:message]}" }
    "#{scope_label} rejected by #{violations.size} content rules. " + parts.join(' | ')
  end
end

.register(rule) ⇒ Object

Raises:

  • (ArgumentError)


45
46
47
48
49
50
# File 'app/services/blog/content_rules.rb', line 45

def register(rule)
  raise ArgumentError, 'rule must be a Blog::ContentRules::Rule' unless rule.is_a?(Rule)

  @mutex.synchronize { @rules << rule }
  rule
end

.reset!Object



52
53
54
# File 'app/services/blog/content_rules.rb', line 52

def reset!
  @mutex.synchronize { @rules = [] }
end

.rules_for(scope) ⇒ Object



65
66
67
# File 'app/services/blog/content_rules.rb', line 65

def rules_for(scope)
  @mutex.synchronize { @rules.select { |r| r.applies_to == scope } }
end

.validate(scope:, html:) ⇒ Object

Returns array of { rule:, message: } for every rule violation found
in the given HTML against rules registered for the given scope.



58
59
60
61
62
63
# File 'app/services/blog/content_rules.rb', line 58

def validate(scope:, html:)
  return [] if html.nil? || html.to_s.strip.empty?

  scoped = @mutex.synchronize { @rules.select { |r| r.applies_to == scope } }
  scoped.flat_map { |r| r.call(html) }
end