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