Skip to content

Commit 1d5a3f5

Browse files
committed
add auto-tag feature
1 parent c032619 commit 1d5a3f5

23 files changed

Lines changed: 512 additions & 4 deletions

app/controllers/admin/meetings_controller.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ def publish
162162
end
163163
end
164164

165+
def auto_tag
166+
@meeting = Meeting.find(params[:id])
167+
version = @meeting.current_version
168+
items = version&.agenda_items&.to_a || []
169+
170+
AutoTaggingService.new(items).call
171+
172+
tagged_count = items.count { |i| i.tags.reload.any? }
173+
redirect_to admin_meeting_path(@meeting),
174+
notice: "Auto-tagged #{tagged_count} of #{items.size} items."
175+
end
176+
165177
def destroy
166178
@meeting = Meeting.find(params[:id])
167179
@meeting.destroy!
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module Admin
2+
class TagRulesController < BaseController
3+
def create
4+
@tag = Tag.find(params[:tag_id])
5+
@rule = @tag.tag_rules.build(rule_params)
6+
7+
if @rule.save
8+
respond_to do |format|
9+
format.turbo_stream
10+
format.html { redirect_to admin_tags_path }
11+
end
12+
else
13+
respond_to do |format|
14+
format.turbo_stream { render turbo_stream: turbo_stream.replace("new_rule_form_#{@tag.id}", partial: "admin/tag_rules/form", locals: { tag: @tag, rule: @rule }) }
15+
format.html { redirect_to admin_tags_path, alert: @rule.errors.full_messages.to_sentence }
16+
end
17+
end
18+
end
19+
20+
def destroy
21+
@rule = TagRule.find(params[:id])
22+
@tag = @rule.tag
23+
@rule.destroy
24+
25+
respond_to do |format|
26+
format.turbo_stream
27+
format.html { redirect_to admin_tags_path }
28+
end
29+
end
30+
31+
private
32+
33+
def rule_params
34+
params.require(:tag_rule).permit(:pattern, :match_type)
35+
end
36+
end
37+
end

app/controllers/admin/tags_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class TagsController < BaseController
77

88
def index
99
@tags = Tag.left_joins(:agenda_item_tags)
10+
.includes(:tag_rules)
1011
.select("tags.*, COUNT(agenda_item_tags.id) AS items_count")
1112
.group("tags.id")
1213

@@ -43,6 +44,11 @@ def destroy
4344
redirect_to admin_tags_path(q: params[:q]), notice: "Tag \"#{@tag.name}\" deleted."
4445
end
4546

47+
def seed_rules
48+
AutoTaggingService.seed_default_rules!
49+
redirect_to admin_tags_path, notice: "Default auto-tag rules seeded."
50+
end
51+
4652
def search
4753
tags = Tag.search(params[:q].to_s).alphabetical.limit(10)
4854
render json: tags.map { |t| { id: t.id, name: t.name } }
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["input", "matchType", "typeButton"]
5+
6+
keydown(event) {
7+
if (event.key === "Enter") {
8+
event.preventDefault()
9+
if (this.inputTarget.value.trim() !== "") {
10+
this.element.requestSubmit()
11+
}
12+
}
13+
}
14+
15+
toggleType() {
16+
const current = this.matchTypeTarget.value
17+
if (current === "keyword") {
18+
this.matchTypeTarget.value = "phrase"
19+
this.typeButtonTarget.textContent = "P"
20+
} else {
21+
this.matchTypeTarget.value = "keyword"
22+
this.typeButtonTarget.textContent = "K"
23+
}
24+
}
25+
}

app/models/tag.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ class Tag < ApplicationRecord
22
include Starrable
33
has_many :agenda_item_tags, dependent: :destroy
44
has_many :agenda_items, through: :agenda_item_tags
5+
has_many :tag_rules, dependent: :destroy
56

67
validates :name, presence: true, uniqueness: { case_sensitive: false }
78

app/models/tag_rule.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class TagRule < ApplicationRecord
2+
belongs_to :tag
3+
4+
enum :match_type, {
5+
keyword: "keyword",
6+
phrase: "phrase"
7+
}
8+
9+
validates :pattern, presence: true
10+
validates :match_type, presence: true
11+
12+
normalizes :pattern, with: ->(p) { p.strip }
13+
14+
def to_regex
15+
escaped = Regexp.escape(pattern)
16+
if keyword?
17+
/\b#{escaped}\b/i
18+
else
19+
/\b#{escaped}/i
20+
end
21+
end
22+
end

app/services/agenda_apply_changes_service.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def initialize(meeting, new_data, accepted_changes)
66
@new_data = new_data
77
@accepted_changes = accepted_changes # array of item_numbers to accept
88
@errors = []
9+
@changed_items = []
910
end
1011

1112
def call
@@ -18,6 +19,8 @@ def call
1819
ActiveRecord::Base.transaction do
1920
apply_accepted_changes(version)
2021
raise ActiveRecord::Rollback if @errors.any?
22+
23+
AutoTaggingService.new(@changed_items).call if @changed_items.any?
2124
end
2225

2326
self
@@ -56,6 +59,7 @@ def update_item(old_item, new_data)
5659
url: new_data[:url],
5760
item_type: new_data[:item_type]
5861
)
62+
@changed_items << old_item
5963
rescue ActiveRecord::RecordInvalid => e
6064
@errors << "Failed to update #{old_item.item_number}: #{e.message}"
6165
end
@@ -76,7 +80,9 @@ def add_item(version, new_data)
7680
position: position
7781
)
7882

79-
unless item.save
83+
if item.save
84+
@changed_items << item
85+
else
8086
@errors << "Failed to add #{new_data[:item_number]}: #{item.errors.full_messages.join(', ')}"
8187
end
8288
end

app/services/agenda_import_service.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def call
2727

2828
build_sections_and_items
2929
raise ActiveRecord::Rollback if @errors.any?
30+
31+
auto_tag_new_items
3032
end
3133

3234
if @errors.any?
@@ -46,6 +48,10 @@ def new_version?
4648

4749
private
4850

51+
def auto_tag_new_items
52+
AutoTaggingService.new(@agenda_version.agenda_items.reload.to_a).call
53+
end
54+
4955
def validate_structure!
5056
unless @data.is_a?(Hash) && @data["meeting"].is_a?(Hash) && @data["sections"].is_a?(Array)
5157
@errors << "Invalid JSON structure: must contain 'meeting' object and 'sections' array"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
class AutoTaggingService
2+
# Default rules used to seed tag_rules when no rules exist in the database.
3+
# Format: { tag: "tag name", rules: [{ pattern: "text", match_type: "keyword"|"phrase" }] }
4+
DEFAULT_RULES = [
5+
{ tag: "parking", rules: [ { pattern: "parking", match_type: "keyword" } ] },
6+
{ tag: "taxes", rules: [ { pattern: "tax abate", match_type: "phrase" },
7+
{ pattern: "property tax", match_type: "phrase" },
8+
{ pattern: "payroll tax", match_type: "phrase" },
9+
{ pattern: "real estate tax", match_type: "phrase" } ] },
10+
{ tag: "payroll", rules: [ { pattern: "payroll", match_type: "keyword" },
11+
{ pattern: "salary", match_type: "keyword" },
12+
{ pattern: "salaries", match_type: "keyword" } ] },
13+
{ tag: "fees", rules: [ { pattern: "fee schedule", match_type: "phrase" } ] },
14+
{ tag: "street safety", rules: [ { pattern: "vehicles and traffic", match_type: "phrase" } ] },
15+
{ tag: "bike lanes", rules: [ { pattern: "bicycle", match_type: "keyword" },
16+
{ pattern: "bike", match_type: "keyword" },
17+
{ pattern: "greenway", match_type: "keyword" },
18+
{ pattern: "scooter", match_type: "phrase" } ] },
19+
{ tag: "environmental", rules: [ { pattern: "soil remed", match_type: "phrase" },
20+
{ pattern: "chromate", match_type: "keyword" },
21+
{ pattern: "environmental protection", match_type: "phrase" },
22+
{ pattern: "contaminat", match_type: "phrase" },
23+
{ pattern: "flood hazard", match_type: "phrase" },
24+
{ pattern: "underground storage tank", match_type: "phrase" } ] },
25+
{ tag: "building", rules: [ { pattern: "redevelopment plan", match_type: "phrase" },
26+
{ pattern: "land development", match_type: "phrase" } ] },
27+
{ tag: "leases", rules: [ { pattern: "lease", match_type: "keyword" } ] },
28+
{ tag: "Infrastructure", rules: [ { pattern: "department of infrastructure", match_type: "phrase" },
29+
{ pattern: "division of engineering", match_type: "phrase" } ] },
30+
{ tag: "budget", rules: [ { pattern: "budget", match_type: "keyword" },
31+
{ pattern: "appropriation", match_type: "phrase" } ] },
32+
{ tag: "cannabis", rules: [ { pattern: "cannabis", match_type: "keyword" } ] },
33+
{ tag: "parks", rules: [ { pattern: "park maintenance", match_type: "phrase" },
34+
{ pattern: "recreation and youth", match_type: "phrase" },
35+
{ pattern: "pershing field", match_type: "phrase" },
36+
{ pattern: "liberty state park", match_type: "phrase" } ] },
37+
{ tag: "housing", rules: [ { pattern: "affordable housing", match_type: "phrase" },
38+
{ pattern: "housing trust", match_type: "phrase" } ] },
39+
{ tag: "appointments", rules: [ { pattern: "appointing", match_type: "keyword" },
40+
{ pattern: "appointment of", match_type: "phrase" } ] },
41+
{ tag: "claims", rules: [ { pattern: "payment of a claim", match_type: "phrase" },
42+
{ pattern: "settlement of the action", match_type: "phrase" } ] },
43+
{ tag: "grants", rules: [ { pattern: "community development block grant", match_type: "phrase" },
44+
{ pattern: "grant fund", match_type: "phrase" } ] },
45+
{ tag: "public safety", rules: [ { pattern: "department of public safety", match_type: "phrase" },
46+
{ pattern: "division of fire", match_type: "phrase" },
47+
{ pattern: "division of police", match_type: "phrase" } ] },
48+
{ tag: "senior services", rules: [ { pattern: "senior citizen", match_type: "phrase" },
49+
{ pattern: "senior center", match_type: "phrase" },
50+
{ pattern: "senior meal", match_type: "phrase" },
51+
{ pattern: "senior lunch", match_type: "phrase" },
52+
{ pattern: "senior affair", match_type: "phrase" },
53+
{ pattern: "congregate", match_type: "phrase" } ] },
54+
{ tag: "water", rules: [ { pattern: "water agreement", match_type: "phrase" },
55+
{ pattern: "jcmua", match_type: "keyword" } ] }
56+
].freeze
57+
58+
def initialize(agenda_items)
59+
@agenda_items = Array(agenda_items)
60+
end
61+
62+
def call
63+
return if @agenda_items.empty?
64+
65+
load_rules
66+
@agenda_items.each { |item| tag_item(item) }
67+
end
68+
69+
# Seeds default rules for tags that have no rules yet.
70+
# Safe to call multiple times — only adds rules to tags that have none.
71+
def self.seed_default_rules!
72+
DEFAULT_RULES.each do |entry|
73+
tag = Tag.where("LOWER(name) = ?", entry[:tag].downcase).first_or_create!(name: entry[:tag])
74+
next if tag.tag_rules.any?
75+
76+
entry[:rules].each do |rule|
77+
tag.tag_rules.create!(pattern: rule[:pattern], match_type: rule[:match_type])
78+
end
79+
end
80+
end
81+
82+
private
83+
84+
def load_rules
85+
@rules = Tag.joins(:tag_rules)
86+
.includes(:tag_rules)
87+
.distinct
88+
.map do |tag|
89+
regexes = tag.tag_rules.map(&:to_regex)
90+
{ tag: tag, regexes: regexes }
91+
end
92+
end
93+
94+
def tag_item(item)
95+
title = item.title.to_s
96+
97+
@rules.each do |rule|
98+
if rule[:regexes].any? { |pat| pat.match?(title) }
99+
item.agenda_item_tags.find_or_create_by!(tag: rule[:tag])
100+
end
101+
end
102+
end
103+
end

app/views/admin/meetings/show.html.erb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
<%= button_to "Unpublish", publish_admin_meeting_path(@meeting, version_id: @agenda_version.id), method: :post, class: "px-3.5 py-2 text-sm font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 ring-1 ring-amber-600/10 transition-colors cursor-pointer", data: { turbo_confirm: "Are you sure you want to unpublish this version? It will no longer be visible to the public." } %>
3333
<% end %>
3434
<% end %>
35+
<% if @agenda_version %>
36+
<%= button_to "Auto Tag", auto_tag_admin_meeting_path(@meeting), method: :post, class: "px-3.5 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-lg hover:bg-blue-100 ring-1 ring-blue-600/10 transition-colors cursor-pointer", data: { turbo_confirm: "Auto-tag all items in this meeting based on keyword rules?" } %>
37+
<% end %>
3538
<% if @meeting.minutes_imported? %>
3639
<%= button_to "Delete Minutes", delete_minutes_admin_meeting_path(@meeting), method: :delete, class: "px-3.5 py-2 text-sm font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 ring-1 ring-amber-600/10 transition-colors cursor-pointer", data: { turbo_confirm: "Are you sure you want to delete minutes data? This will remove all vote records for this meeting." } %>
3740
<% end %>

0 commit comments

Comments
 (0)