Skip to content

Commit 08453a6

Browse files
xrendanclaude
andcommitted
Remove superseded status and filter non-government bills from matching
Replace the superseded commitment status with update_existing in the reconciler — restated commitments now get their text updated with a revision entry, while truly incompatible ones are marked abandoned. Filter out private members' bills and senate public bills from the commitment relevance matching pipeline, since they don't represent government action. Clean up existing bad matches and tainted criteria. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2b17c96 commit 08453a6

15 files changed

Lines changed: 245 additions & 37 deletions

app/avo/resources/commitment.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class Avo::Resources::Commitment < Avo::BaseResource
2-
self.includes = [ :government, :parent, :superseded_by, :policy_area ]
2+
self.includes = [ :government, :parent, :policy_area ]
33
self.search = {
44
query: -> { query.ransack(title_cont: params[:q], m: "or").result(distinct: false) }
55
}
@@ -22,7 +22,6 @@ def fields
2222
field :government, as: :belongs_to
2323
field :policy_area, as: :belongs_to
2424
field :parent, as: :belongs_to
25-
field :superseded_by, as: :belongs_to
2625
field :lead_department, as: :has_one
2726

2827
field :children, as: :has_many

app/controllers/api/burndown_controller.rb

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module Api
22
class BurndownController < ApplicationController
3-
STARTED_STATUSES = %w[in_progress partially_implemented implemented abandoned superseded].freeze
3+
STARTED_STATUSES = %w[in_progress partially_implemented implemented abandoned].freeze
44
COMPLETED_STATUSES = %w[implemented].freeze
55

66
def show
@@ -17,7 +17,7 @@ def show
1717
mandate_end = government.mandate_end
1818

1919
# Build events from commitments and status changes
20-
commitment_records = commitments.select(:id, :status, :created_at)
20+
commitment_records = commitments.select(:id, :status, :date_promised, :created_at)
2121
commitment_ids = commitment_records.map(&:id)
2222

2323
status_changes = CommitmentStatusChange
@@ -32,14 +32,28 @@ def show
3232
first_change_by_commitment[sc.commitment_id] ||= sc.previous_status
3333
end
3434

35+
# For commitments already in a started status without status_change records,
36+
# use their earliest evidence date instead of date_promised
37+
earliest_evidence_dates = earliest_evidence_dates_for(commitment_ids)
38+
3539
# Build sorted events
3640
events = []
3741

3842
commitment_records.each do |c|
3943
initial = first_change_by_commitment[c.id] || c.status
40-
events << { date: c.created_at.to_date, delta_scope: 1,
41-
delta_started: STARTED_STATUSES.include?(initial) ? 1 : 0,
42-
delta_completed: COMPLETED_STATUSES.include?(initial) ? 1 : 0 }
44+
scope_date = c.date_promised || c.created_at.to_date
45+
46+
# Scope always enters on date_promised
47+
events << { date: scope_date, delta_scope: 1, delta_started: 0, delta_completed: 0 }
48+
49+
# Started/completed enter on evidence date (if no status_change drove it)
50+
next if first_change_by_commitment.key?(c.id)
51+
52+
if STARTED_STATUSES.include?(initial)
53+
evidence_date = earliest_evidence_dates[c.id] || scope_date
54+
events << { date: evidence_date, delta_scope: 0, delta_started: 1,
55+
delta_completed: COMPLETED_STATUSES.include?(initial) ? 1 : 0 }
56+
end
4357
end
4458

4559
status_changes.each do |sc|
@@ -95,5 +109,37 @@ def show
95109
series: series
96110
}
97111
end
112+
113+
private
114+
115+
def earliest_evidence_dates_for(commitment_ids)
116+
# Find the earliest real-world date from each commitment's matched evidence.
117+
# For Bills: use the earliest milestone date (first reading, latest_activity, etc.)
118+
# For StatcanDatasets: use last_synced_at
119+
matches = CommitmentMatch.where(commitment_id: commitment_ids).includes(:matchable)
120+
121+
result = {}
122+
matches.each do |cm|
123+
date = evidence_date_for(cm.matchable)
124+
next unless date
125+
126+
existing = result[cm.commitment_id]
127+
result[cm.commitment_id] = date if existing.nil? || date < existing
128+
end
129+
130+
result
131+
end
132+
133+
def evidence_date_for(matchable)
134+
case matchable
135+
when Bill
136+
[
137+
matchable.passed_house_first_reading_at,
138+
matchable.latest_activity_at
139+
].compact.min&.to_date
140+
when StatcanDataset
141+
matchable.last_synced_at&.to_date
142+
end
143+
end
98144
end
99145
end

app/jobs/commitment_relevance_filter_job.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ class CommitmentRelevanceFilterJob < ApplicationJob
22
queue_as :default
33

44
def perform(matchable)
5+
return if matchable.is_a?(Bill) && !matchable.government_bill?
6+
57
commitments = active_commitments_for(matchable)
68
return if commitments.none?
79

@@ -29,7 +31,7 @@ def perform(matchable)
2931
private
3032

3133
def active_commitments_for(matchable)
32-
scope = Commitment.where.not(status: [:abandoned, :superseded])
34+
scope = Commitment.where.not(status: :abandoned)
3335

3436
if matchable.respond_to?(:government_id) && matchable.government_id.present?
3537
scope.where(government_id: matchable.government_id)

app/jobs/source_document_processor_job.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def perform(source_document)
3535
created_commitments = create_commitments(deduped, source_document.government, source, policy_areas, departments)
3636

3737
set_parent_relationships(created_commitments, deduped)
38+
reconcile_commitments(created_commitments, source_document, source)
3839

3940
source_document.update!(
4041
status: :extracted,
@@ -128,6 +129,7 @@ def create_commitments(extracted, government, source, policy_areas, departments)
128129
cs.reference = data["source_reference"]
129130
cs.excerpt = data["original_text"]&.truncate(500)
130131
end
132+
update_commitment_if_drifted(commitment, data, source)
131133
created[data["title"]] = commitment
132134
next
133135
end
@@ -176,4 +178,70 @@ def set_parent_relationships(created, extracted)
176178
child.update!(parent: parent) unless child.parent_id == parent.id
177179
end
178180
end
181+
182+
def update_commitment_if_drifted(commitment, data, source)
183+
tracked_fields = %w[title description original_text]
184+
changes = {}
185+
186+
tracked_fields.each do |field|
187+
new_value = data[field]
188+
next if new_value.blank?
189+
next if commitment.send(field) == new_value
190+
changes[field] = new_value
191+
end
192+
193+
return if changes.empty?
194+
195+
old_values = {
196+
title: commitment.title,
197+
description: commitment.description,
198+
original_text: commitment.original_text
199+
}
200+
new_values = old_values.merge(changes.symbolize_keys)
201+
202+
summarizer = CommitmentDriftSummarizer.create!(record: commitment)
203+
summarizer.extract!(summarizer.prompt(old_values, new_values))
204+
205+
commitment.drift_source = source
206+
commitment.drift_change_summary = summarizer.change_summary
207+
commitment.update!(changes)
208+
end
209+
210+
def reconcile_commitments(created_commitments, source_document, source)
211+
created_ids = created_commitments.values.map(&:id)
212+
active_commitments = Commitment.where(government: source_document.government)
213+
.where.not(id: created_ids)
214+
.where.not(status: :abandoned)
215+
216+
return if active_commitments.empty?
217+
218+
reconciler = CommitmentReconciler.create!(record: source_document)
219+
reconciler.extract!(reconciler.prompt(created_commitments.values, active_commitments))
220+
221+
(reconciler.update_existing || []).each do |entry|
222+
existing = Commitment.find_by(id: entry["existing_commitment_id"])
223+
new_commitment = created_commitments.values.find { |c| c.title == entry["new_commitment_title"] }
224+
next unless existing && new_commitment
225+
226+
data = {
227+
"title" => new_commitment.title,
228+
"description" => new_commitment.description,
229+
"original_text" => new_commitment.original_text
230+
}
231+
update_commitment_if_drifted(existing, data, source)
232+
233+
new_commitment.commitment_sources.update_all(commitment_id: existing.id)
234+
new_commitment.destroy!
235+
created_commitments.delete(entry["new_commitment_title"])
236+
end
237+
238+
(reconciler.abandoned || []).each do |entry|
239+
next unless entry["confidence"].to_f >= 0.6
240+
commitment = Commitment.find_by(id: entry["commitment_id"])
241+
next unless commitment
242+
243+
commitment.abandonment_reason = entry["reason"]
244+
commitment.update!(status: :abandoned)
245+
end
246+
end
179247
end

app/models/bill.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
class Bill < ApplicationRecord
2+
GOVERNMENT_BILL_TYPES = [ "House Government Bill", "Senate Government Bill" ].freeze
3+
24
has_many :commitment_matches, as: :matchable, dependent: :destroy
35

6+
scope :government_bills, -> { where("data->>'BillTypeEn' IN (?)", GOVERNMENT_BILL_TYPES) }
7+
8+
def government_bill?
9+
GOVERNMENT_BILL_TYPES.include?(data&.dig("BillTypeEn"))
10+
end
11+
412
def self.sync_all
513
api_bills_array = BillsFetcher.fetch("https://www.parl.ca/legisinfo/en/bills/json")
614
bills_attributes = api_bills_array.map { |api_data| attributes_from_api(api_data) }

app/models/commitment.rb

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
class Commitment < ApplicationRecord
2+
attr_accessor :drift_source, :drift_change_summary, :abandonment_reason
3+
24
belongs_to :government
35
belongs_to :policy_area, optional: true
46
belongs_to :parent, class_name: "Commitment", optional: true
5-
belongs_to :superseded_by, class_name: "Commitment", optional: true
67

78
has_many :children, class_name: "Commitment", foreign_key: :parent_id, dependent: :destroy
8-
has_many :supersedes, class_name: "Commitment", foreign_key: :superseded_by_id, dependent: :nullify
99
has_many :commitment_sources, dependent: :destroy
1010
has_many :sources, through: :commitment_sources
1111
has_many :criteria, dependent: :destroy
@@ -38,8 +38,7 @@ class Commitment < ApplicationRecord
3838
in_progress: 1,
3939
partially_implemented: 2,
4040
implemented: 3,
41-
abandoned: 4,
42-
superseded: 5
41+
abandoned: 4
4342
}
4443

4544
validates :title, presence: true
@@ -108,8 +107,11 @@ def track_status_change
108107
status_changes.create!(
109108
previous_status: previous,
110109
new_status: current,
111-
changed_at: Time.current
110+
changed_at: Time.current,
111+
reason: abandonment_reason
112112
)
113+
ensure
114+
self.abandonment_reason = nil
113115
end
114116

115117
def tracking_drift?
@@ -128,7 +130,12 @@ def snapshot_revision
128130
description: changes[:description] || description,
129131
original_text: changes[:original_text] || original_text,
130132
target_date: changes[:target_date] || target_date,
131-
revision_date: Date.current
133+
revision_date: Date.current,
134+
source: drift_source,
135+
change_summary: drift_change_summary
132136
)
137+
ensure
138+
self.drift_source = nil
139+
self.drift_change_summary = nil
133140
end
134141
end

app/models/commitment_event.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ class CommitmentEvent < ApplicationRecord
88
legislative_action: 2,
99
funding_allocated: 3,
1010
status_change: 4,
11-
criterion_assessed: 5,
12-
superseded: 6
11+
criterion_assessed: 5
1312
}
1413

1514
enum :action_type, {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
class CommitmentReconciler < Chat
2+
include Structify::Model
3+
4+
MODEL = "gemini-3.1-pro-preview"
5+
6+
after_create { with_model(MODEL, provider: :gemini, assume_exists: true) }
7+
8+
schema_definition do
9+
version 1
10+
name "CommitmentReconciler"
11+
field :update_existing, :array,
12+
description: "Existing commitments that have been restated with evolved language, adjusted targets, or updated scope in this document",
13+
items: {
14+
type: "object", properties: {
15+
"existing_commitment_id" => { type: "integer", description: "ID of the existing commitment to update" },
16+
"new_commitment_title" => { type: "string", description: "Title of the new commitment that restates it" },
17+
"reason" => { type: "string", description: "Brief explanation of what changed" }
18+
}
19+
}
20+
field :abandoned, :array,
21+
description: "Existing commitments that appear to have been dropped or abandoned based on this document",
22+
items: {
23+
type: "object", properties: {
24+
"commitment_id" => { type: "integer", description: "ID of the commitment that appears abandoned" },
25+
"confidence" => { type: "number", description: "Confidence level from 0.0 to 1.0 that this commitment was truly abandoned" },
26+
"reason" => { type: "string", description: "Brief explanation of why this commitment appears abandoned" }
27+
}
28+
}
29+
end
30+
31+
def system_prompt
32+
<<~PROMPT
33+
You are a government policy analyst detecting when official commitments have been updated or abandoned.
34+
35+
Be CONSERVATIVE. Minimize false positives. When in doubt, do not flag.
36+
37+
Two categories:
38+
1. UPDATE EXISTING — same policy goal restated with adjusted wording, targets, or scope.
39+
The underlying intent is preserved. Example: spending target increased, program renamed
40+
and expanded, deadline extended with new scope.
41+
2. ABANDON — fundamentally different approach or explicitly reversed. The old intent is
42+
no longer being pursued. Only flag when the document explicitly contradicts or reverses
43+
a commitment — omission alone is NOT evidence of abandonment.
44+
PROMPT
45+
end
46+
47+
def prompt(new_commitments, existing_commitments)
48+
new_list = new_commitments.map { |c| "- [NEW] #{c.title}: #{c.description}" }.join("\n")
49+
existing_list = existing_commitments.map { |c| "- [ID: #{c.id}] #{c.title}: #{c.description}" }.join("\n")
50+
51+
<<~PROMPT
52+
A new government source document has been processed. Compare the NEW commitments
53+
extracted from it against EXISTING active commitments for the same government.
54+
55+
Identify:
56+
1. UPDATE EXISTING: An existing commitment restated with evolved language, adjusted
57+
targets, or updated scope — the same policy goal, just refined.
58+
2. ABANDONED: An existing commitment that has been explicitly reversed or contradicted
59+
(omission alone is NOT evidence of abandonment).
60+
61+
NEW COMMITMENTS FROM THIS DOCUMENT:
62+
#{new_list}
63+
64+
EXISTING ACTIVE COMMITMENTS:
65+
#{existing_list}
66+
PROMPT
67+
end
68+
end

app/models/criterion_assessor.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def prompt(criterion, evidence_items)
4444
- partially_met: Some evidence of progress but not complete
4545
- not_met: Evidence exists but shows criterion is not satisfied, or contradictory evidence
4646
- not_assessed: Insufficient evidence to make any determination
47-
- no_longer_applicable: The commitment has been superseded or the criterion is moot
47+
- no_longer_applicable: The commitment has been abandoned or the criterion is moot
4848
4949
Be CONSERVATIVE. Only mark as "met" if evidence clearly supports it.
5050
If current status is already "met" and no contradictory evidence, keep it "met".

app/views/commitments/show.json.jbuilder

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,6 @@ if @commitment.parent
2929
end
3030
end
3131

32-
if @commitment.superseded_by
33-
json.superseded_by do
34-
json.(@commitment.superseded_by, :id, :title)
35-
end
36-
end
37-
38-
json.supersedes @commitment.supersedes, :id, :title, :status
39-
4032
json.children @commitment.children, :id, :title, :status
4133

4234
json.sources @commitment.commitment_sources.includes(:source) do |cs|

0 commit comments

Comments
 (0)