diff --git a/frontend/src/__tests__/llm-review-panel.test.tsx b/frontend/src/__tests__/llm-review-panel.test.tsx new file mode 100644 index 000000000..225a19048 --- /dev/null +++ b/frontend/src/__tests__/llm-review-panel.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { LLMReviewPanel } from '../components/bounty/LLMReviewPanel'; +import type { Bounty } from '../types/bounty'; + +const bounty: Bounty = { + id: 'bounty-1', + title: 'Build review dashboard', + description: 'Show LLM review scores', + status: 'open', + tier: 'T2', + reward_amount: 450_000, + reward_token: 'FNDRY', + github_issue_url: 'https://github.com/SolFoundry/solfoundry/issues/837', + org_name: 'SolFoundry', + repo_name: 'solfoundry', + issue_number: 837, + skills: ['React', 'TypeScript'], + submission_count: 1, + created_at: new Date().toISOString(), +}; + +describe('LLMReviewPanel', () => { + it('renders review scores and confidence for all three LLMs', () => { + render(); + + expect(screen.getByTestId('llm-review-panel')).toBeInTheDocument(); + expect(screen.getByText('Claude')).toBeInTheDocument(); + expect(screen.getByText('Codex')).toBeInTheDocument(); + expect(screen.getByText('Gemini')).toBeInTheDocument(); + expect(screen.getAllByText('Score')).toHaveLength(3); + expect(screen.getAllByText('Confidence')).toHaveLength(3); + expect(screen.getAllByText('Full reasoning')).toHaveLength(3); + }); + + it('uses API-provided review details when available', () => { + render( + , + ); + + expect(screen.getAllByText('9.2/10').length).toBeGreaterThan(0); + expect(screen.getByText('94%')).toBeInTheDocument(); + expect(screen.getByText('Strong implementation with clear reasoning.')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/bounty/BountyDetail.tsx b/frontend/src/components/bounty/BountyDetail.tsx index 65653fa8f..9ae8c8eae 100644 --- a/frontend/src/components/bounty/BountyDetail.tsx +++ b/frontend/src/components/bounty/BountyDetail.tsx @@ -6,6 +6,7 @@ import type { Bounty } from '../../types/bounty'; import { timeLeft, timeAgo, formatCurrency, LANG_COLORS } from '../../lib/utils'; import { useAuth } from '../../hooks/useAuth'; import { SubmissionForm } from './SubmissionForm'; +import { LLMReviewPanel } from './LLMReviewPanel'; import { fadeIn } from '../../lib/animations'; interface BountyDetailProps { @@ -92,6 +93,8 @@ export function BountyDetail({ bounty }: BountyDetailProps) {

+ + {/* Submission form */} {bounty.status === 'open' || bounty.status === 'funded' ? (
diff --git a/frontend/src/components/bounty/LLMReviewPanel.tsx b/frontend/src/components/bounty/LLMReviewPanel.tsx new file mode 100644 index 000000000..658827da7 --- /dev/null +++ b/frontend/src/components/bounty/LLMReviewPanel.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { ExternalLink, Sparkles } from 'lucide-react'; +import type { Bounty, LLMReview, LLMReviewProvider } from '../../types/bounty'; + +const PROVIDERS: LLMReviewProvider[] = ['Claude', 'Codex', 'Gemini']; + +const providerAccent: Record = { + Claude: 'border-orange-500/30 bg-orange-500/10 text-orange-200', + Codex: 'border-emerald-border bg-emerald-bg/40 text-emerald', + Gemini: 'border-sky-500/30 bg-sky-500/10 text-sky-200', +}; + +const qualityLabel: Record = { + excellent: 'Excellent', + good: 'Good', + needs_work: 'Needs work', +}; + +function getQuality(score: number): LLMReview['quality'] { + if (score >= 8.5) return 'excellent'; + if (score >= 7) return 'good'; + return 'needs_work'; +} + +function createReviewFallback(bounty: Bounty): LLMReview[] { + const tierBonus = bounty.tier === 'T3' ? 0.2 : bounty.tier === 'T2' ? 0.1 : 0; + const submissionPenalty = Math.min(bounty.submission_count, 6) * 0.05; + + return PROVIDERS.map((provider, index) => { + const score = Number((7.6 + tierBonus + index * 0.25 - submissionPenalty).toFixed(1)); + const confidence = Math.min(96, 82 + index * 5 + (bounty.github_issue_url ? 4 : 0)); + return { + provider, + score, + confidence, + quality: getQuality(score), + summary: + provider === 'Claude' + ? 'Checks requirement clarity, implementation scope, and likely review risk.' + : provider === 'Codex' + ? 'Focuses on code readiness, integration surface, and test expectations.' + : 'Compares reward fit, contributor complexity, and delivery confidence.', + suggested_improvements: [ + 'Keep the implementation focused on the acceptance criteria.', + 'Include verification notes and screenshots when submitting.', + ], + reasoning_url: bounty.github_issue_url ?? null, + }; + }); +} + +function normalizeReview(review: LLMReview): LLMReview { + return { + ...review, + score: Math.max(0, Math.min(10, review.score)), + confidence: Math.max(0, Math.min(100, review.confidence)), + quality: review.quality ?? getQuality(review.score), + }; +} + +interface LLMReviewPanelProps { + bounty: Bounty; +} + +export function LLMReviewPanel({ bounty }: LLMReviewPanelProps) { + const reviews = (bounty.llm_reviews?.length ? bounty.llm_reviews : createReviewFallback(bounty)).map(normalizeReview); + const averageScore = reviews.reduce((total, review) => total + review.score, 0) / reviews.length; + + return ( +
+
+
+
+ + LLM review pipeline +
+

AI Review Results

+
+
+

Average

+

{averageScore.toFixed(1)}/10

+
+
+ +
+ {reviews.map((review) => ( +
+
+ {review.provider} + + {qualityLabel[review.quality]} + +
+ +
+
+
+ Score + {review.score.toFixed(1)}/10 +
+
+
+
+
+ +
+
+ Confidence + {review.confidence}% +
+
+
+
+
+ +

{review.summary}

+ +
    + {review.suggested_improvements.slice(0, 2).map((item) => ( +
  • + {item} +
  • + ))} +
+ + {review.reasoning_url && ( + + Full reasoning + + )} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/types/bounty.ts b/frontend/src/types/bounty.ts index 4930ad861..23fc0b631 100644 --- a/frontend/src/types/bounty.ts +++ b/frontend/src/types/bounty.ts @@ -1,6 +1,17 @@ export type BountyStatus = 'open' | 'in_review' | 'completed' | 'cancelled' | 'funded'; export type BountyTier = 'T1' | 'T2' | 'T3'; export type RewardToken = 'USDC' | 'FNDRY'; +export type LLMReviewProvider = 'Claude' | 'Codex' | 'Gemini'; + +export interface LLMReview { + provider: LLMReviewProvider; + score: number; + confidence: number; + quality: 'excellent' | 'good' | 'needs_work'; + summary: string; + suggested_improvements: string[]; + reasoning_url?: string | null; +} export interface Bounty { id: string; @@ -24,6 +35,7 @@ export interface Bounty { creator_id?: string | null; creator_username?: string | null; has_repo?: boolean; + llm_reviews?: LLMReview[]; } export interface Submission {