Skip to content

feat: Signal Score — AI-assisted application pre-screening#594

Open
divideby0 wants to merge 25 commits intoawesomefoundation:masterfrom
divideby0:feat/0591-signal-score-scripts
Open

feat: Signal Score — AI-assisted application pre-screening#594
divideby0 wants to merge 25 commits intoawesomefoundation:masterfrom
divideby0:feat/0591-signal-score-scripts

Conversation

@divideby0
Copy link
Contributor

Summary

Adds an AI-assisted pre-screening system that helps Awesome Foundation trustees prioritize grant applications by surfacing quality signals and red flags. Uses the Trust Equation framework (Credibility + Reliability + Intimacy) / (1 + Self-Interest) to score applications 0.0–1.0.

Not a replacement for human judgment — a triage tool so trustees can focus review time on the most promising applications.

What's Included

Scoring Pipeline

  • SignalScorer (app/extras/signal_scorer.rb) — Calls Anthropic Messages API (Haiku 4.5, ~$0.02/application) with structured tool_use output for 12 scoring dimensions
  • ScoreGrantJob (app/jobs/score_grant_job.rb) — Async scoring via SuckerPunch with atomic claim (prevents double-scoring), exponential backoff retries, and claim cleanup on failure
  • PreScorer (lib/signal_score/pre_scorer.rb) — Zero-cost deterministic text features (word counts, budget patterns, URL presence)
  • PromptBuilder — Assembles system prompt + tool schema + few-shot examples from DB config

Configuration

  • SignalScoreConfig model — JSONB config with global defaults + chapter-specific deep-merge overrides
  • Migration: signal_score_configs table + metadata JSONB column on projects
  • Chapters can customize model, prompts, category weights, and enable/disable independently

UI

  • Score badges on project list and detail views
  • Expandable "Scoring Factors" breakdown (Trust Equation dimensions)
  • Filter by score threshold, sort by score/date/random
  • Client-side toggle to show/hide scores (default: hidden)
  • Score colors: green (0.7+), yellow (0.5–0.7), orange (0.3–0.5), red (<0.3)

Filtering & Sorting (ProjectFilter)

  • signal_score_above(threshold) — JSONB float extraction with index-friendly query
  • sort_by_signal_score — Unscored projects sort last (COALESCE to -1)
  • sort_by_random — Stable random via md5(id) (no submission-order bias)

Data Pipeline (scripts/)

  • score_grants.rb — Batch scoring with Anthropic cache
  • discover_patterns.rb — Qualitative pattern analysis
  • import_data.rb — CSV → DuckDB setup
  • File-based response cache at .scratch/cache/anthropic/

Tests

34 examples, 0 failures

Spec Examples Coverage
signal_scorer_spec.rb 10 API calls, metadata persistence, error handling, format_application
score_grant_job_spec.rb 6 Scoring, skip logic, atomic claims, retry cleanup
signal_score_config_spec.rb 8 Validations, uniqueness, .resolve deep-merge
project_filter_spec.rb 10 score_above, sort modes (score/date/random)

All specs use WebMock — no real API calls in tests.

Bug Fix

Fixed a retry bug in ScoreGrantJob: retry was re-entering the atomic claim check at the top of the method, which saw the existing {"status": "scoring"} metadata and returned early — meaning retries never actually happened. Extracted score_with_retries! to properly scope the retry loop.

Validation Results (Chicago chapter, 181 labeled applications)

Metric Value
Funded avg score 0.717
Hidden avg score 0.277
Funded > 0.7 88%
Hidden < 0.3 65%
False negatives (funded < 0.3) 0

Not Included

Closes #591, closes #593. Refs #590.

Add CLAUDE.md, basic-memory context, data import
script, .env.local.example, and gitignore updates.

Refs: awesomefoundation#591
Non-secret configuration defaults for local dev.
Personal secrets go in .env.local (see
.env.local.example).

Refs: awesomefoundation#591
README documents .env as standard location for
secrets (AWS keys, etc). Add note that .env.local
takes precedence for personal API keys.

Refs: awesomefoundation#591
PreScorer for deterministic text features.
ScoreGrants for Anthropic batch API with tool_use
structured output. Supports haiku/sonnet/opus.

Refs: awesomefoundation#591
- Add notebooks/01_eda.ipynb: corpus stats, chapter distributions,
  label rates, text field analysis
- Add notebooks/02_feature_engineering.ipynb: pre-scorer features,
  TF-IDF analysis, money extraction, feature correlations
- Add notebooks/03_model_evaluation.ipynb: batch results, model
  comparison, test-retest reliability, cross-chapter analysis
- Add notebooks/helpers.py: shared data source, plotting, money
  extraction, label helpers
- Add international money/currency extraction to pre_scorer.rb
  (supports symbols, prefixed symbols, ISO codes, written forms)
- Add TF-IDF feature support to pre_scorer.rb (static IDF table
  computed from notebooks, loaded at runtime)
- Replace dollar_sign_count with money_mention_count
- All notebooks parameterized via AWESOMEBITS_DB env var,
  no real grant data in git-tracked files

Refs: awesomefoundation#591
- Add config/signal_score/categories.json with 20 canonical
  categories in kebab-case
- Add AnthropicCache: file-based passthrough cache for API
  responses, keyed by SHA256 of request params
- Add PromptBuilder: assembles system prompt, tool schemas,
  few-shot examples with chapter-specific config overrides
  (deep-merge chapter over global default)
- Add pre_scorer_spec.rb with unit tests (money extraction,
  empty fields, TF-IDF) and seeded data tests (tagged
  :signal_score_data, opt-in via SIGNAL_SCORE_DATA=1)
- Prompt supports configurable grant amount and currency
  for international chapters

Refs: awesomefoundation#593, awesomefoundation#591
- categories.json now has slug, name, and description per category
- PromptBuilder uses descriptions in tool schema so the LLM knows
  what each category means
- Category tool properties include per-field descriptions

Refs: awesomefoundation#593
- Replace hardcoded SCORE_TOOL, SYSTEM_PROMPT, format_application
  with PromptBuilder calls
- Integrate AnthropicCache into batch builder (skip cached requests)
- Add --chapter and --no-fewshot flags to build-batch command
- build_user_message helper supports both few-shot and rubric-only modes
- PromptBuilder is now the single source of truth for prompts

Refs: awesomefoundation#593, awesomefoundation#591
- Add signal_score_configs migration: chapter-scoped JSONB config
  with global default (chapter_id = NULL), partial index
- Add SignalScoreConfig model: deep-merge resolution, uniqueness
  validation, global default constraint
- Add SignalScorer: Rails-side entry point wrapping PreScorer
  and future LLM scoring, reads config from DB
- Add signal_score_config_spec with resolve/merge tests

Refs: awesomefoundation#593
- SignalScorer service: Anthropic Messages API with Trust Equation
- ScoreGrantJob: async SuckerPunch job, fires on project create
- Score badge: color-coded score, category, tags, reasoning
- Trust Equation breakdown: expandable feature grid
- Filter controls: sort by score, min score threshold dropdown
- SCSS: uses existing app design tokens

Refs: awesomefoundation#590, awesomefoundation#591
- Custom dropdown with jQuery click handler (mirrors chapter-selection)
- Gray background, border, CSS triangle arrow
- Options as link list with hover states

Refs: awesomefoundation#590
All screenshots now show only synthetic Jan 2026 applications.
No real grant data visible.

Refs: awesomefoundation#590
…rection

- Score dropdown now inline with checkbox row
- Sort cycles: Score ↕ (off) → Score ↓ (desc) → Score ↑ (asc)
- Styled as toggle button matching existing filter controls

Refs: awesomefoundation#590
- Score sort toggle and dropdown now use float: left + margin-top: 20px
  matching the existing short-list-toggle and funded-toggle pattern
- Dropdown height matches checkbox height (21px content + 8px padding)
- Font size matched to 12px (was 13px)

Refs: awesomefoundation#590
- Scores hidden by default, toggled via 'Show scores' checkbox
- Sort modes: Default, Score ↓, Score ↑, Latest, Earliest, Random
- Random uses stable md5(projects.id) hash — consistent order per
  page load, no reviewer bias from submission order or score
- Sort and filter are now separate dropdowns matching chapter style

Refs: awesomefoundation#590
- Remove arrows from sort options
- Default sort is 'Newest first' (original created_at DESC)
- Rename: 'Score, highest' / 'Score, lowest' / 'Oldest first'

Refs: awesomefoundation#590
- Show scores checkbox now toggles via jQuery (no page reload)
- Badges always render hidden, JS shows/hides on checkbox change
- Sort labels: Latest (default), Earliest, Score highest/lowest, Random
- Removed show_scores from URL params (purely client-side state)

Refs: awesomefoundation#590
- Fresh screenshots with final UI (scores hidden/shown, sort modes)
- README section: Signal Score setup, env vars, how it works, costs
- Detail view always shows score badge (full_view flag passthrough)

Refs: awesomefoundation#590, awesomefoundation#591
HIGH:
- Race condition: atomic claim guard in ScoreGrantJob using
  WHERE metadata->'signal_score' IS NULL before API call
- Lost update: atomic JSONB merge via || operator in persist_score!
  instead of Ruby hash merge + update_column

MEDIUM:
- Expression index on composite_score for efficient filtering/sorting
- Error body scrubbed from exceptions (only status code + error type)
- Retry with exponential backoff (3 attempts, 2s/4s delays)
- Failed claims cleaned up so projects can be retried later

LOW:
- JS var -> const (SonarQube)
- Default clause comment on case statement
- Sort direction guard via SORT_DIRECTIONS.fetch (prevents injection)

Refs: awesomefoundation#590, awesomefoundation#591
- Add webmock gem for HTTP stubbing in tests
- Add ProjectFilter specs for signal_score_above, sort_by_signal_score,
  sort_by_date, sort_by_random (6 new examples)
- Fix SignalScorer spec: bypass validation for blank field test
- Fix SignalScoreConfig spec: shoulda-matchers v3 compat
- Fix ScoreGrantJob retry bug: retry was re-entering the atomic claim
  check, causing early return instead of actual retry. Extracted
  score_with_retries! so retries only re-run the scoring call.
- Consolidated release_claim! helper to DRY up cleanup paths

34 examples, 0 failures
@divideby0
Copy link
Contributor Author

A few screenshots

Search / List Page with scores visible

CleanShot 2026-02-23 at 20 46 12@2x

Scoring Factor Breakdown (High Score)

CleanShot 2026-02-23 at 20 46 59@2x

Scoring Factor Breakdown (Low Score)

CleanShot 2026-02-23 at 20 48 54@2x

Score Threshold Filter

CleanShot 2026-02-23 at 20 49 45@2x

Sort Order (Thrown in for Good Measure)

CleanShot 2026-02-23 at 20 50 44@2x

- Migration: guard metadata column with column_exists? check for CI
  (squashed 001 migration skips intermediate migrations)
- JS: persist 'show scores' checkbox in localStorage
- Form: add hidden fields for sort/score_min so form submits preserve
  dropdown state (shortlist, search, date range)
The implicit 0.15 default filtered out unscored projects, breaking
the existing search test and hiding applications before scoring runs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signal Score: test infrastructure, config model, and response cache Signal Score: Ruby scoring scripts and data pipeline

1 participant