diff --git a/.github/workflows/production.yaml b/.github/workflows/production.yaml index e1f6091d..1ca94ceb 100644 --- a/.github/workflows/production.yaml +++ b/.github/workflows/production.yaml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Install node uses: actions/setup-node@v4 with: @@ -24,12 +24,19 @@ jobs: - name: Install dependencies run: npm install - + - name: Run migrations run: npx migrate-mongo up env: MONGODB_URI: ${{ secrets.MONGODB_URI }} + - name: Seed hackbot documentation + run: npm run hackbot:seed + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL }} + - name: Install Vercel CLI run: npm install --global vercel@latest @@ -40,11 +47,14 @@ jobs: printf "${{ secrets.HMAC_INVITE_SECRET }}" | vercel env add HMAC_INVITE_SECRET production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ secrets.OPENAI_API_KEY }}" | vercel env add OPENAI_API_KEY production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }} - + printf "${{ vars.OPENAI_MODEL }}" | vercel env add OPENAI_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ vars.OPENAI_EMBEDDING_MODEL }}" | vercel env add OPENAI_EMBEDDING_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ vars.OPENAI_MAX_TOKENS }}" | vercel env add OPENAI_MAX_TOKENS production --force --token=${{ secrets.VERCEL_TOKEN }} env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} @@ -56,4 +66,4 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - name: Success - run: echo "πŸš€ Deploy successful - BLAST OFF WOO! (woot woot) !!! πŸ• πŸ• πŸ• πŸš€ " \ No newline at end of file + run: echo "πŸš€ Deploy successful - BLAST OFF WOO! (woot woot) !!! πŸ• πŸ• πŸ• πŸš€ " diff --git a/.github/workflows/staging.yaml b/.github/workflows/staging.yaml index a9996fd4..03b184ee 100644 --- a/.github/workflows/staging.yaml +++ b/.github/workflows/staging.yaml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Install node uses: actions/setup-node@v4 with: @@ -26,12 +26,19 @@ jobs: - name: Install dependencies run: npm install - + - name: Run migrations run: npx migrate-mongo up env: MONGODB_URI: ${{ secrets.MONGODB_URI }} + - name: Seed hackbot documentation + run: npm run hackbot:seed + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL }} + - name: Install Vercel CLI run: npm install --global vercel@latest @@ -42,10 +49,14 @@ jobs: printf "${{ secrets.HMAC_INVITE_SECRET }}" | vercel env add HMAC_INVITE_SECRET production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ secrets.OPENAI_API_KEY }}" | vercel env add OPENAI_API_KEY production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }} printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ vars.OPENAI_MODEL }}" | vercel env add OPENAI_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ vars.OPENAI_EMBEDDING_MODEL }}" | vercel env add OPENAI_EMBEDDING_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }} + printf "${{ vars.OPENAI_MAX_TOKENS }}" | vercel env add OPENAI_MAX_TOKENS production --force --token=${{ secrets.VERCEL_TOKEN }} env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} diff --git a/app/(api)/_actions/hackbot/clearKnowledgeDocs.ts b/app/(api)/_actions/hackbot/clearKnowledgeDocs.ts new file mode 100644 index 00000000..58384834 --- /dev/null +++ b/app/(api)/_actions/hackbot/clearKnowledgeDocs.ts @@ -0,0 +1,39 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; + +export interface ClearKnowledgeDocsResult { + ok: boolean; + deletedKnowledge: number; + deletedEmbeddings: number; + error?: string; +} + +export default async function clearKnowledgeDocs(): Promise { + try { + const db = await getDatabase(); + + const knowledgeResult = await db + .collection('hackbot_knowledge') + .deleteMany({}); + + const embeddingsResult = await db + .collection('hackbot_docs') + .deleteMany({ _id: { $regex: '^knowledge-' } }); + + return { + ok: true, + deletedKnowledge: knowledgeResult.deletedCount, + deletedEmbeddings: embeddingsResult.deletedCount, + }; + } catch (e) { + const msg = e instanceof Error ? e.message : 'Unknown error'; + console.error('[clearKnowledgeDocs] Error:', msg); + return { + ok: false, + deletedKnowledge: 0, + deletedEmbeddings: 0, + error: msg, + }; + } +} diff --git a/app/(api)/_actions/hackbot/deleteKnowledgeDoc.ts b/app/(api)/_actions/hackbot/deleteKnowledgeDoc.ts new file mode 100644 index 00000000..e86a3df9 --- /dev/null +++ b/app/(api)/_actions/hackbot/deleteKnowledgeDoc.ts @@ -0,0 +1,30 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { ObjectId } from 'mongodb'; + +export interface DeleteKnowledgeDocResult { + ok: boolean; + error?: string; +} + +export default async function deleteKnowledgeDoc( + id: string +): Promise { + try { + const db = await getDatabase(); + const objectId = new ObjectId(id); + + await db.collection('hackbot_knowledge').deleteOne({ _id: objectId }); + await db.collection('hackbot_docs').deleteOne({ _id: `knowledge-${id}` }); + + console.log(`[deleteKnowledgeDoc] Deleted ${id}`); + return { ok: true }; + } catch (e) { + console.error('[deleteKnowledgeDoc] Error', e); + return { + ok: false, + error: e instanceof Error ? e.message : 'Failed to delete document', + }; + } +} diff --git a/app/(api)/_actions/hackbot/getHackerProfile.ts b/app/(api)/_actions/hackbot/getHackerProfile.ts new file mode 100644 index 00000000..5b69735a --- /dev/null +++ b/app/(api)/_actions/hackbot/getHackerProfile.ts @@ -0,0 +1,17 @@ +'use server'; + +import { auth } from '@/auth'; +import type { HackerProfile } from '@typeDefs/hackbot'; + +export type { HackerProfile }; + +export async function getHackerProfile(): Promise { + const session = await auth(); + if (!session?.user) return null; + const user = session.user as any; + return { + name: user.name ?? undefined, + position: user.position ?? undefined, + is_beginner: user.is_beginner ?? undefined, + }; +} diff --git a/app/(api)/_actions/hackbot/getKnowledgeDocs.ts b/app/(api)/_actions/hackbot/getKnowledgeDocs.ts new file mode 100644 index 00000000..ed040e6e --- /dev/null +++ b/app/(api)/_actions/hackbot/getKnowledgeDocs.ts @@ -0,0 +1,50 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { HackDocType } from '@typeDefs/hackbot'; + +export interface KnowledgeDoc { + id: string; + type: HackDocType; + title: string; + content: string; + url: string | null; + createdAt: string; + updatedAt: string; +} + +export interface GetKnowledgeDocsResult { + ok: boolean; + docs: KnowledgeDoc[]; + error?: string; +} + +export default async function getKnowledgeDocs(): Promise { + try { + const db = await getDatabase(); + const raw = await db + .collection('hackbot_knowledge') + .find({}) + .sort({ updatedAt: -1 }) + .toArray(); + + const docs: KnowledgeDoc[] = raw.map((d: any) => ({ + id: String(d._id), + type: d.type, + title: d.title, + content: d.content, + url: d.url ?? null, + createdAt: d.createdAt?.toISOString?.() ?? new Date().toISOString(), + updatedAt: d.updatedAt?.toISOString?.() ?? new Date().toISOString(), + })); + + return { ok: true, docs }; + } catch (e) { + console.error('[getKnowledgeDocs] Error', e); + return { + ok: false, + docs: [], + error: e instanceof Error ? e.message : 'Failed to load knowledge docs', + }; + } +} diff --git a/app/(api)/_actions/hackbot/getUsageMetrics.ts b/app/(api)/_actions/hackbot/getUsageMetrics.ts new file mode 100644 index 00000000..4715caf3 --- /dev/null +++ b/app/(api)/_actions/hackbot/getUsageMetrics.ts @@ -0,0 +1,59 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; + +export type UsagePeriod = '24h' | '7d' | '30d'; + +export interface UsageMetrics { + totalRequests: number; + totalPromptTokens: number; + totalCompletionTokens: number; + totalCachedTokens: number; + /** 0–1 fraction of prompt tokens that were served from cache */ + cacheHitRate: number; +} + +export async function getUsageMetrics( + period: UsagePeriod = '24h' +): Promise { + const hours = period === '24h' ? 24 : period === '7d' ? 168 : 720; + const since = new Date(Date.now() - hours * 60 * 60 * 1000); + + const db = await getDatabase(); + const [result] = await db + .collection('hackbot_usage') + .aggregate([ + { $match: { timestamp: { $gte: since } } }, + { + $group: { + _id: null, + totalRequests: { $sum: 1 }, + totalPromptTokens: { $sum: '$promptTokens' }, + totalCompletionTokens: { $sum: '$completionTokens' }, + totalCachedTokens: { $sum: '$cachedPromptTokens' }, + }, + }, + ]) + .toArray(); + + if (!result) { + return { + totalRequests: 0, + totalPromptTokens: 0, + totalCompletionTokens: 0, + totalCachedTokens: 0, + cacheHitRate: 0, + }; + } + + return { + totalRequests: result.totalRequests, + totalPromptTokens: result.totalPromptTokens, + totalCompletionTokens: result.totalCompletionTokens, + totalCachedTokens: result.totalCachedTokens, + cacheHitRate: + result.totalPromptTokens > 0 + ? result.totalCachedTokens / result.totalPromptTokens + : 0, + }; +} diff --git a/app/(api)/_actions/hackbot/importKnowledgeDocs.ts b/app/(api)/_actions/hackbot/importKnowledgeDocs.ts new file mode 100644 index 00000000..874956c9 --- /dev/null +++ b/app/(api)/_actions/hackbot/importKnowledgeDocs.ts @@ -0,0 +1,107 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { embedText } from '@utils/hackbot/embedText'; +import { HackDocType } from '@typeDefs/hackbot'; + +export interface ImportDocInput { + type: HackDocType; + title: string; + content: string; + url?: string | null; +} + +export interface ImportKnowledgeDocsResult { + ok: boolean; + successCount: number; + failureCount: number; + failures: string[]; + error?: string; +} + +const VALID_TYPES = new Set([ + 'judging', + 'submission', + 'faq', + 'general', + 'track', + 'event', +]); + +export default async function importKnowledgeDocs( + docs: ImportDocInput[] +): Promise { + if (!Array.isArray(docs) || docs.length === 0) { + return { + ok: false, + successCount: 0, + failureCount: 0, + failures: [], + error: 'No documents provided.', + }; + } + + const db = await getDatabase(); + const now = new Date(); + let successCount = 0; + const failures: string[] = []; + + for (const doc of docs) { + const label = doc.title || '(untitled)'; + + if (!doc.title?.trim() || !doc.content?.trim()) { + failures.push(`${label}: title and content are required`); + continue; + } + + if (!VALID_TYPES.has(doc.type)) { + failures.push(`${label}: invalid type "${doc.type}"`); + continue; + } + + try { + // Insert into hackbot_knowledge + const result = await db.collection('hackbot_knowledge').insertOne({ + type: doc.type, + title: doc.title.trim(), + content: doc.content.trim(), + url: doc.url ?? null, + createdAt: now, + updatedAt: now, + }); + + const newId = String(result.insertedId); + + // Embed and upsert into hackbot_docs + const embedding = await embedText(doc.content.trim()); + await db.collection('hackbot_docs').updateOne( + { _id: `knowledge-${newId}` }, + { + $set: { + type: doc.type, + title: doc.title.trim(), + text: doc.content.trim(), + url: doc.url ?? null, + embedding, + updatedAt: now, + }, + }, + { upsert: true } + ); + + successCount++; + console.log(`[importKnowledgeDocs] βœ“ ${label}`); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Unknown error'; + console.error(`[importKnowledgeDocs] βœ— ${label}:`, msg); + failures.push(`${label}: ${msg}`); + } + } + + return { + ok: failures.length === 0, + successCount, + failureCount: failures.length, + failures, + }; +} diff --git a/app/(api)/_actions/hackbot/reseedHackbot.ts b/app/(api)/_actions/hackbot/reseedHackbot.ts new file mode 100644 index 00000000..0c6b7cf8 --- /dev/null +++ b/app/(api)/_actions/hackbot/reseedHackbot.ts @@ -0,0 +1,88 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { embedText } from '@utils/hackbot/embedText'; + +export interface ReseedResult { + ok: boolean; + successCount: number; + failureCount: number; + failures: string[]; + error?: string; +} + +/** + * Re-embeds all docs from hackbot_knowledge into hackbot_docs. + * Does NOT touch event docs (those are seeded from the events collection via CI). + */ +export default async function reseedHackbot(): Promise { + try { + const db = await getDatabase(); + const knowledgeDocs = await db + .collection('hackbot_knowledge') + .find({}) + .toArray(); + + if (knowledgeDocs.length === 0) { + return { + ok: false, + successCount: 0, + failureCount: 0, + failures: [], + error: 'No knowledge documents found. Add some docs first.', + }; + } + + let successCount = 0; + const failures: string[] = []; + + for (const doc of knowledgeDocs) { + const id = String(doc._id); + try { + const embedding = await embedText(doc.content); + + await db.collection('hackbot_docs').updateOne( + { _id: `knowledge-${id}` }, + { + $set: { + type: doc.type, + title: doc.title, + text: doc.content, + url: doc.url ?? null, + embedding, + updatedAt: new Date(), + }, + }, + { upsert: true } + ); + + successCount++; + console.log(`[reseedHackbot] βœ“ knowledge-${id}`); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Unknown error'; + console.error(`[reseedHackbot] βœ— knowledge-${id}:`, msg); + failures.push(`${doc.title}: ${msg}`); + } + } + + console.log( + `[reseedHackbot] Done: ${successCount}/${knowledgeDocs.length} re-seeded` + ); + + return { + ok: failures.length === 0, + successCount, + failureCount: failures.length, + failures, + }; + } catch (e) { + console.error('[reseedHackbot] Fatal error', e); + return { + ok: false, + successCount: 0, + failureCount: 0, + failures: [], + error: e instanceof Error ? e.message : 'Unexpected error', + }; + } +} diff --git a/app/(api)/_actions/hackbot/saveKnowledgeDoc.ts b/app/(api)/_actions/hackbot/saveKnowledgeDoc.ts new file mode 100644 index 00000000..2ab76cf7 --- /dev/null +++ b/app/(api)/_actions/hackbot/saveKnowledgeDoc.ts @@ -0,0 +1,105 @@ +'use server'; + +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { ObjectId } from 'mongodb'; +import { embedText } from '@utils/hackbot/embedText'; +import { HackDocType } from '@typeDefs/hackbot'; + +export interface SaveKnowledgeDocInput { + id?: string; // If provided, update existing; otherwise create new + type: HackDocType; + title: string; + content: string; + url: string | null; +} + +export interface SaveKnowledgeDocResult { + ok: boolean; + id?: string; + error?: string; +} + +export default async function saveKnowledgeDoc( + input: SaveKnowledgeDocInput +): Promise { + const { id, type, title, content, url } = input; + + if (!title.trim() || !content.trim()) { + return { ok: false, error: 'Title and content are required.' }; + } + + try { + const db = await getDatabase(); + const now = new Date(); + + // Embed the content for vector search + const embedding = await embedText(content); + + if (id) { + // Update existing + const objectId = new ObjectId(id); + await db.collection('hackbot_knowledge').updateOne( + { _id: objectId }, + { + $set: { type, title, content, url: url ?? null, updatedAt: now }, + } + ); + + // Re-embed in hackbot_docs + await db.collection('hackbot_docs').updateOne( + { _id: `knowledge-${id}` }, + { + $set: { + type, + title, + text: content, + url: url ?? null, + embedding, + updatedAt: now, + }, + }, + { upsert: true } + ); + + console.log(`[saveKnowledgeDoc] Updated ${id}`); + return { ok: true, id }; + } else { + // Create new + const result = await db.collection('hackbot_knowledge').insertOne({ + type, + title, + content, + url: url ?? null, + createdAt: now, + updatedAt: now, + }); + + const newId = String(result.insertedId); + + // Embed into hackbot_docs + await db.collection('hackbot_docs').updateOne( + { _id: `knowledge-${newId}` }, + { + $set: { + type, + title, + text: content, + url: url ?? null, + embedding, + updatedAt: now, + }, + }, + { upsert: true } + ); + + console.log(`[saveKnowledgeDoc] Created ${newId}`); + return { ok: true, id: newId }; + } + } catch (e) { + console.error('[saveKnowledgeDoc] Error', e); + return { + ok: false, + error: e instanceof Error ? e.message : 'Failed to save document', + }; + } +} diff --git a/app/(api)/_datalib/hackbot/getHackbotContext.ts b/app/(api)/_datalib/hackbot/getHackbotContext.ts new file mode 100644 index 00000000..65cd4eff --- /dev/null +++ b/app/(api)/_datalib/hackbot/getHackbotContext.ts @@ -0,0 +1,140 @@ +import { HackDoc, HackDocType } from '@typeDefs/hackbot'; +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { embedText } from '@utils/hackbot/embedText'; +import { retryWithBackoff } from '@utils/hackbot/retryWithBackoff'; + +export interface RetrievedContext { + docs: HackDoc[]; + usage?: { + promptTokens?: number; + totalTokens?: number; + }; +} + +interface QueryComplexity { + type: 'simple' | 'moderate' | 'complex'; + docLimit: number; + reason: string; +} + +function analyzeQueryComplexity(query: string): QueryComplexity { + const trimmed = query.trim().toLowerCase(); + const words = trimmed.split(/\s+/); + + // Simple greeting or single fact + if (words.length <= 5) { + if (/^(hi|hello|hey|thanks|thank you|ok|okay)/.test(trimmed)) { + return { type: 'simple', docLimit: 5, reason: 'greeting' }; + } + if (/^(what|when|where|who)\s+(is|are)/.test(trimmed)) { + return { type: 'simple', docLimit: 10, reason: 'single fact question' }; + } + } + + // Timeline/schedule queries (need more docs) + if ( + /\b(schedule|timeline|agenda|itinerary|all events|list)\b/.test(trimmed) || + (words.length >= 3 && /\b(what|show|tell)\b/.test(trimmed)) + ) { + return { type: 'complex', docLimit: 30, reason: 'schedule/list query' }; + } + + // Multiple questions or comparisons + if ( + /\b(and|or|versus|vs|compare)\b/.test(trimmed) || + (trimmed.match(/\?/g) || []).length > 1 + ) { + return { type: 'complex', docLimit: 25, reason: 'multi-part query' }; + } + + // Moderate: specific event or detail + return { type: 'moderate', docLimit: 15, reason: 'specific detail query' }; +} + +export async function retrieveContext( + query: string, + opts?: { limit?: number; preferredTypes?: HackDocType[] } +): Promise { + const trimmed = query.trim(); + + // Analyze query complexity if no explicit limit provided + let limit = opts?.limit; + if (!limit) { + const complexity = analyzeQueryComplexity(trimmed); + limit = complexity.docLimit; + console.log('[hackbot][retrieve][adaptive]', { + query: trimmed, + complexity: complexity.type, + docLimit: limit, + reason: complexity.reason, + }); + } + + try { + const embedding = await retryWithBackoff(() => embedText(trimmed), { + maxAttempts: 3, + delayMs: 1000, + backoffMultiplier: 2, + }); + + const db = await getDatabase(); + const collection = db.collection('hackbot_docs'); + + const preferredTypes = opts?.preferredTypes?.length + ? Array.from(new Set(opts.preferredTypes)) + : null; + + const numCandidates = Math.min(200, Math.max(50, limit * 10)); + + const vectorResults = await collection + .aggregate([ + { + $vectorSearch: { + index: 'hackbot_vector_index', + queryVector: embedding, + path: 'embedding', + numCandidates, + limit, + ...(preferredTypes + ? { + filter: { + type: { $in: preferredTypes }, + }, + } + : {}), + }, + }, + ]) + .toArray(); + + if (!vectorResults.length) { + console.warn( + '[hackbot][retrieve] Vector search returned no results for query:', + trimmed + ); + return { docs: [] }; + } + + const docs: HackDoc[] = vectorResults.map((doc: any) => ({ + id: String(doc._id), + type: doc.type, + title: doc.title, + text: doc.text, + url: doc.url ?? undefined, + })); + + console.log('[hackbot][retrieve][vector]', { + query: trimmed, + docIds: docs.map((d) => d.id), + titles: docs.map((d) => d.title), + }); + + return { docs }; + } catch (err) { + console.error( + '[hackbot][retrieve] Vector search failed (no fallback).', + err + ); + throw err; + } +} diff --git a/app/(api)/_utils/hackbot/embedText.ts b/app/(api)/_utils/hackbot/embedText.ts new file mode 100644 index 00000000..4ebcc1a5 --- /dev/null +++ b/app/(api)/_utils/hackbot/embedText.ts @@ -0,0 +1,13 @@ +import { embed } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +const EMBEDDING_MODEL = + process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small'; + +export async function embedText(text: string): Promise { + const { embedding } = await embed({ + model: openai.embedding(EMBEDDING_MODEL), + value: text, + }); + return embedding; +} diff --git a/app/(api)/_utils/hackbot/eventFiltering.ts b/app/(api)/_utils/hackbot/eventFiltering.ts new file mode 100644 index 00000000..466ed0e4 --- /dev/null +++ b/app/(api)/_utils/hackbot/eventFiltering.ts @@ -0,0 +1,152 @@ +import { + parseRawDate, + getEventEndTime, + getLADateString, +} from './eventFormatting'; + +import type { HackerProfile } from '@typeDefs/hackbot'; + +export type { HackerProfile }; + +export const ROLE_TAGS = new Set([ + 'developer', + 'designer', + 'pm', + 'beginner', + 'other', +]); + +export function isEventRecommended( + ev: any, + profile: HackerProfile | null +): boolean { + if (!profile) return false; + if (!ev.tags || !Array.isArray(ev.tags)) return false; + const tags = ev.tags.map((t: string) => t.toLowerCase()); + if (profile.position && tags.includes(profile.position.toLowerCase())) + return true; + if (profile.is_beginner && tags.includes('beginner')) return true; + return false; +} + +/** + * Returns true if the event is relevant to the hacker's profile. + * Events with no role/experience tags are considered relevant to everyone. + * Events with specific tags are only relevant if the profile matches at least one. + */ +export function isEventRelevantToProfile( + ev: any, + profile: HackerProfile | null +): boolean { + const tags: string[] = Array.isArray(ev.tags) + ? ev.tags.map((t: string) => t.toLowerCase()) + : []; + const roleTags = tags.filter((t) => ROLE_TAGS.has(t)); + // No role tags β†’ relevant to everyone + if (roleTags.length === 0) return true; + // No profile or profile has no useful fields β†’ show everything + if (!profile) return false; + if (!profile.position && profile.is_beginner === undefined) return true; + if (profile.position && roleTags.includes(profile.position.toLowerCase())) + return true; + if (profile.is_beginner && roleTags.includes('beginner')) return true; + return false; +} + +/** Returns the hour (0–23) of a Date in LA timezone. */ +function getLAHour(date: Date): number { + return parseInt( + date.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + hour: 'numeric', + hour12: false, + }), + 10 + ); +} + +/** Filters events by time relationship to "now" in LA timezone. */ +export function applyTimeFilter( + events: any[], + filter: + | 'today' + | 'now' + | 'upcoming' + | 'past' + | 'morning' + | 'afternoon' + | 'evening' + | 'night' +): any[] { + const now = new Date(); + const todayStr = getLADateString(now); + + switch (filter) { + case 'today': { + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + return start && getLADateString(start) === todayStr; + }); + } + case 'now': { + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + if (!start) return false; + const end = getEventEndTime(ev); + return start <= now && now < end; + }); + } + case 'upcoming': { + const threeHoursLater = new Date(now.getTime() + 3 * 60 * 60 * 1000); + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + return start && start > now && start <= threeHoursLater; + }); + } + case 'past': { + return events.filter((ev) => { + const end = getEventEndTime(ev); + return end <= now; + }); + } + // Time-of-day filters (match on start_time hour in LA timezone) + case 'morning': { + // 6 AM – 11:59 AM + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + if (!start) return false; + const h = getLAHour(start); + return h >= 6 && h < 12; + }); + } + case 'afternoon': { + // 12 PM – 4:59 PM + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + if (!start) return false; + const h = getLAHour(start); + return h >= 12 && h < 17; + }); + } + case 'evening': { + // 5 PM – 8:59 PM + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + if (!start) return false; + const h = getLAHour(start); + return h >= 17 && h < 21; + }); + } + case 'night': { + // 9 PM – 5:59 AM (late night / overnight) + return events.filter((ev) => { + const start = parseRawDate(ev.start_time); + if (!start) return false; + const h = getLAHour(start); + return h >= 21 || h < 6; + }); + } + default: + return events; + } +} diff --git a/app/(api)/_utils/hackbot/eventFormatting.ts b/app/(api)/_utils/hackbot/eventFormatting.ts new file mode 100644 index 00000000..eae378b5 --- /dev/null +++ b/app/(api)/_utils/hackbot/eventFormatting.ts @@ -0,0 +1,56 @@ +export function parseRawDate(raw: unknown): Date | null { + let date: Date | null = null; + if (raw instanceof Date) { + date = raw; + } else if (typeof raw === 'string') { + date = new Date(raw); + } else if (raw && typeof raw === 'object' && '$date' in (raw as any)) { + date = new Date((raw as any).$date); + } + return date && !Number.isNaN(date.getTime()) ? date : null; +} + +export function formatEventDateTime(raw: unknown): string | null { + const date = parseRawDate(raw); + if (!date) return null; + return date.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +/** Returns only the time portion (e.g. "3:00 PM") β€” used when end is same day as start. */ +export function formatEventTime(date: Date): string { + return date.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + hour: 'numeric', + minute: '2-digit', + }); +} + +export function isSameCalendarDay(a: Date, b: Date): boolean { + const opts: Intl.DateTimeFormatOptions = { timeZone: 'America/Los_Angeles' }; + return ( + a.toLocaleDateString('en-US', opts) === b.toLocaleDateString('en-US', opts) + ); +} + +/** Returns the event end time, falling back to start_time + 60 min. */ +export function getEventEndTime(ev: any): Date { + const end = parseRawDate(ev.end_time); + if (end) return end; + const start = parseRawDate(ev.start_time); + if (!start) return new Date(); + const fallback = new Date(start.getTime()); + fallback.setMinutes(fallback.getMinutes() + 60); + return fallback; +} + +/** Returns "YYYY-MM-DD" in LA timezone. */ +export function getLADateString(date: Date): string { + return date.toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' }); +} diff --git a/app/(api)/_utils/hackbot/retryWithBackoff.ts b/app/(api)/_utils/hackbot/retryWithBackoff.ts new file mode 100644 index 00000000..fe6ce7a8 --- /dev/null +++ b/app/(api)/_utils/hackbot/retryWithBackoff.ts @@ -0,0 +1,50 @@ +export interface RetryOptions { + maxAttempts: number; + delayMs: number; + backoffMultiplier: number; + retryableErrors?: string[]; +} + +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions +): Promise { + const { + maxAttempts, + delayMs, + backoffMultiplier, + retryableErrors = ['ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'], + } = options; + + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (err: any) { + lastError = err; + + const isRetryable = + retryableErrors.some((code) => err.message?.includes(code)) || + err.status === 429 || + err.status === 500 || + err.status === 502 || + err.status === 503 || + err.status === 504; + + if (!isRetryable || attempt === maxAttempts) { + throw err; + } + + const delay = delayMs * Math.pow(backoffMultiplier, attempt - 1); + console.log( + `[hackbot][retry] Attempt ${attempt}/${maxAttempts} failed. Retrying in ${delay}ms...`, + err.message + ); + + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; +} diff --git a/app/(api)/_utils/hackbot/systemPrompt.ts b/app/(api)/_utils/hackbot/systemPrompt.ts new file mode 100644 index 00000000..85827ea8 --- /dev/null +++ b/app/(api)/_utils/hackbot/systemPrompt.ts @@ -0,0 +1,259 @@ +import type { HackerProfile } from '@typeDefs/hackbot'; + +export const PATH_CONTEXT_MAP: Record = { + '/': 'the Hub homepage (announcements, prize tracks, mentor/director help, Discord)', + '/#prize-tracks': 'the Prize Tracks section of the Hub homepage', + '/#discord': 'the Discord / Stay Up To Date section of the Hub homepage', + '/#mentor-director-help': + 'the Mentor & Director Help section of the Hub homepage', + '/project-info': + 'the Project Info page (submission process and judging process)', + '/project-info#submission': + 'the Submission Process tab of the Project Info page', + '/project-info#judging': 'the Judging Process tab of the Project Info page', + '/starter-kit': 'the Starter Kit page', + '/starter-kit#lets-begin': + 'the "Let\'s Begin" section of the Starter Kit (Hacking 101 intro)', + '/starter-kit#find-a-team': 'the "Find a Team" section of the Starter Kit', + '/starter-kit#ideate': + 'the "Ideate" section of the Starter Kit (brainstorming, previous hacks)', + '/starter-kit#resources': + 'the "Resources" section of the Starter Kit (developer/designer/mentor resources)', + '/schedule': 'the Schedule page', +}; + +export function getPageContext(currentPath: string | undefined): string | null { + if (!currentPath) return null; + return PATH_CONTEXT_MAP[currentPath] ?? null; +} + +export function buildSystemPrompt({ + profile, + pageContext, +}: { + profile: HackerProfile | null; + pageContext: string | null; +}): string { + const sections: string[] = []; + + // ── STABLE SECTION ──────────────────────────────────────────────────────────── + // All invariant content lives here so OpenAI's automatic prefix cache can + // cache this portion across every request (requires identical 1024+ token + // prefix). Time, profile, and page context are appended at the end. + + // Identity & persona + sections.push( + 'You are HackDavis Helper ("Hacky"), a friendly AI assistant for the HackDavis hackathon.', + "You're like a knowledgeable friend who's been to tons of hackathons β€” encouraging, approachable, and genuinely excited to help people succeed. You give real advice, not just links.", + 'When someone asks a broad question ("how do I get started?", "what should I do?"), give them a thoughtful, conversational answer with actual guidance β€” not just a list of links or event cards. Tools (events, links) are supplements to your answer, not replacements for it.', + 'If someone\'s profile is vague or their question could go multiple directions, it\'s okay to ask a quick follow-up to give better advice (e.g. "Are you more into coding, design, or the business side?" or "Have you been to a hackathon before?").' + ); + + // Tone & length + sections.push( + 'Tone: friendly, warm, and conversational. Use contractions ("you\'re", "it\'s") and avoid robotic phrasing. Talk like a helpful person, not a search engine.', + 'LENGTH: Keep responses to 2-4 sentences max. For lists, use short bullet points. Never write multiple paragraphs β€” be concise and helpful, not verbose.', + 'BE PROACTIVE: Never ask "Would you like me to show you workshops/events/links?" β€” just include them. Call get_events and provide_links alongside your answer in the SAME step. The user asked a question; give them the full answer immediately.' + ); + + // Core rules + sections.push( + 'CRITICAL: Only answer questions about HackDavis. Refuse unrelated topics politely.' + ); + sections.push( + 'CRITICAL: Only use facts from the provided context or tool results. Never invent times, dates, or locations.' + ); + sections.push( + 'CRITICAL DISTINCTION: "What prize tracks/prizes should I enter/win?" = PRIZE TRACK question (answer from knowledge, DO NOT call get_events with ACTIVITIES). "What events/workshops/activities should I attend?" = EVENT RECOMMENDATION question (call get_events for workshops + activities). These are entirely different intents β€” never confuse them.' + ); + + // Greetings + sections.push( + 'For simple greetings ("hi", "hello"), respond warmly using the user\'s name if you know it. Introduce yourself briefly and offer to help β€” e.g. "Hey [Name]! I\'m Hacky, your hackathon helper. What can I help you with?" Keep it to 1-2 sentences.' + ); + + // Multi-part questions + sections.push( + 'MULTI-PART QUESTIONS: If the user asks about two different things (e.g. "workshops AND how to find teammates"), make SEPARATE get_events calls for each part. For example: one call with {type:"WORKSHOPS", forProfile:true, limit:3} and another with {search:"team", include_activities:true, limit:3}. Never combine unrelated intents into a single tool call.' + ); + + // Events & schedule + sections.push( + 'For event/schedule questions (times, locations, when something starts/ends):', + ' - ALWAYS call get_events with the most specific filters available.', + ' - When the user asks about a named event or keyword (e.g. "dinner", "opening ceremony"), pass that keyword as the "search" parameter.', + ' - When the user asks about a category (e.g. "workshops", "meals"), pass the type filter (WORKSHOPS, MEALS, ACTIVITIES, GENERAL).', + ' - For time-based queries: use timeFilter="today" for "what\'s happening today?", timeFilter="now" for "what\'s happening right now?", timeFilter="upcoming" for "what\'s coming up?", timeFilter="past" for "what already happened?". For time-of-day: timeFilter="morning" (6 AM–noon), "afternoon" (noon–5 PM), "evening" (5–9 PM), "night" (9 PM+). These can combine with type/search (e.g. {type:"ACTIVITIES", timeFilter:"night"} for "fun things at night").', + ' - For date-specific queries ("second day", "Sunday", "May 10"): use the date parameter (YYYY-MM-DD in LA timezone) β€” compute the date from the current time provided below.', + ' - For broad schedule queries ("what\'s happening on day 2?", "all events Sunday"): use type:null to include WORKSHOPS, MEALS, and GENERAL β€” this gives a full picture of the day. ACTIVITIES are separate and should only be included when explicitly requested.', + ' - IMPORTANT: When the user asks about a specific category, use ONLY that type. "What meals are there?" β†’ {type:"MEALS"} and nothing else. Do NOT also return workshops or other types.', + ' - Use "limit" to cap results when only one or a few events are expected (e.g. "when is dinner?" β†’ limit:3).', + ' - CRITICAL: Write your brief text AND call get_events IN THE SAME STEP β€” never make a bare tool call with no text. Output exactly ONE brief sentence alongside the tool call (e.g. "Here are today\'s workshops!" or "Here\'s what\'s happening right now!").', + ' - CRITICAL: After receiving get_events results, output ZERO additional text β€” your intro was already sent and the event cards render automatically.', + ' - Time filters apply ONLY when the user explicitly specifies a time context ("today", "right now", "tonight", "this morning"). Do NOT infer a time filter from conversation history or context β€” only apply it when the user literally asks for time-specific events.', + ' - Do NOT list event names, times, or locations in your text β€” the UI displays interactive event cards automatically.' + ); + + // Event attendance recommendations (NOT prize tracks) + sections.push( + 'For event attendance recommendations:', + ' - These are questions about which EVENTS to attend β€” not about prize tracks.', + " - DISTINGUISH between sub-cases based on the user's specific intent:", + ' A) User asks about a specific kind of activity by purpose (e.g. "networking events", "team-finding events", "relaxing activities"):', + ' β†’ Call get_events with {include_activities:true} and use search or tags to narrow to relevant events (e.g. search:"mixer" for networking). Return only events that match the intent, not all activities.', + ' B) User asks broadly about fun/social activities ("what activities are there?", "what can I do?", "what\'s fun?"):', + ' β†’ Call get_events ONCE with {type:"ACTIVITIES", include_activities:true, limit:6}. Do NOT add any timeFilter β€” show all activities regardless of when they occur. Do NOT add a WORKSHOPS call.', + ' C) User asks generally about events to attend ("what events should I attend?", "suggest things to do", "what should I do?"):', + ' β†’ Call get_events twice: once with {type:"WORKSHOPS", forProfile:true, limit:3} and once with {type:"ACTIVITIES", forProfile:true, limit:3, include_activities:true}. No timeFilter.', + ' - IMPORTANT: Only add a timeFilter when the user explicitly asks about a specific time ("what\'s happening tonight?", "what\'s on right now?"). For general "what activities are there?" queries, never filter by time β€” the user wants to see the full list.', + ' - Do NOT include MEALS or GENERAL β€” those are self-explanatory.', + ' - Write ONE brief intro sentence IN THE SAME STEP as the get_events call β€” nothing more. (e.g. "Here are the activities at HackDavis!"). Do not write a paragraph before the tool call.', + ' - CRITICAL: After receiving get_events results, output ZERO additional text. Your intro was already sent; the event cards render automatically. This applies even if results are empty.', + ' - Do NOT call provide_links for activity/event recommendation answers β€” event cards carry all relevant info.' + ); + + // Prize track recommendations (base rules β€” profile-specific guidance appended in variable section) + sections.push( + 'For questions about which prize tracks to enter ("what tracks should I pick?", "which tracks are best for me?", "what can I win?", "what tracks can I enter?"):', + ' - This is a KNOWLEDGE question β€” NOT the same as "suggest events to attend". Do NOT call get_events with type ACTIVITIES for this.', + ' - Use the prize track knowledge context. Recommend 3–5 specific tracks with a brief reason for each.', + ' - All hackers: "Best Hack for Social Good" and "Hacker\'s Choice Award" are AUTOMATIC β€” every submission is already entered, no opt-in needed. Mention this upfront.', + ' - Personalize recommendations using any profile-specific guidance provided below.', + ' - OPTIONAL: After your track answer, you MAY call get_events with {type:"WORKSHOPS", forProfile:true, limit:3} to surface relevant workshops. NEVER call with type ACTIVITIES for a track question.' + ); + + // Knowledge & general help + sections.push( + 'For questions about HackDavis rules, submission, judging, resources, the starter kit, tools for hackers, getting started, or general help:', + ' - Give a concise, genuine answer (2-4 sentences) with practical advice. Don\'t just say "check the starter kit" β€” explain what to do.', + " - For beginner/getting-started questions: be encouraging but brief. Give 2-3 actionable tips, then IMMEDIATELY call get_events AND provide_links in the same step β€” don't ask first.", + ' - IMPORTANT: Questions about getting started, building a project, developer/designer resources, APIs, tools, mentors, and starter kit steps ARE on-topic. Answer them from the knowledge context.', + ' - Do NOT mention specific workshop names, times, dates, or locations in your text when also calling get_events β€” event cards show those details.', + ' - PROACTIVE TOOL USAGE for knowledge questions:', + ' βœ“ "how do I get started?" / "where do I begin?" / "beginner help" β†’ ALWAYS call get_events with {type:"WORKSHOPS", forProfile:true, limit:3} AND provide_links. Do NOT use tags:["beginner"] β€” most events lack that tag. Use forProfile:true instead.', + ' βœ“ "what resources for developers/designers/pms?" β†’ role-specific tag filter', + ' βœ— Factual questions (deadlines, judging rubric, team/table numbers, rules) β†’ skip get_events, but still call provide_links', + ' - When you DO call get_events for a knowledge question:', + ' β€’ Use type:"WORKSHOPS". NEVER add a search term β€” pass search:null (avoids 0 results). NEVER use tags:["beginner"] β€” use forProfile:true instead.', + ' β€’ For role-specific questions: use one tag per get_events call (tags:["developer"] then tags:["designer"]).', + ' β€’ ABSOLUTE RULE: After any tool result arrives, output ZERO additional text. The UI handles results automatically.' + ); + + // Links + sections.push( + 'After any substantive knowledge response, call provide_links with 1-3 relevant {label, url} pairs from the knowledge context.', + 'Guidelines for provide_links:', + ' - ALWAYS call it for knowledge answers (judging, submission, deadlines, rules, resources, prize tracks).', + ' - Call it EVEN IF you also called get_events (events and links serve different purposes).', + ' - provide_links is ALWAYS SILENT β€” never announce links in your text. No "Here are some links", no "Check out these links". The UI renders them automatically below your response. Just write your conversational answer and call provide_links β€” the user will see the links appear.', + ' - Pick links directly relevant to the CURRENT question. Use short labels: strip "FAQ:", "Prize Track:", "Starter Kit:" prefixes.', + ' - For resource questions (developer tools, designer tools, APIs, starter kit sections): surface 2-3 links when multiple relevant pages exist.', + ' - Skip only for: greetings, off-topic refusals, and pure event-schedule questions where event cards carry everything.' + ); + + // Don'ts + sections.push( + 'Do NOT:', + '- Invent times, dates, locations, or URLs.', + '- Include URLs in your answer text.', + '- Answer questions completely unrelated to HackDavis (e.g. "write me a Python script", "explain machine learning", unrelated homework). If it has any connection to participating in or preparing for HackDavis, it is on-topic.', + '- Say "based on the context" or "according to the documents" (just answer directly).', + '- List event details (times, dates, locations) in text when get_events has been called or will be called in the same step β€” cards show everything. You may reference an event by general name only (e.g. "beginner workshops") but never include a specific time, date, or location.', + '- Output ANY text after a tool result arrives. Once you have called a tool, your text for this turn is complete β€” do not add summaries, apologies for missing results, or repetitions of what you already said. This is a hard rule with no exceptions.', + '- Call get_events with type ACTIVITIES for prize track questions or general knowledge questions. ACTIVITIES are only for event attendance recommendations.', + '- Call get_events for factual questions that have a single definitive answer (deadlines, judging rubric, team/table number explanation, hackathon rules). The knowledge context IS the answer for these β€” workshops are not a helpful next step.', + '- Use the tools called in previous conversation turns as a pattern to follow. Evaluate the CURRENT question independently on its own merits. Just because get_events was called for the previous question does not mean it should be called for this one.', + '- Ask "Would you like me to show you...?" or "Want me to find...?" β€” just do it. Be proactive.', + '- Use tags:["beginner"] in get_events β€” events are rarely tagged "beginner". Use forProfile:true instead to get relevant results.', + '- Write more than 4 sentences of text. Be concise. Use bullet points for lists instead of paragraphs.' + ); + + // Fallbacks + sections.push( + 'Always complete your response β€” never end mid-sentence.', + 'If you cannot find an answer, say: "I don\'t have that information β€” try reaching out to a mentor or director via the Mentor & Director Help section on the Hub homepage!"', + 'For unrelated questions, say: "Sorry, I can only answer questions about HackDavis. Do you have any questions about the event?"' + ); + + // ── VARIABLE SECTION ────────────────────────────────────────────────────────── + // Appended after the stable prefix so the cache above is not invalidated by + // per-request changes to time, user profile, or current page. + + // Current time β€” rounded to the hour so the variable section stays identical + // for all requests within the same hour, maximising OpenAI prefix-cache hits. + const now = new Date(); + // Snap to the start of the current hour in LA time + const laHourStr = now.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + }); + sections.push( + `The current date and time is approximately: ${laHourStr} (Pacific Time). Assume the current minute is somewhere within this hour.` + ); + + // Profile + if (profile) { + const firstName = profile.name?.split(' ')[0]; + let desc = `You are talking to ${profile.name ?? 'a hacker'}`; + if (profile.position) { + desc += `, a ${profile.is_beginner ? 'beginner ' : ''}${ + profile.position + }`; + } else if (profile.is_beginner) { + desc += ', a beginner'; + } + desc += '.'; + sections.push(desc); + + if (firstName) { + sections.push( + `Use their first name (${firstName}) naturally when it fits β€” not in every sentence.` + ); + } + + const shareable = ['their name']; + if (profile.position) shareable.push(`their role (${profile.position})`); + if (profile.is_beginner !== undefined) + shareable.push( + `that they are${profile.is_beginner ? '' : ' not'} a beginner` + ); + sections.push( + `If they ask what you know about them, you may share: ${shareable.join( + ', ' + )}.` + ); + + // Profile-specific prize track guidance + const trackLines: string[] = []; + if (profile.is_beginner) + trackLines.push( + 'For prize track recommendations: ALWAYS lead with "Best Beginner Hack" as the #1 pick (requires all team members to be first-time hackers).' + ); + if (profile.position === 'developer') + trackLines.push( + 'For prize track recommendations: Strongly recommend "Most Technically Challenging Hack". Also suggest "Best AI/ML Hack", "Best Hardware Hack", or "Best Statistical Model" where relevant.' + ); + if (profile.position === 'designer') + trackLines.push( + 'For prize track recommendations: Lead with "Best UI/UX Design" and "Best User Research".' + ); + if (profile.position === 'pm') + trackLines.push( + 'For prize track recommendations: Lead with "Best Entrepreneurship Hack".' + ); + if (trackLines.length > 0) sections.push(...trackLines); + } + + // Page context + if (pageContext) { + sections.push( + `The user is currently viewing: ${pageContext}. Use this to give more relevant answers.` + ); + } + + return sections.join('\n'); +} diff --git a/app/(api)/api/hackbot/stream/route.ts b/app/(api)/api/hackbot/stream/route.ts new file mode 100644 index 00000000..843102f4 --- /dev/null +++ b/app/(api)/api/hackbot/stream/route.ts @@ -0,0 +1,603 @@ +import { streamText, tool, stepCountIs } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; +import { retrieveContext } from '@datalib/hackbot/getHackbotContext'; +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { auth } from '@/auth'; +import { + parseRawDate, + formatEventDateTime, + formatEventTime, + getLADateString, + getEventEndTime, +} from '@utils/hackbot/eventFormatting'; +import { + isEventRecommended, + isEventRelevantToProfile, + applyTimeFilter, +} from '@utils/hackbot/eventFiltering'; +import type { HackerProfile } from '@typeDefs/hackbot'; +import { getPageContext, buildSystemPrompt } from '@utils/hackbot/systemPrompt'; + +const MAX_USER_MESSAGE_CHARS = 200; +const MAX_HISTORY_MESSAGES = 6; +const EXCLUSIVE_ROLE_TAGS = new Set(['developer', 'designer', 'pm']); + +const fewShotExamples = [ + { + role: 'user' as const, + content: 'When does hacking end?', + }, + { + role: 'assistant' as const, + content: 'Hacking ends on Sunday, May 10 at 11:00 AM Pacific Time.', + }, + { + role: 'user' as const, + content: 'What workshops are today?', + }, + { + role: 'assistant' as const, + content: "Here are today's workshops!", + }, + { + role: 'user' as const, + content: "I'm a beginner, where do I start?", + }, + { + role: 'assistant' as const, + content: + "Don't stress β€” start by picking a problem you care about and keep it simple! Check out the Starter Kit for brainstorming guides and past winning hacks. If you need teammates, hop on the #find-a-team Discord channel. Here are some workshops to help you get going:", + }, + { + role: 'user' as const, + content: 'What prize tracks should I enter?', + }, + { + role: 'assistant' as const, + content: + "Great question! Good news first β€” you're automatically entered in **Best Hack for Social Good** and **Hacker's Choice Award** just by submitting, so those are freebies.\n\nBeyond that, I'd pick tracks that match what your team is actually building. You can select up to 4 on Devpost.", + }, +]; + +export async function POST(request: Request) { + try { + const { messages, currentPath } = await request.json(); + + if (!Array.isArray(messages) || messages.length === 0) { + return Response.json({ error: 'Invalid request' }, { status: 400 }); + } + + const lastMessage = messages[messages.length - 1]; + + if (lastMessage.role !== 'user') { + return Response.json( + { error: 'Last message must be from user.' }, + { status: 400 } + ); + } + + if (lastMessage.content.length > MAX_USER_MESSAGE_CHARS) { + return Response.json( + { + error: `Message too long. Please keep it under ${MAX_USER_MESSAGE_CHARS} characters.`, + }, + { status: 400 } + ); + } + + // Greetings don't need knowledge context β€” skip the embedding round-trip. + const isSimpleGreeting = /^(hi|hello|hey|thanks|thank you|ok|okay)\b/i.test( + lastMessage.content.trim() + ); + + // Run auth and context retrieval in parallel to save ~400 ms. + let session; + let docs; + try { + [session, { docs }] = await Promise.all([ + auth(), + isSimpleGreeting + ? Promise.resolve({ docs: [] }) + : retrieveContext(lastMessage.content), + ]); + } catch (e) { + console.error('[hackbot][stream] Context retrieval error', e); + return Response.json( + { error: 'Search backend unavailable. Please contact an organizer.' }, + { status: 500 } + ); + } + + // Build profile from session (after auth resolves) + const sessionUser = (session as any)?.user as any; + const profile: HackerProfile | null = sessionUser + ? { + name: sessionUser.name ?? undefined, + position: sessionUser.position ?? undefined, + is_beginner: sessionUser.is_beginner ?? undefined, + } + : null; + console.log( + `[hackbot][stream] message="${lastMessage.content?.slice(0, 80)}" name=${ + profile?.name ?? 'n/a' + } position=${profile?.position ?? 'n/a'} beginner=${ + profile?.is_beginner ?? 'n/a' + } path=${currentPath ?? '/'}` + ); + + const contextSummary = + docs && docs.length > 0 + ? docs + .map((d, index) => { + const header = `${index + 1}) [type=${d.type}, title="${ + d.title + }"${d.url ? `, url="${d.url}"` : ''}]`; + return `${header}\n${d.text}`; + }) + .join('\n\n') + : 'No additional knowledge context found.'; + + const pageContext = getPageContext(currentPath); + const systemPrompt = buildSystemPrompt({ profile, pageContext }); + + const chatMessages = [ + { role: 'system', content: systemPrompt }, + ...fewShotExamples, + { + role: 'system', + content: `Knowledge context about HackDavis (rules, submission, judging, tracks, general info):\n\n${contextSummary}`, + }, + ...messages.slice(-MAX_HISTORY_MESSAGES), + ]; + + const model = process.env.OPENAI_MODEL || 'gpt-4o-mini'; + const configuredMaxTokens = parseInt( + process.env.OPENAI_MAX_TOKENS || '600', + 10 + ); + // Reasoning models (o1, o3, o4-mini, gpt-5*) use max_completion_tokens which + // includes BOTH reasoning tokens and output tokens. If the budget is too low, + // reasoning consumes all tokens and no visible text is produced. Auto-scale to + // at least 4000 so reasoning has headroom alongside the actual response. + const isReasoningModel = + /^(o1|o3|o4-mini|gpt-5(?!-chat))/.test(model) || + model.startsWith('codex-mini') || + model.startsWith('computer-use-preview'); + const maxOutputTokens = isReasoningModel + ? Math.max(configuredMaxTokens, 4000) + : configuredMaxTokens; + console.log( + `[hackbot][stream] model=${model} isReasoning=${isReasoningModel} maxOutputTokens=${maxOutputTokens}` + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = streamText({ + model: openai(model) as any, + messages: chatMessages.map((m: any) => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content, + })), + maxOutputTokens, + // Custom stop condition: hard limit of 5 steps, but also stop immediately + // after any step where provide_links was the ONLY tool called AND text has + // already been generated in some step β€” preventing an extra LLM round-trip + // just to "acknowledge" the link annotation (saves ~2 s per response). + // We require prior text so we never stop before the LLM has responded. + stopWhen: (state: any) => { + const { steps } = state as { steps: any[] }; + if (stepCountIs(5)({ steps })) return true; + if (!steps.length) return false; + const toolCalls: any[] = steps[steps.length - 1].toolCalls ?? []; + const onlyProvideLinks = + toolCalls.length > 0 && + toolCalls.every((t: any) => t.toolName === 'provide_links'); + if (!onlyProvideLinks) return false; + // Only short-circuit if the LLM has actually produced some answer text + return steps.some((s: any) => (s.text ?? '').trim().length > 0); + }, + tools: { + get_events: tool({ + description: + 'Fetch the live HackDavis event schedule from the database. Use this for ANY question about event times, locations, schedule, or what is happening when.', + inputSchema: z.object({ + type: z + .string() + .nullable() + .describe( + 'Filter by event type: WORKSHOPS, MEALS, ACTIVITIES, or GENERAL. Pass null to include all types.' + ), + search: z + .string() + .nullable() + .describe( + 'Case-insensitive text search on event name and description (e.g. "dinner", "opening ceremony", "team"). Pass null for no search filter. For broad queries (e.g. "workshops"), use the type filter instead β€” search is for specific keywords.' + ), + limit: z + .number() + .nullable() + .describe( + 'Max number of events to return β€” use when only 1-3 results are expected. Pass null for no limit.' + ), + forProfile: z + .boolean() + .nullable() + .describe( + "When true, filters results to only events relevant to the logged-in hacker's own role/experience. Use ONLY when the user is asking about their own events ('what should I attend?'). Do NOT use when asking about a different role (use the tags filter instead). Pass null to skip." + ), + timeFilter: z + .enum([ + 'today', + 'now', + 'upcoming', + 'past', + 'morning', + 'afternoon', + 'evening', + 'night', + ]) + .nullable() + .describe( + 'Filter events by time: "today" = starts today, "now" = currently live, "upcoming" = starts within 3 hours, "past" = already ended. Time-of-day (LA timezone): "morning" = 6 AM–noon, "afternoon" = noon–5 PM, "evening" = 5–9 PM, "night" = 9 PM–6 AM. Pass null for no time filter.' + ), + include_activities: z + .boolean() + .nullable() + .describe( + 'Allow ACTIVITIES-type events in results. Set to true ONLY when the user is explicitly asking about social/fun activities to attend. NEVER set this for prize track questions, knowledge questions, or workshop queries. Pass null or false otherwise.' + ), + tags: z + .array(z.string()) + .nullable() + .describe( + 'Filter events that have ALL of the specified tags (e.g. ["designer"] to find designer-tagged workshops, ["beginner"] for beginner events). Use this when the user asks about role-specific workshops for a role that may differ from their own profile. Pass null for no tag filter.' + ), + date: z + .string() + .nullable() + .describe( + 'Filter events starting on a specific date (YYYY-MM-DD in LA timezone). Use for date-specific queries ("second day", "Sunday", "May 10"). Compute the date from the current time context in the system prompt. Pass null for no date filter.' + ), + }), + execute: async ({ + type, + search, + limit, + forProfile, + timeFilter, + include_activities, + tags, + date, + }) => { + console.log('[hackbot][stream][tool] get_events input', { + type, + search, + limit, + forProfile, + timeFilter, + include_activities, + tags, + date, + }); + try { + const db = await getDatabase(); + const query: Record = {}; + if (type) query.type = { $regex: type, $options: 'i' }; + if (search) { + const searchRegex = { $regex: search, $options: 'i' }; + query.$or = [ + { name: searchRegex }, + { description: searchRegex }, + ]; + } + if (tags && tags.length > 0) + query.tags = { $in: tags.map((t) => t.toLowerCase()) }; + + const events = await db + .collection('events') + .find(query) + .sort({ start_time: 1 }) + .toArray(); + + // Validate date format before filtering + if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return { + events: [], + message: 'Invalid date format. Use YYYY-MM-DD.', + }; + } + + // Date filter (post-fetch, LA timezone) + const dateFiltered = date + ? events.filter((ev: any) => { + const start = parseRawDate(ev.start_time); + return start && getLADateString(start) === date; + }) + : events; + + // Block ACTIVITIES unless explicitly opted in + const typeFiltered = include_activities + ? dateFiltered + : dateFiltered.filter( + (ev: any) => (ev.type ?? '').toUpperCase() !== 'ACTIVITIES' + ); + + // When filtering by a specific exclusive role tag (designer/developer/pm), + // exclude events also tagged for other exclusive roles β€” those are general + // events for all roles, not role-specific workshops. + const requestedExclusive = (tags ?? []) + .map((t) => t.toLowerCase()) + .filter((t) => EXCLUSIVE_ROLE_TAGS.has(t)); + const roleSpecificFiltered = + requestedExclusive.length > 0 + ? typeFiltered.filter((ev: any) => { + const evTags = (ev.tags ?? []).map((t: string) => + t.toLowerCase() + ); + return !evTags.some( + (t: string) => + EXCLUSIVE_ROLE_TAGS.has(t) && + !requestedExclusive.includes(t) + ); + }) + : typeFiltered; + + // Apply time filter first (before profile and limit) + const timeFiltered = timeFilter + ? applyTimeFilter(roleSpecificFiltered, timeFilter) + : roleSpecificFiltered; + + // Default: exclude past events unless explicitly requested + const now = new Date(); + const futureFiltered = + timeFilter === 'past' + ? timeFiltered + : timeFiltered.filter((ev: any) => { + const end = getEventEndTime(ev); + return end > now; + }); + + const profileFiltered = + forProfile && profile + ? futureFiltered.filter((ev: any) => + isEventRelevantToProfile(ev, profile) + ) + : futureFiltered; + + // Apply limit after all filters + const limited = limit + ? profileFiltered.slice(0, limit) + : profileFiltered; + + console.log('[hackbot][stream][tool] get_events counts', { + total: events.length, + afterDate: dateFiltered.length, + afterType: typeFiltered.length, + afterRole: roleSpecificFiltered.length, + afterTime: timeFiltered.length, + afterFuture: futureFiltered.length, + afterProfile: profileFiltered.length, + afterLimit: limited.length, + }); + if (!limited.length) { + return { events: [], message: 'No events found.' }; + } + + const formatted = limited.map((ev: any) => { + const startDate = parseRawDate(ev.start_time); + const endDate = parseRawDate(ev.end_time); + // Always show only the time for the end (start already shows the date) + const endFormatted = endDate ? formatEventTime(endDate) : null; + return { + id: String(ev._id), + name: String(ev.name || 'Event'), + type: ev.type || null, + start: startDate ? formatEventDateTime(ev.start_time) : null, + end: endFormatted, + startMs: startDate?.getTime() ?? null, + location: ev.location || null, + host: ev.host || null, + tags: Array.isArray(ev.tags) ? ev.tags : [], + isRecommended: isEventRecommended(ev, profile), + compact: /^(GENERAL|MEALS)$/i.test(ev.type ?? ''), + }; + }); + + console.log( + `[hackbot][stream][tool] get_events returned ${formatted.length} events` + ); + return { events: formatted }; + } catch (e) { + console.error('[hackbot][stream][tool] get_events error', e); + return { + events: [], + message: + 'Could not fetch events. Please check the schedule page.', + }; + } + }, + }), + provide_links: tool({ + description: + 'Surface 1-3 relevant links from the knowledge context for the user. Call this AFTER generating your full text response β€” never before. Skip it entirely for greetings, off-topic refusals, and pure event-schedule answers (event cards already carry links).', + inputSchema: z.object({ + links: z + .array( + z.object({ + label: z + .string() + .describe( + 'Short user-friendly label. Strip prefixes like "FAQ:", "Prize Track:", "Starter Kit:". Max 40 chars.' + ), + url: z + .string() + .describe( + 'URL exactly as it appears in the knowledge context.' + ), + }) + ) + .max(3) + .describe( + '1-3 links most relevant to the response. Omit any that are only loosely related.' + ), + }), + execute: async ({ links }) => ({ links }), + }), + }, + }); + + // Build a custom stream using the Data Stream Protocol format so the + // existing widget parser (0: text, 8: annotations, a: tool results) works. + // + // We use a ReadableStream with an inline start() function that consumes + // fullStream directly. This avoids TransformStream backpressure issues that + // cause ResponseAborted when the HTTP layer and IIFE run out of sync. + const enc = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + // ai@6 usage format: { inputTokens, outputTokens, inputTokenDetails.cacheReadTokens } + let finishPromptTokens = 0; + let finishCompletionTokens = 0; + let finishCachedTokens = 0; + let streamError: string | null = null; + + const enq = (line: string) => controller.enqueue(enc.encode(line)); + + // Suppress post-tool text only when the model has already output text + // in a prior step. This prevents the model from "narrating" its own + // tool results (e.g. summarising event cards that the UI already shows). + // + // IMPORTANT: if the model calls tools FIRST (no text in step 1) and + // generates its full response in step 2, we must NOT suppress that text. + // suppressText is therefore only armed after text has been emitted. + let textHasBeenOutput = false; + let suppressText = false; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for await (const part of (result as any).fullStream) { + if (part?.type === 'text-delta') { + // ai@6: text-delta part has `text` field + if (!suppressText) { + enq(`0:${JSON.stringify(part.text ?? '')}\n`); + if (part.text) textHasBeenOutput = true; + } + } else if (part?.type === 'tool-call') { + console.log(`[hackbot][stream][tool] calling: ${part.toolName}`); + // Notify client that a tool call is in flight (for loading indicator) + enq( + `9:${JSON.stringify([ + { + toolCallId: part.toolCallId, + toolName: part.toolName, + state: 'call', + }, + ])}\n` + ); + } else if (part?.type === 'tool-result') { + // Only arm suppressText once the model has already shown text. + // If no text yet, let step-2+ text through (tools-first pattern). + if (textHasBeenOutput) suppressText = true; + // ai@6: tool-result part has `output` field (not `result`) + enq( + `a:${JSON.stringify([ + { + toolCallId: part.toolCallId, + toolName: part.toolName, + state: 'result', + result: part.output, + }, + ])}\n` + ); + } else if (part?.type === 'error') { + console.error( + '[hackbot][stream] OpenAI error in stream:', + part.error + ); + streamError = + (part.error as any)?.message ?? 'OpenAI server error'; + break; + } else if (part?.type === 'finish') { + // ai@6: finish part uses `totalUsage` + finishPromptTokens = part.totalUsage?.inputTokens ?? 0; + finishCompletionTokens = part.totalUsage?.outputTokens ?? 0; + finishCachedTokens = + part.totalUsage?.inputTokenDetails?.cacheReadTokens ?? 0; + } else if ( + part?.type === 'finish-step' && + finishPromptTokens === 0 + ) { + // Fallback: finish-step uses `usage` (not `totalUsage`) + finishPromptTokens = part.usage?.inputTokens ?? 0; + finishCompletionTokens = part.usage?.outputTokens ?? 0; + finishCachedTokens = + part.usage?.inputTokenDetails?.cacheReadTokens ?? 0; + } + } + + // Emit error event to client before closing so the widget can retry + if (streamError) { + enq( + `3:${JSON.stringify('Something went wrong. Please try again.')}\n` + ); + } + controller.close(); + } catch (e) { + console.error('[hackbot][stream] fullStream error', e); + controller.error(e); + return; + } + + // Log + persist usage metrics (fire-and-forget, never blocks response) + console.log('[hackbot][stream] usage', { + promptTokens: finishPromptTokens, + completionTokens: finishCompletionTokens, + cachedPromptTokens: finishCachedTokens, + }); + if (finishPromptTokens > 0) { + getDatabase() + .then((db) => + db.collection('hackbot_usage').insertOne({ + timestamp: new Date(), + model, + promptTokens: finishPromptTokens, + completionTokens: finishCompletionTokens, + cachedPromptTokens: finishCachedTokens, + }) + ) + .catch((err) => + console.error('[hackbot][stream] usage insert failed', err) + ); + } + }, + }); + + return new Response(stream, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + } catch (error: any) { + console.error('[hackbot][stream] Error', error); + + if (error.status === 429) { + return Response.json( + { error: 'Too many requests. Please wait a moment and try again.' }, + { status: 429 } + ); + } + + if (error.status === 401 || error.message?.includes('API key')) { + return Response.json( + { + error: 'AI service configuration error. Please contact an organizer.', + }, + { status: 500 } + ); + } + + return Response.json( + { error: 'Something went wrong. Please try again in a moment.' }, + { status: 500 } + ); + } +} diff --git a/app/_data/hackbot_knowledge_import.json b/app/_data/hackbot_knowledge_import.json new file mode 100644 index 00000000..491637d5 --- /dev/null +++ b/app/_data/hackbot_knowledge_import.json @@ -0,0 +1,356 @@ +[ + { + "type": "submission", + "title": "Submission Process Overview", + "content": "The HackDavis project submission process has 6 steps and is done through Devpost. Step 1: Login to Devpost \u2014 when you click the Devpost link, click 'Join Hackathon' and log in or sign up for a Devpost account if you don't have one already. Step 2: Register for the Event \u2014 register for the HackDavis hackathon on Devpost. Step 3: Create a Project \u2014 click 'Create project'. Only one person per team needs to create a project and complete the remaining steps. Step 4: Invite Teammates \u2014 invite your teammates to join the Devpost project so everyone is correctly associated with the submission. Step 5: Fill Out Details \u2014 fill out required information such as project overview, description, technical details, and any other requested fields. Step 6: Submit Project \u2014 once all information is filled out and teammates are added, submit the project on Devpost before the deadline.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Step 1: Login to Devpost", + "content": "When you click on the Devpost link, you should see the HackDavis Devpost page. Click 'Join Hackathon'. Log in or sign up for a Devpost account if you don't have one already. You must join the hackathon on Devpost before you can create or submit a project.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Step 2: Register for the Event on Devpost", + "content": "After joining the hackathon on Devpost, register for the event. This links your Devpost account to the HackDavis 2026 hackathon and is required before you can create a project.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Step 3: Create a Project on Devpost", + "content": "Click 'Create project' on the HackDavis Devpost hackathon page. Only one person per team needs to create a project and complete the subsequent steps. All teammates will be added to this single project.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Step 4: Invite Teammates on Devpost", + "content": "After creating a project, invite your teammates to join the Devpost project. This ensures all team members are correctly associated with the submission. Each teammate should accept the invitation so they appear as collaborators on the project.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Step 5: Fill Out Project Details on Devpost", + "content": "Fill out all required information for your project on Devpost. This includes your project overview, description, technical details, and any other requested fields. Make sure every section is complete before submitting.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Step 6: Submit Your Project on Devpost", + "content": "Once all project information is filled out and all teammates have been invited, click 'Submit Project' on Devpost. Make sure to do this before the submission deadline. You cannot win prizes if your project is not submitted on Devpost.", + "url": "/project-info#submission" + }, + { + "type": "submission", + "title": "Devpost Submission Tips and Checklist", + "content": "Before submitting on Devpost, make sure you have done the following: (1) Picked 4 relevant prize tracks \u2014 choose up to 4 prize tracks that best describe your project. (2) Added your GitHub and/or Figma links \u2014 include links to your code repository and any design files. (3) Inserted a demo video \u2014 upload or link a video demonstrating your project in action. The HackDavis 2026 Devpost page is at https://hackdavis-2026.devpost.com/", + "url": "/project-info#submission" + }, + { + "type": "judging", + "title": "Judging Process Overview", + "content": "The HackDavis judging process takes place on the second day of the hackathon. The judging day timeline is: 11:00 AM \u2014 submissions due on Devpost; 11:30-12:00 PM \u2014 important announcements about team numbers and table assignments; 12:00-2:00 PM \u2014 demo time with judges visiting your table; 2:00-3:00 PM \u2014 break and Hacker's Choice voting; 3:00-4:00 PM \u2014 closing ceremony. Projects are judged using a rubric: 60% track-specific criteria, 20% social good, 10% creativity, 10% presentation.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Judging Rubric", + "content": "All projects at HackDavis are evaluated using the following rubric: 60% Track-Specific (criteria specific to the prize tracks you selected), 20% Social Good (how well the project benefits society or a community), 10% Creativity (originality and out-of-the-box thinking), 10% Presentation (how clearly and effectively you explain your project).", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Submission Deadline", + "content": "All projects must be submitted on Devpost by 11:00 AM on the second day of the hackathon. Make sure your submission is complete on Devpost before this deadline. Late submissions will not be considered for judging.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Important Announcement: Team Number vs. Table Number", + "content": "Before demo time there is an important announcement about two distinct numbers you need to know. Your team number will be available on Devpost after submission, every team member MUST input their team number on the Hub homepage. Your table number is separate and is provided by HackerHub. This is NOT the same as your team number. Use your table number (NOT your team number) to find your demo table. If you and your team members are not seeing the same table number, contact a HackDavis director immediately.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Demo Time Overview (12:00-2:00 PM)", + "content": "Demo time runs from 12:00 PM to 2:00 PM. During this 2-hour window, judges will visit your table to evaluate your project. Each of the first 3 judges gets 3 minutes of demo time and 3 minutes of Q&A with your team (6 minutes total per judge). Judge 4 and beyond (if applicable) are from MLH, partner NPOs, or sponsors selecting their own winners. Please be at your table during the entire demo period.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Demo Time: Judge Queue and Visit Estimates", + "content": "The HackerHub will show an estimate of when your team will be judged (your position in each judge's queue). Please note these are estimates only \u2014 unforeseen situations may delay or hasten a judge's arrival at your table. You will not be visited by a judge in every round. Some tracks are judged by MLH, partner NPOs, or sponsors selecting their own winners \u2014 if your team did not choose those tracks, you will be judged by the standard three judges. Extra judges from MLH/NPO/sponsor groups do not affect your chances in other tracks; having more than three judges does not give an advantage or disadvantage. MLH = Major League Hacking. NPO = Non-Profit Organizations.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Demo Time: Being Present at Your Table", + "content": "You must be at your table during demo time. If your team is not at your table when a judge arrives, they will mark you as missing and move on. Please track your queue position to avoid delays. If marked missing, an announcement will be made and a HackDavis director will look for you. Teams repeatedly missing or unreachable will be removed from the judging process. If you have an emergency during demo time, contact a HackDavis director immediately.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Demo Time: After Your Team is Judged (Post-Demo)", + "content": "Once your team has been judged by all assigned judges, you are welcome to explore other demo tables and see what other teams have built. Please be mindful not to interrupt judges or teams that are currently presenting. We kindly ask that you wait 1-2 minutes after the start of a new round before visiting tables that do not have a judge present. If your team experiences any disruptions during demos, please report them to the Director Table and they will be addressed promptly.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Break: Hacker's Choice Award Voting (2:00-3:00 PM)", + "content": "After demo time ends (2:00-3:00 PM), you will have about an hour to visit other teams and vote for the Hacker's Choice Award. You can also browse projects in the gallery on Devpost. Meanwhile, panels of judges will be reviewing the top 5 shortlisted projects for each track to select the final winners. Vote for any project but your own! The voting form link will be available on the Hub during this period.", + "url": "/project-info#judging" + }, + { + "type": "judging", + "title": "Closing Ceremony (3:00-4:00 PM)", + "content": "The closing ceremony runs from 3:00 PM to 4:00 PM. Winners for each prize track are announced during this event. If your team needs to leave before or during the closing ceremony, please inform someone at the Director Table. If your team wins a prize and is not at the venue, HackDavis will contact you via email after the event to arrange delivery of your prize.", + "url": "/project-info#judging" + }, + { + "type": "track", + "title": "Prize Track: Best Hack for Social Good", + "content": "Track: Best Hack for Social Good. Filter category: General. Prize: 1st Place \u2014 VR Headset, 2nd Place \u2014 Electric Scooter. Eligibility: Encapsulate your authentic idea of what 'social good' can look like. All entries are automatically considered for this prize category \u2014 no opt-in required.", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Hacker's Choice Award", + "content": "Track: Hacker's Choice Award. Filter category: General. Prize: HackDavis Swag Bag. Eligibility: Awarded to the project with the most votes from 2026 hackers. All entries are automatically considered for this prize category. Vote for any project but your own! Voting happens during the break period after demos.", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Most Technically Challenging Hack", + "content": "Track: Most Technically Challenging Hack. Filter category: Technical. Domain: Software Engineering. Prize: Backlit Keyboard. Eligibility: Projects must showcase breadth and application of technical knowledge. Focuses on use of advanced technical tools and algorithms/data structures, integration of multiple technologies, quality of implementation, technical depth, and performance/scalability. Scoring criteria: (1) Technical Complexity of the Problem \u2014 from basic/well-known problems (score 1) to highly complex or novel problems requiring significant technical insight (score 5). (2) Quality of Engineering \u2014 from incomplete/poorly structured project (score 1) to exceptionally well-engineered, modular, scalable, fault-tolerant and efficient (score 5). (3) Integration of Tools or Techniques \u2014 from minimal external tools (score 1) to skillful integration of multiple advanced technologies like parallelism and optimization (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Beginner Hack", + "content": "Track: Best Beginner Hack. Filter category: General. Domain: Software Engineering. Prize: 24 Inch Monitor. Eligibility: Every team member must be a first-time hacker in order to qualify. Demonstrate a high level of growth through this project, foster creativity and collaboration within the team, and display a commitment to building skills. Scoring criteria: (1) Evidence of Learning and Growth \u2014 from little learning shown and reused known skills (score 1) to a strong grasp of entirely new topics applied effectively (score 5). (2) Team Collaboration \u2014 from disjointed teamwork with unclear roles (score 1) to strong team balance with active support across roles (score 5). (3) Problem-Solving and Persistence \u2014 from giving up easily (score 1) to tackling tough issues with creative persistence (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Interdisciplinary Hack", + "content": "Track: Best Interdisciplinary Hack. Filter category: General. Domain: Software Engineering. Prize: $75 STEAM Giftcard. Eligibility: Leverage multiple perspectives across different disciplines to create a more well-rounded project. At least one member of the team must be a non-CS/CSE/otherwise CS-related major in order to qualify. Scoring criteria: (1) Problem Selection \u2014 from a problem solvable within one discipline (score 1) to a highly original problem that requires all members' disciplines (score 5). (2) Disciplinary Balance \u2014 from all CS-related majors or one discipline dominating (score 1) to disciplines being deeply interwoven with equal importance (score 5). (3) Cross-Field Innovation \u2014 from disciplines barely connected (score 1) to a true blend creating something impossible within one field (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Most Creative Hack", + "content": "Track: Most Creative Hack. Filter category: General. Domain: Business. Prize: Mini Projector. Eligibility: Projects should demonstrate originality, showcase out-of-the-box thinking, and captivate their audience. Scoring criteria: (1) Originality of Concept \u2014 from a common idea similar to known projects (score 1) to a fresh, unexpected concept (score 5). (2) Creative Execution \u2014 from a conventional build with little imagination (score 1) to inventive design with imaginative features (score 5). (3) User Engagement \u2014 from uninspiring or hard to connect with (score 1) to a memorable and captivating experience (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Hardware Hack", + "content": "Track: Best Hardware Hack. Filter category: Technical. Domain: Hardware or Embedded Systems. Prize: Raspberry Pi Kit. Eligibility: Effectively integrate a hardware component into your final project. The final project should be functional, user-friendly, and interactive. Scoring criteria: (1) Hardware Integration \u2014 from disconnected or non-functional hardware (score 1) to seamless integration that is essential to the project (score 5). (2) Feasibility and Technical Soundness \u2014 from an unrealistic approach (score 1) to a well-grounded and executable design that is feasible to reproduce or extend (score 5). (3) User Interaction \u2014 from difficult to operate or requiring technical knowledge (score 1) to intuitive, responsive interaction that feels natural and engaging (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Hack for Social Justice", + "content": "Track: Best Hack for Social Justice. Filter category: General. Domain: Business. Prize: Kindle. Eligibility: Hack must address a social justice issue such as racial inequality, economic injustice, environmental justice, etc. The project should develop tangible solutions and/or raise awareness on these topics. Scoring criteria: (1) Issue Understanding and Community Consideration \u2014 from surface-level grasp with minimal community thought (score 1) to deep insight centered on the voices and needs of affected groups (score 5). (2) Advocacy Effectiveness \u2014 from a passive presentation with no engagement strategy (score 1) to a compelling call to action with practical pathways for audience involvement (score 5). (3) Implementation Feasibility and Impact \u2014 from a conceptual solution with significant barriers (score 1) to a ready-to-launch solution with demonstrated potential for measurable impact (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best UI/UX Design", + "content": "Track: Best UI/UX Design. Filter category: Design. Domain: UI/UX Design. Prize: Figma Full Seat (4-month subscription). Eligibility: Project includes beautiful design and intuitive web experiences that bring joy to users. Shows that the project is not only functional but also delightful; demonstrates wireframing in Figma, responsive design, and promotes intuitive user experiences. Scoring criteria: (1) Visual Design \u2014 from inconsistent style with poor accessibility (score 1) to beautiful, cohesive, polished design with thoughtful inclusivity (score 5). (2) Navigation Flow \u2014 from confusing user journey with hard-to-find key actions (score 1) to effortless, intuitive navigation throughout (score 5). (3) Design Process \u2014 from limited evidence of planning (score 1) to a comprehensive process with wireframes through to a final product (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best User Research", + "content": "Track: Best User Research. Filter category: Design. Domain: UI/UX Design. Prize: ChatGPT+ (4-month subscription). Eligibility: Awarded to a well-researched project that keeps its userbase in mind with an inclusive design aimed to maximize accessibility. Scoring criteria: (1) User Understanding \u2014 from assumptions made with minimal research (score 1) to comprehensive insights into user needs and behaviors (score 5). (2) Depth of Research Methods \u2014 from few or irrelevant data points (score 1) to a thoughtful combination of multiple research methods (score 5). (3) Design Application and Feedback Integration \u2014 from research/feedback ignored or misaligned with design (score 1) to each design element directly tied to research findings (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Entrepreneurship Hack", + "content": "Track: Best Entrepreneurship Hack. Filter category: Business. Domain: Business. Prize: North Face Backpack. Eligibility: No code required. A project that focuses on viability and persuasive power through presentation on the product/service you are trying to sell, relevant customer segments, distribution channels, and associated revenue/profit models. Scoring criteria: (1) Target Customer Clarity \u2014 from vague ideas of potential users (score 1) to detailed customer profiles with validated pain points (score 5). (2) Business Model \u2014 from unclear how the project would make money (score 1) to a well-thought-out pricing and monetization strategy (score 5). (3) Market Differentiation \u2014 from little distinction from existing solutions (score 1) to a clear competitive advantage with strong market positioning (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Statistical Model", + "content": "Track: Best Statistical Model. Filter category: Business. Domain: Data Science or AI/ML. Prize: Bluetooth Speaker. Eligibility: Projects must use exploratory data analysis (EDA) to guide their modeling decisions and hypotheses. Final models should include significance tests and be evaluated with metrics like MSE, R\u00b2, adjusted R\u00b2, precision, or recall, demonstrating clear statistical reasoning aligned with the project's core question or goal. Scoring criteria: (1) Exploratory Data Analysis \u2014 from minimal data exploration (score 1) to comprehensive EDA with insightful visualizations that directly inform model design (score 5). (2) Use of Statistical Tests \u2014 from inappropriate or missing tests (score 1) to proper tests applied correctly to the data and analyzed thoroughly (score 5). (3) Results Interpretation \u2014 from unclear numbers with little explanation (score 1) to insightful interpretation connecting statistics to the real world (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best AI/ML Hack", + "content": "Track: Best AI/ML Hack. Filter category: Sponsor. Domain: Data Science or AI/ML. Prize: $750 in Claude API credits. Eligibility: Project must have unique/creative AI functionality, clean data, accuracy in metrics, presence of high-quality data, utilizing relevant algorithms and ML libraries and/or cloud platforms for development. Participants should show how they collected their data and explain how their AI imitates the human mind. Models should work accurately on unseen circumstances (displays versatility). Scoring criteria: (1) Necessity of AI/ML for Solving the Problem \u2014 from a problem obviously solvable with deterministic algorithms (score 1) to one where deterministic algorithms are unable to solve the problem and AI/ML is the only solution (score 5). (2) Model Performance and Evaluation \u2014 from poor accuracy with minimal metrics (score 1) to strong results backed by solid metrics tested on unseen data or edge cases (score 5). (3) Technical Execution and Use of Tools \u2014 from surface-level tool use with no customization (score 1) to deep technical execution with custom methods and advanced techniques (score 5).", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Hack for Women's Center", + "content": "Track: Best Hack for Women's Center. Filter category: Non-Profit. Prize: Aroma Diffuser. Eligibility criteria will be announced \u2014 check the prize tracks page or Devpost for the latest details.", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track: Best Hack for ASUCD Pantry", + "content": "Track: Best Hack for ASUCD Pantry. Filter category: Non-Profit. Prize: Pokemon Packs. Eligibility criteria will be announced \u2014 check the prize tracks page or Devpost for the latest details.", + "url": "/#prize-tracks" + }, + { + "type": "track", + "title": "Prize Track Categories Overview", + "content": "HackDavis 2026 prize tracks are organized into the following filter categories: General (Best Hack for Social Good, Hacker's Choice Award, Best Beginner Hack, Best Interdisciplinary Hack, Most Creative Hack, Best Hack for Social Justice), Technical (Most Technically Challenging Hack, Best Hardware Hack), Design (Best UI/UX Design, Best User Research), Business (Best Entrepreneurship Hack, Best Statistical Model), Sponsor (Best AI/ML Hack), Non-Profit (Best Hack for Women's Center, Best Hack for ASUCD Pantry). You can select up to 4 relevant prize tracks for your Devpost submission.", + "url": "/#prize-tracks" + }, + { + "type": "general", + "title": "For Beginners: Starter Kit", + "content": "HackDavis has created a Starter Kit for all beginner hackers to help get their hack started. The Starter Kit includes resources, past winning hacks, and more. It is designed to help first-time hackathon participants navigate their first hackathon experience. You can access it through the Starter Kit link on the Hub.", + "url": "/starter-kit#lets-begin" + }, + { + "type": "general", + "title": "Brainstorming Your Project Idea", + "content": "When brainstorming your hackathon project, ask yourself these key questions: What problem are you solving for? Who are your users and what are their pain points? What makes your solution unique? These questions will help you define a focused, impactful project that stands out to judges.", + "url": "/starter-kit#ideate" + }, + { + "type": "general", + "title": "Mentor and Director Help via Discord", + "content": "HackDavis offers two types of support via Discord. Mentor Help: if you are stuck on a technical problem and need guidance, contact a HackDavis mentor through the Discord server at https://discord.gg/wc6QQEc. Director Help: if you have questions about the event, logistics, rules, or anything else, contact a HackDavis director through the same Discord server at https://discord.gg/wc6QQEc.", + "url": "/#mentor-director-help" + }, + { + "type": "general", + "title": "Finding a Team on Discord", + "content": "If you are looking to join a team or need additional members, use the #find-a-team channel on the HackDavis Discord server. Introduce yourself with your major, experience level, skills, and what kind of team or role you are looking for. The Discord server is at https://discord.gg/wc6QQEc. This is also a great way to meet other hackers and find people with complementary skills.", + "url": "/starter-kit#find-a-team" + }, + { + "type": "general", + "title": "HackDavis Hub Resources", + "content": "The HackerHub provides all the resources you need throughout the hackathon. Feel free to refer to the Hub anytime during the event. The Hub includes: the schedule, project submission information, judging process details, prize track information, a starter kit for beginners, and more. The HackDavis team is here to help you succeed.", + "url": "/" + }, + { + "type": "faq", + "title": "FAQ: How do I submit my project?", + "content": "To submit your project, follow the 6-step Devpost process: (1) Login or sign up on Devpost and click 'Join Hackathon' on the HackDavis Devpost page. (2) Register for the event. (3) Click 'Create project' \u2014 only one team member needs to do this. (4) Invite your teammates to the project. (5) Fill out all project details including overview, description, and technical information. (6) Click 'Submit Project' before the 11:00 AM deadline on the second day. The Devpost link is https://hackdavis-2026.devpost.com/", + "url": "/project-info#submission" + }, + { + "type": "faq", + "title": "FAQ: Does every team member need to create a Devpost project?", + "content": "No. Only one person per team needs to create the Devpost project. That person then invites all teammates to the project. Make sure all team members accept the invitation so they are listed as collaborators on the submission.", + "url": "/project-info#submission" + }, + { + "type": "faq", + "title": "FAQ: What should I include in my Devpost submission?", + "content": "Your Devpost submission should include: a project overview and detailed description, technical details, up to 4 relevant prize tracks you are entering, your GitHub repository link and/or Figma design file link, and a demo video showing your project in action. Be thorough \u2014 judges review your Devpost submission.", + "url": "/project-info#submission" + }, + { + "type": "faq", + "title": "FAQ: When is the submission deadline?", + "content": "The Devpost submission deadline is 11:00 AM on the second day of the hackathon (Sunday). All project submissions must be completed on Devpost before this time. Late submissions will not be considered for judging.", + "url": "/project-info#submission" + }, + { + "type": "faq", + "title": "FAQ: How are projects judged?", + "content": "Projects are judged using a rubric with four components: 60% Track-Specific criteria (based on the prize tracks you chose), 20% Social Good (how well the project benefits society), 10% Creativity (originality), and 10% Presentation. During demo time (12:00-2:00 PM), judges visit your table \u2014 each of the first 3 judges gets 3 minutes of demo time and 3 minutes of Q&A. Panels of judges then select winners from the top 5 shortlisted projects per track during the break.", + "url": "/project-info#judging" + }, + { + "type": "faq", + "title": "FAQ: Why might some teams get more judges than others?", + "content": "Some tracks are judged by MLH (Major League Hacking), partner NPOs (Non-Profit Organizations), or sponsors who select their own winners. If your team has selected those tracks, you may receive additional judges. If your team has not chosen those tracks, you will be judged by the standard three judges. Extra judges from MLH, NPO, or sponsor groups do not affect your chances in other tracks. Having more than three judges does not give an advantage or disadvantage.", + "url": "/project-info#judging" + }, + { + "type": "faq", + "title": "FAQ: What happens if my team is not at the table when a judge arrives?", + "content": "If your team is not at your table when a judge arrives, they will mark you as missing and move on. Your team will be placed at the end of that judge's queue. An announcement will be made and a HackDavis director will look for you. Teams that are repeatedly missing or unreachable will be removed from the judging process. Please be at your table during the entire demo time (12:00-2:00 PM) to avoid this.", + "url": "/project-info#judging" + }, + { + "type": "faq", + "title": "FAQ: What is the Hacker's Choice Award?", + "content": "The Hacker's Choice Award is voted on by fellow hackers \u2014 not judges. After demo time ends (during the 2:00-3:00 PM break), you can visit other teams and vote for the project you think deserves this award. All entries are automatically considered. You can vote for any project but your own. The winner receives a HackDavis Swag Bag. The voting form link will be available on the Hub when voting opens.", + "url": "/project-info#judging" + }, + { + "type": "faq", + "title": "FAQ: What is my team number vs. table number?", + "content": "These are two different numbers. Your team number is assigned by Devpost and will be available on Devpost after your submission. Every team member must enter their team number on the HackerHub homepage. Your table number is assigned by HackerHub and tells you where to sit during demo time. Use your table number (NOT your team number) to find your demo table. If you and your teammates are seeing different table numbers, contact a HackDavis director.", + "url": "/project-info#judging" + }, + { + "type": "faq", + "title": "FAQ: What if my team wins a prize but we're not at the closing ceremony?", + "content": "If your team wins a prize and is not at the venue during the closing ceremony, HackDavis will contact you via email after the event to arrange delivery of your prize. If your team needs to leave before or during the closing ceremony, please inform someone at the Director Table.", + "url": "/project-info#judging" + }, + { + "type": "faq", + "title": "FAQ: I'm a beginner \u2014 where do I start?", + "content": "Welcome! HackDavis has a Starter Kit specifically for beginners. It includes resources, past winning hacks, and tips to get your project started. You can also attend the Hackathons 101 workshop, reach out to mentors on Discord (https://discord.gg/wc6QQEc), and join the #find-a-team channel if you need teammates. Start by brainstorming: what problem are you solving, who are your users, and what makes your solution unique?", + "url": "/starter-kit#lets-begin" + }, + { + "type": "faq", + "title": "FAQ: How do I find a team?", + "content": "If you are looking for a team or need additional teammates, use the #find-a-team channel on the HackDavis Discord server (https://discord.gg/wc6QQEc). Post an introduction with your name, major, year, skills, experience level, and what kind of project you want to work on. There is also a Team Mixer activity at the start of the event where you can meet other hackers in person.", + "url": "/starter-kit#find-a-team" + }, + { + "type": "faq", + "title": "FAQ: How do I contact a mentor or director?", + "content": "You can reach HackDavis mentors and directors through the Discord server at https://discord.gg/wc6QQEc. Use the Discord server to contact a mentor if you are stuck on a technical problem, or contact a director if you have event-related questions. During the event, you can also find directors at the Director Table on-site.", + "url": "/#mentor-director-help" + }, + { + "type": "faq", + "title": "FAQ: How many prize tracks can I enter?", + "content": "You can select up to 4 relevant prize tracks on your Devpost submission. Choose the tracks that best match your project. Some tracks (Best Hack for Social Good and Hacker's Choice Award) automatically include all entries \u2014 no opt-in required.", + "url": "/#prize-tracks" + }, + { + "type": "faq", + "title": "FAQ: Do I need to be a CS major to participate?", + "content": "No! HackDavis welcomes participants from all majors. In fact, there is a prize track specifically for teams with at least one non-CS member: Best Interdisciplinary Hack. There is also a Best Entrepreneurship Hack track that requires no code at all. You can contribute as a designer, researcher, business person, or domain expert.", + "url": "/#prize-tracks" + }, + { + "type": "general", + "title": "What Happens During the Hackathon (Overview)", + "content": "HackDavis has two main phases: the Submission Process and the Judging Process. During hacking, teams build their projects and submit them to Devpost before the 11:00 AM deadline on day two. The judging process follows: judges visit tables from 12:00-2:00 PM for demos, teams vote for the Hacker's Choice Award from 2:00-3:00 PM, and the closing ceremony with prize announcements runs from 3:00-4:00 PM. Use the HackerHub to track the schedule, your judge queue position, and submission status.", + "url": "/project-info" + }, + { + "type": "general", + "title": "Starter Kit: Let's Begin (Hacking 101)", + "content": "The 'Let's Begin' section of the Starter Kit covers the Hacking 101 Workshop. The workshop takes place on May 9th from 11:30 AM to 1:00 PM in ARC Ballroom A. A panel of experienced hackers will walk you through the hackathon process, how to get started with a project, and what to expect during the event. If you missed it, a recap of the workshop slides is also available in this section. This is the ideal first stop for anyone new to hackathons.", + "url": "/starter-kit#lets-begin" + }, + { + "type": "general", + "title": "Starter Kit: Find a Team", + "content": "The 'Find a Team' section of the Starter Kit helps you connect with teammates. There is a Team Formation Mixer on May 9th from 8:30 AM to 10:00 AM in ARC Ballroom A β€” a great in-person opportunity to find your team before hacking begins. If you still need teammates, use the #find-a-team channel on the HackDavis Discord server (https://discord.gg/wc6QQEc). Post your major, skills, experience level, and what kind of project you want to build. This section also includes a 'Find the Right Fit' guide to help you figure out your role on a team (developer, designer, PM, etc.).", + "url": "/starter-kit#find-a-team" + }, + { + "type": "general", + "title": "Starter Kit: Ideate (Brainstorming and Previous Hacks)", + "content": "The 'Ideate' section of the Starter Kit helps you come up with your project idea. It includes a Brainstorm guide with key questions to ask yourself: What problem are you solving? Who are your users and what are their pain points? What makes your solution unique? The section also showcases Previous Winning Hacks from past HackDavis events so you can see examples of successful projects for inspiration. Additionally, Mentor Resources are listed here β€” mentors can help you refine your idea and give technical guidance via Discord.", + "url": "/starter-kit#ideate" + }, + { + "type": "general", + "title": "Starter Kit: Resources (Developer, Designer, and Mentor Resources)", + "content": "The 'Resources' section of the Starter Kit is your toolkit for building your project. It includes: Developer Resources β€” APIs, libraries, frameworks, and tools relevant to building at HackDavis. Designer Resources β€” Figma templates, UI kits, color palettes, and design references. Mentor Resources β€” how to reach HackDavis mentors for technical help via Discord (https://discord.gg/wc6QQEc). A general 'You're Ready!' overview is also included with tips on making the most of your hackathon experience. This section is useful throughout the entire event.", + "url": "/starter-kit#resources" + } +] diff --git a/app/_types/hackbot.ts b/app/_types/hackbot.ts new file mode 100644 index 00000000..489a12a3 --- /dev/null +++ b/app/_types/hackbot.ts @@ -0,0 +1,84 @@ +// ── Shared Hackbot types ──────────────────────────────────────────────────── +// Pure type file β€” no 'use server'/'use client' directive. +// Safe to import from server actions, API routes, client components, and utils. + +// ── Knowledge doc types ───────────────────────────────────────────────────── + +export type HackDocType = + | 'event' + | 'track' + | 'judging' + | 'submission' + | 'faq' + | 'general'; + +export interface HackDoc { + id: string; + type: HackDocType; + title: string; + text: string; + url?: string; +} + +// ── Hacker profile ────────────────────────────────────────────────────────── + +export type HackerProfile = { + name?: string; + position?: string; + is_beginner?: boolean; +}; + +// ── Chat message types ────────────────────────────────────────────────────── + +export type HackbotMessageRole = 'user' | 'assistant' | 'system'; + +export interface HackbotMessage { + role: HackbotMessageRole; + content: string; +} + +export interface HackbotResponse { + ok: boolean; + answer: string; + url?: string; + error?: string; + usage?: { + chat?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + embeddings?: { + inputTokens?: number; + totalTokens?: number; + }; + }; +} + +// ── Widget types ──────────────────────────────────────────────────────────── + +export type HackbotEvent = { + id: string; + name: string; + type: string | null; + start: string | null; + end: string | null; + startMs: number | null; + location: string | null; + host: string | null; + tags: string[]; + isRecommended?: boolean; + /** True for GENERAL/MEALS β€” rendered as a compact inline row */ + compact?: boolean; +}; + +export type HackbotLink = { label: string; url: string }; + +export type HackbotChatMessage = { + role: 'user' | 'assistant'; + content: string; + /** @deprecated use links[] instead; kept for localStorage backwards-compat */ + url?: string; + links?: HackbotLink[]; + events?: HackbotEvent[]; +}; diff --git a/auth.ts b/auth.ts index 6839c29b..32c04658 100644 --- a/auth.ts +++ b/auth.ts @@ -9,8 +9,11 @@ import { GetManyUsers } from '@datalib/users/getUser'; declare module 'next-auth' { interface User { id?: string; + name?: string | null; email?: string | null; role: string; + position?: string; + is_beginner?: boolean; } interface Session extends DefaultSession { @@ -18,6 +21,8 @@ declare module 'next-auth' { id: string; email: string; role: string; + position?: string; + is_beginner?: boolean; } & DefaultSession['user']; } } @@ -27,6 +32,8 @@ declare module 'next-auth/jwt' { id: string; email: string; role: string; + position?: string; + is_beginner?: boolean; } } @@ -67,8 +74,11 @@ export const { auth, handlers, signIn, signOut } = NextAuth({ return { id: user._id, + name: user.name, email: user.email, role: user.role, + position: user.position, + is_beginner: user.is_beginner, }; } catch (error) { if (error instanceof z.ZodError) { @@ -84,15 +94,21 @@ export const { auth, handlers, signIn, signOut } = NextAuth({ async jwt({ token, user }) { if (user) { token.id = user.id ?? 'User ID not found'; + token.name = user.name; token.email = user.email ?? 'User email not found'; token.role = user.role; + token.position = user.position; + token.is_beginner = user.is_beginner; } return token; }, async session({ session, token }) { session.user.id = token.id; + session.user.name = token.name; session.user.email = token.email; session.user.role = token.role; + session.user.position = token.position; + session.user.is_beginner = token.is_beginner; return session; }, }, diff --git a/package-lock.json b/package-lock.json index 8465c54c..48039019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "hackdavis-hacker-hub", "version": "0.1.0", "dependencies": { + "@ai-sdk/openai": "^3.0.37", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@floating-ui/dom": "^1.7.6", @@ -23,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@szhsin/react-accordion": "^1.4.0", "@vercel/analytics": "^1.6.1", + "ai": "^6.0.105", "bcryptjs": "^2.4.3", "chart.js": "^4.4.9", "class-variance-authority": "^0.7.1", @@ -90,6 +92,68 @@ "typescript": "^5" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.59.tgz", + "integrity": "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.37", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.37.tgz", + "integrity": "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.16.tgz", + "integrity": "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -463,18 +527,18 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -614,12 +678,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -2004,13 +2068,13 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -3541,6 +3605,18 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -3560,9 +3636,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -4003,6 +4079,15 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -4979,6 +5064,23 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5099,6 +5201,14 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -5668,6 +5778,147 @@ } } }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -5713,6 +5964,24 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.105", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.105.tgz", + "integrity": "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.59", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5831,7 +6100,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -6115,7 +6384,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -7153,9 +7422,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/csv-parse": { @@ -7383,6 +7652,14 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -9054,6 +9331,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -9099,6 +9384,17 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -9122,6 +9418,14 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -9132,6 +9436,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -10447,6 +10760,17 @@ "node": ">=8" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11959,6 +12283,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -12155,6 +12485,14 @@ "node": ">=18" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12284,6 +12622,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -12674,9 +13023,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -13884,9 +14233,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -13903,7 +14252,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -15693,6 +16042,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "version": "5.49.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz", + "integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/synckit": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", @@ -16578,6 +16955,29 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -17033,10 +17433,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 6e34c147..43ac0a30 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "seed": "run-script-os", "seed:nix": "node --env-file='.env' \"scripts/dbSeed.mjs\"", "seed:windows": "node --env-file=\".\\.env\" \".\\scripts\\dbSeed.mjs\"", + "hackbot:seed": "node --env-file='.env' scripts/hackbotSeedCI.mjs", "test": "run-script-os", "test:nix": "echo 'module.exports = { presets: [[\"@babel/preset-env\"]] };' > babel.config.js && npx jest --detectOpenHandles && rm babel.config.js", "test:windows": "echo module.exports = { presets: [['@babel/preset-env']] }; > babel.config.js && npx jest --detectOpenHandles && rm babel.config.js", "lint": "next lint" }, "dependencies": { + "@ai-sdk/openai": "^3.0.37", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@floating-ui/dom": "^1.7.6", @@ -34,6 +36,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@szhsin/react-accordion": "^1.4.0", "@vercel/analytics": "^1.6.1", + "ai": "^6.0.105", "bcryptjs": "^2.4.3", "chart.js": "^4.4.9", "class-variance-authority": "^0.7.1", diff --git a/scripts/hackbotSeed.mjs b/scripts/hackbotSeed.mjs new file mode 100644 index 00000000..d699acc7 --- /dev/null +++ b/scripts/hackbotSeed.mjs @@ -0,0 +1,157 @@ +import { getClient } from '../app/(api)/_utils/mongodb/mongoClient.mjs'; +import readline from 'readline'; + +const HACKBOT_COLLECTION = 'hackbot_docs'; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const OPENAI_EMBEDDING_MODEL = + process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small'; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + +function askQuestion(question) { + return new Promise((resolve) => { + rl.question(question, (answer) => { + resolve(answer); + }); + }); +} + +async function embedText(text) { + const response = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_EMBEDDING_MODEL, + input: text, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI embeddings error: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return data.data[0].embedding; +} + +async function seedHackbotDocs({ wipe }) { + if (!OPENAI_API_KEY) { + console.error( + 'OPENAI_API_KEY is not set. Run with: node --env-file=".env" scripts/hackbotSeed.mjs' + ); + return; + } + + const client = await getClient(); + try { + await client.connect(); + } catch (err) { + console.error( + 'MongoDB connection failed. Check MONGODB_URI.\n' + `Details: ${err.message}` + ); + return; + } + + const db = client.db(); + const collection = db.collection(HACKBOT_COLLECTION); + + if (wipe === 'y') { + // Only wipe knowledge docs β€” event docs are served live via tool calls + await collection.deleteMany({ _id: { $regex: '^knowledge-' } }); + console.log(`Wiped knowledge docs from: ${HACKBOT_COLLECTION}`); + } + + // Load knowledge docs from hackbot_knowledge collection + let knowledgeDocs = []; + try { + knowledgeDocs = await db.collection('hackbot_knowledge').find({}).toArray(); + console.log(`Loaded ${knowledgeDocs.length} knowledge docs from database`); + } catch (err) { + console.warn('Failed to load knowledge docs:', err.message); + } + + if (knowledgeDocs.length === 0) { + console.warn( + 'No knowledge docs found. Add docs via the admin panel at /admin/hackbot first.' + ); + await client.close(); + return; + } + + const docs = knowledgeDocs.map((doc) => ({ + _id: `knowledge-${String(doc._id)}`, + type: doc.type, + title: doc.title, + text: doc.content, + url: doc.url || null, + })); + + console.log(`Preparing to embed and upsert ${docs.length} knowledge docs...`); + console.log(`Using OpenAI model: ${OPENAI_EMBEDDING_MODEL}`); + + let successCount = 0; + for (const doc of docs) { + try { + const embedding = await embedText(doc.text); + + await collection.updateOne( + { _id: doc._id }, + { + $set: { + type: doc.type, + title: doc.title, + text: doc.text, + url: doc.url || null, + embedding, + }, + }, + { upsert: true } + ); + + successCount += 1; + console.log(`Upserted doc ${doc._id}`); + } catch (err) { + console.error(`Failed to upsert doc ${doc._id}:`, err.message); + } + } + + console.log( + `Done. Successfully upserted ${successCount}/${docs.length} docs.` + ); + + await client.close(); +} + +async function gatherInputAndRun() { + try { + let wipe = ''; + while (wipe !== 'y' && wipe !== 'n') { + // eslint-disable-next-line no-await-in-loop + wipe = ( + await askQuestion( + `Seed "${HACKBOT_COLLECTION}" from hackbot_knowledge collection. Wipe existing knowledge docs first? (y/n): ` + ) + ).toLowerCase(); + if (wipe !== 'y' && wipe !== 'n') { + console.log('Please enter either "y" or "n".'); + } + } + + rl.close(); + + await seedHackbotDocs({ wipe }); + } catch (err) { + console.error('Error while seeding hackbot docs:', err); + rl.close(); + } +} + +// Run when invoked via `node --env-file=".env" scripts/hackbotSeed.mjs` +await gatherInputAndRun(); diff --git a/scripts/hackbotSeedCI.mjs b/scripts/hackbotSeedCI.mjs new file mode 100644 index 00000000..b9a1543c --- /dev/null +++ b/scripts/hackbotSeedCI.mjs @@ -0,0 +1,137 @@ +import { getClient } from '../app/(api)/_utils/mongodb/mongoClient.mjs'; + +const HACKBOT_COLLECTION = 'hackbot_docs'; +const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +const OPENAI_EMBEDDING_MODEL = + process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small'; + +async function embedText(text) { + const response = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: OPENAI_EMBEDDING_MODEL, + input: text, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI embeddings error: ${response.status} ${errorText}`); + } + + const data = await response.json(); + return data.data[0].embedding; +} + +async function seedHackbotDocs() { + if (!OPENAI_API_KEY) { + console.error('[hackbotSeedCI] OPENAI_API_KEY is not set'); + process.exit(1); + } + + console.log('[hackbotSeedCI] Starting seeding process...'); + console.log(`[hackbotSeedCI] Using OpenAI model: ${OPENAI_EMBEDDING_MODEL}`); + + const client = await getClient(); + + try { + await client.connect(); + console.log('[hackbotSeedCI] Connected to MongoDB'); + } catch (err) { + console.error('[hackbotSeedCI] MongoDB connection failed:', err.message); + process.exit(1); + } + + const db = client.db(); + const collection = db.collection(HACKBOT_COLLECTION); + + // Wipe only knowledge docs (not event docs β€” events are served live via tool calls) + await collection.deleteMany({ _id: { $regex: '^knowledge-' } }); + console.log(`[hackbotSeedCI] Wiped knowledge docs from: ${HACKBOT_COLLECTION}`); + + // Load knowledge docs from hackbot_knowledge collection + let knowledgeDocs = []; + try { + knowledgeDocs = await db.collection('hackbot_knowledge').find({}).toArray(); + console.log( + `[hackbotSeedCI] Loaded ${knowledgeDocs.length} knowledge docs from database` + ); + } catch (err) { + console.warn( + '[hackbotSeedCI] Failed to load knowledge docs:', + err.message + ); + } + + if (knowledgeDocs.length === 0) { + console.warn( + '[hackbotSeedCI] No knowledge docs to seed. Add docs via the admin panel at /admin/hackbot.' + ); + await client.close(); + return; + } + + const docs = knowledgeDocs.map((doc) => ({ + _id: `knowledge-${String(doc._id)}`, + type: doc.type, + title: doc.title, + text: doc.content, + url: doc.url || null, + })); + + console.log( + `[hackbotSeedCI] Preparing to embed and upsert ${docs.length} docs` + ); + + let successCount = 0; + for (const doc of docs) { + try { + const embedding = await embedText(doc.text); + + await collection.updateOne( + { _id: doc._id }, + { + $set: { + type: doc.type, + title: doc.title, + text: doc.text, + url: doc.url || null, + embedding, + }, + }, + { upsert: true } + ); + + successCount += 1; + console.log(`[hackbotSeedCI] Upserted doc ${doc._id}`); + } catch (err) { + console.error( + `[hackbotSeedCI] Failed to upsert doc ${doc._id}:`, + err.message + ); + } + } + + console.log( + `[hackbotSeedCI] Done. Successfully upserted ${successCount}/${docs.length} docs.` + ); + + await client.close(); + + if (successCount < docs.length) { + console.error( + `[hackbotSeedCI] Some docs failed. Success: ${successCount}/${docs.length}` + ); + process.exit(1); + } +} + +// Run +seedHackbotDocs().catch((err) => { + console.error('[hackbotSeedCI] Fatal error:', err); + process.exit(1); +}); diff --git a/tailwind.config.ts b/tailwind.config.ts index 5b2a30cd..418a22c9 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -122,11 +122,16 @@ const config: Config = { transform: 'translateX(-75%)', }, }, + 'hackbot-slide-in': { + from: { opacity: '0', transform: 'translateY(8px)' }, + to: { opacity: '1', transform: 'translateY(0)' }, + }, }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', moveClouds: 'moveClouds 30s linear infinite', + 'hackbot-slide-in': 'hackbot-slide-in 0.28s ease-out both', }, fontFamily: { jakarta: ['var(--font-jakarta)'],