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



67
68
69
# File 'app/services/blog/content_rules.rb', line 67

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



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

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)


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

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



50
51
52
# File 'app/services/blog/content_rules.rb', line 50

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

.rules_for(scope) ⇒ Object



63
64
65
# File 'app/services/blog/content_rules.rb', line 63

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.



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

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