diff --git a/.gitignore b/.gitignore
index 6241b69..b33788a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,6 @@
# Local AI agent notes
/CLAUDE.md
.superpowers/
+
+# Dangerous local-only scripts
+/bin/clone-production-db
diff --git a/app/jobs/extract_committee_members_job.rb b/app/jobs/extract_committee_members_job.rb
index c1ebae6..cdf96a9 100644
--- a/app/jobs/extract_committee_members_job.rb
+++ b/app/jobs/extract_committee_members_job.rb
@@ -37,6 +37,7 @@ def create_attendance_records(meeting, data)
member = Member.resolve(name)
next unless member
+ next if meeting.meeting_attendances.exists?(member: member)
meeting.meeting_attendances.create!(
member: member, status: "present", attendee_type: "voting_member"
@@ -48,6 +49,7 @@ def create_attendance_records(meeting, data)
member = Member.resolve(name)
next unless member
+ next if meeting.meeting_attendances.exists?(member: member)
meeting.meeting_attendances.create!(
member: member, status: "absent", attendee_type: "voting_member"
@@ -60,6 +62,7 @@ def create_attendance_records(meeting, data)
member = Member.resolve(name)
next unless member
+ next if meeting.meeting_attendances.exists?(member: member)
meeting.meeting_attendances.create!(
member: member, status: "present", attendee_type: "non_voting_staff",
@@ -73,6 +76,7 @@ def create_attendance_records(meeting, data)
member = Member.resolve(name)
next unless member
+ next if meeting.meeting_attendances.exists?(member: member)
meeting.meeting_attendances.create!(
member: member, status: "present", attendee_type: "guest"
diff --git a/app/services/ai/open_ai_service.rb b/app/services/ai/open_ai_service.rb
index d46b792..edd09c2 100644
--- a/app/services/ai/open_ai_service.rb
+++ b/app/services/ai/open_ai_service.rb
@@ -603,10 +603,12 @@ def analyze_meeting_content(doc_text, kb_context, type, source: nil)
template = PromptTemplate.find_by!(key: "analyze_meeting_content")
committee_ctx = prepare_committee_context
system_role = template.interpolate_system_role(committee_context: committee_ctx)
+ body_name = source.respond_to?(:body_name) ? source.body_name.to_s : ""
placeholders = {
kb_context: kb_context.to_s,
committee_context: committee_ctx,
type: type.to_s,
+ body_name: body_name,
doc_text: doc_text.truncate(100_000)
}
prompt = template.interpolate(**placeholders)
diff --git a/db/seeds/prompt_templates.rb b/db/seeds/prompt_templates.rb
index 55c3937..8539333 100644
--- a/db/seeds/prompt_templates.rb
+++ b/db/seeds/prompt_templates.rb
@@ -166,6 +166,7 @@
{ "name" => "kb_context", "description" => "Knowledge base context chunks" },
{ "name" => "committee_context", "description" => "Active committees and descriptions" },
{ "name" => "type", "description" => "Document type: packet or minutes" },
+ { "name" => "body_name", "description" => "Meeting body name (e.g. City Council Meeting)" },
{ "name" => "doc_text", "description" => "Meeting document text (truncated to 50k)" }
]
},
diff --git a/lib/prompt_template_data.rb b/lib/prompt_template_data.rb
index ad57f4b..2eb2bdc 100644
--- a/lib/prompt_template_data.rb
+++ b/lib/prompt_template_data.rb
@@ -804,12 +804,24 @@ module PromptTemplateData
get more scrutiny.
ROLE
instructions: <<~PROMPT.strip
- Analyze the provided {{type}} text and return a JSON object with the
- structure specified below.
+ Analyze the provided {{type}} text for a **{{body_name}}** and return
+ a JSON object with the structure specified below.
{{kb_context}}
{{committee_context}}
+
+ This document is for a {{body_name}}. It may contain embedded minutes
+ from other committees or commissions (e.g., Plan Commission, Room Tax
+ Commission, Park & Rec Board) that were included as consent agenda
+ items for acceptance or approval.
+
+ ONLY extract headline, highlights, public_input, and item_details from
+ the {{body_name}} proceedings. Ignore all content from embedded minutes
+ of other bodies — their public comments, motions, discussions, and
+ roll calls belong to those other meetings, not this one.
+
+
- Write in plain language a resident would use at a neighborhood
gathering. No government jargon ("motion to waive reading and
diff --git a/test/jobs/extract_committee_members_job_test.rb b/test/jobs/extract_committee_members_job_test.rb
index d3269cf..4e5889a 100644
--- a/test/jobs/extract_committee_members_job_test.rb
+++ b/test/jobs/extract_committee_members_job_test.rb
@@ -373,6 +373,48 @@ def stub_ai_response(response_hash)
mock_service.verify
end
+ test "skips duplicate when two names resolve to same member" do
+ member = Member.create!(name: "Smith")
+ MemberAlias.create!(member: member, name: "Councilmember Smith")
+
+ ai_response = {
+ "voting_members_present" => [ "Smith", "Councilmember Smith" ],
+ "voting_members_absent" => [],
+ "non_voting_staff" => [],
+ "guests" => []
+ }
+ mock_service = stub_ai_response(ai_response)
+
+ Ai::OpenAiService.stub :new, mock_service do
+ assert_difference "MeetingAttendance.count", 1 do
+ ExtractCommitteeMembersJob.perform_now(@meeting.id)
+ end
+ end
+ mock_service.verify
+ end
+
+ test "skips duplicate when same member appears across categories" do
+ member = Member.create!(name: "Kyle Kordell")
+
+ ai_response = {
+ "voting_members_present" => [ "Kyle Kordell" ],
+ "voting_members_absent" => [],
+ "non_voting_staff" => [ { "name" => "Kyle Kordell", "capacity" => "City Manager" } ],
+ "guests" => []
+ }
+ mock_service = stub_ai_response(ai_response)
+
+ Ai::OpenAiService.stub :new, mock_service do
+ assert_difference "MeetingAttendance.count", 1 do
+ ExtractCommitteeMembersJob.perform_now(@meeting.id)
+ end
+ end
+
+ attendance = @meeting.meeting_attendances.find_by(member: member)
+ assert_equal "voting_member", attendance.attendee_type
+ mock_service.verify
+ end
+
test "handles JSON parse error gracefully" do
mock_service = Minitest::Mock.new
mock_service.expect :extract_committee_members, "not valid json" do |text|
diff --git a/test/services/ai/open_ai_service_analyze_meeting_test.rb b/test/services/ai/open_ai_service_analyze_meeting_test.rb
index 9aa63b7..e6c3516 100644
--- a/test/services/ai/open_ai_service_analyze_meeting_test.rb
+++ b/test/services/ai/open_ai_service_analyze_meeting_test.rb
@@ -59,4 +59,40 @@ class OpenAiServiceAnalyzeMeetingTest < ActiveSupport::TestCase
assert prompt_text.include?("procedural") || prompt_text.include?("adjourn"),
"Prompt must mention procedural filtering"
end
+
+ test "analyze_meeting_content includes body_name in prompt to scope content extraction" do
+ captured_params = nil
+
+ mock_chat = lambda do |parameters:|
+ captured_params = parameters
+ {
+ "choices" => [ {
+ "message" => {
+ "content" => {
+ "headline" => "Test headline",
+ "highlights" => [],
+ "public_input" => [],
+ "item_details" => []
+ }.to_json
+ }
+ } ]
+ }
+ end
+
+ meeting = Meeting.create!(body_name: "City Council Meeting", starts_at: Time.current, detail_page_url: "https://example.com/meeting/1")
+
+ @service.instance_variable_get(:@client).stub :chat, mock_chat do
+ @service.send(:analyze_meeting_content, "Test packet text", "kb context", "packet", source: meeting)
+ end
+
+ prompt_text = captured_params[:messages].map { |m| m[:content] }.join(" ")
+
+ # Must include the body name so AI knows which meeting's content to extract
+ assert prompt_text.include?("City Council Meeting"),
+ "Prompt must include body_name to scope extraction to the primary meeting"
+
+ # Must instruct AI to ignore embedded committee minutes
+ assert prompt_text.downcase.include?("embedded") || prompt_text.downcase.include?("subordinate") || prompt_text.downcase.include?("other committee"),
+ "Prompt must warn about embedded minutes from other committees"
+ end
end