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
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.
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
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 |