Class: Blog::ContentRules
- Inherits:
-
Object
- Object
- Blog::ContentRules
- 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
- .all ⇒ Object
-
.format_error(violations, scope:) ⇒ String?
Format violations into a single error message for tool error responses.
- .register(rule) ⇒ Object
- .reset! ⇒ Object
- .rules_for(scope) ⇒ Object
-
.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.
Class Method Details
.all ⇒ Object
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.
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
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 |