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



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

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



76
77
78
79
80
81
82
83
84
85
86
87
# File 'app/services/blog/content_rules.rb', line 76

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)


41
42
43
44
45
46
# File 'app/services/blog/content_rules.rb', line 41

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



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

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

.rules_for(scope) ⇒ Object



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

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.



54
55
56
57
58
59
# File 'app/services/blog/content_rules.rb', line 54

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