diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..82c7fd1a --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Environment variables for Awesomebits +# Copy to .env for local development (gitignored) +# +# The README documents .env as the standard location. +# For extra safety, dotenv-rails also loads .env.local +# (which takes precedence) — use that for personal API keys. + +# AWS (S3 uploads) +# AWS_ACCESS_KEY_ID=XXX +# AWS_SECRET_ACCESS_KEY=YYY +# AWS_BUCKET=your-bucket-name + +# Database (defaults work for local dev) +# DB_NAME=awesomefoundation_development +# POSTGRES_HOST=localhost +# POSTGRES_USER= +# POSTGRES_PASSWORD= + +# Spam detection +# SPAM_REGEXP= diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 00000000..1a368f89 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,9 @@ +# Personal API keys — takes precedence over .env +# Copy to .env.local and fill in your keys (gitignored) +# +# dotenv-rails loads .env.local after .env, so values here +# override anything in .env. Use this for secrets you don't +# want to accidentally commit. + +# Signal Score (LLM scoring via Anthropic Batch API) +# ANTHROPIC_API_KEY=sk-ant-... diff --git a/.gitignore b/.gitignore index f60b08fa..39349260 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ .ruby-gemset .bundle .env +.env.local .envrc +.scratch/ +context/memory/.basic-memory/ db/*.sqlite3 log/*.log tmp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..bcefbc0d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md — Instructions for Claude Code Sessions + +This file is read automatically at the start of every Claude Code session in this workspace. + +## Project + +**Awesomebits** — the web application for the Awesome Foundation, a global micro-granting community that gives $1,000 grants to "awesome" projects. This is a fork (`divideby0/awesomebits`) of the upstream repo (`awesomefoundation/awesomebits`). + +We're building **Signal Score** — an AI-assisted application pre-screening system that helps trustees prioritize grant applications by surfacing quality signals. See GitHub issue #590. + +## Project Structure + +``` +. +├── app/ # Rails application +│ ├── extras/ # SpamChecker, SpamClassifier +│ ├── models/ # ActiveRecord models (Project, Chapter, etc.) +│ └── ... +├── context/ +│ └── memory/ # basic-memory knowledge base for Signal Score +├── scripts/ +│ └── signal-score/ # Signal Score tooling (Ruby) +├── .scratch/ # Local data, never committed +│ └── data/ # Parquet, CSV, DuckDB files +├── CLAUDE.md # This file +└── ... +``` + +## Tech Stack + +- **Ruby on Rails** — existing app (Ruby 3.3.6, Rails 7.2.3) +- **PostgreSQL** — production database +- **Ruby** — Signal Score scripts (same toolchain, future Rails integration) +- **DuckDB** — local analytical queries on historical grant data + +## Secrets & Configuration + +- **`.env.local`** — personal API keys and secrets (gitignored). Never use `.env` for secrets. +- `dotenv-rails` is in the Gemfile and loads `.env.local` automatically. +- Required keys: + ``` + ANTHROPIC_API_KEY=sk-ant-... + ``` + +## Git & Commits + +### Commit Lint +- **Header max 50 characters** — this is strict, plan for it +- **Body lines max 72 characters** +- **Format:** `type(scope): description` +- **Types:** feat, fix, refactor, chore, docs, test, perf, ci +- **Valid scopes:** signal-score, spam, data, scripts, context, infra, docs, deps, repo +- **References required:** Include `Refs: #591` or `Closes: #N` in the body +- **Subject must be lowercase** — no sentence-case, start-case, pascal-case, or upper-case + +### Branch Convention +- Feature branches: `feat/-` (e.g. `feat/0591-signal-score-scripts`) +- Always branch from `master` (upstream default) + +### Git Remotes +- `origin` → `awesomefoundation/awesomebits` (upstream, read-only for us) +- `fork` → `divideby0/awesomebits` (our fork, push here) +- Push to `fork`, PR against `origin` + +## Data + +### Historical Grant Data +Historical application data is exported from the Awesome Foundation production database and stored in a privileged Google Drive folder. Contact @divideby0 for access. + +### Local Data Setup +Data files live in `.scratch/data/` (gitignored). Expected files: +- `projects.csv` → `projects.parquet` → loaded into `awesomebits.duckdb` +- `chapters.csv` → `chapters.parquet` → loaded into `awesomebits.duckdb` +- Additional: `comments.csv`, sample sets, validation scores + +### DuckDB +```bash +duckdb .scratch/data/awesomebits.duckdb +``` + +Use for analytical queries on historical data. Never connect to production. + +## Signal Score Architecture + +### Scoring Pipeline +1. Application text → LLM batch API (Anthropic) +2. Structured JSON output with Trust Equation dimensions +3. Composite score 0.0–1.0 + feature breakdown + flags + +### Trust Equation +`T = (Credibility + Reliability + Intimacy) / (1 + Self-Interest)` + +### Key Files +- `scripts/signal-score/score_grants.rb` — batch scoring via Anthropic API +- `scripts/signal-score/import_data.rb` — CSV → Parquet → DuckDB +- `context/memory/` — research notes, analysis, pattern discovery + +## Context: Existing Spam Detection + +Two existing systems (bot detection only, not content analysis): +- `app/extras/spam_checker.rb` — blocklist + identical fields +- `app/extras/spam_classifier.rb` — behavioral JS metadata analysis + +Signal Score is complementary — it analyzes content, not form behavior. + +## Notifying Evie + +When you finish a task, get stuck, or need feedback: +```bash +openclaw agent --agent main --message "[CC: ] " --timeout 30 +``` + +Keep messages concise — they'll be read on a phone. diff --git a/Gemfile b/Gemfile index 7f74aacb..457d410c 100644 --- a/Gemfile +++ b/Gemfile @@ -78,6 +78,7 @@ group :test do gem 'shoulda-matchers', '~> 3' gem 'timecop' gem 'turnip' + gem 'webmock' end group :staging, :production do diff --git a/Gemfile.lock b/Gemfile.lock index 3f32a009..4b81c516 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -152,6 +152,9 @@ GEM concurrent-ruby (1.3.4) connection_pool (2.5.0) content_disposition (1.0.0) + crack (1.0.1) + bigdecimal + rexml crass (1.0.6) csv (3.3.3) cucumber-gherkin (28.0.0) @@ -200,6 +203,7 @@ GEM activerecord (>= 4.0.0) globalid (1.2.1) activesupport (>= 6.1) + hashdiff (1.2.1) high_voltage (4.0.0) honeypot-captcha (1.0.1) htmlentities (4.3.4) @@ -475,6 +479,10 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + webmock (3.26.1) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.9.1) websocket (1.2.11) websocket-driver (0.8.0) @@ -560,6 +568,7 @@ DEPENDENCIES tus-server (~> 2.3) uppy-s3_multipart (~> 1.2) web-console + webmock will_paginate (~> 3.3.1) xmlrpc diff --git a/README.md b/README.md index e8609a36..27ba14de 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,44 @@ AWS_SECRET_ACCESS_KEY=YYY AWS_BUCKET=your-bucket-name ``` +### Signal Score (AI-Assisted Pre-Screening) + +Signal Score uses the Anthropic API to pre-screen grant applications, helping trustees +prioritize their review. Scores are advisory only and never auto-hide applications. + +**Setup:** + +1. Get an Anthropic API key from [console.anthropic.com](https://console.anthropic.com) +2. Add it to `.env.local` (create this file if it doesn't exist — it's gitignored): + +```shell +ANTHROPIC_API_KEY=sk-ant-... +``` + +3. Enable scoring for a chapter by inserting a config row: + +```sql +INSERT INTO signal_score_configs (chapter_id, scoring_config, prompt_overrides, category_config, enabled, created_at, updated_at) +VALUES (NULL, '{"model": "claude-haiku-4-5-20251001"}'::jsonb, '{}'::jsonb, '{}'::jsonb, true, NOW(), NOW()); +``` + +Set `chapter_id` to NULL for a global default, or a specific chapter ID for per-chapter config. + +**How it works:** +- New applications are scored asynchronously via `ScoreGrantJob` (SuckerPunch) +- Each score costs ~$0.01 via the Anthropic Haiku model (~2 seconds per app) +- Results are stored in `projects.metadata['signal_score']` as JSONB +- Trustees can filter by score threshold and sort by score on the dashboard +- Score badges are hidden by default — trustees opt in via "Show scores" checkbox +- The scoring rubric uses a Trust Equation framework: `T = (C + R + I) / (1 + S)` + +**Dashboard controls:** +- **Show scores** — toggle score badges on individual applications +- **Sort** — Latest, Earliest, Score highest, Score lowest, Random (stable hash) +- **Filter** — All apps, Hide low signal (default), Borderline+, Solid+, Strong only + +**Cost at scale:** ~$0.01/app. A chapter receiving 50 apps/month = ~$0.50/month. + Subdomains ---------- diff --git a/app/assets/javascripts/application-score-filter.js b/app/assets/javascripts/application-score-filter.js new file mode 100644 index 00000000..69fff156 --- /dev/null +++ b/app/assets/javascripts/application-score-filter.js @@ -0,0 +1,46 @@ +/* Score filter dropdowns — each .score-filter-wrapper is independent */ + +$('a.score-selection').on('click', function(event) { + event.preventDefault(); + const $wrapper = $(this).closest('.score-filter-wrapper'); + const $selector = $wrapper.find('.score-selector'); + const isOpen = $selector.hasClass('expanded'); + + // Close all dropdowns first + $('.score-selector').removeClass('expanded'); + $('a.score-selection').removeClass('expanded'); + + if (!isOpen) { + $selector.addClass('expanded'); + $(this).addClass('expanded'); + } +}); + +$(document).click(function(e) { + if (!$(e.target).closest('.score-filter-wrapper').length) { + $('.score-selector').removeClass('expanded'); + $('a.score-selection').removeClass('expanded'); + } +}); + +/* Show/hide score badges — client-side toggle with localStorage persistence */ +(function() { + var STORAGE_KEY = 'awesomebits-show-scores'; + var $checkbox = $('#show-scores'); + + // Restore saved state on page load + if (localStorage.getItem(STORAGE_KEY) === 'true') { + $checkbox.prop('checked', true); + $('.signal-score').show(); + } + + $checkbox.on('change', function() { + var checked = $(this).is(':checked'); + localStorage.setItem(STORAGE_KEY, checked); + if (checked) { + $('.signal-score').show(); + } else { + $('.signal-score').hide(); + } + }); +})(); diff --git a/app/assets/stylesheets/_signal-score.scss b/app/assets/stylesheets/_signal-score.scss new file mode 100644 index 00000000..7cc1beee --- /dev/null +++ b/app/assets/stylesheets/_signal-score.scss @@ -0,0 +1,195 @@ +// Signal Score — AI-assisted grant pre-screening +// Matches existing awesomebits design language + +// Score filter dropdowns — floated like the other filter controls +.score-filter-wrapper { + float: left; + margin-right: 10px; + margin-top: 20px; + position: relative; + + a.score-selection { + background: rgb(230,230,230); + border: 1px solid rgb(190,190,190); + border-radius: 3px; + display: block; + height: 21px; + padding: 8px 34px 0 10px; + text-decoration: none; + position: relative; + + &:hover { + background: rgb(220,220,220); + } + + span { + color: rgb(55,55,55); + font: normal 12px $sans-serif; + text-shadow: 0 1px rgba(255,255,255, 0.8); + } + + span.arrow { + border-top: 5px solid rgb(150,150,150); + border-right: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 5px solid transparent; + position: absolute; + top: 13px; + right: 10px; + } + } + + ol.score-selector { + background: rgb(255,255,255); + border: 1px solid rgb(150,150,150); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: 0 3px 5px 0 rgba(0,0,0, 0.2); + display: none; + position: absolute; + z-index: 10; + margin-top: -1px; + margin-bottom: 0; + min-width: 100%; + padding: 0; + + li { + border-bottom: 1px solid rgb(200,200,200); + list-style: none; + margin: 0 5px; + padding: 3px 0; + + &:last-child { border: none; } + + a { + color: rgb(55,55,55); + display: block; + padding: 5px 8px; + font: normal 12px/16px $sans-serif; + text-decoration: none; + white-space: nowrap; + + &:hover { + background: rgb(240,240,240); + border-radius: 3px; + } + } + } + } + + ol.score-selector.expanded { + display: block; + } + + a.score-selection.expanded { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + +// Score badge + +.signal-score { + margin: 0; + padding: 8px 10px; + border-left: 4px solid rgb(200,200,200); + font: normal 12px/16px $sans-serif; + + &--strong { border-left-color: #2ecc71; } + &--solid { border-left-color: #f39c12; } + &--borderline { border-left-color: #e67e22; } + &--weak { border-left-color: #e74c3c; } + + &__header { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; + } + + &__value { + font: bold 16px/18px $sans-serif; + + .signal-score--strong & { color: #2ecc71; } + .signal-score--solid & { color: #f39c12; } + .signal-score--borderline & { color: #e67e22; } + .signal-score--weak & { color: #e74c3c; } + } + + &__label { + font: 500 13px/18px $sans-serif; + color: $base-font-color; + } + + &__category { + background: rgb(230,230,230); + border: 1px solid rgb(200,200,200); + border-radius: 3px; + padding: 1px 6px; + font: normal 11px/14px $sans-serif; + color: rgb(100,100,100); + } + + &__tag { + background: #e8f0f8; + border: 1px solid #c8d8e8; + border-radius: 3px; + padding: 1px 5px; + font: normal 10px/13px $sans-serif; + color: #4a7aaa; + } + + &__reason { + margin: 4px 0 0 0; + padding: 0; + font: italic 12px/16px $serif; + color: rgb(120,120,120); + } + + &__details { + margin-top: 4px; + + summary { + cursor: pointer; + font: normal 11px/14px $sans-serif; + color: rgb(160,160,160); + + &:hover { color: $pink; } + } + } + + &__features { + margin-top: 4px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 3px; + } + + &__feat { + display: flex; + justify-content: space-between; + padding: 2px 4px; + background: rgb(248,248,248); + border-radius: 2px; + } + + &__feat-name { + font: normal 11px/14px $sans-serif; + color: rgb(120,120,120); + text-transform: capitalize; + } + + &__feat-val { + font: 500 11px/14px $sans-serif; + } + + &__feat--good { color: #2ecc71; } + &__feat--bad { color: #e67e22; } + + &__ai-warning { + margin: 4px 0 0 0; + padding: 0; + font: normal 11px/14px $sans-serif; + color: #e74c3c; + } +} diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 042988d8..3b33980a 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -37,5 +37,6 @@ @import 'pagination'; @import 'magnific-popup'; +@import 'signal-score'; @import 'owl.carousel.min'; @import 'owl.theme.default.min'; diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index b647cf89..ecfc5096 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -27,6 +27,28 @@ def index project_filter.funded end + # Signal Score filtering — only applied when explicitly requested via score_min param + @signal_score_min = params.key?(:score_min) ? params[:score_min].to_f : 0 + @sort_mode = params[:sort] if %w[score_desc score_asc earliest latest random].include?(params[:sort]) + + if @signal_score_min > 0 + project_filter.signal_score_above(@signal_score_min) + end + + case @sort_mode + when "score_desc" + project_filter.sort_by_signal_score(:desc) + when "score_asc" + project_filter.sort_by_signal_score(:asc) + when "earliest" + project_filter.sort_by_date(:asc) + when "latest" + project_filter.sort_by_date(:desc) + when "random" + project_filter.sort_by_random + # else: default sort (created_at DESC) applied by ProjectFilter#initialize + end + @q = params[:q].to_s.strip unless @q.blank? diff --git a/app/extras/project_filter.rb b/app/extras/project_filter.rb index 31aee442..b0f05895 100644 --- a/app/extras/project_filter.rb +++ b/app/extras/project_filter.rb @@ -29,6 +29,33 @@ def funded self end + def signal_score_above(threshold) + @projects = @projects.where("(metadata->'signal_score'->>'composite_score')::float >= ?", threshold) + self + end + + SORT_DIRECTIONS = { asc: "ASC", desc: "DESC" }.freeze + + def sort_by_signal_score(direction = :desc) + sql_dir = SORT_DIRECTIONS.fetch(direction, "DESC") + @projects = @projects.reorder( + Arel.sql("COALESCE((metadata->'signal_score'->>'composite_score')::float, -1) #{sql_dir}") + ) + self + end + + def sort_by_date(direction = :desc) + @projects = @projects.reorder(created_at: direction) + self + end + + def sort_by_random + # Stable random: hash the project ID so the order is consistent per page load + # but looks random to the reviewer (no bias from submission order or score) + @projects = @projects.reorder(Arel.sql("md5(projects.id::text)")) + self + end + def not_pending_moderation @projects = @projects.left_joins(:project_moderation).where(project_moderations: {id: nil}).or(@projects.merge(ProjectModeration.approved)) self diff --git a/app/extras/signal_scorer.rb b/app/extras/signal_scorer.rb new file mode 100644 index 00000000..638778b0 --- /dev/null +++ b/app/extras/signal_scorer.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require "net/http" +require "json" +require "uri" + +# SignalScorer — AI-assisted grant application pre-screening. +# +# Two-layer pipeline: +# 1. PreScorer: deterministic text features (zero cost, local) +# 2. LLM Scorer: Anthropic API with Trust Equation rubric +# +# Usage: +# scorer = SignalScorer.new(project) +# result = scorer.score! # full pipeline: pre-score + LLM +# scorer.composite_score # 0.0-1.0 +# scorer.reason # one-sentence explanation +# scorer.primary_category # "public-art" +# scorer.tags # ["mentorship", "emerging-artists"] +# +# The result is persisted to project.metadata["signal_score"]. +# +class SignalScorer + ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages" + DEFAULT_MODEL = "claude-haiku-4-5-20251001" + MAX_TOKENS = 1024 + + attr_reader :project, :score_result + + class ScoringError < StandardError; end + + def initialize(project, api_key: nil) + @project = project + @api_key = api_key || ENV["ANTHROPIC_API_KEY"] + @config = SignalScoreConfig.resolve(project.chapter) rescue {} + end + + # Full scoring pipeline: LLM score + persist to metadata. + def score! + raise ScoringError, "No Anthropic API key configured" unless @api_key.present? + + # Build the prompt + application_text = format_application + system_prompt = build_system_prompt + + # Call Anthropic Messages API with tool_use for structured output + response = call_anthropic( + system: system_prompt, + messages: [{ "role" => "user", "content" => "Score this grant application:\n\n#{application_text}" }], + tools: [score_tool], + tool_choice: { "type" => "tool", "name" => "score_application" } + ) + + # Extract tool result + tool_block = response.dig("content")&.find { |c| c["type"] == "tool_use" } + raise ScoringError, "No tool_use block in response" unless tool_block + + @score_result = tool_block["input"] + + # Persist to metadata + persist_score! + + @score_result + end + + def composite_score + score_result&.dig("composite_score") + end + + def reason + score_result&.dig("reason") + end + + def flags + score_result&.dig("flags") || [] + end + + def features + score_result&.dig("features") || {} + end + + def primary_category + (project.metadata.dig("signal_score", "primary_category") rescue nil) || + score_result&.dig("primary_category") + end + + def tags + (project.metadata.dig("signal_score", "tags") rescue nil) || + score_result&.dig("tags") || [] + end + + # Score color for UI badge + def score_color + s = composite_score + return "gray" unless s + case s + when 0.7..1.0 then "green" + when 0.5...0.7 then "yellow" + when 0.3...0.5 then "orange" + else "red" + end + end + + # Human-readable score label + def score_label + s = composite_score + return "Unscored" unless s + case s + when 0.7..1.0 then "Strong" + when 0.5...0.7 then "Solid" + when 0.3...0.5 then "Borderline" + when 0.1...0.3 then "Weak" + else "Low Signal" + end + end + + private + + def format_application + parts = [] + parts << "Title: #{project.title}" if project.title.present? + parts << "About Me: #{project.about_me}" if project.about_me.present? + parts << "About Project: #{project.about_project}" if project.about_project.present? + parts << "Use for Money: #{project.use_for_money}" if project.use_for_money.present? + parts << "URL: #{project.url}" if project.url.present? + + # Include extra answers if present + (1..3).each do |i| + q = project.send("extra_question_#{i}") rescue nil + a = project.send("extra_answer_#{i}") rescue nil + if q.present? && a.present? + parts << "#{q}: #{a}" + end + end + + parts.join("\n\n") + end + + def build_system_prompt + # Use PromptBuilder if available (scripts), otherwise inline + if defined?(PromptBuilder) + builder = PromptBuilder.new(chapter: project.chapter&.name) + builder.system_prompt + else + default_system_prompt + end + end + + def score_tool + if defined?(PromptBuilder) + PromptBuilder::SCORE_TOOL + else + { + "name" => "score_application", + "description" => "Score a grant application and extract structured features", + "input_schema" => { + "type" => "object", + "required" => %w[composite_score reason flags features], + "properties" => { + "composite_score" => { "type" => "number", "description" => "Overall score 0.0-1.0" }, + "reason" => { "type" => "string", "description" => "One-sentence explanation" }, + "flags" => { "type" => "array", "items" => { "type" => "string" } }, + "features" => { + "type" => "object", + "required" => %w[credibility reliability intimacy self_interest specificity creativity budget_alignment catalytic_potential community_benefit personal_voice ai_spam_likelihood ai_writing_likelihood], + "properties" => %w[credibility reliability intimacy self_interest specificity creativity budget_alignment catalytic_potential community_benefit personal_voice ai_spam_likelihood ai_writing_likelihood].each_with_object({}) { |k, h| h[k] = { "type" => "number" } }, + }, + }, + }, + } + end + end + + def call_anthropic(system:, messages:, tools:, tool_choice:) + uri = URI(ANTHROPIC_API_URL) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.read_timeout = 30 + + model = @config.dig("scoring_config", "model") || DEFAULT_MODEL + + body = { + "model" => model, + "max_tokens" => MAX_TOKENS, + "system" => system, + "messages" => messages, + "tools" => tools, + "tool_choice" => tool_choice, + } + + request = Net::HTTP::Post.new(uri.path) + request["Content-Type"] = "application/json" + request["x-api-key"] = @api_key + request["anthropic-version"] = "2023-06-01" + request.body = body.to_json + + response = http.request(request) + + unless response.code.to_i == 200 + # Log only status code and error type — don't leak response body which may contain user content + error_type = begin + JSON.parse(response.body).dig("error", "type") + rescue + "unknown" + end + raise ScoringError, "Anthropic API error #{response.code} (#{error_type})" + end + + JSON.parse(response.body) + end + + def persist_score! + return unless @score_result + + score_data = @score_result.merge( + "scored_at" => Time.current.iso8601, + "model" => @config.dig("scoring_config", "model") || DEFAULT_MODEL, + "variant" => "live" + ) + + # Atomic JSONB merge — avoids lost updates from concurrent metadata writes. + # Uses || operator to merge into existing metadata rather than replacing it. + Project.where(id: project.id).update_all( + ["metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('signal_score', ?::jsonb), updated_at = ?", + score_data.to_json, Time.current] + ) + + project.reload + end + + def default_system_prompt + <<~PROMPT + You are an expert grant application screener for the Awesome Foundation. + + The Awesome Foundation is a global network of volunteer "micro-trustees" who each chip in to award $1,000 grants for awesome projects. No strings attached — the money goes to creative, community-benefiting, unique ideas. + + Score each application using the score_application tool. Extract structured features to help trustees prioritize their review. + + ## Scoring Rubric (composite_score: 0.0 to 1.0) + - 0.0-0.1: Clear spam, gibberish, test submissions + - 0.1-0.3: Real but very weak — business pitches, personal fundraising, vague ideas + - 0.3-0.5: Borderline — decent concept but missing details, unclear community benefit + - 0.5-0.7: Solid — clear project, community benefit, actionable plan + - 0.7-0.9: Strong — creative, specific, well-articulated, exactly what AF funds + - 0.9-1.0: Exceptional — innovative, clearly impactful, inspiring + + ## Feature Dimensions (Trust Equation: T = (C + R + I) / (1 + S)) + Numerator (higher = better): + - credibility: Clear budget, realistic plan, relevant expertise (0-1) + - reliability: Track record, prior work, organizational backing (0-1) + - intimacy: Connection to community, local ties, authentic voice (0-1) + Denominator (higher = worse): + - self_interest: Money primarily benefits applicant? (0-1) + Additional: + - specificity, creativity, budget_alignment, catalytic_potential, community_benefit, personal_voice (all 0-1) + - ai_spam_likelihood: Mass-generated? (0-1) + - ai_writing_likelihood: AI writing patterns? INFORMATIONAL ONLY (0-1) + + ## Flags: spam, ai_spam, duplicate, incomplete, wrong_location, business_pitch, personal_fundraising, low_effort + + ## Key Principles + - AF values creativity, community impact, and fun + - $1,000 is small — projects should be scoped appropriately + - "Too weird for traditional funders" = MORE awesome, not less + - ~28% of applications are typically review-worthy + PROMPT + end +end diff --git a/app/jobs/score_grant_job.rb b/app/jobs/score_grant_job.rb new file mode 100644 index 00000000..f804dc82 --- /dev/null +++ b/app/jobs/score_grant_job.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "sucker_punch" + +# Scores a grant application asynchronously via the Signal Score pipeline. +# +# Enqueued after a new project is created. Uses the Anthropic Messages API +# to generate structured scores with the Trust Equation rubric. +# +# Usage: +# ScoreGrantJob.perform_async(project_id) +# ScoreGrantJob.new.perform(project_id) # synchronous, for testing +# +class ScoreGrantJob + include SuckerPunch::Job + + MAX_RETRIES = 3 + + def perform(project_id) + ActiveRecord::Base.connection_pool.with_connection do + # Atomic claim: only proceed if no score exists yet. + # This prevents double-scoring when multiple workers pick up the same job. + claimed = Project.where(id: project_id) + .where("metadata IS NULL OR metadata->'signal_score' IS NULL") + .update_all("metadata = COALESCE(metadata, '{}'::jsonb) || '{\"signal_score\": {\"status\": \"scoring\"}}'::jsonb") + return unless claimed > 0 + + project = Project.find(project_id) + + # Skip if scoring is disabled for this chapter + config = SignalScoreConfig.resolve(project.chapter) rescue {} + unless config["enabled"] + release_claim!(project_id) + return + end + + score_with_retries!(project, project_id) + rescue => e + Rails.logger.error("[SignalScore] Unexpected error scoring project ##{project_id}: #{e.class}: #{e.message}") + release_claim!(project_id) + end + end + + private + + def score_with_retries!(project, project_id) + attempts = 0 + + begin + attempts += 1 + scorer = SignalScorer.new(project) + scorer.score! + + Rails.logger.info( + "[SignalScore] Project ##{project_id} scored: " \ + "#{scorer.composite_score} (#{scorer.score_label})" + ) + rescue SignalScorer::ScoringError => e + Rails.logger.error("[SignalScore] Failed to score project ##{project_id}: #{e.message}") + + if attempts < MAX_RETRIES + sleep_time = 2**attempts # 2s, 4s + Rails.logger.info("[SignalScore] Retrying project ##{project_id} in #{sleep_time}s (attempt #{attempts + 1}/#{MAX_RETRIES})") + sleep(sleep_time) + retry + end + + Rails.logger.error("[SignalScore] Gave up on project ##{project_id} after #{MAX_RETRIES} attempts") + release_claim!(project_id) + end + end + + def release_claim!(project_id) + Project.where(id: project_id) + .where("metadata->'signal_score'->>'status' = 'scoring'") + .update_all("metadata = metadata - 'signal_score'") + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 1704e14d..a8298344 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -36,6 +36,7 @@ class Project < ApplicationRecord before_save :delete_photos after_create :analyze_for_spam + after_create :enqueue_signal_score # For dependency injection cattr_accessor :mailer @@ -272,6 +273,10 @@ def analyze_for_spam end end + def enqueue_signal_score + ScoreGrantJob.perform_async(id) if ENV["ANTHROPIC_API_KEY"].present? + end + # before save def ensure_funded_description if winner? diff --git a/app/models/signal_score_config.rb b/app/models/signal_score_config.rb new file mode 100644 index 00000000..305a4799 --- /dev/null +++ b/app/models/signal_score_config.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Per-chapter configuration for the Signal Score pipeline. +# +# A row with chapter_id = NULL is the global default. +# Chapter-specific rows override any fields they set (deep merge). +# +# JSONB columns: +# scoring_config: model, temperature, few_shot_count, score_threshold, grant_amount, currency +# prompt_overrides: system_prompt, rubric_addendum, few_shot_project_ids +# category_config: weights, disabled, custom +# +class SignalScoreConfig < ApplicationRecord + belongs_to :chapter, optional: true + + validates :chapter_id, uniqueness: { allow_nil: true } + validate :only_one_global_default + + scope :global_default, -> { where(chapter_id: nil) } + scope :enabled, -> { where(enabled: true) } + + # Resolve config for a chapter: deep-merge chapter overrides onto global default. + def self.resolve(chapter = nil) + global = global_default.first + return {} unless global + + base = { + "scoring_config" => global.scoring_config, + "prompt_overrides" => global.prompt_overrides, + "category_config" => global.category_config, + "enabled" => global.enabled, + } + + if chapter + override = find_by(chapter: chapter) + if override + base = deep_merge(base, { + "scoring_config" => override.scoring_config, + "prompt_overrides" => override.prompt_overrides, + "category_config" => override.category_config, + "enabled" => override.enabled, + }) + end + end + + base + end + + private + + def only_one_global_default + if chapter_id.nil? && self.class.global_default.where.not(id: id).exists? + errors.add(:chapter_id, "global default already exists") + end + end + + def self.deep_merge(base, overlay) + base.merge(overlay) do |_key, old_val, new_val| + if old_val.is_a?(Hash) && new_val.is_a?(Hash) + deep_merge(old_val, new_val) + elsif new_val.nil? || (new_val.is_a?(Hash) && new_val.empty?) + old_val + else + new_val + end + end + end +end diff --git a/app/views/projects/_project.html.erb b/app/views/projects/_project.html.erb index 3cf52221..d6885914 100644 --- a/app/views/projects/_project.html.erb +++ b/app/views/projects/_project.html.erb @@ -1,6 +1,6 @@
<%= "winner" if project.winner? %>" id="project<%= project.id %>" data-id="<%= project.id %>" data-controller="jump" data-jump-scroll-value="<%= local_assigns[:scroll] == true %>"> <% if !project.hidden? || @display_project_even_if_hidden %> - <%= render "projects/project_details", project: project, moderation: local_assigns[:moderation] %> + <%= render "projects/project_details", project: project, moderation: local_assigns[:moderation], full_view: local_assigns[:full_view] %> <% else %> <%= render "projects/project_hidden", project: project, moderation: local_assigns[:moderation] %> <% end %> diff --git a/app/views/projects/_project_details.html.erb b/app/views/projects/_project_details.html.erb index 3e226dd8..425352f4 100644 --- a/app/views/projects/_project_details.html.erb +++ b/app/views/projects/_project_details.html.erb @@ -26,6 +26,11 @@ + <% signal_score = project.metadata&.dig("signal_score") %> + <% if signal_score && signal_score["composite_score"] %> + <%= render "projects/signal_score_badge", signal_score: signal_score, always_show: local_assigns[:full_view] %> + <% end %> +