diff --git a/.env.development.example b/.env.development.example index be19eaa9..a265efeb 100644 --- a/.env.development.example +++ b/.env.development.example @@ -23,3 +23,9 @@ EXTERNAL_API_KEY=dev_api_key # SLACK_CONTRIBUTORS_CHANNEL_ID= # Optional: Slack channel for streak break + leaderboard messages (#forge) # SLACK_STREAKS_CHANNEL_ID= + +# Read-only Airtable PAT for the YSWS Unified Database, used by the review +# duplicate/double-dip scan. Needs data.records:read on the unified base. +# Production sets this through Admin -> API keys instead, which stores it +# encrypted in the database and survives deploys. +UNIFIED_DB_PAT= diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index cde70c30..333f2d77 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -1,9 +1,9 @@ class Admin::ProjectsController < Admin::ApplicationController - REVIEW_SCREEN_ACTIONS = %i[review repo_tree commit_stats changes_since_review ai_requirements_check ai_requirements_check_status].freeze + REVIEW_SCREEN_ACTIONS = %i[review repo_tree commit_stats changes_since_review ai_requirements_check ai_requirements_check_status check_draft_justification].freeze before_action :require_projects_permission!, except: REVIEW_SCREEN_ACTIONS + [ :unflag_for_review ] before_action :require_review_screen_access!, only: REVIEW_SCREEN_ACTIONS - before_action :set_project, only: [ :show, :review, :destroy, :restore, :toggle_hidden, :toggle_shadow_ban, :toggle_staff_pick, :change_tier, :add_note, :destroy_note, :update_note, :flag_for_review, :unflag_for_review, :mark_unbuilt, :reverse_review, :ai_requirements_check, :ai_requirements_check_status, :repo_tree, :commit_stats, :changes_since_review, :send_checkpoint_message, :send_dm_message ] + before_action :set_project, only: [ :show, :review, :destroy, :restore, :toggle_hidden, :toggle_shadow_ban, :toggle_staff_pick, :change_tier, :add_note, :destroy_note, :update_note, :flag_for_review, :unflag_for_review, :mark_unbuilt, :reverse_review, :ai_requirements_check, :ai_requirements_check_status, :check_draft_justification, :repo_tree, :commit_stats, :changes_since_review, :send_checkpoint_message, :send_dm_message ] def index scope = policy_scope(Project).includes(:user, :ships) @@ -360,7 +360,16 @@ def unflag_for_review # reviewing itself". JustificationLint already runs on every keystroke for # free; this is the paid second opinion, so it is on demand only. def check_draft_justification - authorize @project, :review? + # Deliberately not `authorize`: Pundit's failure handler redirects to an HTML + # page, and a fetch() follows that redirect and then chokes parsing HTML as + # JSON — which surfaces to the reviewer as a generic "request failed" with no + # hint that it was a permissions problem. + unless policy(@project).review? + return render json: { + result: { "overall" => "error", + "message" => "You don't have review permission for #{@project.review_tier.to_s.tr('_', ' ')}, so the audit can't run." } + }, status: :forbidden + end result = AiRequirementsChecker.audit_justification( text: params[:justification].to_s, @@ -371,6 +380,13 @@ def check_draft_justification render json: { result: result } rescue AiRequirementsChecker::Error => e render json: { result: { "overall" => "error", "message" => e.message } } + rescue StandardError => e + # A timeout or a malformed model response should say so, not 500 into the + # frontend's catch-all. + Rails.logger.error("[JustificationAudit] project=#{@project&.id.inspect} #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + render json: { result: { "overall" => "error", "message" => "The audit hit an unexpected error (#{e.class}). Try again." } }, + status: :internal_server_error end REQUIREMENTS_CHECKER_DECISIONS = %w[requirements_met return].freeze diff --git a/app/javascript/components/admin/review/types.ts b/app/javascript/components/admin/review/types.ts index bbc05e92..912d079d 100644 --- a/app/javascript/components/admin/review/types.ts +++ b/app/javascript/components/admin/review/types.ts @@ -167,6 +167,7 @@ export interface DuplicateScan { macondo: { id: string; title: string | null; shipped: boolean }[] unified_available: boolean unified_error: boolean + unified_checked: boolean verdict: 'clear' | 'review' | 'blocked' reason?: string scanned_at: string diff --git a/app/javascript/lib/justificationPreview.ts b/app/javascript/lib/justificationPreview.ts index b24acd8f..d4784240 100644 --- a/app/javascript/lib/justificationPreview.ts +++ b/app/javascript/lib/justificationPreview.ts @@ -111,7 +111,8 @@ export function buildJustification(ctx: JustificationContext): string { const deflationRaw = Math.max(0, Math.round((ctx.claimed_hours - ctx.approved_hours) * 10) / 10) const deflation = String(deflationRaw).replace(/\.0$/, '') const deflationReason = (ctx.deflation_reason ?? '').trim() - const deflationSuffix = deflationRaw > 0 && deflationReason ? ` — reason: ${deflationReason}` : '' + // A multi-line list of per-entry reasons reads as a block, not a clause. + const deflationSuffix = deflationRaw > 0 && deflationReason ? `:\n${deflationReason}` : '' const reasoning = (ctx.assessment ?? '').trim() || '(no justification provided)' const additional = (ctx.additional_justification ?? '').trim() const additionalBlock = additional ? `\nAdditional justification:\n${additional}\n` : '' diff --git a/app/javascript/pages/Admin/AirtableQueue/Show.tsx b/app/javascript/pages/Admin/AirtableQueue/Show.tsx index e4bb5e3a..ce7c9f53 100644 --- a/app/javascript/pages/Admin/AirtableQueue/Show.tsx +++ b/app/javascript/pages/Admin/AirtableQueue/Show.tsx @@ -293,7 +293,7 @@ export default function AdminAirtableQueueShow({

- Verifies if the justification is up to standard using gemini :D + Audits the justification against the Unified DB standard before it goes out.