From 00ddece86187f84878c2a2a2e07390e663fcf261 Mon Sep 17 00:00:00 2001 From: Andre Robitaille <7423320+AndreRobitaille@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:28:16 -0500 Subject: [PATCH 1/4] fix: handle duplicate member resolution in ExtractCommitteeMembersJob Member.resolve() can map different raw names to the same Member via alias lookup or title stripping, causing uniqueness violations on MeetingAttendance. Skip duplicate attendance creation for same member. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/jobs/extract_committee_members_job.rb | 4 ++ .../extract_committee_members_job_test.rb | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) 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/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| From 85f0abcc4a803b150d38f94f1d870e7f34c9b135 Mon Sep 17 00:00:00 2001 From: Andre Robitaille <7423320+AndreRobitaille@users.noreply.github.com> Date: Thu, 9 Apr 2026 04:53:52 -0500 Subject: [PATCH 2/4] chore: gitignore local production DB clone script Prevents bin/clone-production-db (a destructive data sync tool) from being accidentally committed to the repository. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 540b0b19fd2a91f570d73e8c57f1e767df9f02bd Mon Sep 17 00:00:00 2001 From: Andre Robitaille <7423320+AndreRobitaille@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:08:56 -0500 Subject: [PATCH 3/4] fix: scope meeting summarization to primary meeting body City Council packets and minutes contain embedded minutes from subordinate committees (Plan Commission, Room Tax Commission, etc.) included for acceptance. The AI was extracting public comments and item details from those embedded minutes as if they belonged to the Council meeting. Adds body_name to the analyze_meeting_content prompt so the AI knows which body's proceedings to extract from, and adds a instruction to explicitly ignore embedded committee content. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/services/ai/open_ai_service.rb | 2 ++ lib/prompt_template_data.rb | 16 +++++++-- .../open_ai_service_analyze_meeting_test.rb | 36 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) 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/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/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 From 5d361509c79d1aadb6d0173ad2aad7fbfed8e5dc Mon Sep 17 00:00:00 2001 From: Andre Robitaille <7423320+AndreRobitaille@users.noreply.github.com> Date: Thu, 9 Apr 2026 05:19:04 -0500 Subject: [PATCH 4/4] chore: add body_name to analyze_meeting_content placeholder metadata Co-Authored-By: Claude Opus 4.6 (1M context) --- db/seeds/prompt_templates.rb | 1 + 1 file changed, 1 insertion(+) 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)" } ] },