From a9da1f34b8b786a22d98f542457624fdcf1941dd Mon Sep 17 00:00:00 2001 From: pexp13 Date: Tue, 12 May 2026 23:05:55 +0500 Subject: [PATCH 1/3] add sentiment migration for ratings table --- scripts/add_sentiment.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 scripts/add_sentiment.sql diff --git a/scripts/add_sentiment.sql b/scripts/add_sentiment.sql new file mode 100644 index 0000000..7f49c12 --- /dev/null +++ b/scripts/add_sentiment.sql @@ -0,0 +1,15 @@ +-- Add sentiment_label to ratings + +ALTER TABLE ratings + ADD COLUMN IF NOT EXISTS sentiment_label TEXT; + +ALTER TABLE ratings + ADD CONSTRAINT ratings_sentiment_label_check + CHECK ( + sentiment_label IS NULL OR + sentiment_label IN ('positive', 'negative', 'neutral') + ); + +UPDATE ratings + SET sentiment_label = 'neutral' + WHERE sentiment_label IS NULL; \ No newline at end of file From 1f1a28cda154f366114d262db66d447747f54854 Mon Sep 17 00:00:00 2001 From: pexp13 Date: Thu, 14 May 2026 23:11:48 +0500 Subject: [PATCH 2/3] add sentiment analysis utility and integration --- package-lock.json | 18 +++++++++ package.json | 2 + src/lib/sentiment.ts | 34 +++++++++++++++++ src/pages/api/ratings/route.ts | 67 +++++++++++++++++----------------- src/types/supabase.ts | 5 ++- 5 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 src/lib/sentiment.ts diff --git a/package-lock.json b/package-lock.json index 055222c..c92f2fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "react-hot-toast": "^2.5.2", "react-resizable-panels": "^2.1.3", "recharts": "^2.12.7", + "sentiment": "^5.0.2", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss": "^3.3.3", @@ -81,6 +82,7 @@ }, "devDependencies": { "@types/crypto-js": "^4.2.2", + "@types/sentiment": "^5.0.4", "dotenv": "^17.0.1", "ts-node": "^10.9.2", "tsx": "^4.20.6" @@ -3525,6 +3527,13 @@ "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", "license": "MIT" }, + "node_modules/@types/sentiment": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/sentiment/-/sentiment-5.0.4.tgz", + "integrity": "sha512-6FL0CYijhnrR3gHbu7boAJn8zRCekJXBPfIHLkIgWbkY+hz5Dwfsq79FM7l/tLZKuEgQWktnzf6JqV2UCWKrbg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -8338,6 +8347,15 @@ "node": ">=10" } }, + "node_modules/sentiment": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/sentiment/-/sentiment-5.0.2.tgz", + "integrity": "sha512-ZeC3y0JsOYTdwujt5uOd7ILJNilbgFzUtg/LEG4wUv43LayFNLZ28ec8+Su+h3saHlJmIwYxBzfDHHZuiMA15g==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 7d03d34..6372dce 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "react-hot-toast": "^2.5.2", "react-resizable-panels": "^2.1.3", "recharts": "^2.12.7", + "sentiment": "^5.0.2", "sonner": "^1.5.0", "tailwind-merge": "^2.5.2", "tailwindcss": "^3.3.3", @@ -83,6 +84,7 @@ }, "devDependencies": { "@types/crypto-js": "^4.2.2", + "@types/sentiment": "^5.0.4", "dotenv": "^17.0.1", "ts-node": "^10.9.2", "tsx": "^4.20.6" diff --git a/src/lib/sentiment.ts b/src/lib/sentiment.ts new file mode 100644 index 0000000..c15334f --- /dev/null +++ b/src/lib/sentiment.ts @@ -0,0 +1,34 @@ +import Sentiment from 'sentiment'; + +export type SentimentLabel = 'positive' | 'negative' | 'neutral'; + +export interface SentimentResult { + label: SentimentLabel; + score: number; +} + +const analyzer = new Sentiment(); + +export function analyzeSentiment(text: string, overallRating?: number): SentimentResult { + const { comparative } = analyzer.analyze(text || ''); + + let score = Math.max(-1, Math.min(1, comparative / 5)); + + + if (typeof overallRating === 'number') { + const clampedRating = Math.max(1, Math.min(5, overallRating)); + const ratingScore = (clampedRating - 3) / 2; + + score = (score * 0.6) + (ratingScore * 0.4); + } + + if (score > 0.1) { + return { label: 'positive', score }; + } + + if (score < -0.1) { + return { label: 'negative', score }; + } + + return { label: 'neutral', score }; +} \ No newline at end of file diff --git a/src/pages/api/ratings/route.ts b/src/pages/api/ratings/route.ts index d86d4b1..17ec624 100644 --- a/src/pages/api/ratings/route.ts +++ b/src/pages/api/ratings/route.ts @@ -3,23 +3,24 @@ import { supabase } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin'; import { createFuzzyTimestamp, sanitizeContent } from '@/lib/anonymization'; import { RatingInsert } from '@/types/supabase'; +import { analyzeSentiment } from '@/lib/sentiment'; // POST /api/ratings - Create a new rating export async function POST(request: Request) { try { // Get session to verify authentication const { data: { session } } = await supabase.auth.getSession(); - + if (!session?.user?.id) { return NextResponse.json( { error: 'Authentication required' }, { status: 401 } ); } - + // Get JSON data from request const json = await request.json(); - const { + const { targetId, targetType, ratingMetrics, @@ -27,7 +28,7 @@ export async function POST(request: Request) { semester, year } = json; - + // Validate required fields if (!targetId || !targetType || !ratingMetrics || !semester || !year) { return NextResponse.json( @@ -35,7 +36,7 @@ export async function POST(request: Request) { { status: 400 } ); } - + // Validate target type if (targetType !== 'course' && targetType !== 'professor') { return NextResponse.json( @@ -43,14 +44,14 @@ export async function POST(request: Request) { { status: 400 } ); } - + // Get the anonymous ID for the authenticated user const { data: anonymousData, error: anonymousError } = await supabase .from('users') .select('anonymous_id') .eq('auth_id', session.user.id) .single(); - + if (anonymousError || !anonymousData?.anonymous_id) { console.error('Error fetching anonymous ID:', anonymousError); return NextResponse.json( @@ -58,11 +59,14 @@ export async function POST(request: Request) { { status: 500 } ); } - + // Process the rating data const sanitizedComment = comment ? sanitizeContent(comment) : null; const displayDate = createFuzzyTimestamp(); - + + // Analyze sentiment of the comment (neutral if no comment) + const sentiment = analyzeSentiment(sanitizedComment ?? '', ratingMetrics?.overall); + // Check if user has already rated this target const { data: existingRating, error: checkError } = await supabase .from('ratings') @@ -71,7 +75,7 @@ export async function POST(request: Request) { .eq('target_id', targetId) .eq('target_type', targetType) .single(); - + // If user has already rated, return error if (existingRating) { return NextResponse.json( @@ -79,7 +83,7 @@ export async function POST(request: Request) { { status: 409 } ); } - + // Create the rating const ratingData: RatingInsert = { anonymous_id: anonymousData.anonymous_id, @@ -87,20 +91,21 @@ export async function POST(request: Request) { target_type: targetType, rating_metrics: ratingMetrics, comment: sanitizedComment, + sentiment_label: sentiment.label, semester, year, display_date: displayDate, helpfulness_score: 0, is_flagged: false }; - + // Use admin client to create rating (to bypass RLS if needed) const { data, error } = await supabaseAdmin .from('ratings') .insert(ratingData) .select('id') .single(); - + if (error) { console.error('Error creating rating:', error); return NextResponse.json( @@ -108,18 +113,16 @@ export async function POST(request: Request) { { status: 500 } ); } - - // Update the rating statistics view or trigger - // This would typically be handled by a database trigger - + return NextResponse.json({ success: true, data: { id: data.id, - displayDate + displayDate, + sentimentLabel: sentiment.label, } }); - + } catch (error) { console.error('Unexpected error in ratings API:', error); return NextResponse.json( @@ -139,7 +142,7 @@ export async function GET(request: Request) { const pageSize = parseInt(searchParams.get('pageSize') || '10'); const sortBy = searchParams.get('sortBy') || 'created_at'; const sortOrder = searchParams.get('sortOrder') || 'desc'; - + // Validate required params if (!targetId || !targetType) { return NextResponse.json( @@ -147,7 +150,7 @@ export async function GET(request: Request) { { status: 400 } ); } - + // Validate target type if (targetType !== 'course' && targetType !== 'professor') { return NextResponse.json( @@ -155,33 +158,31 @@ export async function GET(request: Request) { { status: 400 } ); } - + // Calculate pagination const from = (page - 1) * pageSize; const to = from + pageSize - 1; - + // Query ratings let query = supabase .from('ratings') - .select('id, rating_metrics, comment, semester, year, display_date, created_at, helpfulness_score') + .select('id, rating_metrics, comment, sentiment_label, semester, year, display_date, created_at, helpfulness_score') .eq('target_id', targetId) .eq('target_type', targetType) .eq('is_flagged', false) .range(from, to); - + // Apply sorting if (sortBy === 'helpfulness') { query = query.order('helpfulness_score', { ascending: sortOrder === 'asc' }); } else if (sortBy === 'date') { query = query.order('created_at', { ascending: sortOrder === 'asc' }); } else if (sortBy === 'rating') { - // For rating, we need to sort by the overall metric within the rating_metrics JSONB field - // This might need a different approach depending on your database query = query.order('rating_metrics->overall', { ascending: sortOrder === 'asc' }); } const { data: ratings, error, count } = await query; - + if (error) { console.error('Error fetching ratings:', error); return NextResponse.json( @@ -189,7 +190,7 @@ export async function GET(request: Request) { { status: 500 } ); } - + // Get total count for pagination const { count: totalCount, error: countError } = await supabase .from('ratings') @@ -197,11 +198,11 @@ export async function GET(request: Request) { .eq('target_id', targetId) .eq('target_type', targetType) .eq('is_flagged', false); - + if (countError) { console.error('Error counting ratings:', countError); } - + return NextResponse.json({ data: ratings, meta: { @@ -211,7 +212,7 @@ export async function GET(request: Request) { totalPages: Math.ceil((totalCount || 0) / pageSize) } }); - + } catch (error) { console.error('Unexpected error in ratings API:', error); return NextResponse.json( @@ -219,4 +220,4 @@ export async function GET(request: Request) { { status: 500 } ); } -} \ No newline at end of file +} diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 3beb502..6ad36d9 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -143,6 +143,7 @@ export interface Database { approachability?: number } comment: string | null + sentiment_label: 'positive' | 'negative' | 'neutral' | null semester: string year: number display_date: string @@ -165,6 +166,7 @@ export interface Database { approachability?: number } comment?: string | null + sentiment_label?: 'positive' | 'negative' | 'neutral' | null semester: string year: number display_date: string @@ -187,6 +189,7 @@ export interface Database { approachability?: number } comment?: string | null + sentiment_label?: 'positive' | 'negative' | 'neutral' | null semester?: string year?: number display_date?: string @@ -362,4 +365,4 @@ export type Department = Database['public']['Tables']['departments']['Row'] export type DepartmentInsert = Database['public']['Tables']['departments']['Insert'] export type CourseStats = Database['public']['Views']['course_stats']['Row'] -export type ProfessorStats = Database['public']['Views']['professor_stats']['Row'] \ No newline at end of file +export type ProfessorStats = Database['public']['Views']['professor_stats']['Row'] From 0c9b7c343624fc11447df21401ce110b0de50c88 Mon Sep 17 00:00:00 2001 From: pexp13 Date: Thu, 14 May 2026 23:24:31 +0500 Subject: [PATCH 3/3] add JSDoc to analyzeSentiment --- src/lib/sentiment.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/sentiment.ts b/src/lib/sentiment.ts index c15334f..b276a84 100644 --- a/src/lib/sentiment.ts +++ b/src/lib/sentiment.ts @@ -9,12 +9,19 @@ export interface SentimentResult { const analyzer = new Sentiment(); +/** + * Analyzes the sentiment of a review comment. + * Optionally blends the result with a numeric overall rating (60/40 weight). + * + * @param text - The review comment text + * @param overallRating - Optional numeric rating (1–5) + * @returns SentimentResult with label and normalized score (-1 to 1) + */ export function analyzeSentiment(text: string, overallRating?: number): SentimentResult { const { comparative } = analyzer.analyze(text || ''); let score = Math.max(-1, Math.min(1, comparative / 5)); - if (typeof overallRating === 'number') { const clampedRating = Math.max(1, Math.min(5, overallRating)); const ratingScore = (clampedRating - 3) / 2;