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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -65,3 +68,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

26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -460,6 +480,7 @@ DEPENDENCIES
tzinfo-data
unitsdb (~> 2.1.0)
web-console
webmock

BUNDLED WITH
4.0.5
42 changes: 42 additions & 0 deletions app/controllers/state_standards_controller.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions app/helpers/state_standards_helper.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions app/jobs/retarget_standards_job.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/lesson.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/models/standard.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
38 changes: 38 additions & 0 deletions app/models/state_standard_tagging.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/models/topic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions app/views/lessons/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@
<p class="text-gray-600 mb-6"><%= @lesson.objective %></p>
<% end %>

<% state_taggings = @lesson.state_standard_taggings.fresh %>
<% if @lesson.standards.any? || state_taggings.any? %>
<div class="flex flex-wrap gap-2 mb-6">
<% @lesson.standards.each do |standard| %>
<span class="text-xs px-2 py-1 bg-gray-100 rounded text-gray-700"><%= standard.code %></span>
<% 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 %>
</div>
<% end %>

<div class="space-y-4">
<div class="bg-white rounded-lg border border-gray-200 p-5">
<h2 class="font-semibold mb-3">View Components</h2>
Expand Down
34 changes: 34 additions & 0 deletions app/views/state_standards/code.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<% content_for :breadcrumbs do %>
<%= link_to "States", state_standards_path, class: "text-gray-600 hover:text-indigo-700" %>
<span class="text-gray-400">/</span>
<%= link_to @framework, state_standard_path(@framework), class: "text-gray-600 hover:text-indigo-700" %>
<span class="text-gray-400">/</span>
<span class="text-gray-600"><%= @code %></span>
<% end %>

<h1 class="text-2xl font-bold mb-1"><%= @code %></h1>
<% if (statement = @taggings.first.state_statement).present? %>
<p class="text-gray-600 mb-6"><%= statement %></p>
<% end %>

<div class="space-y-3">
<% @taggings.each do |tagging| %>
<div class="bg-white rounded-lg border border-gray-200 p-4 flex justify-between items-start">
<div>
<h2 class="font-semibold">
<%= link_to taggable_label(tagging.taggable), taggable_path(tagging.taggable), class: "text-indigo-700 hover:underline" %>
</h2>
<p class="text-xs text-gray-500 mt-1">
from CCSS <%= tagging.standard.code %>
&middot; <%= tagging.relationship %>
<% if tagging.confidence.present? %>
&middot; confidence <%= number_with_precision(tagging.confidence, precision: 2) %>
<% end %>
</p>
</div>
<span class="text-xs px-2 py-1 rounded shrink-0 ml-4 <%= tagging.review_state == "confirmed" ? "bg-green-100 text-green-700" : "bg-yellow-100 text-yellow-700" %>">
<%= tagging.review_state %>
</span>
</div>
<% end %>
</div>
Loading