Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@
# Local AI agent notes
/CLAUDE.md
.superpowers/

# Dangerous local-only scripts
/bin/clone-production-db
4 changes: 4 additions & 0 deletions app/jobs/extract_committee_members_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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",
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions app/services/ai/open_ai_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions db/seeds/prompt_templates.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
]
},
Expand Down
16 changes: 14 additions & 2 deletions lib/prompt_template_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}}

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

<guidelines>
- Write in plain language a resident would use at a neighborhood
gathering. No government jargon ("motion to waive reading and
Expand Down
42 changes: 42 additions & 0 deletions test/jobs/extract_committee_members_job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
36 changes: 36 additions & 0 deletions test/services/ai/open_ai_service_analyze_meeting_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading