From 73e7eb3a7bbe422b272f6bf39ec6f90718bfb344 Mon Sep 17 00:00:00 2001 From: DIodide Date: Sat, 17 Jan 2026 04:38:34 -0500 Subject: [PATCH] semantic player search impl --- package-lock.json | 10 + package.json | 1 + .../migration.sql | 21 + .../migrations/20260117093011_/migration.sql | 19 + prisma/schema.prisma | 17 + scripts/generate-embeddings.ts | 499 ++++++++++++++++++ .../_components/TalentAnalysisSection.tsx | 107 ++++ .../_components/TalentFilterPanel.tsx | 348 ++++++++++++ .../_components/TalentPlayerCard.tsx | 218 ++++++++ .../_components/TalentPlayerModal.tsx | 425 +++++++++++++++ .../_components/TalentResultsGrid.tsx | 110 ++++ .../_components/TalentSearchBar.tsx | 112 ++++ .../dashboard/coaches/find-talent/page.tsx | 251 +++++++++ src/app/dashboard/coaches/page.tsx | 10 +- src/env.js | 5 + src/lib/server/embeddings.ts | 341 ++++++++++++ src/lib/server/gemini.ts | 303 +++++++++++ src/server/api/root.ts | 2 + src/server/api/routers/talentSearch.ts | 450 ++++++++++++++++ src/types/talent-search.ts | 194 +++++++ 20 files changed, 3438 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260117000000_add_vector_search/migration.sql create mode 100644 prisma/migrations/20260117093011_/migration.sql create mode 100644 scripts/generate-embeddings.ts create mode 100644 src/app/dashboard/coaches/find-talent/_components/TalentAnalysisSection.tsx create mode 100644 src/app/dashboard/coaches/find-talent/_components/TalentFilterPanel.tsx create mode 100644 src/app/dashboard/coaches/find-talent/_components/TalentPlayerCard.tsx create mode 100644 src/app/dashboard/coaches/find-talent/_components/TalentPlayerModal.tsx create mode 100644 src/app/dashboard/coaches/find-talent/_components/TalentResultsGrid.tsx create mode 100644 src/app/dashboard/coaches/find-talent/_components/TalentSearchBar.tsx create mode 100644 src/app/dashboard/coaches/find-talent/page.tsx create mode 100644 src/lib/server/embeddings.ts create mode 100644 src/lib/server/gemini.ts create mode 100644 src/server/api/routers/talentSearch.ts create mode 100644 src/types/talent-search.ts diff --git a/package-lock.json b/package-lock.json index 790c2cf..06675d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@clerk/elements": "^0.23.32", "@clerk/nextjs": "^6.20.2", "@clerk/themes": "^2.2.49", + "@google/generative-ai": "^0.24.1", "@hookform/resolvers": "^5.0.1", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", @@ -1055,6 +1056,15 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@google/generative-ai": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz", + "integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@hookform/resolvers": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", diff --git a/package.json b/package.json index 501e6d6..41a1fca 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@clerk/elements": "^0.23.32", "@clerk/nextjs": "^6.20.2", "@clerk/themes": "^2.2.49", + "@google/generative-ai": "^0.24.1", "@hookform/resolvers": "^5.0.1", "@mdx-js/loader": "^3.1.0", "@mdx-js/react": "^3.1.0", diff --git a/prisma/migrations/20260117000000_add_vector_search/migration.sql b/prisma/migrations/20260117000000_add_vector_search/migration.sql new file mode 100644 index 0000000..7a697ae --- /dev/null +++ b/prisma/migrations/20260117000000_add_vector_search/migration.sql @@ -0,0 +1,21 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS vector; + +-- CreateTable +CREATE TABLE player_embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE, + embedding vector(768) NOT NULL, + embedding_text TEXT NOT NULL, + created_at TIMESTAMP(6) DEFAULT NOW(), + updated_at TIMESTAMP(6) DEFAULT NOW() +); + +-- CreateIndex +CREATE INDEX player_embeddings_player_id_idx ON player_embeddings(player_id); + +-- CreateIndex (IVFFlat index for fast similarity search) +-- Note: For production, adjust the 'lists' parameter based on your data size +-- Rule of thumb: lists = sqrt(number_of_rows), minimum 1 +CREATE INDEX player_embeddings_vector_idx ON player_embeddings + USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); diff --git a/prisma/migrations/20260117093011_/migration.sql b/prisma/migrations/20260117093011_/migration.sql new file mode 100644 index 0000000..4663810 --- /dev/null +++ b/prisma/migrations/20260117093011_/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - Made the column `created_at` on table `player_embeddings` required. This step will fail if there are existing NULL values in that column. + - Made the column `updated_at` on table `player_embeddings` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "player_embeddings" DROP CONSTRAINT "player_embeddings_player_id_fkey"; + +-- DropIndex +DROP INDEX "player_embeddings_vector_idx"; + +-- AlterTable +ALTER TABLE "player_embeddings" ALTER COLUMN "created_at" SET NOT NULL, +ALTER COLUMN "updated_at" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "player_embeddings" ADD CONSTRAINT "player_embeddings_player_id_fkey" FOREIGN KEY ("player_id") REFERENCES "players"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b62c851..139a507 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -40,6 +40,7 @@ model Player { favorited_by CoachFavorite[] combine_registrations CombineRegistration[] Conversation Conversation[] + embedding PlayerEmbedding? game_profiles PlayerGameProfile[] league_participations PlayerLeague[] performance_stats PlayerPerformanceStats[] @@ -59,6 +60,22 @@ model Player { @@map("players") } +/// Player embeddings for AI-powered semantic search +/// Note: The 'embedding' field is a vector(768) type managed via raw SQL +/// Prisma doesn't natively support pgvector, so we use Unsupported type +model PlayerEmbedding { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + player_id String @unique @db.Uuid + embedding Unsupported("vector(768)") + embedding_text String + created_at DateTime @default(now()) @db.Timestamp(6) + updated_at DateTime @default(now()) @db.Timestamp(6) + player Player @relation(fields: [player_id], references: [id], onDelete: Cascade) + + @@index([player_id]) + @@map("player_embeddings") +} + /// Coaches table with Clerk integration model Coach { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid diff --git a/scripts/generate-embeddings.ts b/scripts/generate-embeddings.ts new file mode 100644 index 0000000..6478433 --- /dev/null +++ b/scripts/generate-embeddings.ts @@ -0,0 +1,499 @@ +#!/usr/bin/env tsx + +/** + * Player Embeddings Generation Script + * + * This script generates vector embeddings for existing players in the database + * using Google Gemini's text-embedding-004 model. + * + * Usage: + * npx tsx scripts/generate-embeddings.ts [options] + * + * Options: + * --only-missing Only generate embeddings for players without existing embeddings + * --batch-size N Process N players at a time (default: 10) + * --batch-delay N Wait N milliseconds between batches (default: 1000) + * --dry-run Show what would be processed without actually generating embeddings + */ + +// Load environment variables from .env file +import dotenv from "dotenv"; +dotenv.config(); + +import { db } from "../src/server/db"; + +// Colors for console output +const colors = { + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + reset: "\x1b[0m", + cyan: "\x1b[36m", + magenta: "\x1b[35m", + dim: "\x1b[2m", +}; + +const log = { + info: (msg: string) => console.log(`${colors.blue}ℹ️ ${msg}${colors.reset}`), + success: (msg: string) => + console.log(`${colors.green}✅ ${msg}${colors.reset}`), + warning: (msg: string) => + console.log(`${colors.yellow}⚠️ ${msg}${colors.reset}`), + error: (msg: string) => console.log(`${colors.red}❌ ${msg}${colors.reset}`), + step: (msg: string) => console.log(`${colors.cyan}🔄 ${msg}${colors.reset}`), + data: (msg: string) => + console.log(`${colors.magenta}📄 ${msg}${colors.reset}`), + dim: (msg: string) => console.log(`${colors.dim} ${msg}${colors.reset}`), +}; + +// Parse command line arguments +function parseArgs(): { + onlyMissing: boolean; + batchSize: number; + batchDelay: number; + dryRun: boolean; +} { + const args = process.argv.slice(2); + const config = { + onlyMissing: false, + batchSize: 10, + batchDelay: 1000, + dryRun: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case "--only-missing": + config.onlyMissing = true; + break; + case "--batch-size": + config.batchSize = parseInt(args[++i] ?? "10", 10); + break; + case "--batch-delay": + config.batchDelay = parseInt(args[++i] ?? "1000", 10); + break; + case "--dry-run": + config.dryRun = true; + break; + case "--help": + console.log(` +Player Embeddings Generation Script + +Usage: + npx tsx scripts/generate-embeddings.ts [options] + +Options: + --only-missing Only generate embeddings for players without existing embeddings + --batch-size N Process N players at a time (default: 10) + --batch-delay N Wait N milliseconds between batches (default: 1000) + --dry-run Show what would be processed without actually generating embeddings + --help Show this help message + `); + process.exit(0); + } + } + + return config; +} + +// Player data structure for embedding generation +interface PlayerData { + id: string; + firstName: string; + lastName: string; + username: string | null; + location: string | null; + bio: string | null; + school: string | null; + schoolType: string | null; + classYear: string | null; + gpa: number | null; + intendedMajor: string | null; + mainGame: string | null; + gameProfiles: { + game: string; + username: string; + rank: string | null; + role: string | null; + agents: string[]; + playStyle: string | null; + }[]; +} + +// Build text representation for embedding +function buildEmbeddingText(player: PlayerData): string { + const parts: string[] = []; + + // Basic info + parts.push(`Player: ${player.firstName} ${player.lastName}`); + if (player.username) { + parts.push(`Username: ${player.username}`); + } + + // Location + if (player.location) { + parts.push(`Location: ${player.location}`); + } + + // Academic info + if (player.school) { + parts.push(`School: ${player.school}`); + } + if (player.schoolType) { + const typeLabel = + player.schoolType === "HIGH_SCHOOL" + ? "High School" + : player.schoolType === "COLLEGE" + ? "College" + : "University"; + parts.push(`School Type: ${typeLabel}`); + } + if (player.classYear) { + parts.push(`Class Year: ${player.classYear}`); + } + if (player.gpa !== null && player.gpa !== undefined) { + parts.push(`GPA: ${player.gpa}`); + } + if (player.intendedMajor) { + parts.push(`Intended Major: ${player.intendedMajor}`); + } + + // Bio + if (player.bio) { + parts.push(`Bio: ${player.bio}`); + } + + // Main game + if (player.mainGame) { + parts.push(`Main Game: ${player.mainGame}`); + } + + // Game profiles + if (player.gameProfiles.length > 0) { + const gameDetails = player.gameProfiles + .map((profile) => { + const details: string[] = [profile.game]; + if (profile.rank) details.push(`Rank: ${profile.rank}`); + if (profile.role) details.push(`Role: ${profile.role}`); + if (profile.agents.length > 0) + details.push(`Plays: ${profile.agents.join(", ")}`); + if (profile.playStyle) details.push(`Style: ${profile.playStyle}`); + return details.join(", "); + }) + .join("; "); + parts.push(`Games: ${gameDetails}`); + } + + return parts.join(". "); +} + +// Dynamically import Gemini to avoid issues with env validation +async function getGeminiEmbedding(text: string): Promise { + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + + const apiKey = process.env.GOOGLE_GEMINI_API_KEY; + if (!apiKey) { + throw new Error("GOOGLE_GEMINI_API_KEY is not set in environment variables"); + } + + const genAI = new GoogleGenerativeAI(apiKey); + const model = genAI.getGenerativeModel({ model: "text-embedding-004" }); + + const result = await model.embedContent(text); + const embedding = result.embedding; + + if (!embedding?.values || embedding.values.length === 0) { + throw new Error("Empty embedding returned from Gemini"); + } + + return embedding.values; +} + +// Format embedding for PostgreSQL +function formatEmbeddingForPostgres(embedding: number[]): string { + return `[${embedding.join(",")}]`; +} + +// Get players to process +async function getPlayersToProcess(onlyMissing: boolean): Promise< + Array<{ + id: string; + first_name: string; + last_name: string; + username: string | null; + location: string | null; + bio: string | null; + school: string | null; + class_year: string | null; + gpa: { toString(): string } | null; + intended_major: string | null; + school_ref: { name: string; type: string } | null; + main_game: { name: string } | null; + game_profiles: Array<{ + username: string; + rank: string | null; + role: string | null; + agents: string[]; + play_style: string | null; + game: { name: string }; + }>; + }> +> { + if (onlyMissing) { + // Get players without embeddings + const playersWithoutEmbeddings = await db.$queryRaw<{ id: string }[]>` + SELECT p.id + FROM players p + LEFT JOIN player_embeddings pe ON p.id = pe.player_id + WHERE pe.id IS NULL + `; + + if (playersWithoutEmbeddings.length === 0) { + return []; + } + + const playerIds = playersWithoutEmbeddings.map((p) => p.id); + + return db.player.findMany({ + where: { id: { in: playerIds } }, + include: { + school_ref: { select: { name: true, type: true } }, + main_game: { select: { name: true } }, + game_profiles: { + include: { game: { select: { name: true } } }, + }, + }, + }); + } + + // Get all players + return db.player.findMany({ + include: { + school_ref: { select: { name: true, type: true } }, + main_game: { select: { name: true } }, + game_profiles: { + include: { game: { select: { name: true } } }, + }, + }, + }); +} + +// Process a single player +async function processPlayer( + player: { + id: string; + first_name: string; + last_name: string; + username: string | null; + location: string | null; + bio: string | null; + school: string | null; + class_year: string | null; + gpa: { toString(): string } | null; + intended_major: string | null; + school_ref: { name: string; type: string } | null; + main_game: { name: string } | null; + game_profiles: Array<{ + username: string; + rank: string | null; + role: string | null; + agents: string[]; + play_style: string | null; + game: { name: string }; + }>; + }, + dryRun: boolean, +): Promise { + const playerData: PlayerData = { + id: player.id, + firstName: player.first_name, + lastName: player.last_name, + username: player.username, + location: player.location, + bio: player.bio, + school: player.school_ref?.name ?? player.school, + schoolType: player.school_ref?.type ?? null, + classYear: player.class_year, + gpa: player.gpa ? parseFloat(player.gpa.toString()) : null, + intendedMajor: player.intended_major, + mainGame: player.main_game?.name ?? null, + gameProfiles: player.game_profiles.map((profile) => ({ + game: profile.game.name, + username: profile.username, + rank: profile.rank, + role: profile.role, + agents: profile.agents, + playStyle: profile.play_style, + })), + }; + + const embeddingText = buildEmbeddingText(playerData); + + if (dryRun) { + log.dim(`Would generate embedding for text (${embeddingText.length} chars)`); + return true; + } + + // Generate embedding + const embedding = await getGeminiEmbedding(embeddingText); + const embeddingVector = formatEmbeddingForPostgres(embedding); + + // Upsert into database + await db.$executeRaw` + INSERT INTO player_embeddings (player_id, embedding, embedding_text, updated_at) + VALUES (${player.id}::uuid, ${embeddingVector}::vector, ${embeddingText}, NOW()) + ON CONFLICT (player_id) + DO UPDATE SET + embedding = ${embeddingVector}::vector, + embedding_text = ${embeddingText}, + updated_at = NOW() + `; + + return true; +} + +async function main() { + const config = parseArgs(); + + console.log("\n" + "=".repeat(80)); + log.info("Player Embeddings Generation Script"); + console.log("=".repeat(80) + "\n"); + + log.info(`Configuration:`); + log.dim(` Only missing: ${config.onlyMissing}`); + log.dim(` Batch size: ${config.batchSize}`); + log.dim(` Batch delay: ${config.batchDelay}ms`); + log.dim(` Dry run: ${config.dryRun}`); + console.log(); + + // Check if Gemini API key is configured + if (!process.env.GOOGLE_GEMINI_API_KEY) { + log.error("GOOGLE_GEMINI_API_KEY is not set in environment variables"); + log.info("Please add GOOGLE_GEMINI_API_KEY to your .env file"); + process.exit(1); + } + + try { + // Get players to process + log.step("Fetching players to process..."); + const players = await getPlayersToProcess(config.onlyMissing); + + if (players.length === 0) { + log.success("No players to process!"); + await db.$disconnect(); + return; + } + + log.info(`Found ${players.length} players to process`); + console.log(); + + // Process statistics + let processed = 0; + let succeeded = 0; + let failed = 0; + const failedPlayers: Array<{ id: string; name: string; error: string }> = []; + + // Process in batches + for (let i = 0; i < players.length; i += config.batchSize) { + const batch = players.slice(i, i + config.batchSize); + const batchNum = Math.floor(i / config.batchSize) + 1; + const totalBatches = Math.ceil(players.length / config.batchSize); + + log.step(`Processing batch ${batchNum}/${totalBatches} (${batch.length} players)...`); + + // Process batch in parallel + const results = await Promise.allSettled( + batch.map(async (player) => { + try { + await processPlayer(player, config.dryRun); + return { success: true, player }; + } catch (error) { + throw { player, error }; + } + }), + ); + + // Count results + for (const result of results) { + processed++; + if (result.status === "fulfilled") { + succeeded++; + log.data( + ` ✓ ${result.value.player.first_name} ${result.value.player.last_name}`, + ); + } else { + failed++; + const reason = result.reason as { + player: { id: string; first_name: string; last_name: string }; + error: unknown; + }; + const errorMsg = + reason.error instanceof Error + ? reason.error.message + : "Unknown error"; + failedPlayers.push({ + id: reason.player.id, + name: `${reason.player.first_name} ${reason.player.last_name}`, + error: errorMsg, + }); + log.warning( + ` ✗ ${reason.player.first_name} ${reason.player.last_name}: ${errorMsg}`, + ); + } + } + + // Delay between batches (except for last batch) + if (i + config.batchSize < players.length) { + log.dim(` Waiting ${config.batchDelay}ms before next batch...`); + await new Promise((resolve) => setTimeout(resolve, config.batchDelay)); + } + } + + // Summary + console.log("\n" + "=".repeat(80)); + log.success(config.dryRun ? "Dry run completed!" : "Embedding generation completed!"); + console.log("=".repeat(80)); + log.info(`Processed: ${processed} players`); + log.info(`Succeeded: ${succeeded} players`); + log.info(`Failed: ${failed} players`); + + if (failedPlayers.length > 0) { + console.log("\n" + "-".repeat(80)); + log.warning("Failed players:"); + failedPlayers.forEach(({ name, error }) => { + log.error(` ${name}: ${error}`); + }); + } + + // Get final stats + if (!config.dryRun) { + console.log("\n" + "-".repeat(80)); + const [embeddingCount] = await db.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count FROM player_embeddings + `; + const [playerCount] = await db.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count FROM players + `; + const coverage = Math.round( + (Number(embeddingCount.count) / Number(playerCount.count)) * 100, + ); + log.info(`Embedding coverage: ${embeddingCount.count}/${playerCount.count} (${coverage}%)`); + } + } catch (error) { + log.error( + `An error occurred: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + console.error(error); + process.exit(1); + } finally { + await db.$disconnect(); + } +} + +// Run the script +main().catch((error) => { + console.error("Unhandled error:", error); + process.exit(1); +}); diff --git a/src/app/dashboard/coaches/find-talent/_components/TalentAnalysisSection.tsx b/src/app/dashboard/coaches/find-talent/_components/TalentAnalysisSection.tsx new file mode 100644 index 0000000..f357837 --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/_components/TalentAnalysisSection.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { + CheckCircle, + AlertCircle, + FileText, + Sparkles, +} from "lucide-react"; + +interface TalentAnalysisSectionProps { + title: string; + icon: "overview" | "pros" | "cons"; + content?: string | string[]; + isLoading?: boolean; +} + +const iconMap = { + overview: FileText, + pros: CheckCircle, + cons: AlertCircle, +}; + +const colorMap = { + overview: { + bg: "bg-cyan-500/10", + border: "border-cyan-500/30", + text: "text-cyan-400", + iconBg: "bg-cyan-500/20", + }, + pros: { + bg: "bg-emerald-500/10", + border: "border-emerald-500/30", + text: "text-emerald-400", + iconBg: "bg-emerald-500/20", + }, + cons: { + bg: "bg-amber-500/10", + border: "border-amber-500/30", + text: "text-amber-400", + iconBg: "bg-amber-500/20", + }, +}; + +export function TalentAnalysisSection({ + title, + icon, + content, + isLoading = false, +}: TalentAnalysisSectionProps) { + const Icon = iconMap[icon]; + const colors = colorMap[icon]; + + if (isLoading) { + return ( +
+
+
+ +
+ +
+
+ + + +
+
+ ); + } + + const isEmpty = + !content || + (Array.isArray(content) && content.length === 0) || + (typeof content === "string" && content.trim() === ""); + + return ( +
+
+
+ +
+

{title}

+
+ + {isEmpty ? ( +

+ No information available +

+ ) : Array.isArray(content) ? ( +
    + {content.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+ ) : ( +

{content}

+ )} +
+ ); +} diff --git a/src/app/dashboard/coaches/find-talent/_components/TalentFilterPanel.tsx b/src/app/dashboard/coaches/find-talent/_components/TalentFilterPanel.tsx new file mode 100644 index 0000000..075a7ad --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/_components/TalentFilterPanel.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { X, Filter, ChevronDown, ChevronUp } from "lucide-react"; +import { useState } from "react"; +import { api } from "@/trpc/react"; + +const CLASS_YEARS = [ + "2024", + "2025", + "2026", + "2027", + "2028", + "2029", + "2030", +]; + +const SCHOOL_TYPES = [ + { value: "HIGH_SCHOOL", label: "High School" }, + { value: "COLLEGE", label: "College" }, + { value: "UNIVERSITY", label: "University" }, +] as const; + +const US_STATES = [ + "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", + "Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", + "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana", + "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", + "Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", + "New Hampshire", "New Jersey", "New Mexico", "New York", + "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", + "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", + "Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", + "West Virginia", "Wisconsin", "Wyoming" +]; + +export interface TalentFilters { + gameId?: string; + classYears?: string[]; + schoolTypes?: ("HIGH_SCHOOL" | "COLLEGE" | "UNIVERSITY")[]; + locations?: string[]; + minGpa?: number; + maxGpa?: number; + roles?: string[]; +} + +interface TalentFilterPanelProps { + filters: TalentFilters; + onChange: (filters: TalentFilters) => void; + onClear: () => void; +} + +export function TalentFilterPanel({ + filters, + onChange, + onClear, +}: TalentFilterPanelProps) { + const [isExpanded, setIsExpanded] = useState(false); + + // Fetch available games + const { data: availableGames } = api.playerProfile.getAvailableGames.useQuery( + undefined, + { + staleTime: 15 * 60 * 1000, + refetchOnWindowFocus: false, + } + ); + + const hasActiveFilters = + filters.gameId || + (filters.classYears && filters.classYears.length > 0) || + (filters.schoolTypes && filters.schoolTypes.length > 0) || + (filters.locations && filters.locations.length > 0) || + filters.minGpa !== undefined || + filters.maxGpa !== undefined; + + const activeFilterCount = [ + filters.gameId, + filters.classYears?.length, + filters.schoolTypes?.length, + filters.locations?.length, + filters.minGpa !== undefined || filters.maxGpa !== undefined, + ].filter(Boolean).length; + + const handleGameChange = (value: string) => { + onChange({ + ...filters, + gameId: value === "all" ? undefined : value, + }); + }; + + const handleClassYearToggle = (year: string) => { + const current = filters.classYears ?? []; + const updated = current.includes(year) + ? current.filter((y) => y !== year) + : [...current, year]; + onChange({ + ...filters, + classYears: updated.length > 0 ? updated : undefined, + }); + }; + + const handleSchoolTypeToggle = (type: "HIGH_SCHOOL" | "COLLEGE" | "UNIVERSITY") => { + const current = filters.schoolTypes ?? []; + const updated = current.includes(type) + ? current.filter((t) => t !== type) + : [...current, type]; + onChange({ + ...filters, + schoolTypes: updated.length > 0 ? updated : undefined, + }); + }; + + const handleLocationChange = (value: string) => { + if (value === "all") { + onChange({ ...filters, locations: undefined }); + } else { + const current = filters.locations ?? []; + if (!current.includes(value)) { + onChange({ ...filters, locations: [...current, value] }); + } + } + }; + + const handleRemoveLocation = (location: string) => { + const updated = (filters.locations ?? []).filter((l) => l !== location); + onChange({ + ...filters, + locations: updated.length > 0 ? updated : undefined, + }); + }; + + return ( +
+ {/* Header */} +
+ + {hasActiveFilters && ( + + )} +
+ + {/* Quick filters - always visible */} +
+ {/* Game Filter */} + + + {/* Class Year Quick Filters */} +
+ {CLASS_YEARS.slice(0, 4).map((year) => ( + + ))} +
+
+ + {/* Expanded filters */} + {isExpanded && ( +
+ {/* Class Year - All Options */} +
+ +
+ {CLASS_YEARS.map((year) => ( + + ))} +
+
+ + {/* School Type */} +
+ +
+ {SCHOOL_TYPES.map((type) => ( + + ))} +
+
+ + {/* Location */} +
+ +
+ + {filters.locations && filters.locations.length > 0 && ( +
+ {filters.locations.map((location) => ( + handleRemoveLocation(location)} + > + {location} + + + ))} +
+ )} +
+
+ + {/* GPA Range */} +
+ +
+ + onChange({ + ...filters, + minGpa: e.target.value + ? parseFloat(e.target.value) + : undefined, + }) + } + className="w-24 border-gray-700 bg-gray-800 text-white" + /> + to + + onChange({ + ...filters, + maxGpa: e.target.value + ? parseFloat(e.target.value) + : undefined, + }) + } + className="w-24 border-gray-700 bg-gray-800 text-white" + /> +
+
+
+ )} +
+ ); +} diff --git a/src/app/dashboard/coaches/find-talent/_components/TalentPlayerCard.tsx b/src/app/dashboard/coaches/find-talent/_components/TalentPlayerCard.tsx new file mode 100644 index 0000000..c9f3afd --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/_components/TalentPlayerCard.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + MapPin, + GraduationCap, + Bookmark, + Sparkles, + Star, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TalentSearchResult } from "@/types/talent-search"; + +interface TalentPlayerCardProps { + player: TalentSearchResult; + onSelect: (player: TalentSearchResult) => void; + onFavoriteToggle?: (player: TalentSearchResult) => void; +} + +function getSimilarityColor(score: number): string { + if (score >= 0.8) return "from-emerald-500 to-green-600"; + if (score >= 0.6) return "from-cyan-500 to-blue-600"; + if (score >= 0.4) return "from-yellow-500 to-orange-600"; + return "from-gray-500 to-gray-600"; +} + +function getSimilarityLabel(score: number): string { + if (score >= 0.8) return "Excellent Match"; + if (score >= 0.6) return "Strong Match"; + if (score >= 0.4) return "Good Match"; + return "Potential Match"; +} + +export function TalentPlayerCard({ + player, + onSelect, + onFavoriteToggle, +}: TalentPlayerCardProps) { + const similarityPercent = Math.round(player.similarityScore * 100); + const gpaNumber = player.academicInfo.gpa; + + // Get best rank from game profiles + const primaryProfile = player.gameProfiles[0]; + + return ( + onSelect(player)} + className="group relative cursor-pointer overflow-hidden border-gray-800 bg-gray-900/80 transition-all duration-300 hover:border-cyan-500/50 hover:shadow-lg hover:shadow-cyan-500/10" + > + {/* Similarity Score Badge */} +
+
+ + {similarityPercent}% +
+
+ + {/* Favorite Button */} + {onFavoriteToggle && ( + + )} + +
+ {/* Header with Avatar and Name */} +
+ + + + {player.firstName.charAt(0)} + {player.lastName.charAt(0)} + + +
+

+ {player.firstName} {player.lastName} +

+ {player.username && ( +

@{player.username}

+ )} +

+ {getSimilarityLabel(player.similarityScore)} +

+
+
+ + {/* Main Game Badge */} + {player.mainGame && ( + + {player.mainGame.name} + + )} + + {/* Player Details */} +
+ {/* School */} +
+ + + {player.school.name ?? "No school listed"} + +
+ + {/* Location */} + {player.location && ( +
+ + {player.location} +
+ )} + + {/* Class Year & GPA */} +
+ {player.academicInfo.classYear && ( + + Class of{" "} + + {player.academicInfo.classYear} + + + )} + {gpaNumber && ( + + GPA:{" "} + = 3.5 + ? "text-green-400" + : gpaNumber >= 3.0 + ? "text-yellow-400" + : "text-white" + )} + > + {gpaNumber.toFixed(2)} + + + )} +
+
+ + {/* Game Stats */} + {primaryProfile && ( +
+
+
+ {primaryProfile.rank && ( + + {primaryProfile.rank} + + )} + {primaryProfile.role && ( + + {primaryProfile.role} + + )} +
+ {(primaryProfile.combineScore || primaryProfile.leagueScore) && ( +
+ {primaryProfile.combineScore && ( + + + {primaryProfile.combineScore.toFixed(1)} + + )} +
+ )} +
+
+ )} + + {/* View Profile Button - appears on hover */} +
+ +
+
+
+ ); +} diff --git a/src/app/dashboard/coaches/find-talent/_components/TalentPlayerModal.tsx b/src/app/dashboard/coaches/find-talent/_components/TalentPlayerModal.tsx new file mode 100644 index 0000000..d49d4d0 --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/_components/TalentPlayerModal.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + GraduationCap, + MapPin, + Mail, + BookOpen, + Trophy, + Star, + Bookmark, + ExternalLink, + Sparkles, + MessageSquare, + Copy, +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { api } from "@/trpc/react"; +import { TalentAnalysisSection } from "./TalentAnalysisSection"; +import type { TalentSearchResult } from "@/types/talent-search"; +import Link from "next/link"; + +interface TalentPlayerModalProps { + player: TalentSearchResult | null; + isOpen: boolean; + onClose: () => void; + onFavoriteToggle?: (player: TalentSearchResult) => void; +} + +function getSimilarityColor(score: number): string { + if (score >= 0.8) return "from-emerald-500 to-green-600"; + if (score >= 0.6) return "from-cyan-500 to-blue-600"; + if (score >= 0.4) return "from-yellow-500 to-orange-600"; + return "from-gray-500 to-gray-600"; +} + +function getSimilarityLabel(score: number): string { + if (score >= 0.8) return "Excellent Match"; + if (score >= 0.6) return "Strong Match"; + if (score >= 0.4) return "Good Match"; + return "Potential Match"; +} + +export function TalentPlayerModal({ + player, + isOpen, + onClose, + onFavoriteToggle, +}: TalentPlayerModalProps) { + // Fetch AI analysis when modal opens + const { + data: analysis, + isLoading: isLoadingAnalysis, + error: analysisError, + } = api.talentSearch.getAnalysis.useQuery( + { playerId: player?.id ?? "" }, + { + enabled: isOpen && !!player?.id, + staleTime: 24 * 60 * 60 * 1000, // Cache for 24 hours + retry: 1, + } + ); + + if (!player) return null; + + const similarityPercent = Math.round(player.similarityScore * 100); + const gpaNumber = player.academicInfo.gpa; + + // Get best combine and league scores + const bestCombineScore = Math.max( + ...player.gameProfiles.map((p) => p.combineScore ?? 0) + ); + const bestLeagueScore = Math.max( + ...player.gameProfiles.map((p) => p.leagueScore ?? 0) + ); + + const handleCopyEmail = async () => { + // Note: We'd need to add email to the TalentSearchResult type + // For now, this is a placeholder + toast.info("Contact info available via full profile"); + }; + + const handleFavoriteToggle = () => { + if (onFavoriteToggle) { + onFavoriteToggle(player); + toast.success( + player.isFavorited ? "Removed from prospects" : "Added to prospects" + ); + } + }; + + return ( + + + + Player Profile + + + {/* Header Section */} +
+ {/* Similarity Score Badge */} +
+
+ + {similarityPercent}% Match +
+
+ +
+ {/* Player Avatar */} +
+ + + + {player.firstName.charAt(0)} + {player.lastName.charAt(0)} + + + + {/* Main Game Badge */} + {player.mainGame && ( + + {player.mainGame.name} + + )} + + {/* Favorite Badge */} + {player.isFavorited && ( + + + Bookmarked + + )} +
+ + {/* Player Details */} +
+
+

+ {player.firstName} {player.lastName} +

+ {player.username && ( +

@{player.username}

+ )} +

+ {getSimilarityLabel(player.similarityScore)} +

+
+ + {/* Academic & School Info */} +
+
+
+ +
+
+ {player.school.name ?? "No school listed"} +
+ {player.school.type && ( +
+ {player.school.type.replace("_", " ")} +
+ )} +
+
+ {player.location && ( +
+ + {player.location} +
+ )} +
+ +
+
+ +
+ Class Year: +
+ {player.academicInfo.classYear + ? `Class of ${player.academicInfo.classYear}` + : "N/A"} +
+
+
+
+ +
+ GPA: +
= 3.5 + ? "text-green-400" + : gpaNumber >= 3.0 + ? "text-yellow-400" + : "text-white" + : "text-gray-400" + )} + > + {gpaNumber ? gpaNumber.toFixed(2) : "N/A"} +
+
+
+
+
+ + {/* Major */} + {player.academicInfo.intendedMajor && ( +
+ + Intended Major: + + {player.academicInfo.intendedMajor} + +
+ )} +
+ + {/* EVAL Scores */} +
+

+ EVAL Scores +

+
+
+
+ {bestCombineScore > 0 ? bestCombineScore.toFixed(1) : "N/A"} +
+
Combine Score
+
+
+
+ {bestLeagueScore > 0 ? bestLeagueScore.toFixed(1) : "N/A"} +
+
League Score
+
+
+
+
+
+ + {/* AI Analysis Section */} +
+
+ +

+ AI Analysis +

+ {analysis?.isCached && ( + + Cached + + )} +
+ + {analysisError ? ( +
+

+ Unable to generate analysis. Please try again later. +

+
+ ) : ( +
+ + + +
+ )} +
+ + {/* Game Profiles */} + {player.gameProfiles.length > 0 && ( +
+

+ Game Profiles +

+
+ {player.gameProfiles.map((profile, index) => ( +
+
+

+ {profile.gameName} +

+ + @{profile.username} + +
+
+ {profile.rank && ( + + {profile.rank} + + )} + {profile.role && ( + + {profile.role} + + )} + {profile.playStyle && ( + + {profile.playStyle} + + )} +
+ {profile.agents.length > 0 && ( +
+ Agents: {profile.agents.join(", ")} +
+ )} +
+ ))} +
+
+ )} + + {/* Bio */} + {player.bio && ( +
+

+ About +

+

{player.bio}

+
+ )} + + {/* Action Buttons */} +
+
+ {onFavoriteToggle && ( + + )} + +
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/src/app/dashboard/coaches/find-talent/_components/TalentResultsGrid.tsx b/src/app/dashboard/coaches/find-talent/_components/TalentResultsGrid.tsx new file mode 100644 index 0000000..3e339d1 --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/_components/TalentResultsGrid.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton"; +import { TalentPlayerCard } from "./TalentPlayerCard"; +import { Users, SearchX, Sparkles } from "lucide-react"; +import type { TalentSearchResult } from "@/types/talent-search"; + +interface TalentResultsGridProps { + results: TalentSearchResult[]; + isLoading?: boolean; + query?: string; + onSelectPlayer: (player: TalentSearchResult) => void; + onFavoriteToggle?: (player: TalentSearchResult) => void; +} + +function LoadingSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+ +
+ + +
+
+ +
+ + +
+
+ ))} +
+ ); +} + +function EmptyState({ hasSearched }: { hasSearched: boolean }) { + return ( +
+
+ {hasSearched ? ( + + ) : ( + + )} +
+

+ {hasSearched ? "No players found" : "Discover talent with AI"} +

+

+ {hasSearched + ? "Try adjusting your search query or filters to find more players." + : "Use natural language to describe the type of player you're looking for. Our AI will find the best matches."} +

+
+ ); +} + +export function TalentResultsGrid({ + results, + isLoading = false, + query, + onSelectPlayer, + onFavoriteToggle, +}: TalentResultsGridProps) { + if (isLoading) { + return ; + } + + if (results.length === 0) { + return ; + } + + return ( +
+ {/* Results header */} +
+
+ + + {results.length}{" "} + player{results.length !== 1 ? "s" : ""} found + +
+ {query && ( +

+ Sorted by AI relevance to your search +

+ )} +
+ + {/* Results grid */} +
+ {results.map((player) => ( + + ))} +
+
+ ); +} diff --git a/src/app/dashboard/coaches/find-talent/_components/TalentSearchBar.tsx b/src/app/dashboard/coaches/find-talent/_components/TalentSearchBar.tsx new file mode 100644 index 0000000..7ba7e1a --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/_components/TalentSearchBar.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Search, Sparkles, X } from "lucide-react"; +import { useState, useEffect } from "react"; + +interface TalentSearchBarProps { + value: string; + onChange: (value: string) => void; + onSearch: () => void; + isLoading?: boolean; + placeholder?: string; +} + +export function TalentSearchBar({ + value, + onChange, + onSearch, + isLoading = false, + placeholder = "Describe the player you're looking for...", +}: TalentSearchBarProps) { + const [localValue, setLocalValue] = useState(value); + + // Sync local value with external value + useEffect(() => { + setLocalValue(value); + }, [value]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onChange(localValue); + onSearch(); + }; + + const handleClear = () => { + setLocalValue(""); + onChange(""); + }; + + const exampleQueries = [ + "Aggressive duelist with high first-blood rate", + "IGL with strong communication skills", + "College-bound players from California", + "Support players with high assist stats", + ]; + + return ( +
+
+
+
+ + setLocalValue(e.target.value)} + placeholder={placeholder} + className="h-14 rounded-xl border-gray-700 bg-gray-800/80 pl-12 pr-10 text-lg text-white placeholder:text-gray-500 focus:border-cyan-500 focus:ring-cyan-500/20" + disabled={isLoading} + /> + {localValue && ( + + )} +
+ +
+
+ + {/* Example queries */} + {!value && ( +
+ Try: + {exampleQueries.map((query, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/dashboard/coaches/find-talent/page.tsx b/src/app/dashboard/coaches/find-talent/page.tsx new file mode 100644 index 0000000..1cc6c3a --- /dev/null +++ b/src/app/dashboard/coaches/find-talent/page.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useUser } from "@clerk/nextjs"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sparkles, + ZapIcon, + AlertCircleIcon, + ArrowLeftIcon, +} from "lucide-react"; +import Link from "next/link"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; + +import { TalentSearchBar } from "./_components/TalentSearchBar"; +import { + TalentFilterPanel, + type TalentFilters, +} from "./_components/TalentFilterPanel"; +import { TalentResultsGrid } from "./_components/TalentResultsGrid"; +import { TalentPlayerModal } from "./_components/TalentPlayerModal"; +import type { TalentSearchResult } from "@/types/talent-search"; + +export default function FindTalentPage() { + const { user } = useUser(); + const [searchQuery, setSearchQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [filters, setFilters] = useState({}); + const [selectedPlayer, setSelectedPlayer] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + // Check if coach is onboarded + const canAccess = + user?.publicMetadata?.onboarded === true && + user?.publicMetadata?.userType === "coach"; + + // Check if AI search is available + const { data: availabilityData } = api.talentSearch.isAvailable.useQuery( + undefined, + { + enabled: canAccess, + } + ); + + // Search query + const { + data: searchResults, + isLoading: isSearching, + refetch: executeSearch, + } = api.talentSearch.search.useQuery( + { + query: submittedQuery, + gameId: filters.gameId, + classYears: filters.classYears, + schoolTypes: filters.schoolTypes, + locations: filters.locations, + minGpa: filters.minGpa, + maxGpa: filters.maxGpa, + limit: 50, + }, + { + enabled: canAccess && !!submittedQuery && availabilityData?.isAvailable, + staleTime: 5 * 60 * 1000, + retry: 1, + } + ); + + // Favorite mutations + const utils = api.useUtils(); + const favoriteMutation = api.playerSearch.favoritePlayer.useMutation({ + onSuccess: () => { + void utils.talentSearch.search.invalidate(); + toast.success("Added to prospects"); + }, + onError: (error: { message: string }) => { + toast.error(`Failed to add to prospects: ${error.message}`); + }, + }); + + const unfavoriteMutation = api.playerSearch.unfavoritePlayer.useMutation({ + onSuccess: () => { + void utils.talentSearch.search.invalidate(); + toast.info("Removed from prospects"); + }, + onError: (error: { message: string }) => { + toast.error(`Failed to remove from prospects: ${error.message}`); + }, + }); + + const handleSearch = useCallback(() => { + if (searchQuery.trim()) { + setSubmittedQuery(searchQuery.trim()); + } + }, [searchQuery]); + + const handleClearFilters = useCallback(() => { + setFilters({}); + }, []); + + const handleSelectPlayer = useCallback((player: TalentSearchResult) => { + setSelectedPlayer(player); + setIsModalOpen(true); + }, []); + + const handleCloseModal = useCallback(() => { + setIsModalOpen(false); + setSelectedPlayer(null); + }, []); + + const handleFavoriteToggle = useCallback( + (player: TalentSearchResult) => { + if (player.isFavorited) { + unfavoriteMutation.mutate({ player_id: player.id }); + } else { + favoriteMutation.mutate({ player_id: player.id }); + } + }, + [favoriteMutation, unfavoriteMutation] + ); + + // Not authorized view + if (!canAccess) { + return ( +
+ + + + + Access Required + + + +

+ You need to complete your coach onboarding to access the AI-powered + talent search feature. +

+ + + +
+
+
+ ); + } + + // AI not configured view + if (availabilityData && !availabilityData.isAvailable) { + return ( +
+ + + + + Feature Unavailable + + + +

+ {availabilityData.message} +

+ + + +
+
+
+ ); + } + + return ( +
+ {/* Hero Header */} +
+
+ +
+
+
+
+
+ +
+

+ Find Talent +

+ + AI-Powered + +
+

+ Describe the player you're looking for in natural language. Our AI + will find the best matches from our player database. +

+
+ + + +
+
+
+ + {/* Search Section */} +
+ + + +
+ + {/* Results Section */} + + + {/* Player Detail Modal */} + +
+ ); +} diff --git a/src/app/dashboard/coaches/page.tsx b/src/app/dashboard/coaches/page.tsx index 5292251..a912d7b 100644 --- a/src/app/dashboard/coaches/page.tsx +++ b/src/app/dashboard/coaches/page.tsx @@ -241,12 +241,12 @@ export default function CoachesDashboard() { const quickActions = [ { - title: "Search Players", - description: "Find and scout talented players", - href: "/dashboard/coaches/player-search", - color: "from-blue-500 to-blue-600", + title: "Find Talent", + description: "AI-powered player discovery", + href: "/dashboard/coaches/find-talent", + color: "from-cyan-500 to-purple-600", requiresOnboarding: true, - comingSoon: true, + comingSoon: false, }, { title: "Create Tryout", diff --git a/src/env.js b/src/env.js index 879553a..477a41c 100644 --- a/src/env.js +++ b/src/env.js @@ -28,6 +28,9 @@ export const env = createEnv({ // GSE External Database GSE_DATABASE_URL: z.string().url().optional(), + // Google Gemini AI (for talent search embeddings and analysis) + GOOGLE_GEMINI_API_KEY: z.string().optional(), + // Discord webhook URLs (optional) DISCORD_WEBHOOK_GENERAL: z.string().optional(), DISCORD_WEBHOOK_ADMIN: z.string().optional(), @@ -76,6 +79,8 @@ export const env = createEnv({ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, GSE_DATABASE_URL: process.env.GSE_DATABASE_URL, + // Google Gemini AI + GOOGLE_GEMINI_API_KEY: process.env.GOOGLE_GEMINI_API_KEY, // Discord webhook URLs DISCORD_WEBHOOK_GENERAL: process.env.DISCORD_WEBHOOK_GENERAL, DISCORD_WEBHOOK_ADMIN: process.env.DISCORD_WEBHOOK_ADMIN, diff --git a/src/lib/server/embeddings.ts b/src/lib/server/embeddings.ts new file mode 100644 index 0000000..4d7742c --- /dev/null +++ b/src/lib/server/embeddings.ts @@ -0,0 +1,341 @@ +import "server-only"; + +/** + * Embedding service for player vector search + * Handles creating, updating, and searching player embeddings using pgvector + */ + +import { db } from "@/server/db"; +import { + generatePlayerEmbedding, + generateQueryEmbedding, + formatEmbeddingForPostgres, + isGeminiConfigured, +} from "./gemini"; +import type { + PlayerEmbeddingData, + TalentSearchFilters, + VectorSearchRow, + EmbeddingRefreshOptions, + EmbeddingBatchResult, +} from "@/types/talent-search"; + +/** + * Fetch a player's data for embedding generation + */ +async function getPlayerForEmbedding( + playerId: string, +): Promise { + const player = await db.player.findUnique({ + where: { id: playerId }, + include: { + school_ref: { + select: { + name: true, + type: true, + }, + }, + main_game: { + select: { + name: true, + }, + }, + game_profiles: { + include: { + game: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!player) { + return null; + } + + return { + id: player.id, + firstName: player.first_name, + lastName: player.last_name, + username: player.username, + location: player.location, + bio: player.bio, + school: player.school_ref?.name ?? player.school, + schoolType: player.school_ref?.type ?? null, + classYear: player.class_year, + gpa: player.gpa ? parseFloat(player.gpa.toString()) : null, + intendedMajor: player.intended_major, + mainGame: player.main_game?.name ?? null, + gameProfiles: player.game_profiles.map((profile) => ({ + game: profile.game.name, + username: profile.username, + rank: profile.rank, + role: profile.role, + agents: profile.agents, + playStyle: profile.play_style, + })), + }; +} + +/** + * Create or update a player's embedding + */ +export async function upsertPlayerEmbedding(playerId: string): Promise { + if (!isGeminiConfigured()) { + throw new Error("Gemini API is not configured"); + } + + const playerData = await getPlayerForEmbedding(playerId); + if (!playerData) { + throw new Error(`Player not found: ${playerId}`); + } + + const { embedding, embeddingText } = await generatePlayerEmbedding(playerData); + const embeddingVector = formatEmbeddingForPostgres(embedding); + + // Use raw SQL since Prisma doesn't support vector type natively + await db.$executeRaw` + INSERT INTO player_embeddings (player_id, embedding, embedding_text, updated_at) + VALUES (${playerId}::uuid, ${embeddingVector}::vector, ${embeddingText}, NOW()) + ON CONFLICT (player_id) + DO UPDATE SET + embedding = ${embeddingVector}::vector, + embedding_text = ${embeddingText}, + updated_at = NOW() + `; +} + +/** + * Delete a player's embedding + */ +export async function deletePlayerEmbedding(playerId: string): Promise { + await db.$executeRaw` + DELETE FROM player_embeddings WHERE player_id = ${playerId}::uuid + `; +} + +/** + * Batch refresh embeddings for all players or only those missing embeddings + */ +export async function refreshAllEmbeddings( + options: EmbeddingRefreshOptions = {}, +): Promise { + const { onlyMissing = false, batchSize = 10, batchDelay = 1000 } = options; + + if (!isGeminiConfigured()) { + throw new Error("Gemini API is not configured"); + } + + // Get players to process + let playerIds: { id: string }[]; + + if (onlyMissing) { + // Get players without embeddings + playerIds = await db.$queryRaw<{ id: string }[]>` + SELECT p.id + FROM players p + LEFT JOIN player_embeddings pe ON p.id = pe.player_id + WHERE pe.id IS NULL + `; + } else { + // Get all players + playerIds = await db.player.findMany({ + select: { id: true }, + }); + } + + const result: EmbeddingBatchResult = { + processed: 0, + succeeded: 0, + failed: 0, + failedIds: [], + }; + + // Process in batches + for (let i = 0; i < playerIds.length; i += batchSize) { + const batch = playerIds.slice(i, i + batchSize); + + await Promise.all( + batch.map(async ({ id }) => { + result.processed++; + try { + await upsertPlayerEmbedding(id); + result.succeeded++; + } catch (error) { + console.error(`Failed to generate embedding for player ${id}:`, error); + result.failed++; + result.failedIds.push(id); + } + }), + ); + + // Delay between batches to avoid rate limiting + if (i + batchSize < playerIds.length) { + await new Promise((resolve) => setTimeout(resolve, batchDelay)); + } + } + + return result; +} + +/** + * Search players by vector similarity + * Returns player IDs sorted by similarity to the query + */ +export async function searchPlayersBySimilarity( + query: string, + filters: TalentSearchFilters = {}, +): Promise { + if (!isGeminiConfigured()) { + throw new Error("Gemini API is not configured"); + } + + const { + limit = 50, + minSimilarity = 0.3, + gameId, + classYears, + schoolTypes, + locations, + minGpa, + maxGpa, + roles, + } = filters; + + // Generate embedding for the search query + const queryEmbedding = await generateQueryEmbedding(query); + const queryVector = formatEmbeddingForPostgres(queryEmbedding); + + // Build dynamic WHERE conditions + const conditions: string[] = []; + const params: (string | number | string[])[] = []; + let paramIndex = 1; + + // Always filter by minimum similarity + // Using 1 - cosine_distance as similarity score + conditions.push(`1 - (pe.embedding <=> $${paramIndex}::vector) >= $${paramIndex + 1}`); + params.push(queryVector, minSimilarity); + paramIndex += 2; + + // Game filter + if (gameId) { + conditions.push(`EXISTS ( + SELECT 1 FROM player_game_profiles pgp + WHERE pgp.player_id = p.id AND pgp.game_id = $${paramIndex}::uuid + )`); + params.push(gameId); + paramIndex++; + } + + // Class year filter + if (classYears && classYears.length > 0) { + conditions.push(`p.class_year = ANY($${paramIndex}::text[])`); + params.push(classYears); + paramIndex++; + } + + // School type filter + if (schoolTypes && schoolTypes.length > 0) { + conditions.push(`EXISTS ( + SELECT 1 FROM schools s + WHERE s.id = p.school_id AND s.type = ANY($${paramIndex}::text[]) + )`); + params.push(schoolTypes); + paramIndex++; + } + + // Location filter + if (locations && locations.length > 0) { + const locationConditions = locations + .map(() => { + const condition = `p.location ILIKE $${paramIndex}`; + paramIndex++; + return condition; + }) + .join(" OR "); + conditions.push(`(${locationConditions})`); + locations.forEach((loc) => params.push(`%${loc}%`)); + } + + // GPA filter + if (minGpa !== undefined) { + conditions.push(`p.gpa >= $${paramIndex}`); + params.push(minGpa); + paramIndex++; + } + if (maxGpa !== undefined) { + conditions.push(`p.gpa <= $${paramIndex}`); + params.push(maxGpa); + paramIndex++; + } + + // Role filter + if (roles && roles.length > 0) { + conditions.push(`EXISTS ( + SELECT 1 FROM player_game_profiles pgp + WHERE pgp.player_id = p.id AND pgp.role = ANY($${paramIndex}::text[]) + )`); + params.push(roles); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // Execute the vector similarity search + // Using raw SQL since Prisma doesn't support pgvector operations + const sql = ` + SELECT + pe.player_id, + 1 - (pe.embedding <=> $1::vector) as similarity + FROM player_embeddings pe + INNER JOIN players p ON p.id = pe.player_id + ${whereClause} + ORDER BY pe.embedding <=> $1::vector ASC + LIMIT $${paramIndex} + `; + + params.push(limit); + + // Execute raw query with dynamic parameters + const results = await db.$queryRawUnsafe(sql, ...params); + + return results; +} + +/** + * Get the count of players with embeddings + */ +export async function getEmbeddingCount(): Promise { + const result = await db.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count FROM player_embeddings + `; + return Number(result[0].count); +} + +/** + * Get the count of players without embeddings + */ +export async function getMissingEmbeddingCount(): Promise { + const result = await db.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count + FROM players p + LEFT JOIN player_embeddings pe ON p.id = pe.player_id + WHERE pe.id IS NULL + `; + return Number(result[0].count); +} + +/** + * Check if a player has an embedding + */ +export async function hasEmbedding(playerId: string): Promise { + const result = await db.$queryRaw<[{ exists: boolean }]>` + SELECT EXISTS( + SELECT 1 FROM player_embeddings WHERE player_id = ${playerId}::uuid + ) as exists + `; + return result[0].exists; +} diff --git a/src/lib/server/gemini.ts b/src/lib/server/gemini.ts new file mode 100644 index 0000000..614306b --- /dev/null +++ b/src/lib/server/gemini.ts @@ -0,0 +1,303 @@ +import "server-only"; + +/** + * Gemini AI service for generating embeddings and player analysis + */ + +import { GoogleGenerativeAI, type EmbedContentResponse } from "@google/generative-ai"; +import { env } from "@/env"; +import type { + PlayerEmbeddingData, + PlayerAnalysis, + CoachContext, +} from "@/types/talent-search"; + +// Singleton pattern for Gemini client +let genAI: GoogleGenerativeAI | null = null; + +/** + * Get or create the Gemini AI client + */ +function getGeminiClient(): GoogleGenerativeAI { + if (!env.GOOGLE_GEMINI_API_KEY) { + throw new Error( + "GOOGLE_GEMINI_API_KEY is not configured. Please add it to your environment variables.", + ); + } + + if (!genAI) { + genAI = new GoogleGenerativeAI(env.GOOGLE_GEMINI_API_KEY); + } + + return genAI; +} + +/** + * Build a text representation of a player for embedding + * This creates a searchable text that captures the player's key attributes + */ +export function buildPlayerEmbeddingText(player: PlayerEmbeddingData): string { + const parts: string[] = []; + + // Basic info + parts.push(`Player: ${player.firstName} ${player.lastName}`); + if (player.username) { + parts.push(`Username: ${player.username}`); + } + + // Location + if (player.location) { + parts.push(`Location: ${player.location}`); + } + + // Academic info + if (player.school) { + parts.push(`School: ${player.school}`); + } + if (player.schoolType) { + const typeLabel = + player.schoolType === "HIGH_SCHOOL" + ? "High School" + : player.schoolType === "COLLEGE" + ? "College" + : "University"; + parts.push(`School Type: ${typeLabel}`); + } + if (player.classYear) { + parts.push(`Class Year: ${player.classYear}`); + } + if (player.gpa !== null && player.gpa !== undefined) { + parts.push(`GPA: ${player.gpa}`); + } + if (player.intendedMajor) { + parts.push(`Intended Major: ${player.intendedMajor}`); + } + + // Bio + if (player.bio) { + parts.push(`Bio: ${player.bio}`); + } + + // Main game + if (player.mainGame) { + parts.push(`Main Game: ${player.mainGame}`); + } + + // Game profiles + if (player.gameProfiles.length > 0) { + const gameDetails = player.gameProfiles + .map((profile) => { + const details: string[] = [profile.game]; + if (profile.rank) details.push(`Rank: ${profile.rank}`); + if (profile.role) details.push(`Role: ${profile.role}`); + if (profile.agents.length > 0) + details.push(`Plays: ${profile.agents.join(", ")}`); + if (profile.playStyle) details.push(`Style: ${profile.playStyle}`); + return details.join(", "); + }) + .join("; "); + parts.push(`Games: ${gameDetails}`); + } + + return parts.join(". "); +} + +/** + * Generate embedding vector for a search query + * Uses Gemini's text-embedding-004 model + */ +export async function generateQueryEmbedding( + query: string, +): Promise { + const client = getGeminiClient(); + const model = client.getGenerativeModel({ model: "text-embedding-004" }); + + try { + const result: EmbedContentResponse = await model.embedContent(query); + const embedding = result.embedding; + + if (!embedding?.values || embedding.values.length === 0) { + throw new Error("Empty embedding returned from Gemini"); + } + + return embedding.values; + } catch (error) { + console.error("Error generating query embedding:", error); + throw new Error( + `Failed to generate query embedding: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +/** + * Generate embedding vector for a player profile + * Uses Gemini's text-embedding-004 model + */ +export async function generatePlayerEmbedding( + player: PlayerEmbeddingData, +): Promise<{ embedding: number[]; embeddingText: string }> { + const client = getGeminiClient(); + const model = client.getGenerativeModel({ model: "text-embedding-004" }); + + const embeddingText = buildPlayerEmbeddingText(player); + + try { + const result: EmbedContentResponse = await model.embedContent(embeddingText); + const embedding = result.embedding; + + if (!embedding?.values || embedding.values.length === 0) { + throw new Error("Empty embedding returned from Gemini"); + } + + return { + embedding: embedding.values, + embeddingText, + }; + } catch (error) { + console.error("Error generating player embedding:", error); + throw new Error( + `Failed to generate player embedding: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +/** + * Generate a complete AI analysis of a player for a coach + * Returns overview, pros, and cons + */ +export async function generateCompleteAnalysis( + player: PlayerEmbeddingData, + coachContext: CoachContext, +): Promise { + const client = getGeminiClient(); + const model = client.getGenerativeModel({ model: "gemini-2.5-flash-lite" }); + + const prompt = buildAnalysisPrompt(player, coachContext); + + try { + const result = await model.generateContent(prompt); + const response = result.response; + const text = response.text(); + + // Parse the structured response + return parseAnalysisResponse(text); + } catch (error) { + console.error("Error generating player analysis:", error); + throw new Error( + `Failed to generate player analysis: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +/** + * Build the prompt for player analysis + */ +function buildAnalysisPrompt( + player: PlayerEmbeddingData, + coachContext: CoachContext, +): string { + const playerInfo = buildPlayerEmbeddingText(player); + + let coachInfo = "a coach"; + if (coachContext.schoolName) { + coachInfo = `a coach at ${coachContext.schoolName}`; + if (coachContext.schoolType) { + const typeLabel = + coachContext.schoolType === "HIGH_SCHOOL" + ? "high school" + : coachContext.schoolType === "COLLEGE" + ? "college" + : "university"; + coachInfo += ` (${typeLabel})`; + } + } + + const gamesContext = + coachContext.games.length > 0 + ? `The coach's team competes in: ${coachContext.games.join(", ")}.` + : ""; + + return `You are an esports recruiting assistant helping ${coachInfo} evaluate a potential player. +${gamesContext} + +Analyze the following player profile and provide a structured assessment: + +${playerInfo} + +Please provide your analysis in the following JSON format (no markdown, just pure JSON): +{ + "overview": "A 2-3 sentence overview of the player highlighting their key attributes and potential fit for collegiate/scholastic esports.", + "pros": ["strength 1", "strength 2", "strength 3"], + "cons": ["area for improvement 1", "area for improvement 2"] +} + +Focus on: +- Competitive gaming experience and achievements +- Academic standing and potential +- Game-specific skills and versatility +- Team fit and coachability indicators +- Be balanced and constructive in the cons section - frame them as growth areas rather than weaknesses + +Important: Return ONLY the JSON object, no additional text or formatting.`; +} + +/** + * Parse the AI response into structured analysis + */ +function parseAnalysisResponse(text: string): PlayerAnalysis { + try { + // Clean up the response - remove any markdown formatting + let cleanText = text.trim(); + if (cleanText.startsWith("```json")) { + cleanText = cleanText.slice(7); + } + if (cleanText.startsWith("```")) { + cleanText = cleanText.slice(3); + } + if (cleanText.endsWith("```")) { + cleanText = cleanText.slice(0, -3); + } + cleanText = cleanText.trim(); + + const parsed = JSON.parse(cleanText) as { + overview?: string; + pros?: string[]; + cons?: string[]; + }; + + return { + overview: + parsed.overview ?? "Unable to generate overview for this player.", + pros: parsed.pros ?? [], + cons: parsed.cons ?? [], + generatedAt: new Date(), + isCached: false, + }; + } catch (error) { + console.error("Error parsing analysis response:", error, "Raw text:", text); + // Return a fallback analysis if parsing fails + return { + overview: + "Unable to generate detailed analysis at this time. Please try again later.", + pros: [], + cons: [], + generatedAt: new Date(), + isCached: false, + }; + } +} + +/** + * Check if the Gemini API is configured and available + */ +export function isGeminiConfigured(): boolean { + return !!env.GOOGLE_GEMINI_API_KEY; +} + +/** + * Format embedding array for PostgreSQL vector type + * Converts number[] to the format expected by pgvector: '[0.1,0.2,...]' + */ +export function formatEmbeddingForPostgres(embedding: number[]): string { + return `[${embedding.join(",")}]`; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 1cfd573..89ea6e0 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -19,6 +19,7 @@ import { adminManagementRouter } from "@/server/api/routers/adminManagement"; import { publicSearchRouter } from "@/server/api/routers/publicSearch"; import { gseRouter } from "@/server/api/routers/gse"; import { paymentsRouter } from "@/server/api/routers/payments"; +import { talentSearchRouter } from "@/server/api/routers/talentSearch"; /** * This is the primary router for your server. @@ -46,6 +47,7 @@ export const appRouter = createTRPCRouter({ adminManagement: adminManagementRouter, gse: gseRouter, payments: paymentsRouter, + talentSearch: talentSearchRouter, }); // export type definition of API diff --git a/src/server/api/routers/talentSearch.ts b/src/server/api/routers/talentSearch.ts new file mode 100644 index 0000000..9372671 --- /dev/null +++ b/src/server/api/routers/talentSearch.ts @@ -0,0 +1,450 @@ +/** + * tRPC router for AI-powered talent search + * Provides semantic search and AI analysis endpoints for coaches + */ + +import { z } from "zod"; +import { + createTRPCRouter, + onboardedCoachProcedure, + adminProcedure, +} from "@/server/api/trpc"; +import { TRPCError } from "@trpc/server"; +import { + searchPlayersBySimilarity, + upsertPlayerEmbedding, + refreshAllEmbeddings, + getEmbeddingCount, + getMissingEmbeddingCount, +} from "@/lib/server/embeddings"; +import { + generateCompleteAnalysis, + isGeminiConfigured, +} from "@/lib/server/gemini"; +import type { + TalentSearchResult, + TalentGameProfile, + PlayerAnalysis, + PlayerEmbeddingData, + CoachContext, +} from "@/types/talent-search"; + +// Input schemas +const searchInputSchema = z.object({ + query: z.string().min(1, "Search query is required"), + gameId: z.string().uuid().optional(), + classYears: z.array(z.string()).optional(), + schoolTypes: z + .array(z.enum(["HIGH_SCHOOL", "COLLEGE", "UNIVERSITY"])) + .optional(), + locations: z.array(z.string()).optional(), + minGpa: z.number().min(0).max(4.0).optional(), + maxGpa: z.number().min(0).max(4.0).optional(), + roles: z.array(z.string()).optional(), + limit: z.number().min(1).max(100).default(50), + minSimilarity: z.number().min(0).max(1).default(0.3), +}); + +const analysisInputSchema = z.object({ + playerId: z.string().uuid(), +}); + +const refreshEmbeddingsInputSchema = z.object({ + onlyMissing: z.boolean().default(true), + batchSize: z.number().min(1).max(50).default(10), + batchDelay: z.number().min(100).max(10000).default(1000), +}); + +export const talentSearchRouter = createTRPCRouter({ + /** + * Semantic search for players using natural language queries + * Returns players ranked by similarity to the query + */ + search: onboardedCoachProcedure + .input(searchInputSchema) + .query(async ({ ctx, input }) => { + // Check if Gemini is configured + if (!isGeminiConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "AI search is not available. The GOOGLE_GEMINI_API_KEY environment variable is not configured.", + }); + } + + const coachId = ctx.coachId; + + try { + // Perform vector similarity search + const searchResults = await searchPlayersBySimilarity(input.query, { + gameId: input.gameId, + classYears: input.classYears, + schoolTypes: input.schoolTypes, + locations: input.locations, + minGpa: input.minGpa, + maxGpa: input.maxGpa, + roles: input.roles, + limit: input.limit, + minSimilarity: input.minSimilarity, + }); + + if (searchResults.length === 0) { + return { + results: [] as TalentSearchResult[], + totalCount: 0, + query: input.query, + }; + } + + // Fetch full player data for the results + const playerIds = searchResults.map((r) => r.player_id); + const similarityMap = new Map( + searchResults.map((r) => [r.player_id, r.similarity]), + ); + + const players = await ctx.db.player.findMany({ + where: { + id: { in: playerIds }, + }, + include: { + school_ref: { + select: { + id: true, + name: true, + type: true, + state: true, + }, + }, + main_game: { + select: { + id: true, + name: true, + short_name: true, + icon: true, + color: true, + }, + }, + game_profiles: { + include: { + game: { + select: { + id: true, + name: true, + short_name: true, + icon: true, + color: true, + }, + }, + }, + }, + favorited_by: { + where: { coach_id: coachId }, + select: { id: true }, + }, + }, + }); + + // Transform and sort by similarity + const results: TalentSearchResult[] = players + .map((player) => { + const similarity = similarityMap.get(player.id) ?? 0; + + const gameProfiles: TalentGameProfile[] = player.game_profiles.map( + (profile) => ({ + gameId: profile.game.id, + gameName: profile.game.name, + gameShortName: profile.game.short_name, + username: profile.username, + rank: profile.rank, + rating: profile.rating, + role: profile.role, + agents: profile.agents, + playStyle: profile.play_style, + combineScore: profile.combine_score, + leagueScore: profile.league_score, + }), + ); + + return { + id: player.id, + firstName: player.first_name, + lastName: player.last_name, + username: player.username, + imageUrl: player.image_url, + location: player.location, + bio: player.bio, + school: { + id: player.school_ref?.id ?? null, + name: player.school_ref?.name ?? player.school, + type: player.school_ref?.type ?? null, + state: player.school_ref?.state ?? null, + }, + academicInfo: { + classYear: player.class_year, + gpa: player.gpa ? parseFloat(player.gpa.toString()) : null, + graduationDate: player.graduation_date, + intendedMajor: player.intended_major, + }, + mainGame: player.main_game + ? { + id: player.main_game.id, + name: player.main_game.name, + shortName: player.main_game.short_name, + icon: player.main_game.icon, + color: player.main_game.color, + } + : null, + gameProfiles, + similarityScore: similarity, + isFavorited: player.favorited_by.length > 0, + }; + }) + .sort((a, b) => b.similarityScore - a.similarityScore); + + return { + results, + totalCount: results.length, + query: input.query, + }; + } catch (error) { + console.error("Error in talent search:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error + ? error.message + : "Failed to perform talent search", + }); + } + }), + + /** + * Generate AI analysis for a specific player + * Returns overview, pros, and cons + */ + getAnalysis: onboardedCoachProcedure + .input(analysisInputSchema) + .query(async ({ ctx, input }) => { + // Check if Gemini is configured + if (!isGeminiConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "AI analysis is not available. The GOOGLE_GEMINI_API_KEY environment variable is not configured.", + }); + } + + try { + // Fetch player data for analysis + const player = await ctx.db.player.findUnique({ + where: { id: input.playerId }, + include: { + school_ref: { + select: { + name: true, + type: true, + }, + }, + main_game: { + select: { + name: true, + }, + }, + game_profiles: { + include: { + game: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + if (!player) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Player not found", + }); + } + + // Build player data for analysis + const playerData: PlayerEmbeddingData = { + id: player.id, + firstName: player.first_name, + lastName: player.last_name, + username: player.username, + location: player.location, + bio: player.bio, + school: player.school_ref?.name ?? player.school, + schoolType: player.school_ref?.type ?? null, + classYear: player.class_year, + gpa: player.gpa ? parseFloat(player.gpa.toString()) : null, + intendedMajor: player.intended_major, + mainGame: player.main_game?.name ?? null, + gameProfiles: player.game_profiles.map((profile) => ({ + game: profile.game.name, + username: profile.username, + rank: profile.rank, + role: profile.role, + agents: profile.agents, + playStyle: profile.play_style, + })), + }; + + // Get coach context for personalized analysis + const coach = await ctx.db.coach.findUnique({ + where: { id: ctx.coachId }, + include: { + school_ref: { + select: { + name: true, + type: true, + }, + }, + teams: { + include: { + game: { + select: { + name: true, + }, + }, + }, + }, + }, + }); + + const coachContext: CoachContext = { + schoolName: coach?.school_ref?.name ?? coach?.school ?? null, + schoolType: coach?.school_ref?.type ?? null, + games: coach?.teams?.map((t) => t.game.name) ?? [], + }; + + // Generate AI analysis + const analysis = await generateCompleteAnalysis(playerData, coachContext); + + return analysis; + } catch (error) { + console.error("Error generating player analysis:", error); + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error + ? error.message + : "Failed to generate player analysis", + }); + } + }), + + /** + * Admin endpoint to refresh embeddings for all players + */ + refreshEmbeddings: adminProcedure + .input(refreshEmbeddingsInputSchema) + .mutation(async ({ input }) => { + // Check if Gemini is configured + if (!isGeminiConfigured()) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Embedding generation is not available. The GOOGLE_GEMINI_API_KEY environment variable is not configured.", + }); + } + + try { + const result = await refreshAllEmbeddings({ + onlyMissing: input.onlyMissing, + batchSize: input.batchSize, + batchDelay: input.batchDelay, + }); + + return { + success: true, + ...result, + }; + } catch (error) { + console.error("Error refreshing embeddings:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + error instanceof Error + ? error.message + : "Failed to refresh embeddings", + }); + } + }), + + /** + * Admin endpoint to get embedding statistics + */ + getEmbeddingStats: adminProcedure.query(async () => { + try { + const [embeddingCount, missingCount] = await Promise.all([ + getEmbeddingCount(), + getMissingEmbeddingCount(), + ]); + + return { + totalEmbeddings: embeddingCount, + missingEmbeddings: missingCount, + totalPlayers: embeddingCount + missingCount, + coveragePercent: + embeddingCount + missingCount > 0 + ? Math.round( + (embeddingCount / (embeddingCount + missingCount)) * 100, + ) + : 0, + isConfigured: isGeminiConfigured(), + }; + } catch (error) { + console.error("Error getting embedding stats:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to get embedding statistics", + }); + } + }), + + /** + * Update a single player's embedding (triggered on profile update) + */ + updatePlayerEmbedding: adminProcedure + .input(z.object({ playerId: z.string().uuid() })) + .mutation(async ({ input }) => { + if (!isGeminiConfigured()) { + // Silently skip if not configured - don't block profile updates + return { success: false, reason: "Gemini not configured" }; + } + + try { + await upsertPlayerEmbedding(input.playerId); + return { success: true }; + } catch (error) { + console.error( + `Error updating embedding for player ${input.playerId}:`, + error, + ); + // Don't throw - embedding updates should not block other operations + return { + success: false, + reason: error instanceof Error ? error.message : "Unknown error", + }; + } + }), + + /** + * Check if AI search feature is available + */ + isAvailable: onboardedCoachProcedure.query(async () => { + return { + isAvailable: isGeminiConfigured(), + message: isGeminiConfigured() + ? "AI-powered talent search is available" + : "AI search is not configured. Contact your administrator to enable this feature.", + }; + }), +}); diff --git a/src/types/talent-search.ts b/src/types/talent-search.ts new file mode 100644 index 0000000..f19b867 --- /dev/null +++ b/src/types/talent-search.ts @@ -0,0 +1,194 @@ +/** + * TypeScript types for the AI-powered talent search feature + */ + +import type { SchoolType } from "@prisma/client"; + +/** + * Filter options for talent search queries + */ +export interface TalentSearchFilters { + /** Natural language search query */ + query?: string; + /** Filter by specific game ID */ + gameId?: string; + /** Filter by graduation years */ + classYears?: string[]; + /** Filter by school types */ + schoolTypes?: SchoolType[]; + /** Filter by locations (states/regions) */ + locations?: string[]; + /** Minimum GPA filter */ + minGpa?: number; + /** Maximum GPA filter */ + maxGpa?: number; + /** Filter by game roles */ + roles?: string[]; + /** Filter by rank tiers */ + rankTiers?: string[]; + /** Maximum number of results */ + limit?: number; + /** Minimum similarity score threshold (0-1) */ + minSimilarity?: number; +} + +/** + * A player result from semantic search with similarity score + */ +export interface TalentSearchResult { + /** Player's database ID */ + id: string; + /** First name */ + firstName: string; + /** Last name */ + lastName: string; + /** Username/gamertag */ + username: string | null; + /** Profile image URL */ + imageUrl: string | null; + /** Player's location */ + location: string | null; + /** Player's bio */ + bio: string | null; + /** School information */ + school: { + id: string | null; + name: string | null; + type: SchoolType | null; + state: string | null; + }; + /** Academic information */ + academicInfo: { + classYear: string | null; + gpa: number | null; + graduationDate: string | null; + intendedMajor: string | null; + }; + /** Main game information */ + mainGame: { + id: string; + name: string; + shortName: string; + icon: string | null; + color: string | null; + } | null; + /** Game profiles with ranks and roles */ + gameProfiles: TalentGameProfile[]; + /** Similarity score from vector search (0-1) */ + similarityScore: number; + /** Whether the coach has favorited this player */ + isFavorited: boolean; +} + +/** + * Game profile for talent search results + */ +export interface TalentGameProfile { + /** Game ID */ + gameId: string; + /** Game name */ + gameName: string; + /** Game short name */ + gameShortName: string; + /** In-game username */ + username: string; + /** Current rank */ + rank: string | null; + /** Rating/MMR */ + rating: number | null; + /** Primary role */ + role: string | null; + /** Agents/characters played */ + agents: string[]; + /** Play style */ + playStyle: string | null; + /** Combine score (if applicable) */ + combineScore: number | null; + /** League score (if applicable) */ + leagueScore: number | null; +} + +/** + * AI-generated analysis of a player + */ +export interface PlayerAnalysis { + /** AI-generated overview paragraph */ + overview: string; + /** List of player strengths/pros */ + pros: string[]; + /** List of areas for development/cons */ + cons: string[]; + /** When the analysis was generated */ + generatedAt: Date; + /** Whether analysis is cached or fresh */ + isCached: boolean; +} + +/** + * Player data structure used for generating embeddings + */ +export interface PlayerEmbeddingData { + id: string; + firstName: string; + lastName: string; + username: string | null; + location: string | null; + bio: string | null; + school: string | null; + schoolType: SchoolType | null; + classYear: string | null; + gpa: number | null; + intendedMajor: string | null; + mainGame: string | null; + gameProfiles: { + game: string; + username: string; + rank: string | null; + role: string | null; + agents: string[]; + playStyle: string | null; + }[]; +} + +/** + * Coach context for generating personalized analysis + */ +export interface CoachContext { + schoolName: string | null; + schoolType: SchoolType | null; + games: string[]; +} + +/** + * Options for embedding refresh operations + */ +export interface EmbeddingRefreshOptions { + /** Only process players without embeddings */ + onlyMissing?: boolean; + /** Batch size for processing */ + batchSize?: number; + /** Delay between batches in ms */ + batchDelay?: number; +} + +/** + * Result of a batch embedding operation + */ +export interface EmbeddingBatchResult { + /** Total players processed */ + processed: number; + /** Successfully embedded */ + succeeded: number; + /** Failed to embed */ + failed: number; + /** Player IDs that failed */ + failedIds: string[]; +} + +/** + * Raw vector search result from database + */ +export interface VectorSearchRow { + player_id: string; + similarity: number; +}