From d2c1782939c4f11187fa250f85c09c8caa810d36 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:41:17 -0600 Subject: [PATCH 1/7] fix: add ruby-vips and webmock dependencies Active Storage's engine loads the vips variant transformer unconditionally at boot on Rails 8.1, so the app can't even boot without ruby-vips despite not using image variants yet. webmock (test group) is needed to stub the Interstandard HTTP calls added in the next commits. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- Gemfile | 9 +++++++++ Gemfile.lock | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/Gemfile b/Gemfile index 416c7aa..22646b3 100644 --- a/Gemfile +++ b/Gemfile @@ -44,6 +44,10 @@ gem "thruster", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] gem "image_processing", "~> 2.0" +# Rails 8.1's Active Storage engine loads the vips transformer unconditionally +# at boot (even though this app doesn't use variants yet), so this is required +# just to boot the app, not only to process images. +gem "ruby-vips", "~> 2.0" # Headless Chrome for HTML-to-PDF rendering gem "ferrum" @@ -59,6 +63,11 @@ gem "lutaml-model", "= 0.7.7" # backtrack cleanly and fails on any `bundle update`, even for unrelated gems. gem "unitsdb", "~> 2.1.0" +group :test do + # Stub Net::HTTP calls to the Interstandard API in tests + gem "webmock", require: false +end + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" diff --git a/Gemfile.lock b/Gemfile.lock index e2bbfa2..200d6ea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,6 +107,9 @@ GEM thor (~> 1.0) concurrent-ruby (1.3.8) connection_pool (3.0.2) + crack (1.0.1) + bigdecimal + rexml crass (1.0.7) date (3.5.1) debug (1.11.1) @@ -132,11 +135,19 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.4.0) activesupport (>= 6.1) + hashdiff (1.2.1) htmlentities (4.4.2) i18n (1.15.2) concurrent-ruby (~> 1.0) @@ -307,6 +318,7 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) + rexml (3.4.4) rubocop (1.86.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -336,6 +348,9 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) + ruby-vips (2.3.0) + ffi (~> 1.12) + logger rubyzip (2.4.1) scanf (1.0.0) securerandom (0.4.1) @@ -413,6 +428,10 @@ GEM actionview (>= 8.0.0) bindex (>= 0.4.0) railties (>= 8.0.0) + webmock (3.26.4) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.2) websocket-driver (0.8.2) base64 @@ -450,6 +469,7 @@ DEPENDENCIES puma (>= 5.0) rails (~> 8.1.2) rubocop-rails-omakase + ruby-vips (~> 2.0) solid_cable solid_cache solid_queue @@ -460,6 +480,7 @@ DEPENDENCIES tzinfo-data unitsdb (~> 2.1.0) web-console + webmock BUNDLED WITH 4.0.5 From 53ad2a982205dca8f5c9a5cfb63a36eba85c792a Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:41:27 -0600 Subject: [PATCH 2/7] feat: StateStandardTagging model for CCSS-to-state retargeting Lessons and Topics keep their CCSS StandardTagging as-is; a StateStandardTagging records a retargeted state code alongside it (target framework, state code/statement, relationship, confidence, review state, disputed, provenance, edge_provenance_ref). Re-running the retarget task (next commit) upserts by the unique key and marks a code no longer confirmed `stale_at` instead of deleting it, so review history and retirement flags survive a transient hiccup. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- app/models/lesson.rb | 1 + app/models/standard.rb | 1 + app/models/state_standard_tagging.rb | 38 ++++++++++ app/models/topic.rb | 1 + ...13160000_create_state_standard_taggings.rb | 34 +++++++++ db/schema.rb | 28 ++++++- test/models/state_standard_tagging_test.rb | 76 +++++++++++++++++++ 7 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 app/models/state_standard_tagging.rb create mode 100644 db/migrate/20260913160000_create_state_standard_taggings.rb create mode 100644 test/models/state_standard_tagging_test.rb diff --git a/app/models/lesson.rb b/app/models/lesson.rb index 5f2a767..760508f 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -8,6 +8,7 @@ class Lesson < ApplicationRecord has_many :supplemental_resources, as: :resourceable, dependent: :destroy has_many :standard_taggings, as: :taggable has_many :standards, through: :standard_taggings + has_many :state_standard_taggings, as: :taggable validates :number, presence: true, uniqueness: { scope: :topic_id } validates :position, presence: true diff --git a/app/models/standard.rb b/app/models/standard.rb index b0369e9..689b21e 100644 --- a/app/models/standard.rb +++ b/app/models/standard.rb @@ -1,5 +1,6 @@ class Standard < ApplicationRecord has_many :standard_taggings, dependent: :destroy + has_many :state_standard_taggings, dependent: :destroy validates :code, presence: true, uniqueness: true validates :description, presence: true diff --git a/app/models/state_standard_tagging.rb b/app/models/state_standard_tagging.rb new file mode 100644 index 0000000..f72aac7 --- /dev/null +++ b/app/models/state_standard_tagging.rb @@ -0,0 +1,38 @@ +# A Lesson or Topic retargeted from a CCSS Standard onto one state +# framework's code, via the Interstandard translator. One row per +# (taggable, target_framework, state_code, standard_id) — the same taggable +# can carry several state codes for the same framework (a composed match) +# and codes across several frameworks. +# +# Re-running standards:retarget upserts by that key rather than deleting: +# a code missing from the latest report is marked `stale_at` instead, so a +# transient Interstandard hiccup can't silently erase confirmed history. +class StateStandardTagging < ApplicationRecord + belongs_to :taggable, polymorphic: true + belongs_to :standard + + validates :target_framework, presence: true + validates :state_code, presence: true + validates :relationship, presence: true + validates :review_state, presence: true + validates :retargeted_at, presence: true + validates :standard_id, uniqueness: { + scope: [ :taggable_type, :taggable_id, :target_framework, :state_code ] + } + + scope :for_framework, ->(framework) { where(target_framework: framework) } + scope :fresh, -> { where(stale_at: nil, retired: false) } + scope :stale, -> { where.not(stale_at: nil) } + + # Marks this row stale as of now, rather than destroying it, so it keeps + # its history and can flip back to fresh on a later confirmed re-run. + def mark_stale! + update!(stale_at: Time.current) if stale_at.nil? + end + + # Clears a previously-set stale mark when a fresh retarget confirms the + # row again. + def unmark_stale! + update!(stale_at: nil) unless stale_at.nil? + end +end diff --git a/app/models/topic.rb b/app/models/topic.rb index 3d9de33..2225a94 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -4,6 +4,7 @@ class Topic < ApplicationRecord has_many :supplemental_resources, as: :resourceable, dependent: :destroy has_many :standard_taggings, as: :taggable has_many :standards, through: :standard_taggings + has_many :state_standard_taggings, as: :taggable validates :letter, presence: true, uniqueness: { scope: :content_module_id } validates :title, presence: true diff --git a/db/migrate/20260913160000_create_state_standard_taggings.rb b/db/migrate/20260913160000_create_state_standard_taggings.rb new file mode 100644 index 0000000..36ac751 --- /dev/null +++ b/db/migrate/20260913160000_create_state_standard_taggings.rb @@ -0,0 +1,34 @@ +class CreateStateStandardTaggings < ActiveRecord::Migration[8.1] + def change + create_table :state_standard_taggings do |t| + t.bigint :taggable_id, null: false + t.string :taggable_type, null: false + t.bigint :standard_id, null: false + t.string :target_framework, null: false + t.string :state_code, null: false + t.text :state_statement + t.string :relationship, null: false + t.float :confidence + t.string :review_state, null: false + t.boolean :disputed, null: false, default: false + t.string :provenance + t.string :edge_provenance_ref + t.boolean :retired, null: false, default: false + t.datetime :retargeted_at, null: false + t.datetime :stale_at + + t.timestamps + end + + add_index :state_standard_taggings, + [ :taggable_type, :taggable_id, :target_framework, :state_code, :standard_id ], + unique: true, + name: "idx_state_standard_taggings_uniqueness" + add_index :state_standard_taggings, [ :taggable_type, :taggable_id ], name: "index_state_standard_taggings_on_taggable" + add_index :state_standard_taggings, :standard_id + add_index :state_standard_taggings, [ :target_framework, :state_code ] + add_index :state_standard_taggings, :stale_at + + add_foreign_key :state_standard_taggings, :standards + end +end diff --git a/db/schema.rb b/db/schema.rb index ca26bac..70ff2fe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_25_120001) do +ActiveRecord::Schema[8.1].define(version: 2026_09_13_160000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -150,6 +150,31 @@ t.index ["grade_level"], name: "index_standards_on_grade_level" end + create_table "state_standard_taggings", force: :cascade do |t| + t.float "confidence" + t.datetime "created_at", null: false + t.boolean "disputed", default: false, null: false + t.string "edge_provenance_ref" + t.string "provenance" + t.string "relationship", null: false + t.datetime "retargeted_at", null: false + t.boolean "retired", default: false, null: false + t.string "review_state", null: false + t.datetime "stale_at" + t.bigint "standard_id", null: false + t.string "state_code", null: false + t.text "state_statement" + t.bigint "taggable_id", null: false + t.string "taggable_type", null: false + t.string "target_framework", null: false + t.datetime "updated_at", null: false + t.index ["stale_at"], name: "index_state_standard_taggings_on_stale_at" + t.index ["standard_id"], name: "index_state_standard_taggings_on_standard_id" + t.index ["taggable_type", "taggable_id", "target_framework", "state_code", "standard_id"], name: "idx_state_standard_taggings_uniqueness", unique: true + t.index ["taggable_type", "taggable_id"], name: "index_state_standard_taggings_on_taggable" + t.index ["target_framework", "state_code"], name: "idx_on_target_framework_state_code_33e601b1ea" + end + create_table "supplemental_resources", force: :cascade do |t| t.text "content_html" t.datetime "created_at", null: false @@ -189,5 +214,6 @@ add_foreign_key "lessons", "topics" add_foreign_key "problem_sets", "lessons" add_foreign_key "standard_taggings", "standards" + add_foreign_key "state_standard_taggings", "standards" add_foreign_key "topics", "content_modules" end diff --git a/test/models/state_standard_tagging_test.rb b/test/models/state_standard_tagging_test.rb new file mode 100644 index 0000000..619c857 --- /dev/null +++ b/test/models/state_standard_tagging_test.rb @@ -0,0 +1,76 @@ +require "test_helper" + +class StateStandardTaggingTest < ActiveSupport::TestCase + setup do + @grade = Grade.create!(number: 5, title: "Grade 5") + @content_module = @grade.content_modules.create!(number: 1, title: "Module 1", position: 1) + @topic = @content_module.topics.create!(letter: "A", title: "Topic A", position: 1) + @lesson = @topic.lessons.create!(number: 1, position: 1) + @standard = Standard.create!(code: "5.NF.1", domain: "Number and Operations—Fractions", description: "d", grade_level: 5) + end + + def base_attrs + { + taggable: @lesson, + standard: @standard, + target_framework: "co-math-2020", + state_code: "5.NF.1", + relationship: "exact", + review_state: "confirmed", + retargeted_at: Time.current + } + end + + test "valid with required attributes" do + tagging = StateStandardTagging.new(base_attrs) + assert tagging.valid? + end + + test "unique on taggable + target_framework + state_code + standard" do + StateStandardTagging.create!(base_attrs) + duplicate = StateStandardTagging.new(base_attrs) + + assert_not duplicate.valid? + assert_raises(ActiveRecord::RecordInvalid) { duplicate.save! } + end + + test "same state_code for a different standard on the same taggable is allowed (composed match)" do + StateStandardTagging.create!(base_attrs) + other_standard = Standard.create!(code: "5.NF.2", domain: "d", description: "d", grade_level: 5) + + other = StateStandardTagging.new(base_attrs.merge(standard: other_standard)) + assert other.valid? + end + + test "fresh scope excludes stale and retired rows" do + fresh = StateStandardTagging.create!(base_attrs) + stale = StateStandardTagging.create!(base_attrs.merge(state_code: "5.NF.2", stale_at: Time.current)) + retired = StateStandardTagging.create!(base_attrs.merge(state_code: "5.NF.3", retired: true)) + + assert_includes StateStandardTagging.fresh, fresh + assert_not_includes StateStandardTagging.fresh, stale + assert_not_includes StateStandardTagging.fresh, retired + end + + test "mark_stale! sets stale_at once and unmark_stale! clears it" do + tagging = StateStandardTagging.create!(base_attrs) + + tagging.mark_stale! + assert tagging.stale_at.present? + + stale_at = tagging.stale_at + tagging.mark_stale! + assert_equal stale_at, tagging.reload.stale_at + + tagging.unmark_stale! + assert_nil tagging.reload.stale_at + end + + test "for_framework scopes by target_framework" do + StateStandardTagging.create!(base_attrs) + other = StateStandardTagging.create!(base_attrs.merge(target_framework: "tx-teks-math", state_code: "111.7.b.3.G")) + + assert_includes StateStandardTagging.for_framework("co-math-2020"), StateStandardTagging.first + assert_not_includes StateStandardTagging.for_framework("co-math-2020"), other + end +end From 2abacb840c04c6bab490186d7d43c6b33b466357 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:41:36 -0600 Subject: [PATCH 3/7] feat: Interstandard API client Thin Net::HTTP client for the Interstandard translator API: submit() posts a bulk retargeting submission, fetch_report() reads its report, poll() backs off between fetches until the submission reaches a terminal status (or times out). Raises Interstandard::Unauthorized on 401, Interstandard::RateLimited (with Retry-After) on 429, and Interstandard::RequestFailed (carrying the API's error code) on anything else non-2xx. Base URL and API key come from config/initializers/interstandard.rb (INTERSTANDARD_URL / INTERSTANDARD_API_KEY env vars). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- config/initializers/interstandard.rb | 18 +++ lib/interstandard/client.rb | 154 ++++++++++++++++++++++++++ test/lib/interstandard/client_test.rb | 79 +++++++++++++ test/test_helper.rb | 3 + 4 files changed, 254 insertions(+) create mode 100644 config/initializers/interstandard.rb create mode 100644 lib/interstandard/client.rb create mode 100644 test/lib/interstandard/client_test.rb diff --git a/config/initializers/interstandard.rb b/config/initializers/interstandard.rb new file mode 100644 index 0000000..eefe4ea --- /dev/null +++ b/config/initializers/interstandard.rb @@ -0,0 +1,18 @@ +# Configuration for the Interstandard translator API this app retargets +# CCSS taggings against. See lib/interstandard/client.rb and +# lib/tasks/standards.rake (standards:retarget, standards:retarget_all). +module Interstandard + # No trailing slash. + BASE_URL = ENV.fetch("INTERSTANDARD_URL", "https://interstandard.jumpstartlab.com") + + # Set in production/staging secrets; standards:retarget raises a clear + # error if this is blank when it actually tries to call out. + API_KEY = ENV["INTERSTANDARD_API_KEY"] + + # standards:retarget_all iterates this list unless a target is given. + DEFAULT_TARGETS = ENV.fetch("INTERSTANDARD_TARGETS", "co-math-2020,tx-teks-math") + .split(",").map(&:strip).reject(&:blank?).freeze + + # The CCSS framework slug our own Standard rows are tagged against. + SOURCE_FRAMEWORK = "ccss-math".freeze +end diff --git a/lib/interstandard/client.rb b/lib/interstandard/client.rb new file mode 100644 index 0000000..cbdddea --- /dev/null +++ b/lib/interstandard/client.rb @@ -0,0 +1,154 @@ +require "net/http" +require "json" +require "uri" + +module Interstandard + # Base for every error this client raises. Rescue this to catch anything + # that went wrong talking to Interstandard. + class Error < StandardError; end + + # 401 from Interstandard: the API key is missing, malformed, or revoked. + class Unauthorized < Error; end + + # 429 from Interstandard: either the per-key request rate or the + # submission-frequency cap was exceeded. #retry_after, when the response + # carried it, is seconds to wait before trying again. + class RateLimited < Error + attr_reader :retry_after + + def initialize(message, retry_after: nil) + super(message) + @retry_after = retry_after + end + end + + # Any other non-2xx response (422 validation errors, 404, 5xx, ...). + class RequestFailed < Error + attr_reader :status, :code + + def initialize(message, status:, code: nil) + super(message) + @status = status + @code = code + end + end + + # #poll gave up before the submission reached a terminal status. + class PollTimeout < Error; end + + # Thin client for the Interstandard translator API (docs/api.md in the + # interstandard repo). Used by standards:retarget to submit CCSS taggings + # for bulk retargeting onto a state framework and read back the report. + class Client + DEFAULT_OPEN_TIMEOUT = 10 + DEFAULT_READ_TIMEOUT = 30 + + def initialize(base_url: Interstandard::BASE_URL, api_key: Interstandard::API_KEY) + @base_url = base_url.to_s.chomp("/") + @api_key = api_key + end + + # rows: an array of { item_id:, source_framework:, code: } hashes. + # Returns the parsed JSON submission envelope, e.g. + # { "id" => "...", "status" => "queued", "rows_total" => 3, ... } + def submit(rows, target_framework) + body = { target_framework: target_framework, rows: rows } + post("/api/v1/submissions", body) + end + + # Returns the parsed JSON report for one submission id. + def fetch_report(id) + get("/api/v1/submissions/#{URI.encode_uri_component(id)}") + end + + # Polls fetch_report(id) until its "status" is "done" or "failed", or + # `timeout` seconds have elapsed, backing off between polls (starting + # at `interval` seconds, doubling up to `max_interval`). Raises + # Interstandard::PollTimeout if the deadline passes first. + def poll(id, timeout: 300, interval: 2, max_interval: 20) + deadline = monotonic_now + timeout + wait = interval + + loop do + report = fetch_report(id) + return report if %w[done failed].include?(report["status"]) + + if monotonic_now >= deadline + raise PollTimeout, "submission #{id} did not finish within #{timeout}s (last status: #{report["status"].inspect})" + end + + sleep(wait) + wait = [ wait * 2, max_interval ].min + end + end + + private + + def monotonic_now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def get(path) + request(Net::HTTP::Get.new(uri_for(path))) + end + + def post(path, body) + request = Net::HTTP::Post.new(uri_for(path)) + request["Content-Type"] = "application/json" + request.body = JSON.generate(body) + request(request) + end + + def uri_for(path) + URI.parse("#{@base_url}#{path}") + end + + def request(req) + raise Unauthorized, "INTERSTANDARD_API_KEY is not set" if @api_key.blank? + + req["Authorization"] = "Bearer #{@api_key}" + uri = req.uri + + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", + open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT) do |http| + http.request(req) + end + + handle(response) + end + + def handle(response) + case response.code.to_i + when 200, 201, 202 + parse(response) + when 401 + raise Unauthorized, error_message(response, default: "Interstandard rejected the API key (401)") + when 429 + payload = parse(response) rescue {} + raise RateLimited.new( + error_message(response, default: "Interstandard rate-limited this request (429)"), + retry_after: response["Retry-After"]&.to_i + ) + else + payload = parse(response) rescue {} + code = payload.is_a?(Hash) ? payload.dig("error", "code") : nil + raise RequestFailed.new( + error_message(response, default: "Interstandard request failed (#{response.code})"), + status: response.code.to_i, + code: code + ) + end + end + + def parse(response) + return {} if response.body.blank? + JSON.parse(response.body) + end + + def error_message(response, default:) + payload = JSON.parse(response.body) rescue nil + message = payload.is_a?(Hash) ? payload.dig("error", "message") : nil + message.presence || default + end + end +end diff --git a/test/lib/interstandard/client_test.rb b/test/lib/interstandard/client_test.rb new file mode 100644 index 0000000..433c6fc --- /dev/null +++ b/test/lib/interstandard/client_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Interstandard::ClientTest < ActiveSupport::TestCase + setup do + @client = Interstandard::Client.new(base_url: "https://interstandard.example", api_key: "test-key") + end + + test "submit posts rows and target_framework and returns the parsed submission" do + stub_request(:post, "https://interstandard.example/api/v1/submissions") + .with( + headers: { "Authorization" => "Bearer test-key", "Content-Type" => "application/json" }, + body: { target_framework: "co-math-2020", rows: [ { item_id: "a", source_framework: "ccss-math", code: "5.NF.1" } ] }.to_json + ) + .to_return(status: 202, body: { id: "sub-1", status: "queued", rows_total: 1, rows_done: 0 }.to_json, headers: { "Content-Type" => "application/json" }) + + result = @client.submit([ { item_id: "a", source_framework: "ccss-math", code: "5.NF.1" } ], "co-math-2020") + + assert_equal "sub-1", result["id"] + assert_equal "queued", result["status"] + end + + test "fetch_report returns the parsed report" do + stub_request(:get, "https://interstandard.example/api/v1/submissions/sub-1") + .with(headers: { "Authorization" => "Bearer test-key" }) + .to_return(status: 200, body: { id: "sub-1", status: "done", rows: [] }.to_json, headers: { "Content-Type" => "application/json" }) + + result = @client.fetch_report("sub-1") + + assert_equal "done", result["status"] + end + + test "poll returns once status is done, backing off between attempts" do + stub_request(:get, "https://interstandard.example/api/v1/submissions/sub-1") + .to_return( + { status: 200, body: { status: "queued" }.to_json }, + { status: 200, body: { status: "done", rows: [] }.to_json } + ) + + result = @client.poll("sub-1", timeout: 5, interval: 0.01) + assert_equal "done", result["status"] + end + + test "poll raises PollTimeout if the deadline passes before a terminal status" do + stub_request(:get, "https://interstandard.example/api/v1/submissions/sub-1") + .to_return(status: 200, body: { status: "queued" }.to_json) + + assert_raises(Interstandard::PollTimeout) { @client.poll("sub-1", timeout: 0.05, interval: 0.02, max_interval: 0.02) } + end + + test "raises Unauthorized on a 401" do + stub_request(:get, "https://interstandard.example/api/v1/submissions/sub-1") + .to_return(status: 401, body: { error: { code: "invalid_api_key", message: "nope" } }.to_json) + + error = assert_raises(Interstandard::Unauthorized) { @client.fetch_report("sub-1") } + assert_equal "nope", error.message + end + + test "raises RateLimited on a 429 and captures Retry-After" do + stub_request(:get, "https://interstandard.example/api/v1/submissions/sub-1") + .to_return(status: 429, headers: { "Retry-After" => "30" }, body: { error: { code: "rate_limited", message: "slow down" } }.to_json) + + error = assert_raises(Interstandard::RateLimited) { @client.fetch_report("sub-1") } + assert_equal 30, error.retry_after + end + + test "raises RequestFailed with the error code on other non-2xx responses" do + stub_request(:post, "https://interstandard.example/api/v1/submissions") + .to_return(status: 422, body: { error: { code: "rows_required", message: "no rows" } }.to_json) + + error = assert_raises(Interstandard::RequestFailed) { @client.submit([], "co-math-2020") } + assert_equal "rows_required", error.code + assert_equal 422, error.status + end + + test "raises Unauthorized without making a request when no api key is configured" do + client = Interstandard::Client.new(base_url: "https://interstandard.example", api_key: nil) + assert_raises(Interstandard::Unauthorized) { client.fetch_report("sub-1") } + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 7c60862..820e9e9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,9 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" +require "webmock/minitest" + +WebMock.disable_net_connect!(allow_localhost: true) module ActiveSupport class TestCase From f0ade4bf9c7ef4a037bb9df896d37b31281b7271 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:41:46 -0600 Subject: [PATCH 4/7] feat: standards:retarget rake tasks, coverage task, and weekly job standards:retarget[target-framework] submits every CCSS StandardTagging to Interstandard, polls the report, and stores confirmed exact/grade_shifted results via StateStandardTagging; no_confirmed_match and invalid rows are listed in the task's summary and stored nowhere. standards:retarget_all runs it for every framework in INTERSTANDARD_TARGETS (default co-math-2020,tx-teks-math). standards:coverage prints, per framework, the fraction of a grade's lessons with a confirmed state code (grade 5 by default, matching Unit 10's success criterion; a number or "all" for others). RetargetStandardsJob wraps retarget_all for a weekly Solid Queue recurring run (config/recurring.yml), so demotions/retirements on the Interstandard side propagate automatically. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- app/jobs/retarget_standards_job.rb | 18 ++++ config/recurring.yml | 4 + lib/standards/coverage.rb | 31 +++++++ lib/standards/retargeter.rb | 115 ++++++++++++++++++++++++ lib/tasks/standards.rake | 44 +++++++++ test/lib/standards/retargeter_test.rb | 123 ++++++++++++++++++++++++++ test/tasks/standards_coverage_test.rb | 48 ++++++++++ test/tasks/standards_retarget_test.rb | 75 ++++++++++++++++ 8 files changed, 458 insertions(+) create mode 100644 app/jobs/retarget_standards_job.rb create mode 100644 lib/standards/coverage.rb create mode 100644 lib/standards/retargeter.rb create mode 100644 test/lib/standards/retargeter_test.rb create mode 100644 test/tasks/standards_coverage_test.rb create mode 100644 test/tasks/standards_retarget_test.rb diff --git a/app/jobs/retarget_standards_job.rb b/app/jobs/retarget_standards_job.rb new file mode 100644 index 0000000..01fb1be --- /dev/null +++ b/app/jobs/retarget_standards_job.rb @@ -0,0 +1,18 @@ +# Scheduled (see config/recurring.yml) weekly re-run of standards:retarget +# for every framework in Interstandard::DEFAULT_TARGETS, so demotions, +# retirements, and newly confirmed edges on the Interstandard side keep +# propagating into our StateStandardTaggings without a human running the +# rake task by hand. +class RetargetStandardsJob < ApplicationJob + queue_as :default + + def perform(target_frameworks = Interstandard::DEFAULT_TARGETS) + target_frameworks.each do |target_framework| + report = Standards::Retargeter.new.call(target_framework) + Rails.logger.info( + "[RetargetStandardsJob] #{target_framework}: stored=#{report.stored} " \ + "no_confirmed_match=#{report.no_confirmed_match.size} invalid=#{report.invalid.size} stale=#{report.stale}" + ) + end + end +end diff --git a/config/recurring.yml b/config/recurring.yml index b4207f9..556d92f 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -13,3 +13,7 @@ production: clear_solid_queue_finished_jobs: command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" schedule: every hour at minute 12 + retarget_state_standards: + class: RetargetStandardsJob + queue: default + schedule: every week diff --git a/lib/standards/coverage.rb b/lib/standards/coverage.rb new file mode 100644 index 0000000..ad20a42 --- /dev/null +++ b/lib/standards/coverage.rb @@ -0,0 +1,31 @@ +module Standards + # Fraction of a grade's lessons carrying at least one confirmed, non-stale, + # non-retired state code for a given target framework. The success + # criterion for Unit 10 is this at grade 5; standards:coverage also + # accepts other grades (or "all"). + class Coverage + Result = Struct.new(:framework, :grade_number, :total, :with_confirmed, keyword_init: true) do + def fraction + total.zero? ? 0.0 : with_confirmed.to_f / total + end + + def percent + (fraction * 100).round(1) + end + end + + def self.call(framework:, grade_number: nil) + lessons = Lesson.joins(topic: { content_module: :grade }) + lessons = lessons.where(grades: { number: grade_number }) if grade_number + + total = lessons.count + confirmed_ids = StateStandardTagging.fresh + .for_framework(framework) + .where(taggable_type: "Lesson", taggable_id: lessons.select(:id)) + .distinct + .pluck(:taggable_id) + + Result.new(framework: framework, grade_number: grade_number, total: total, with_confirmed: confirmed_ids.size) + end + end +end diff --git a/lib/standards/retargeter.rb b/lib/standards/retargeter.rb new file mode 100644 index 0000000..fafc55e --- /dev/null +++ b/lib/standards/retargeter.rb @@ -0,0 +1,115 @@ +module Standards + # Submits every existing (CCSS) StandardTagging to Interstandard for one + # target framework, then stores confirmed exact/grade_shifted results as + # StateStandardTaggings. Used by lib/tasks/standards.rake's + # standards:retarget[framework] task. + # + # Idempotent: re-running upserts by StateStandardTagging's unique key + # (taggable, target_framework, state_code, standard) rather than deleting, + # and marks rows absent from the latest report `stale_at` instead of + # destroying them, so review_state/disputed history survives a transient + # Interstandard hiccup. + class Retargeter + RELATIONSHIPS_TO_STORE = %w[exact grade_shifted].freeze + + Report = Struct.new(:target_framework, :stored, :no_confirmed_match, :invalid, :stale, keyword_init: true) do + def initialize(**kwargs) + super(stored: 0, no_confirmed_match: [], invalid: [], stale: 0, **kwargs) + end + end + + def initialize(client: Interstandard::Client.new) + @client = client + end + + def call(target_framework) + report = Report.new(target_framework: target_framework) + taggings = StandardTagging.includes(:standard, :taggable).to_a + return report if taggings.empty? + + by_item_id = taggings.index_by { |tagging| item_id_for(tagging) } + submission = @client.submit(taggings.map { |tagging| row_for(tagging) }, target_framework) + payload = @client.poll(submission["id"]) + + seen_keys = [] + + Array(payload["rows"]).each do |row| + tagging = by_item_id[row["item_id"]] + next unless tagging + + case row["status"] + when "matched" + confirmed = confirmed_results(row) + confirmed.each do |result| + seen_keys << store_result!(tagging, target_framework, result) + report.stored += 1 + end + report.no_confirmed_match << row["item_id"] if confirmed.empty? + when "no_confirmed_match" + report.no_confirmed_match << row["item_id"] + when "invalid" + report.invalid << { item_id: row["item_id"], error: row["error"] } + end + end + + mark_stale!(target_framework, seen_keys, report) + report + end + + private + + def confirmed_results(row) + Array(row["results"]).select do |result| + RELATIONSHIPS_TO_STORE.include?(result["relationship"]) && + result["review_state"] == "confirmed" && + !result["disputed"] + end + end + + def row_for(tagging) + { item_id: item_id_for(tagging), source_framework: Interstandard::SOURCE_FRAMEWORK, code: tagging.standard.code } + end + + def item_id_for(tagging) + "#{tagging.taggable_type}:#{tagging.taggable_id}:#{tagging.standard.code}" + end + + def store_result!(tagging, target_framework, result) + taggable = tagging.taggable + + record = StateStandardTagging.find_or_initialize_by( + taggable: taggable, + target_framework: target_framework, + state_code: result["code"], + standard: tagging.standard + ) + record.assign_attributes( + state_statement: result["statement"], + relationship: result["relationship"], + confidence: result["confidence"], + review_state: result["review_state"], + disputed: result["disputed"] || false, + provenance: result.dig("provenance", "kind"), + edge_provenance_ref: result.dig("provenance", "ref"), + retired: false, + retargeted_at: Time.current, + stale_at: nil + ) + record.save! + + [ taggable.class.polymorphic_name, taggable.id, target_framework, result["code"], tagging.standard_id ] + end + + def mark_stale!(target_framework, seen_keys, report) + seen = seen_keys.map { |key| key.join("|") }.to_set + + StateStandardTagging.for_framework(target_framework).where(stale_at: nil).find_each do |existing| + key = [ existing.taggable_type, existing.taggable_id, existing.target_framework, existing.state_code, existing.standard_id ].join("|") + next if seen.include?(key) + + existing.mark_stale! + report.stale += 1 + end + end + end +end diff --git a/lib/tasks/standards.rake b/lib/tasks/standards.rake index b0a5045..5022c03 100644 --- a/lib/tasks/standards.rake +++ b/lib/tasks/standards.rake @@ -1,3 +1,17 @@ +def print_retarget_report(report) + puts "Target framework: #{report.target_framework}" + puts "Stored (confirmed exact/grade_shifted) rows: #{report.stored}" + puts "Rows with no confirmed match: #{report.no_confirmed_match.size}" + report.no_confirmed_match.each { |item_id| puts " #{item_id}" } + + if report.invalid.any? + puts "Invalid rows: #{report.invalid.size}" + report.invalid.each { |row| puts " #{row[:item_id]}: #{row[:error]}" } + end + + puts "Marked stale (no longer confirmed): #{report.stale}" if report.stale.positive? +end + namespace :standards do desc "Tag topics (and their lessons) with CCSS focus standards parsed from EngageNY overview HTML" task tag_focus: :environment do @@ -33,4 +47,34 @@ namespace :standards do puts "\nNo unresolved codes." end end + + desc "Submit every CCSS StandardTagging to Interstandard for TARGET_FRAMEWORK and store confirmed state matches" + task :retarget, [ :target_framework ] => :environment do |_, args| + target_framework = args[:target_framework] + abort "Usage: bin/rails 'standards:retarget[target-framework-slug]'" if target_framework.blank? + + print_retarget_report(Standards::Retargeter.new.call(target_framework)) + end + + desc "Run standards:retarget for every framework in INTERSTANDARD_TARGETS (default: co-math-2020,tx-teks-math)" + task retarget_all: :environment do + Interstandard::DEFAULT_TARGETS.each do |target_framework| + puts "== #{target_framework} ==" + print_retarget_report(Standards::Retargeter.new.call(target_framework)) + puts + end + end + + desc "Print, per target framework, the fraction of a grade's lessons with a confirmed state code (default grade: 5; pass 'all' for every grade)" + task :coverage, [ :grade ] => :environment do |_, args| + grade_arg = args[:grade].presence || "5" + grade_numbers = grade_arg == "all" ? Grade.order(:number).pluck(:number) : [ Integer(grade_arg) ] + + Interstandard::DEFAULT_TARGETS.each do |framework| + grade_numbers.each do |grade_number| + result = Standards::Coverage.call(framework: framework, grade_number: grade_number) + puts "#{framework} (grade #{grade_number}): #{result.with_confirmed}/#{result.total} lessons (#{result.percent}%)" + end + end + end end diff --git a/test/lib/standards/retargeter_test.rb b/test/lib/standards/retargeter_test.rb new file mode 100644 index 0000000..901d2a5 --- /dev/null +++ b/test/lib/standards/retargeter_test.rb @@ -0,0 +1,123 @@ +require "test_helper" + +class Standards::RetargeterTest < ActiveSupport::TestCase + setup do + @grade = Grade.create!(number: 5, title: "Grade 5") + @content_module = @grade.content_modules.create!(number: 1, title: "Module 1", position: 1) + @topic = @content_module.topics.create!(letter: "A", title: "Topic A", position: 1) + @lesson = @topic.lessons.create!(number: 1, position: 1) + + @matched_standard = Standard.create!(code: "5.NF.1", domain: "d", description: "d", grade_level: 5) + @unmatched_standard = Standard.create!(code: "5.NF.2", domain: "d", description: "d", grade_level: 5) + + @lesson.standard_taggings.create!(standard: @matched_standard) + @lesson.standard_taggings.create!(standard: @unmatched_standard) + end + + def matched_item_id + "Lesson:#{@lesson.id}:5.NF.1" + end + + def unmatched_item_id + "Lesson:#{@lesson.id}:5.NF.2" + end + + def stub_submission(rows_report) + stub_request(:post, "https://interstandard.example/api/v1/submissions") + .to_return(status: 202, body: { id: "sub-1", status: "queued" }.to_json, headers: { "Content-Type" => "application/json" }) + + stub_request(:get, "https://interstandard.example/api/v1/submissions/sub-1") + .to_return(status: 200, body: { id: "sub-1", status: "done", rows: rows_report }.to_json, headers: { "Content-Type" => "application/json" }) + end + + def client + Interstandard::Client.new(base_url: "https://interstandard.example", api_key: "test-key") + end + + test "stores only confirmed exact/grade_shifted results and reports the rest" do + stub_submission([ + { + "item_id" => matched_item_id, + "status" => "matched", + "results" => [ + { "framework" => "co-math-2020", "code" => "CO.5.NF.1", "statement" => "does fractions", + "relationship" => "exact", "confidence" => 1.0, "review_state" => "confirmed", "disputed" => false, + "provenance" => { "kind" => "case_import", "ref" => "assoc-1" } }, + { "framework" => "co-math-2020", "code" => "CO.5.NF.9", "statement" => "unconfirmed one", + "relationship" => "exact", "confidence" => 0.4, "review_state" => "proposed", "disputed" => false, + "provenance" => { "kind" => "ai_proposal", "ref" => "prop-1" } } + ] + }, + { "item_id" => unmatched_item_id, "status" => "no_confirmed_match", "results" => [] } + ]) + + report = Standards::Retargeter.new(client: client).call("co-math-2020") + + assert_equal 1, report.stored + assert_equal [ unmatched_item_id ], report.no_confirmed_match + assert_empty report.invalid + + tagging = StateStandardTagging.sole + assert_equal @lesson, tagging.taggable + assert_equal @matched_standard, tagging.standard + assert_equal "co-math-2020", tagging.target_framework + assert_equal "CO.5.NF.1", tagging.state_code + assert_equal "exact", tagging.relationship + assert_equal "confirmed", tagging.review_state + assert_not tagging.disputed + assert_equal "case_import", tagging.provenance + assert_equal "assoc-1", tagging.edge_provenance_ref + end + + test "invalid rows are reported and store nothing" do + stub_submission([ + { "item_id" => matched_item_id, "status" => "invalid", "error" => "unknown code" }, + { "item_id" => unmatched_item_id, "status" => "no_confirmed_match", "results" => [] } + ]) + + report = Standards::Retargeter.new(client: client).call("co-math-2020") + + assert_equal 0, report.stored + assert_equal [ { item_id: matched_item_id, error: "unknown code" } ], report.invalid + end + + test "re-run marks a previously confirmed row stale when it no longer appears confirmed" do + stub_submission([ + { "item_id" => matched_item_id, "status" => "matched", "results" => [ + { "framework" => "co-math-2020", "code" => "CO.5.NF.1", "statement" => "s", + "relationship" => "exact", "confidence" => 1.0, "review_state" => "confirmed", "disputed" => false, + "provenance" => { "kind" => "case_import", "ref" => "assoc-1" } } + ] }, + { "item_id" => unmatched_item_id, "status" => "no_confirmed_match", "results" => [] } + ]) + Standards::Retargeter.new(client: client).call("co-math-2020") + assert_equal 1, StateStandardTagging.fresh.count + + stub_submission([ + { "item_id" => matched_item_id, "status" => "no_confirmed_match", "results" => [] }, + { "item_id" => unmatched_item_id, "status" => "no_confirmed_match", "results" => [] } + ]) + report = Standards::Retargeter.new(client: client).call("co-math-2020") + + assert_equal 1, report.stale + assert_equal 0, StateStandardTagging.fresh.count + assert_equal 1, StateStandardTagging.stale.count + end + + test "re-run with the same confirmed result upserts rather than duplicating" do + result = { "framework" => "co-math-2020", "code" => "CO.5.NF.1", "statement" => "s", + "relationship" => "exact", "confidence" => 1.0, "review_state" => "confirmed", "disputed" => false, + "provenance" => { "kind" => "case_import", "ref" => "assoc-1" } } + rows = [ + { "item_id" => matched_item_id, "status" => "matched", "results" => [ result ] }, + { "item_id" => unmatched_item_id, "status" => "no_confirmed_match", "results" => [] } + ] + + stub_submission(rows) + Standards::Retargeter.new(client: client).call("co-math-2020") + stub_submission(rows) + Standards::Retargeter.new(client: client).call("co-math-2020") + + assert_equal 1, StateStandardTagging.count + end +end diff --git a/test/tasks/standards_coverage_test.rb b/test/tasks/standards_coverage_test.rb new file mode 100644 index 0000000..5b17d4a --- /dev/null +++ b/test/tasks/standards_coverage_test.rb @@ -0,0 +1,48 @@ +require "test_helper" +require "rake" + +class StandardsCoverageTaskTest < ActiveSupport::TestCase + setup do + Rails.application.load_tasks unless Rake::Task.task_defined?("standards:coverage") + Rake::Task["standards:coverage"].reenable + + @grade5 = Grade.create!(number: 5, title: "Grade 5") + cm5 = @grade5.content_modules.create!(number: 1, title: "Module 1", position: 1) + topic5 = cm5.topics.create!(letter: "A", title: "Topic A", position: 1) + @lesson5_confirmed = topic5.lessons.create!(number: 1, position: 1) + @lesson5_bare = topic5.lessons.create!(number: 2, position: 2) + + @grade6 = Grade.create!(number: 6, title: "Grade 6") + cm6 = @grade6.content_modules.create!(number: 1, title: "Module 1", position: 1) + topic6 = cm6.topics.create!(letter: "A", title: "Topic A", position: 1) + @lesson6 = topic6.lessons.create!(number: 1, position: 1) + + standard = Standard.create!(code: "5.NF.1", domain: "d", description: "d", grade_level: 5) + + StateStandardTagging.create!( + taggable: @lesson5_confirmed, standard: standard, target_framework: "co-math-2020", + state_code: "CO.5.NF.1", relationship: "exact", review_state: "confirmed", retargeted_at: Time.current + ) + end + + test "prints the fraction of grade 5 lessons with a confirmed code, per configured framework, by default" do + out, = capture_io { Rake::Task["standards:coverage"].invoke } + + assert_match(%r{co-math-2020 \(grade 5\): 1/2 lessons \(50\.0%\)}, out) + assert_match(%r{tx-teks-math \(grade 5\): 0/2 lessons \(0\.0%\)}, out) + assert_no_match(/grade 6/, out) + end + + test "accepts a specific grade" do + out, = capture_io { Rake::Task["standards:coverage"].invoke("6") } + + assert_match(%r{co-math-2020 \(grade 6\): 0/1 lessons \(0\.0%\)}, out) + end + + test "'all' prints every grade" do + out, = capture_io { Rake::Task["standards:coverage"].invoke("all") } + + assert_match(/grade 5/, out) + assert_match(/grade 6/, out) + end +end diff --git a/test/tasks/standards_retarget_test.rb b/test/tasks/standards_retarget_test.rb new file mode 100644 index 0000000..07d857b --- /dev/null +++ b/test/tasks/standards_retarget_test.rb @@ -0,0 +1,75 @@ +require "test_helper" +require "rake" + +class StandardsRetargetTaskTest < ActiveSupport::TestCase + setup do + Rails.application.load_tasks unless Rake::Task.task_defined?("standards:retarget") + Rake::Task["standards:retarget"].reenable + Rake::Task["standards:retarget_all"].reenable + + @grade = Grade.create!(number: 5, title: "Grade 5") + content_module = @grade.content_modules.create!(number: 1, title: "Module 1", position: 1) + topic = content_module.topics.create!(letter: "A", title: "Topic A", position: 1) + @lesson = topic.lessons.create!(number: 1, position: 1) + standard = Standard.create!(code: "5.NF.1", domain: "d", description: "d", grade_level: 5) + @lesson.standard_taggings.create!(standard: standard) + @item_id = "Lesson:#{@lesson.id}:5.NF.1" + end + + def stub_submission_and_report(target_framework, rows_report) + stub_request(:post, "#{Interstandard::BASE_URL}/api/v1/submissions") + .with(body: hash_including("target_framework" => target_framework)) + .to_return(status: 202, body: { id: "sub-1", status: "queued" }.to_json, headers: { "Content-Type" => "application/json" }) + + stub_request(:get, "#{Interstandard::BASE_URL}/api/v1/submissions/sub-1") + .to_return(status: 200, body: { id: "sub-1", status: "done", rows: rows_report }.to_json, headers: { "Content-Type" => "application/json" }) + end + + test "standards:retarget stores only confirmed rows and prints a summary" do + stub_submission_and_report("co-math-2020", [ + { "item_id" => @item_id, "status" => "matched", "results" => [ + { "framework" => "co-math-2020", "code" => "CO.5.NF.1", "statement" => "s", "relationship" => "exact", + "confidence" => 1.0, "review_state" => "confirmed", "disputed" => false, + "provenance" => { "kind" => "case_import", "ref" => "assoc-1" } } + ] } + ]) + + with_interstandard_api_key("test-key") do + out, = capture_io { Rake::Task["standards:retarget"].invoke("co-math-2020") } + assert_match(/Target framework: co-math-2020/, out) + assert_match(/Stored \(confirmed exact\/grade_shifted\) rows: 1/, out) + end + + assert_equal 1, StateStandardTagging.count + assert_equal "CO.5.NF.1", StateStandardTagging.first.state_code + end + + test "standards:retarget reports a no_confirmed_match row and stores nothing for it" do + stub_submission_and_report("co-math-2020", [ + { "item_id" => @item_id, "status" => "no_confirmed_match", "results" => [] } + ]) + + with_interstandard_api_key("test-key") do + out, = capture_io { Rake::Task["standards:retarget"].invoke("co-math-2020") } + assert_match(/Rows with no confirmed match: 1/, out) + assert_match(/#{Regexp.escape(@item_id)}/, out) + end + + assert_equal 0, StateStandardTagging.count + end + + private + + # standards:retarget builds Interstandard::Client with no args, which + # defaults to the Interstandard::API_KEY constant (nil in test unless a + # real key is set) — swap it in for the duration of the block. + def with_interstandard_api_key(key) + original = Interstandard::API_KEY + Interstandard.send(:remove_const, :API_KEY) + Interstandard.const_set(:API_KEY, key) + yield + ensure + Interstandard.send(:remove_const, :API_KEY) + Interstandard.const_set(:API_KEY, original) + end +end From d9673a44412192136ed340bc250eb450c4e8b937 Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:41:55 -0600 Subject: [PATCH 5/7] feat: browse lessons/topics by state standard /states lists target frameworks with confirmed-tagging counts; /states/:framework lists that framework's codes with statements; /states/:framework/:code lists the lessons/topics tagged to it (non-stale, non-retired only), linking back into the existing grade/module/topic/lesson browse hierarchy. Lesson and Topic show pages now display their CCSS codes plus any confirmed state codes, linking to the per-code page. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- app/controllers/state_standards_controller.rb | 42 ++++++++++++ app/helpers/state_standards_helper.rb | 22 ++++++ app/views/lessons/show.html.erb | 14 ++++ app/views/state_standards/code.html.erb | 34 ++++++++++ app/views/state_standards/index.html.erb | 21 ++++++ app/views/state_standards/show.html.erb | 21 ++++++ app/views/topics/show.html.erb | 13 ++++ config/routes.rb | 4 ++ .../state_standards_controller_test.rb | 67 +++++++++++++++++++ 9 files changed, 238 insertions(+) create mode 100644 app/controllers/state_standards_controller.rb create mode 100644 app/helpers/state_standards_helper.rb create mode 100644 app/views/state_standards/code.html.erb create mode 100644 app/views/state_standards/index.html.erb create mode 100644 app/views/state_standards/show.html.erb create mode 100644 test/controllers/state_standards_controller_test.rb diff --git a/app/controllers/state_standards_controller.rb b/app/controllers/state_standards_controller.rb new file mode 100644 index 0000000..2127f5b --- /dev/null +++ b/app/controllers/state_standards_controller.rb @@ -0,0 +1,42 @@ +# Browse CCSS-to-state retargeting results (see lib/standards/retargeter.rb): +# a list of target frameworks, each framework's codes, and the lessons/ +# topics tagged to a given code. Only non-stale, non-retired taggings are +# shown — a demoted or superseded state code drops off the browse pages the +# next time standards:retarget runs, without losing its history. +class StateStandardsController < ApplicationController + def index + frameworks = StateStandardTagging.fresh.distinct.order(:target_framework).pluck(:target_framework) + + @framework_stats = frameworks.map do |framework| + scope = StateStandardTagging.fresh.for_framework(framework) + { + framework: framework, + code_count: scope.distinct.count(:state_code), + tagging_count: scope.count + } + end + end + + def show + @framework = params[:framework] + scope = StateStandardTagging.fresh.for_framework(@framework) + raise ActiveRecord::RecordNotFound if scope.none? + + @codes = scope.group(:state_code) + .order(:state_code) + .pluck(:state_code, Arel.sql("MAX(state_statement)"), Arel.sql("COUNT(*)")) + .map { |code, statement, count| { code: code, statement: statement, count: count } } + end + + def code + @framework = params[:framework] + @code = params[:code] + @taggings = StateStandardTagging.fresh + .for_framework(@framework) + .where(state_code: @code) + .includes(:taggable) + .order(:relationship) + .to_a + raise ActiveRecord::RecordNotFound if @taggings.empty? + end +end diff --git a/app/helpers/state_standards_helper.rb b/app/helpers/state_standards_helper.rb new file mode 100644 index 0000000..ba80e96 --- /dev/null +++ b/app/helpers/state_standards_helper.rb @@ -0,0 +1,22 @@ +module StateStandardsHelper + # Path to a StateStandardTagging's taggable (a Lesson or a Topic) in the + # existing nested grade/module/topic/lesson browse hierarchy. + def taggable_path(taggable) + case taggable + when Lesson + topic = taggable.topic + content_module = topic.content_module + grade_content_module_topic_lesson_path(content_module.grade, content_module, topic, taggable) + when Topic + content_module = taggable.content_module + grade_content_module_topic_path(content_module.grade, content_module, taggable) + end + end + + def taggable_label(taggable) + case taggable + when Lesson then taggable.label + when Topic then "Topic #{taggable.letter}: #{taggable.title}" + end + end +end diff --git a/app/views/lessons/show.html.erb b/app/views/lessons/show.html.erb index f35f6e5..b0ef287 100644 --- a/app/views/lessons/show.html.erb +++ b/app/views/lessons/show.html.erb @@ -13,6 +13,20 @@

<%= @lesson.objective %>

<% end %> +<% state_taggings = @lesson.state_standard_taggings.fresh %> +<% if @lesson.standards.any? || state_taggings.any? %> +
+ <% @lesson.standards.each do |standard| %> + <%= standard.code %> + <% end %> + <% state_taggings.each do |tagging| %> + <%= link_to "#{tagging.target_framework}: #{tagging.state_code}", + state_standard_code_path(tagging.target_framework, tagging.state_code), + class: "text-xs px-2 py-1 bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100" %> + <% end %> +
+<% end %> +

View Components

diff --git a/app/views/state_standards/code.html.erb b/app/views/state_standards/code.html.erb new file mode 100644 index 0000000..a5eea4b --- /dev/null +++ b/app/views/state_standards/code.html.erb @@ -0,0 +1,34 @@ +<% content_for :breadcrumbs do %> + <%= link_to "States", state_standards_path, class: "text-gray-600 hover:text-indigo-700" %> + / + <%= link_to @framework, state_standard_path(@framework), class: "text-gray-600 hover:text-indigo-700" %> + / + <%= @code %> +<% end %> + +

<%= @code %>

+<% if (statement = @taggings.first.state_statement).present? %> +

<%= statement %>

+<% end %> + +
+ <% @taggings.each do |tagging| %> +
+
+

+ <%= link_to taggable_label(tagging.taggable), taggable_path(tagging.taggable), class: "text-indigo-700 hover:underline" %> +

+

+ from CCSS <%= tagging.standard.code %> + · <%= tagging.relationship %> + <% if tagging.confidence.present? %> + · confidence <%= number_with_precision(tagging.confidence, precision: 2) %> + <% end %> +

+
+ "> + <%= tagging.review_state %> + +
+ <% end %> +
diff --git a/app/views/state_standards/index.html.erb b/app/views/state_standards/index.html.erb new file mode 100644 index 0000000..9ee8650 --- /dev/null +++ b/app/views/state_standards/index.html.erb @@ -0,0 +1,21 @@ +

State Standards

+ +<% if @framework_stats.empty? %> +

+ No lessons or topics have been retargeted to a state framework yet. + Run bin/rails standards:retarget[framework-slug]. +

+<% else %> +
+ <% @framework_stats.each do |stat| %> +
+

+ <%= link_to stat[:framework], state_standard_path(stat[:framework]), class: "text-indigo-700 hover:underline" %> +

+

+ <%= pluralize(stat[:code_count], "code") %> · <%= pluralize(stat[:tagging_count], "tagging") %> +

+
+ <% end %> +
+<% end %> diff --git a/app/views/state_standards/show.html.erb b/app/views/state_standards/show.html.erb new file mode 100644 index 0000000..eb9c302 --- /dev/null +++ b/app/views/state_standards/show.html.erb @@ -0,0 +1,21 @@ +<% content_for :breadcrumbs do %> + <%= link_to "States", state_standards_path, class: "text-gray-600 hover:text-indigo-700" %> + / + <%= @framework %> +<% end %> + +

<%= @framework %>

+ +
+ <% @codes.each do |entry| %> +
+

+ <%= link_to entry[:code], state_standard_code_path(@framework, entry[:code]), class: "text-indigo-700 hover:underline" %> +

+ <% if entry[:statement].present? %> +

<%= entry[:statement] %>

+ <% end %> +

<%= pluralize(entry[:count], "tagging") %>

+
+ <% end %> +
diff --git a/app/views/topics/show.html.erb b/app/views/topics/show.html.erb index fa96a2a..16a61ac 100644 --- a/app/views/topics/show.html.erb +++ b/app/views/topics/show.html.erb @@ -9,6 +9,19 @@

Topic <%= @topic.letter %>

<%= @topic.title %>

+<% if @topic.standards.any? %> +
+ <% @topic.standards.each do |standard| %> + <%= standard.code %> + <% end %> + <% @topic.state_standard_taggings.fresh.each do |tagging| %> + <%= link_to "#{tagging.target_framework}: #{tagging.state_code}", + state_standard_code_path(tagging.target_framework, tagging.state_code), + class: "text-xs px-2 py-1 bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100" %> + <% end %> +
+<% end %> + <% supplementals = displayable_supplemental_resources(@topic.supplemental_resources) %> <% if supplementals.any? %>
diff --git a/config/routes.rb b/config/routes.rb index 00ef916..6f40ee9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -18,4 +18,8 @@ get :pdf end end + + get "states", to: "state_standards#index", as: :state_standards + get "states/:framework", to: "state_standards#show", as: :state_standard + get "states/:framework/:code", to: "state_standards#code", as: :state_standard_code, constraints: { code: /[^\/]+/ } end diff --git a/test/controllers/state_standards_controller_test.rb b/test/controllers/state_standards_controller_test.rb new file mode 100644 index 0000000..2f11aa2 --- /dev/null +++ b/test/controllers/state_standards_controller_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class StateStandardsControllerTest < ActionDispatch::IntegrationTest + setup do + grade = Grade.create!(number: 5, title: "Grade 5") + content_module = grade.content_modules.create!(number: 1, title: "Module 1", position: 1) + @topic = content_module.topics.create!(letter: "A", title: "Topic A", position: 1) + @lesson = @topic.lessons.create!(number: 1, position: 1) + @standard = Standard.create!(code: "5.NF.1", domain: "d", description: "d", grade_level: 5) + + @fresh = StateStandardTagging.create!( + taggable: @lesson, standard: @standard, target_framework: "co-math-2020", + state_code: "CO.5.NF.1", state_statement: "Adds fractions", relationship: "exact", + review_state: "confirmed", retargeted_at: Time.current + ) + end + + test "index lists frameworks with fresh taggings and their counts" do + get state_standards_path + assert_response :success + assert_match "co-math-2020", response.body + end + + test "index omits a framework whose only taggings are stale" do + @fresh.mark_stale! + + get state_standards_path + assert_response :success + assert_no_match "co-math-2020", response.body + end + + test "show lists the framework's codes with statements" do + get state_standard_path("co-math-2020") + assert_response :success + assert_match "CO.5.NF.1", response.body + assert_match "Adds fractions", response.body + end + + test "show 404s for a framework with no fresh taggings" do + get state_standard_path("nonexistent-framework") + assert_response :not_found + end + + test "code page lists the taggable tagged to that code, linking to it" do + get state_standard_code_path("co-math-2020", "CO.5.NF.1") + assert_response :success + assert_match @lesson.label, response.body + end + + test "code page 404s for a code with no fresh taggings" do + get state_standard_code_path("co-math-2020", "CO.5.NF.999") + assert_response :not_found + end + + test "code page excludes retired taggings" do + @fresh.update!(retired: true) + + get state_standard_code_path("co-math-2020", "CO.5.NF.1") + assert_response :not_found + end + + test "lesson show links to the state code page" do + get grade_content_module_topic_lesson_path(@lesson.topic.content_module.grade, @lesson.topic.content_module, @lesson.topic, @lesson) + assert_response :success + assert_match state_standard_code_path("co-math-2020", "CO.5.NF.1"), response.body + end +end From 632f434fa110487b4bc1f80b637d52fa406c66cd Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:42:04 -0600 Subject: [PATCH 6/7] ci: add a test job with a Postgres service; document the retarget workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repo's CI had no test job at all — scan_ruby/scan_js/lint only, so a red test suite never blocked a merge. Add one against a postgres:16 service (DATABASE_URL, mirroring the fleet's ci.yml pattern), installing libvips since Active Storage now requires it at boot. CLAUDE.md documents the new env vars, rake tasks, and the weekly Solid Queue recurring job. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ CLAUDE.md | 26 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db3b878..3b60a61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,40 @@ jobs: - name: Lint code for consistent style run: bin/rubocop -f github + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: commonmath_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432/commonmath_test + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Install libvips (required at boot by Active Storage's variant transformer) + run: sudo apt-get update && sudo apt-get install -y libvips + + - name: Prepare test database + run: bin/rails db:test:prepare + + - name: Run tests + run: bin/rails test + diff --git a/CLAUDE.md b/CLAUDE.md index 73db219..1580f35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,32 @@ EngageNY DOCXs → Aspose → Structured HTML → Parse/Import → PostgreSQL - Textbook indexing for reference (not reproduction) is fair use per Google Books precedent - See `references/` and `docs/research/research-curriculum-licensing.md` +## State Standards (Interstandard integration) + +CommonMath's own `standards` table only carries CCSS. `StateStandardTagging` +retargets those CCSS taggings onto other state frameworks (Colorado, Texas, +...) via the [Interstandard](https://github.com/JumpstartLab/interstandard) +translator API (`docs/api.md` there), and the app is browsable by state at +`/states`. + +- `lib/interstandard/client.rb` — thin `Net::HTTP` client: `submit`, `fetch_report`, `poll`. +- `lib/standards/retargeter.rb` — submits every `StandardTagging`, stores confirmed `exact`/`grade_shifted` results as `StateStandardTagging` rows, marks anything no longer confirmed `stale_at` (never deletes). +- `lib/standards/coverage.rb` — the success-criterion query: fraction of a grade's lessons with a confirmed state code. + +Env vars: `INTERSTANDARD_URL` (defaults to the production map), `INTERSTANDARD_API_KEY` (required to actually call out — get one at `/api_keys` on Interstandard), `INTERSTANDARD_TARGETS` (comma-separated target framework slugs, default `co-math-2020,tx-teks-math`). + +Rake tasks: + +```bash +bin/rails standards:retarget[co-math-2020] # one framework +bin/rails standards:retarget_all # every framework in INTERSTANDARD_TARGETS +bin/rails standards:coverage # grade 5, per framework (the success criterion) +bin/rails standards:coverage[6] # a specific grade +bin/rails standards:coverage[all] # every grade +``` + +`RetargetStandardsJob` re-runs `standards:retarget_all` weekly via Solid Queue's recurring tasks (`config/recurring.yml`), so demotions/retirements on the Interstandard side propagate without a human running the task by hand. + ## Development Commands ```bash From a823bf5c7319514e94da5f194896f2ffadd32bcf Mon Sep 17 00:00:00 2001 From: Jeff Casimir Date: Sun, 13 Sep 2026 15:43:59 -0600 Subject: [PATCH 7/7] fix: install libvips in scan_js too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/importmap audit boots the whole app (config/application.rb), which now requires ruby-vips/libvips just to load — the test job already installed it, scan_js didn't and failed on this PR's CI run. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011EqZeVdG5q6KDS1Q8sbcxu --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b60a61..5a3a89c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: with: bundler-cache: true + - name: Install libvips (bin/importmap boots the app, which now requires it) + run: sudo apt-get update && sudo apt-get install -y libvips + - name: Scan for security vulnerabilities in JavaScript dependencies run: bin/importmap audit