Skip to content

Commit e8c5866

Browse files
xrendanclaude
andcommitted
Add source_type filtering, source linking on status changes, and evidence date fixes
- Add source_type filter to commitments, dashboard, and burndown APIs - Add source_id to commitment_status_changes with backfill from commitment sources - Use source document date for status change changed_at instead of extraction date - Include url and date in all source serializations (events, assessments, revisions, status history) - Add rake tasks for backfilling, dedup, reevaluation, and database backup - Update criterion assessor and status deriver models Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34e6066 commit e8c5866

27 files changed

Lines changed: 1233 additions & 94 deletions

.claude/settings.local.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
"Bash(git push:*)",
77
"Bash(curl:*)",
88
"Bash(mkdir:*)",
9-
"Bash(grep:*)"
9+
"Bash(grep:*)",
10+
"Bash(gem pristine:*)"
1011
],
1112
"deny": []
1213
}
13-
}
14+
}

.ruby-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ruby-3.4.4
1+
ruby-3.4.7

Procfile.dev

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
web: bin/rails server -p ${PORT:-3000}
2+
worker: bundle exec good_job start --max-threads=15

app/controllers/api/burndown_controller.rb

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
module Api
22
class BurndownController < ApplicationController
3-
STARTED_STATUSES = %w[in_progress partially_implemented implemented abandoned].freeze
4-
COMPLETED_STATUSES = %w[implemented].freeze
3+
STARTED_STATUSES = %w[in_progress completed abandoned].freeze
4+
COMPLETED_STATUSES = %w[completed].freeze
5+
ABANDONED_STATUSES = %w[abandoned].freeze
56

67
def show
78
government = Government.find(params[:government_id])
89
commitments = government.commitments
910

11+
if params[:source_type].present?
12+
commitments = commitments.joins(:sources).where(sources: { source_type: params[:source_type] }).distinct
13+
end
14+
1015
policy_area = nil
1116
if params[:policy_area_slug].present?
1217
policy_area = PolicyArea.find_by!(slug: params[:policy_area_slug])
@@ -38,46 +43,65 @@ def show
3843
first_change_by_commitment[sc.commitment_id] ||= sc.previous_status
3944
end
4045

41-
# For commitments already in a started status without status_change records,
42-
# use their earliest evidence date instead of date_promised
43-
earliest_evidence_dates = earliest_evidence_dates_for(commitment_ids)
46+
# Gather all real-world evidence dates per commitment
47+
# (from CommitmentMatch → Bill/StatcanDataset and from CriterionAssessment → Source)
48+
all_evidence_dates = evidence_dates_for(commitment_ids)
4449

45-
# Build sorted events
50+
# Build sorted events, tracking effective state per commitment
51+
# to avoid double-counting from duplicate status change records
4652
events = []
53+
effective_state = {}
4754

4855
commitment_records.each do |c|
4956
initial = first_change_by_commitment[c.id] || c.status
57+
effective_state[c.id] = initial
5058
scope_date = c.date_promised || c.created_at.to_date
5159

5260
# Scope always enters on date_promised
53-
events << { date: scope_date, delta_scope: 1, delta_started: 0, delta_completed: 0 }
54-
55-
# Started/completed enter on evidence date (if no status_change drove it)
56-
next if first_change_by_commitment.key?(c.id)
61+
events << { date: scope_date, delta_scope: 1, delta_started: 0, delta_completed: 0, delta_abandoned: 0 }
5762

63+
# Add initial state event for all commitments that start in a non-default state
5864
if STARTED_STATUSES.include?(initial)
59-
evidence_date = earliest_evidence_dates[c.id] || scope_date
65+
evidence_date = all_evidence_dates[c.id]&.min || scope_date
6066
events << { date: evidence_date, delta_scope: 0, delta_started: 1,
61-
delta_completed: COMPLETED_STATUSES.include?(initial) ? 1 : 0 }
67+
delta_completed: COMPLETED_STATUSES.include?(initial) ? 1 : 0,
68+
delta_abandoned: ABANDONED_STATUSES.include?(initial) ? 1 : 0 }
6269
end
6370
end
6471

6572
status_changes.each do |sc|
66-
ds = 0
67-
dc = 0
73+
# Use effective state to calculate deltas, skipping duplicate transitions
74+
current = effective_state[sc.commitment_id]
75+
next if sc.new_status == current
6876

69-
was_started = STARTED_STATUSES.include?(sc.previous_status)
77+
was_started = STARTED_STATUSES.include?(current)
7078
now_started = STARTED_STATUSES.include?(sc.new_status)
79+
ds = 0
7180
ds = 1 if now_started && !was_started
7281
ds = -1 if !now_started && was_started
7382

74-
was_completed = COMPLETED_STATUSES.include?(sc.previous_status)
83+
was_completed = COMPLETED_STATUSES.include?(current)
7584
now_completed = COMPLETED_STATUSES.include?(sc.new_status)
85+
dc = 0
7686
dc = 1 if now_completed && !was_completed
7787
dc = -1 if !now_completed && was_completed
7888

79-
events << { date: sc.changed_at.to_date, delta_scope: 0,
80-
delta_started: ds, delta_completed: dc }
89+
was_abandoned = ABANDONED_STATUSES.include?(current)
90+
now_abandoned = ABANDONED_STATUSES.include?(sc.new_status)
91+
da = 0
92+
da = 1 if now_abandoned && !was_abandoned
93+
da = -1 if !now_abandoned && was_abandoned
94+
95+
# Use the latest real-world evidence date before the job ran,
96+
# falling back to the job run date if no evidence dates exist
97+
job_date = sc.changed_at.to_date
98+
dates = all_evidence_dates[sc.commitment_id] || []
99+
event_date = dates.select { |d| d <= job_date }.max || dates.min || job_date
100+
101+
events << { date: event_date, delta_scope: 0,
102+
delta_started: ds, delta_completed: dc, delta_abandoned: da }
103+
104+
effective_state[sc.commitment_id] = sc.new_status
81105
end
82106

83107
events.sort_by! { |e| e[:date] }
@@ -86,24 +110,26 @@ def show
86110
scope = 0
87111
started = 0
88112
completed = 0
113+
abandoned = 0
89114
series = []
90115
current_date = nil
91116

92117
events.each do |e|
93118
if current_date && e[:date] != current_date
94-
series << { date: current_date.iso8601, scope: scope, started: started, completed: completed }
119+
series << { date: current_date.iso8601, scope: scope, started: started, completed: completed, abandoned: abandoned }
95120
end
96121
current_date = e[:date]
97122
scope += e[:delta_scope]
98123
started += e[:delta_started]
99124
completed += e[:delta_completed]
125+
abandoned += e[:delta_abandoned]
100126
end
101127

102-
series << { date: current_date.iso8601, scope: scope, started: started, completed: completed } if current_date
128+
series << { date: current_date.iso8601, scope: scope, started: started, completed: completed, abandoned: abandoned } if current_date
103129

104130
# Also emit today if last event was before today
105131
if current_date && current_date < Date.current
106-
series << { date: Date.current.iso8601, scope: scope, started: started, completed: completed }
132+
series << { date: Date.current.iso8601, scope: scope, started: started, completed: completed, abandoned: abandoned }
107133
end
108134

109135
render json: {
@@ -119,21 +145,28 @@ def show
119145

120146
private
121147

122-
def earliest_evidence_dates_for(commitment_ids)
123-
# Find the earliest real-world date from each commitment's matched evidence.
124-
# For Bills: use the earliest milestone date (first reading, latest_activity, etc.)
125-
# For StatcanDatasets: use last_synced_at
126-
matches = CommitmentMatch.where(commitment_id: commitment_ids).includes(:matchable)
148+
def evidence_dates_for(commitment_ids)
149+
result = Hash.new { |h, k| h[k] = [] }
127150

128-
result = {}
129-
matches.each do |cm|
151+
# Real-world dates from matched evidence (Bills, StatcanDatasets)
152+
CommitmentMatch.where(commitment_id: commitment_ids).includes(:matchable).each do |cm|
130153
date = evidence_date_for(cm.matchable)
131-
next unless date
132-
133-
existing = result[cm.commitment_id]
134-
result[cm.commitment_id] = date if existing.nil? || date < existing
154+
result[cm.commitment_id] << date if date
135155
end
136156

157+
# Real-world dates from criterion assessment sources
158+
CriterionAssessment
159+
.joins(criterion: :commitment)
160+
.joins("LEFT JOIN sources ON sources.id = criterion_assessments.source_id")
161+
.where(criteria: { commitment_id: commitment_ids })
162+
.where.not(sources: { date: nil })
163+
.pluck("criteria.commitment_id", "sources.date")
164+
.each do |cid, date|
165+
result[cid] << date
166+
end
167+
168+
# Deduplicate
169+
result.each_value(&:uniq!)
137170
result
138171
end
139172

@@ -146,6 +179,8 @@ def evidence_date_for(matchable)
146179
].compact.min&.to_date
147180
when StatcanDataset
148181
matchable.last_synced_at&.to_date
182+
when Entry
183+
matchable.published_at&.to_date
149184
end
150185
end
151186
end

app/controllers/api/dashboard_controller.rb

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ class DashboardController < ApplicationController
33
def at_a_glance
44
government = Government.find(params[:government_id])
55

6-
policy_areas = PolicyArea.ordered.includes(commitments: :lead_department).where(commitments: { government_id: government.id }).distinct
6+
base_scope = government.commitments
7+
if params[:source_type].present?
8+
base_scope = base_scope.joins(:sources).where(sources: { source_type: params[:source_type] }).distinct
9+
end
10+
11+
base_commitment_ids = base_scope.pluck(:id)
12+
13+
policy_areas = PolicyArea.ordered.includes(commitments: :lead_department).where(commitments: { id: base_commitment_ids }).distinct
714

815
data = policy_areas.map do |pa|
9-
area_commitments = pa.commitments.where(government_id: government.id)
16+
area_commitments = pa.commitments.where(id: base_commitment_ids)
1017
{
1118
id: pa.id,
1219
name: pa.name,
@@ -25,7 +32,7 @@ def at_a_glance
2532
}
2633
end
2734

28-
unassigned = government.commitments.where(policy_area_id: nil)
35+
unassigned = Commitment.where(id: base_commitment_ids, policy_area_id: nil)
2936
if unassigned.any?
3037
data << {
3138
id: nil,
@@ -45,23 +52,21 @@ def at_a_glance
4552
}
4653
end
4754

48-
all_commitments = government.commitments
55+
all_commitments = Commitment.where(id: base_commitment_ids)
4956
total = all_commitments.count
5057
status_counts = all_commitments.group(:status).count
5158

5259
not_started = status_counts.fetch("not_started", 0)
53-
implemented = status_counts.fetch("implemented", 0)
54-
in_progress = status_counts.fetch("in_progress", 0) +
55-
status_counts.fetch("partially_implemented", 0)
56-
successful = all_commitments.joins(:success_criteria).merge(Criterion.where(status: :met)).distinct.count
60+
completed = status_counts.fetch("completed", 0)
61+
in_progress = status_counts.fetch("in_progress", 0)
5762

5863
render json: {
5964
government: { id: government.id, name: government.name },
6065
total_commitments: total,
6166
summary: {
6267
not_started: { count: not_started, label: "Not Started", subtitle: "no action taken" },
63-
completed: { count: implemented, label: "Completed", subtitle: "of #{total} commitments" },
64-
successful: { count: successful, label: "Successful", subtitle: "meeting success criteria" }
68+
completed: { count: completed, label: "Completed", subtitle: "of #{total} commitments" },
69+
in_progress: { count: in_progress, label: "In Progress", subtitle: "action taken" }
6570
},
6671
status_counts: status_counts,
6772
policy_areas: data

app/controllers/commitments_controller.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ def index
2222
@commitments = @commitments.joins(:lead_commitment_department).joins("INNER JOIN departments AS lead_depts ON lead_depts.id = commitment_departments.department_id").where(lead_depts: { slug: params[:lead_department] })
2323
end
2424

25+
if params[:source_type].present?
26+
@commitments = @commitments.joins(:sources).where(sources: { source_type: params[:source_type] }).distinct
27+
end
28+
2529
@commitments = apply_sorting(@commitments)
2630
@total_count = @commitments.count
2731
@commitments = @commitments.limit(page_size).offset(page_offset)
@@ -48,7 +52,7 @@ def sort_direction
4852
end
4953

5054
def page_size
51-
[(params[:per_page] || 50).to_i, 100].min
55+
[(params[:per_page] || 50).to_i, 1000].min
5256
end
5357

5458
def page_offset

app/jobs/commitment_assessment_job.rb

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def perform(commitment)
1010
return if unassessed_matches.empty?
1111

1212
evidence_items = unassessed_matches.map(&:matchable).compact
13+
latest_evidence_date = evidence_items.filter_map { |m| evidence_date_for(m) }.max || Time.current
1314

1415
commitment.criteria.find_each do |criterion|
1516
assessor = CriterionAssessor.create!(record: criterion)
@@ -23,18 +24,28 @@ def perform(commitment)
2324
previous_status: criterion.status,
2425
new_status: new_status,
2526
evidence_notes: assessor.assessment["evidence_notes"],
26-
assessed_at: Time.current
27+
assessed_at: latest_evidence_date
2728
)
2829

2930
criterion.update!(
3031
status: new_status,
3132
evidence_notes: assessor.assessment["evidence_notes"],
32-
assessed_at: Time.current
33+
assessed_at: latest_evidence_date
3334
)
3435
end
3536

36-
unassessed_matches.update_all(assessed: true, assessed_at: Time.current)
37-
commitment.update!(last_assessed_at: Time.current)
37+
unassessed_matches.update_all(assessed: true, assessed_at: latest_evidence_date)
38+
commitment.update!(last_assessed_at: latest_evidence_date)
3839
CommitmentStatusDerivationJob.perform_later(commitment)
3940
end
41+
42+
private
43+
44+
def evidence_date_for(matchable)
45+
case matchable
46+
when Entry then matchable.published_at
47+
when Bill then [matchable.passed_house_first_reading_at, matchable.latest_activity_at].compact.max
48+
when StatcanDataset then matchable.last_synced_at
49+
end
50+
end
4051
end

app/jobs/commitment_dedup_job.rb

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ class CommitmentDedupJob < ApplicationJob
22
queue_as :default
33

44
def perform(government)
5-
commitments = government.commitments.where.not(status: :abandoned).order(:id)
5+
commitments = government.commitments.includes(:lead_department).where.not(status: :abandoned).order(:id)
66
return if commitments.size < 2
77

88
Rails.logger.info("CommitmentDedupJob: Checking #{commitments.size} commitments for duplicates")
99

10-
commitments.each_slice(50) do |batch|
10+
commitments.group_by(&:lead_department).each do |department, batch|
11+
next if batch.size < 2
12+
13+
Rails.logger.info("CommitmentDedupJob: Checking #{batch.size} commitments for #{department&.display_name || 'unassigned'}")
14+
1115
finder = CommitmentDedupFinder.create!(record: government)
1216
finder.extract!(finder.prompt(batch))
1317

@@ -37,11 +41,6 @@ def merge_commitment(keep, duplicate, reason)
3741
duplicate.commitment_departments.where.not(department_id: keep.commitment_departments.select(:department_id)).update_all(commitment_id: keep.id)
3842
duplicate.commitment_departments.where(department_id: keep.commitment_departments.select(:department_id)).delete_all
3943

40-
if duplicate.respond_to?(:superseded_by_id)
41-
duplicate.update_columns(superseded_by_id: keep.id)
42-
end
43-
44-
duplicate.abandonment_reason = "Duplicate of commitment ##{keep.id}: #{reason}"
45-
duplicate.update!(status: :abandoned)
44+
duplicate.destroy!
4645
end
4746
end

app/jobs/commitment_relevance_filter_job.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def perform(matchable)
2222
) do |cm|
2323
cm.relevance_score = match_data["relevance_score"]
2424
cm.relevance_reasoning = match_data["relevance_reasoning"]
25-
cm.matched_at = Time.current
25+
cm.matched_at = evidence_date_for(matchable) || Time.current
2626
end
2727
end
2828
end
@@ -49,6 +49,7 @@ def evidence_date_for(matchable)
4949
case matchable
5050
when Entry then matchable.published_at&.to_date
5151
when Bill then matchable.passed_house_first_reading_at&.to_date
52+
when StatcanDataset then matchable.last_synced_at&.to_date
5253
end
5354
end
5455

app/jobs/commitment_status_derivation_job.rb

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,8 @@ def perform(commitment)
2222
"from #{commitment.status} to #{recommended} (confidence: #{confidence}) — #{reasoning}"
2323
)
2424

25+
evidence_date = commitment.criteria.maximum(:assessed_at)
26+
commitment.status_changed_at = evidence_date
2527
commitment.update!(status: recommended)
26-
27-
commitment.status_changes.create!(
28-
previous_status: commitment.status_before_last_save,
29-
new_status: recommended,
30-
changed_at: Time.current,
31-
reason: "AI-derived: #{reasoning}"
32-
)
3328
end
3429
end

0 commit comments

Comments
 (0)