diff --git a/.github/workflows/production.yaml b/.github/workflows/production.yaml index 19823802..17e42a2f 100644 --- a/.github/workflows/production.yaml +++ b/.github/workflows/production.yaml @@ -5,7 +5,27 @@ on: types: [published] jobs: + check_hackbot_knowledge: + name: Check if hackbot_knowledge.json changed + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.filter.outputs.hackbot_knowledge }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check file changes + uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + hackbot_knowledge: + - 'app/_data/hackbot_knowledge.json' + deploy_staging: + needs: check_hackbot_knowledge name: Deploy Production runs-on: ubuntu-latest environment: @@ -29,6 +49,15 @@ jobs: env: MONGODB_URI: ${{ secrets.MONGODB_URI }} + - name: Seed hackbot documentation + if: needs.check_hackbot_knowledge.outputs.changed == 'true' + run: npm run hackbot:seed + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_EMBEDDING_MODEL: ${{ vars.GOOGLE_EMBEDDING_MODEL }} + HACKBOT_MODE: google + - name: Lint and build code, then publish to Vercel run: npx vercel --token ${{ secrets.VERCEL_TOKEN }} -n ${{ vars.VERCEL_PROJECT }} --yes --prod env: @@ -41,6 +70,11 @@ jobs: SENDER_EMAIL: ${{ vars.SENDER_EMAIL }} SENDER_PWD: ${{ secrets.SENDER_PWD }} CHECK_IN_CODE: ${{ secrets.CHECK_IN_CODE }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_MODEL: ${{ vars.GOOGLE_MODEL }} + GOOGLE_EMBEDDING_MODEL: ${{ vars.GOOGLE_EMBEDDING_MODEL }} + GOOGLE_MAX_TOKENS: ${{ vars.GOOGLE_MAX_TOKENS }} + HACKBOT_MODE: google - name: Success run: echo "πŸš€ Deploy successful - BLAST OFF WOO! (woot woot) !!! πŸ• πŸ• πŸ• πŸš€ " \ No newline at end of file diff --git a/.github/workflows/staging.yaml b/.github/workflows/staging.yaml index e201a8f5..7a354917 100644 --- a/.github/workflows/staging.yaml +++ b/.github/workflows/staging.yaml @@ -7,7 +7,27 @@ on: workflow_dispatch: jobs: + check_hackbot_knowledge: + name: Check if hackbot_knowledge.json changed + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.filter.outputs.hackbot_knowledge }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Check file changes + uses: dorny/paths-filter@v2 + id: filter + with: + filters: | + hackbot_knowledge: + - 'app/_data/hackbot_knowledge.json' + deploy_staging: + needs: check_hackbot_knowledge name: Deploy Staging runs-on: ubuntu-latest environment: @@ -31,6 +51,15 @@ jobs: env: MONGODB_URI: ${{ secrets.MONGODB_URI }} + - name: Seed hackbot documentation + if: needs.check_hackbot_knowledge.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' + run: npm run hackbot:seed + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_EMBEDDING_MODEL: ${{ vars.GOOGLE_EMBEDDING_MODEL }} + HACKBOT_MODE: google + - name: Lint and build code, then publish to Vercel run: npx vercel --debug --token ${{ secrets.VERCEL_TOKEN }} -n ${{ vars.VERCEL_PROJECT }} --yes --prod env: @@ -43,6 +72,11 @@ jobs: SENDER_EMAIL: ${{ vars.SENDER_EMAIL }} SENDER_PWD: ${{ secrets.SENDER_PWD }} CHECK_IN_CODE: ${{ secrets.CHECK_IN_CODE }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + GOOGLE_MODEL: ${{ vars.GOOGLE_MODEL }} + GOOGLE_EMBEDDING_MODEL: ${{ vars.GOOGLE_EMBEDDING_MODEL }} + GOOGLE_MAX_TOKENS: ${{ vars.GOOGLE_MAX_TOKENS }} + HACKBOT_MODE: google - name: Success run: echo "πŸš€ Deploy successful - BLAST OFF WOO! (woot woot) !!! πŸ• πŸ• πŸ• πŸš€ " diff --git a/app/(api)/_actions/hackbot/askHackbot.ts b/app/(api)/_actions/hackbot/askHackbot.ts new file mode 100644 index 00000000..8c8210e5 --- /dev/null +++ b/app/(api)/_actions/hackbot/askHackbot.ts @@ -0,0 +1,509 @@ +import { retrieveContext } from '@datalib/hackbot/getHackbotContext'; + +export type HackbotMessageRole = 'user' | 'assistant' | 'system'; + +interface RetryOptions { + maxAttempts: number; + delayMs: number; + backoffMultiplier: number; + retryableErrors?: string[]; +} + +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 || // Rate limit + err.status === 500 || // Server error + err.status === 502 || // Bad gateway + err.status === 503 || // Service unavailable + err.status === 504; // Gateway timeout + + 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; +} + +export interface HackbotMessage { + role: HackbotMessageRole; + content: string; +} + +export interface HackbotResponse { + ok: boolean; + answer: string; + url?: string; + error?: string; + usage?: { + chat?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; + embeddings?: { + promptTokens?: number; + totalTokens?: number; + }; + }; +} + +const MAX_USER_MESSAGE_CHARS = 200; +const MAX_HISTORY_MESSAGES = 10; +const MAX_ANSWER_WORDS = 180; + +function parseIsoToMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +function truncateToWords(text: string, maxWords: number): string { + const words = text.trim().split(/\s+/); + if (words.length <= maxWords) return text.trim(); + return words.slice(0, maxWords).join(' ') + '...'; +} + +function stripExternalDomains(text: string): string { + // Replace absolute URLs like https://hackdavis.io/path with just /path. + return text.replace(/https?:\/\/[^\s)]+(\/[\w#/?=&.-]*)/g, '$1'); +} + +export async function askHackbot( + messages: HackbotMessage[] +): Promise { + if (!messages.length) { + return { ok: false, answer: '', error: 'No messages provided.' }; + } + + const last = messages[messages.length - 1]; + + if (last.role !== 'user') { + return { ok: false, answer: '', error: 'Last message must be from user.' }; + } + + if (last.content.length > MAX_USER_MESSAGE_CHARS) { + return { + ok: false, + answer: '', + error: `Message too long. Please keep it under ${MAX_USER_MESSAGE_CHARS} characters.`, + }; + } + + const trimmedHistory = messages.slice(-MAX_HISTORY_MESSAGES); + + let docs; + let embeddingsUsage: + | { + promptTokens?: number; + totalTokens?: number; + } + | undefined; + try { + // Use adaptive context retrieval - limit determined by query complexity + ({ docs, usage: embeddingsUsage } = await retrieveContext(last.content)); + } catch (e) { + console.error('Hackbot context retrieval error', e); + return { + ok: false, + answer: '', + error: + 'HackDavis Helper search backend is not configured (vector search unavailable). Please contact an organizer.', + }; + } + + if (!docs || docs.length === 0) { + return { + ok: false, + answer: '', + error: + 'HackDavis Helper could not find any context documents in its vector index. Please contact an organizer.', + }; + } + + // Present event context in chronological order so the model doesn't + // β€œpick a few” out of order when asked for itinerary/timeline questions. + const sortedDocs = (() => { + const eventDocs = docs.filter((d: any) => d.type === 'event'); + const otherDocs = docs.filter((d: any) => d.type !== 'event'); + + eventDocs.sort((a: any, b: any) => { + const aMs = parseIsoToMs(a.startISO); + const bMs = parseIsoToMs(b.startISO); + + if (aMs === null && bMs === null) return 0; + if (aMs === null) return 1; + if (bMs === null) return -1; + return aMs - bMs; + }); + + return [...eventDocs, ...otherDocs]; + })(); + + const primaryUrl = + sortedDocs.find((d) => d.type === 'event' && d.url)?.url ?? + sortedDocs.find((d) => d.url)?.url; + + const contextSummary = sortedDocs + .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'); + + const systemPrompt = + 'You are HackDavis Helper ("Hacky"), an AI assistant for the HackDavis hackathon. ' + + // CRITICAL CONSTRAINTS + 'CRITICAL: Your response MUST be under 200 tokens (~150 words). Be extremely concise. ' + + 'CRITICAL: Only answer questions about HackDavis. Refuse unrelated topics politely. ' + + 'CRITICAL: Only use facts from the provided context. Never invent times, dates, or locations. ' + + // PERSONALITY + 'You are friendly, helpful, and conversational. Use contractions ("you\'re", "it\'s") and avoid robotic phrasing. ' + + // HANDLING GREETINGS + 'For simple greetings ("hi", "hello"), respond warmly: "Hi, I\'m Hacky! I can help with questions about HackDavis." Keep it brief (1 sentence). ' + + // HANDLING QUESTIONS + 'For questions about HackDavis: ' + + '1. First, silently identify the most relevant context document by matching key terms to document titles. ' + + '2. If multiple documents seem relevant (e.g., similar event names), ask ONE short clarifying question instead of guessing. ' + + '3. Answer directly in 2-3 sentences using only context facts. ' + + '4. For time/location questions: Use only explicit times and locations from context. If both start and end times exist, provide the full range ("3:00 PM to 4:00 PM"). ' + + '5. For schedule/timeline questions: Format as a bullet list, ordered chronologically. Include only items from context. ' + + // WHAT NOT TO DO + 'Do NOT: ' + + '- Invent times, dates, locations, or URLs not in context. ' + + '- Include URLs in your answer text (UI shows separate "More info" link). ' + + '- Use generic hackathon knowledge; only use provided context. ' + + '- Answer coding, homework, or general knowledge questions. ' + + '- Say "based on the context" or "according to the documents" (just answer directly). ' + + // HANDLING UNKNOWNS + 'If you cannot find an answer in context, say: "I don\'t have that information. Please ask an organizer or check the HackDavis website." ' + + // REFUSING UNRELATED QUESTIONS + 'For unrelated questions (not about HackDavis), say: "Sorry, I can only answer questions about HackDavis. Do you have any questions about the event?"'; + + // Few-shot examples to demonstrate desired format + const fewShotExamples = [ + { + role: 'user' as const, + content: 'When does hacking end?', + }, + { + role: 'assistant' as const, + content: 'Hacking ends on Sunday, April 20 at 11:00 AM Pacific Time.', + }, + { + role: 'user' as const, + content: 'What workshops are available?', + }, + { + role: 'assistant' as const, + content: + 'We have several workshops including:\nβ€’ Hackathons 101 (Sat 11:40 AM)\nβ€’ Getting Started with Git & GitHub (Sat 1:10 PM)\nβ€’ Intro to UI/UX (Sat 4:10 PM)\nβ€’ Hacking with LLMs (Sat 2:10 PM)', + }, + ]; + + // Prepare messages for the chat model + const chatMessages = [ + { role: 'system', content: systemPrompt }, + ...fewShotExamples, + { + role: 'system', + content: `Context documents about HackDavis (use these to answer):\n\n${contextSummary}`, + }, + ...trimmedHistory.map((m) => ({ + role: m.role, + content: m.content, + })), + ]; + + const mode = process.env.HACKBOT_MODE || 'google'; + + if (mode === 'google') { + // Google AI implementation with Vercel AI SDK + try { + const { generateText } = await import('ai'); + const { google } = await import('@ai-sdk/google'); + + const startedAt = Date.now(); + const model = process.env.GOOGLE_MODEL || 'gemini-1.5-flash'; + const maxTokens = parseInt(process.env.GOOGLE_MAX_TOKENS || '200', 10); + + const { text, usage } = await retryWithBackoff( + () => + generateText({ + model: google(model), + messages: chatMessages.map((m) => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content, + })), + maxTokens, + }), + { + maxAttempts: 2, + delayMs: 2000, + backoffMultiplier: 2, + } + ); + + console.log('[hackbot][google][chat]', { + model, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + ms: Date.now() - startedAt, + }); + + const answer = truncateToWords( + stripExternalDomains(text), + MAX_ANSWER_WORDS + ); + + return { + ok: true, + answer, + url: primaryUrl, + usage: { + chat: { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + }, + embeddings: embeddingsUsage, + }, + }; + } catch (e: any) { + console.error('[hackbot][google] Error', e); + + // Differentiate error types for better UX + if (e.status === 429) { + return { + ok: false, + answer: '', + error: 'Too many requests. Please wait a moment and try again.', + }; + } + + if ( + e.message?.includes('ECONNREFUSED') || + e.message?.includes('ETIMEDOUT') + ) { + return { + ok: false, + answer: '', + error: + 'Cannot reach AI service. Please check your connection or try again later.', + }; + } + + if (e.status === 401 || e.message?.includes('API key')) { + return { + ok: false, + answer: '', + error: + 'AI service configuration error. Please contact an organizer.', + }; + } + + return { + ok: false, + answer: '', + error: 'Something went wrong. Please try again in a moment.', + }; + } + } else if (mode === 'openai') { + // OpenAI implementation with Vercel AI SDK + try { + const { generateText } = await import('ai'); + const { openai } = await import('@ai-sdk/openai'); + + const startedAt = Date.now(); + const model = process.env.OPENAI_MODEL || 'gpt-4o'; + const maxTokens = parseInt(process.env.OPENAI_MAX_TOKENS || '200', 10); + + const { text, usage } = await retryWithBackoff( + () => + generateText({ + model: openai(model), + messages: chatMessages.map((m) => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content, + })), + maxTokens, + }), + { + maxAttempts: 2, + delayMs: 2000, + backoffMultiplier: 2, + } + ); + + console.log('[hackbot][openai][chat]', { + model, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + ms: Date.now() - startedAt, + }); + + const answer = truncateToWords( + stripExternalDomains(text), + MAX_ANSWER_WORDS + ); + + return { + ok: true, + answer, + url: primaryUrl, + usage: { + chat: { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + }, + embeddings: embeddingsUsage, + }, + }; + } catch (e: any) { + console.error('[hackbot][openai] Error', e); + + // Differentiate error types for better UX + if (e.status === 429) { + return { + ok: false, + answer: '', + error: 'Too many requests. Please wait a moment and try again.', + }; + } + + if ( + e.message?.includes('ECONNREFUSED') || + e.message?.includes('ETIMEDOUT') + ) { + return { + ok: false, + answer: '', + error: + 'Cannot reach AI service. Please check your connection or try again later.', + }; + } + + if (e.status === 401 || e.message?.includes('API key')) { + return { + ok: false, + answer: '', + error: + 'AI service configuration error. Please contact an organizer.', + }; + } + + return { + ok: false, + answer: '', + error: 'Something went wrong. Please try again in a moment.', + }; + } + } else { + // Ollama implementation (local dev fallback) + const ollamaUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + + try { + const startedAt = Date.now(); + const response = await fetch(`${ollamaUrl}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'llama3.2', + messages: chatMessages, + stream: false, + }), + }); + + if (!response.ok) { + return { + ok: false, + answer: '', + error: 'Upstream model error. Please try again later.', + }; + } + + const data = await response.json(); + const rawAnswer: string = data?.message?.content?.toString() ?? ''; + + const promptTokens = + typeof data?.prompt_eval_count === 'number' + ? data.prompt_eval_count + : undefined; + const completionTokens = + typeof data?.eval_count === 'number' ? data.eval_count : undefined; + const totalTokens = + typeof promptTokens === 'number' && typeof completionTokens === 'number' + ? promptTokens + completionTokens + : undefined; + + console.log('[hackbot][ollama][chat]', { + model: data?.model ?? 'unknown', + promptTokens, + completionTokens, + totalTokens, + ms: Date.now() - startedAt, + }); + + const answer = truncateToWords( + stripExternalDomains(rawAnswer), + MAX_ANSWER_WORDS + ); + + return { + ok: true, + answer, + url: primaryUrl, + usage: { + chat: { + promptTokens, + completionTokens, + totalTokens, + }, + embeddings: embeddingsUsage, + }, + }; + } catch (e) { + console.error('[hackbot][ollama] Error', e); + return { + ok: false, + answer: '', + error: 'Something went wrong. Please try again later.', + }; + } + } +} diff --git a/app/(api)/_datalib/hackbot/getHackbotContext.ts b/app/(api)/_datalib/hackbot/getHackbotContext.ts new file mode 100644 index 00000000..8af3579f --- /dev/null +++ b/app/(api)/_datalib/hackbot/getHackbotContext.ts @@ -0,0 +1,463 @@ +import { HackDoc, HackDocType } from './hackbotTypes'; +import { getDatabase } from '@utils/mongodb/mongoClient.mjs'; +import { ObjectId } from 'mongodb'; + +export interface RetrievedContext { + docs: HackDoc[]; + usage?: { + promptTokens?: number; + totalTokens?: number; + }; +} + +interface QueryComplexity { + type: 'simple' | 'moderate' | 'complex'; + docLimit: number; + reason: string; +} + +interface RetryOptions { + maxAttempts: number; + delayMs: number; + backoffMultiplier: number; + retryableErrors?: string[]; +} + +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 || // Rate limit + err.status === 500 || // Server error + err.status === 502 || // Bad gateway + err.status === 503 || // Service unavailable + err.status === 504; // Gateway timeout + + 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; +} + +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' }; +} + +function formatEventDateTime(raw: unknown): string | 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); + } + + if (!date || Number.isNaN(date.getTime())) return null; + + return date.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatLiveEventDoc(event: any): { + title: string; + text: string; + url: string; + startISO?: string; + endISO?: string; +} { + const title = String(event?.name || 'Event'); + const type = event?.type ? String(event.type) : ''; + const start = formatEventDateTime(event?.start_time); + const end = formatEventDateTime(event?.end_time); + const location = event?.location ? String(event.location) : ''; + const host = event?.host ? String(event.host) : ''; + const tags = Array.isArray(event?.tags) ? event.tags.map(String) : []; + + const parts = [ + `Event: ${title}`, + type ? `Type: ${type}` : '', + // Machine-readable anchors to allow reliable chronological ordering. + event?.start_time instanceof Date + ? `StartISO: ${event.start_time.toISOString()}` + : '', + event?.end_time instanceof Date + ? `EndISO: ${event.end_time.toISOString()}` + : '', + start ? `Starts (Pacific Time): ${start}` : '', + end ? `Ends (Pacific Time): ${end}` : '', + location ? `Location: ${location}` : '', + host ? `Host: ${host}` : '', + tags.length ? `Tags: ${tags.join(', ')}` : '', + ].filter(Boolean); + + return { + title, + text: parts.join('\n'), + url: '/hackers/hub/schedule', + startISO: + event?.start_time instanceof Date + ? event.start_time.toISOString() + : undefined, + endISO: + event?.end_time instanceof Date + ? event.end_time.toISOString() + : undefined, + }; +} + +async function getGoogleEmbedding(query: string): Promise<{ + embedding: number[]; + usage?: { + promptTokens?: number; + totalTokens?: number; + }; +} | null> { + try { + const { embed } = await import('ai'); + const { google } = await import('@ai-sdk/google'); + + const startedAt = Date.now(); + const model = process.env.GOOGLE_EMBEDDING_MODEL || 'text-embedding-004'; + + const { embedding, usage } = await embed({ + model: google.textEmbeddingModel(model), + value: query, + }); + + console.log('[hackbot][google][embeddings]', { + model, + tokens: usage.tokens, + ms: Date.now() - startedAt, + }); + + return { + embedding, + usage: { + promptTokens: usage.tokens, + totalTokens: usage.tokens, + }, + }; + } catch (err) { + console.error('[hackbot][embeddings][google] Failed', err); + return null; + } +} + +async function getOpenAIEmbedding(query: string): Promise<{ + embedding: number[]; + usage?: { + promptTokens?: number; + totalTokens?: number; + }; +} | null> { + try { + const { embed } = await import('ai'); + const { openai } = await import('@ai-sdk/openai'); + + const startedAt = Date.now(); + const model = + process.env.OPENAI_EMBEDDING_MODEL || 'text-embedding-3-small'; + + const { embedding, usage } = await embed({ + model: openai.embedding(model), + value: query, + }); + + console.log('[hackbot][openai][embeddings]', { + model, + tokens: usage.tokens, + ms: Date.now() - startedAt, + }); + + return { + embedding, + usage: { + promptTokens: usage.tokens, + totalTokens: usage.tokens, + }, + }; + } catch (err) { + console.error('[hackbot][embeddings][openai] Failed', err); + return null; + } +} + +async function getOllamaEmbedding(query: string): Promise<{ + embedding: number[]; + usage?: { + promptTokens?: number; + totalTokens?: number; + }; +} | null> { + const ollamaUrl = process.env.OLLAMA_URL || 'http://localhost:11434'; + + try { + const startedAt = Date.now(); + const res = await fetch(`${ollamaUrl}/api/embeddings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'llama3.2', prompt: query }), + }); + + if (!res.ok) { + console.error( + '[hackbot][embeddings] Upstream error', + res.status, + res.statusText + ); + return null; + } + + const data = await res.json(); + if (!data || !Array.isArray(data.embedding)) { + console.error('[hackbot][embeddings] Invalid response shape'); + return null; + } + + const promptTokens = + typeof data?.prompt_eval_count === 'number' + ? data.prompt_eval_count + : undefined; + const totalTokens = + typeof data?.eval_count === 'number' + ? data.eval_count + : typeof promptTokens === 'number' + ? promptTokens + : undefined; + + console.log('[hackbot][ollama][embeddings]', { + model: data?.model ?? 'unknown', + promptTokens, + totalTokens, + ms: Date.now() - startedAt, + }); + + return { + embedding: data.embedding as number[], + usage: { + promptTokens, + totalTokens, + }, + }; + } catch (err) { + console.error('[hackbot][embeddings][ollama] Failed', err); + return null; + } +} + +async function getQueryEmbedding(query: string): Promise<{ + embedding: number[]; + usage?: { + promptTokens?: number; + totalTokens?: number; + }; +} | null> { + const mode = process.env.HACKBOT_MODE || 'google'; + + if (mode === 'google') { + return getGoogleEmbedding(query); + } else if (mode === 'openai') { + return getOpenAIEmbedding(query); + } else { + return getOllamaEmbedding(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, + }); + } + + // Vector-only search over hackbot_docs in MongoDB. + try { + const embeddingResult = await retryWithBackoff( + () => getQueryEmbedding(trimmed), + { + maxAttempts: 3, + delayMs: 1000, + backoffMultiplier: 2, + } + ); + + if (!embeddingResult) { + console.error( + '[hackbot][retrieve] No embedding available for query; vector search required.' + ); + throw new Error('Embedding unavailable after retries'); + } + + const embedding = embeddingResult.embedding; + + 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.'); + 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, + })); + + // Hydrate event docs from the live `events` collection so the answer + // always reflects the current schedule (times/locations), even if the + // vector index was seeded earlier. + const eventsCollection = db.collection('events'); + await Promise.all( + docs.map(async (d) => { + if (d.type !== 'event') return; + + const suffix = d.id.startsWith('event-') + ? d.id.slice('event-'.length) + : ''; + let event: any | null = null; + + if (suffix && ObjectId.isValid(suffix)) { + event = await eventsCollection.findOne({ _id: new ObjectId(suffix) }); + } + + if (!event && d.title) { + event = await eventsCollection.findOne({ name: d.title }); + } + + if (!event) return; + + const live = formatLiveEventDoc(event); + d.title = live.title; + d.text = live.text; + d.url = live.url; + + // Attach sortable timestamps for server-side ordering. + (d as any).startISO = live.startISO; + (d as any).endISO = live.endISO; + }) + ); + + console.log('[hackbot][retrieve][vector]', { + query: trimmed, + docIds: docs.map((d) => d.id), + titles: docs.map((d) => d.title), + }); + + return { docs, usage: embeddingResult.usage }; + } catch (err) { + console.error( + '[hackbot][retrieve] Vector search failed (no fallback).', + err + ); + throw err; + } +} diff --git a/app/(api)/_datalib/hackbot/hackbotTypes.ts b/app/(api)/_datalib/hackbot/hackbotTypes.ts new file mode 100644 index 00000000..779f4633 --- /dev/null +++ b/app/(api)/_datalib/hackbot/hackbotTypes.ts @@ -0,0 +1,9 @@ +export type HackDocType = 'event' | 'track' | 'judging' | 'submission'; + +export interface HackDoc { + id: string; + type: HackDocType; + title: string; + text: string; + url?: string; +} diff --git a/app/(api)/api/hackbot/route.ts b/app/(api)/api/hackbot/route.ts new file mode 100644 index 00000000..355dd9a9 --- /dev/null +++ b/app/(api)/api/hackbot/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { askHackbot, HackbotMessage } from '@actions/hackbot/askHackbot'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const messages = (body?.messages ?? []) as HackbotMessage[]; + + if (!Array.isArray(messages) || messages.length === 0) { + return NextResponse.json( + { + ok: false, + answer: '', + error: 'Invalid request body. Expected { messages: [...] }.', + }, + { status: 400 } + ); + } + + const result = await askHackbot(messages); + + return NextResponse.json( + { + ok: result.ok, + answer: result.answer, + url: result.url, + usage: result.usage ?? null, + error: result.error ?? null, + }, + { status: result.ok ? 200 : 400 } + ); + } catch (e) { + return NextResponse.json( + { + ok: false, + answer: '', + error: 'Invalid JSON body.', + }, + { status: 400 } + ); + } +} diff --git a/app/(api)/api/hackbot/stream/route.ts b/app/(api)/api/hackbot/stream/route.ts new file mode 100644 index 00000000..db730fb9 --- /dev/null +++ b/app/(api)/api/hackbot/stream/route.ts @@ -0,0 +1,199 @@ +import { streamText } from 'ai'; +import { google } from '@ai-sdk/google'; +import { openai } from '@ai-sdk/openai'; +import { retrieveContext } from '@datalib/hackbot/getHackbotContext'; +import { HackbotMessage } from '@actions/hackbot/askHackbot'; + +const MAX_USER_MESSAGE_CHARS = 200; +const MAX_HISTORY_MESSAGES = 10; + +function parseIsoToMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} + +function stripExternalDomains(text: string): string { + return text.replace(/https?:\/\/[^\s)]+(\/[\w#/?=&.-]*)/g, '$1'); +} + +export async function POST(request: Request) { + try { + const { messages } = 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 } + ); + } + + // Retrieve context using adaptive retrieval + let docs; + try { + ({ docs } = await 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 } + ); + } + + if (!docs || docs.length === 0) { + return Response.json( + { error: 'No context found. Please contact an organizer.' }, + { status: 500 } + ); + } + + // Sort docs chronologically (events first) + const sortedDocs = (() => { + const eventDocs = docs.filter((d: any) => d.type === 'event'); + const otherDocs = docs.filter((d: any) => d.type !== 'event'); + + eventDocs.sort((a: any, b: any) => { + const aMs = parseIsoToMs(a.startISO); + const bMs = parseIsoToMs(b.startISO); + + if (aMs === null && bMs === null) return 0; + if (aMs === null) return 1; + if (bMs === null) return -1; + return aMs - bMs; + }); + + return [...eventDocs, ...otherDocs]; + })(); + + const contextSummary = sortedDocs + .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'); + + const systemPrompt = + 'You are HackDavis Helper ("Hacky"), an AI assistant for the HackDavis hackathon. ' + + 'CRITICAL: Your response MUST be under 200 tokens (~150 words). Be extremely concise. ' + + 'CRITICAL: Only answer questions about HackDavis. Refuse unrelated topics politely. ' + + 'CRITICAL: Only use facts from the provided context. Never invent times, dates, or locations. ' + + 'You are friendly, helpful, and conversational. Use contractions ("you\'re", "it\'s") and avoid robotic phrasing. ' + + 'For simple greetings ("hi", "hello"), respond warmly: "Hi, I\'m Hacky! I can help with questions about HackDavis." Keep it brief (1 sentence). ' + + 'For questions about HackDavis: ' + + '1. First, silently identify the most relevant context document by matching key terms to document titles. ' + + '2. If multiple documents seem relevant (e.g., similar event names), ask ONE short clarifying question instead of guessing. ' + + '3. Answer directly in 2-3 sentences using only context facts. ' + + '4. For time/location questions: Use only explicit times and locations from context. If both start and end times exist, provide the full range ("3:00 PM to 4:00 PM"). ' + + '5. For schedule/timeline questions: Format as a bullet list, ordered chronologically. Include only items from context. ' + + 'Do NOT: ' + + '- Invent times, dates, locations, or URLs not in context. ' + + '- Include URLs in your answer text (UI shows separate "More info" link). ' + + '- Use generic hackathon knowledge; only use provided context. ' + + '- Answer coding, homework, or general knowledge questions. ' + + '- Say "based on the context" or "according to the documents" (just answer directly). ' + + 'If you cannot find an answer in context, say: "I don\'t have that information. Please ask an organizer or check the HackDavis website." ' + + 'For unrelated questions (not about HackDavis), say: "Sorry, I can only answer questions about HackDavis. Do you have any questions about the event?"'; + + const fewShotExamples = [ + { + role: 'user' as const, + content: 'When does hacking end?', + }, + { + role: 'assistant' as const, + content: 'Hacking ends on Sunday, April 20 at 11:00 AM Pacific Time.', + }, + { + role: 'user' as const, + content: 'What workshops are available?', + }, + { + role: 'assistant' as const, + content: + 'We have several workshops including:\nβ€’ Hackathons 101 (Sat 11:40 AM)\nβ€’ Getting Started with Git & GitHub (Sat 1:10 PM)\nβ€’ Intro to UI/UX (Sat 4:10 PM)\nβ€’ Hacking with LLMs (Sat 2:10 PM)', + }, + ]; + + const chatMessages = [ + { role: 'system', content: systemPrompt }, + ...fewShotExamples, + { + role: 'system', + content: `Context documents about HackDavis (use these to answer):\n\n${contextSummary}`, + }, + ...messages.slice(-MAX_HISTORY_MESSAGES), + ]; + + // Stream response using Vercel AI SDK + const mode = process.env.HACKBOT_MODE || 'google'; + let result; + + if (mode === 'google') { + const model = process.env.GOOGLE_MODEL || 'gemini-1.5-flash'; + const maxTokens = parseInt(process.env.GOOGLE_MAX_TOKENS || '200', 10); + + result = streamText({ + model: google(model), + messages: chatMessages.map((m: any) => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content, + })), + maxTokens, + }); + } else { + // OpenAI fallback + const model = process.env.OPENAI_MODEL || 'gpt-4o'; + const maxTokens = parseInt(process.env.OPENAI_MAX_TOKENS || '200', 10); + + result = streamText({ + model: openai(model), + messages: chatMessages.map((m: any) => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content, + })), + maxTokens, + }); + } + + return result.toDataStreamResponse(); + } catch (error: any) { + console.error('[hackbot][stream] Error', error); + + // Differentiate error types + 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/(pages)/(hackers)/(hub)/layout.tsx b/app/(pages)/(hackers)/(hub)/layout.tsx index 602aadd8..9e806735 100644 --- a/app/(pages)/(hackers)/(hub)/layout.tsx +++ b/app/(pages)/(hackers)/(hub)/layout.tsx @@ -1,5 +1,6 @@ import ProtectedDisplay from '@components/ProtectedDisplay/ProtectedDisplay'; import Navbar from '@components/Navbar/Navbar'; +import HackbotWidget from '@pages/(hackers)/_components/Hackbot/HackbotWidget'; export default function Layout({ children }: { children: React.ReactNode }) { return ( @@ -9,6 +10,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { > {children} + ); } diff --git a/app/(pages)/(hackers)/_components/Hackbot/HackbotWidget.tsx b/app/(pages)/(hackers)/_components/Hackbot/HackbotWidget.tsx new file mode 100644 index 00000000..66e7ff50 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Hackbot/HackbotWidget.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@pages/_globals/components/ui/button'; + +export type HackbotChatMessage = { + role: 'user' | 'assistant'; + content: string; + url?: string; +}; + +const MAX_USER_MESSAGE_CHARS = 200; +const STORAGE_KEY = 'hackbot_chat_history'; +const MAX_STORED_MESSAGES = 20; + +export default function HackbotWidget() { + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState(() => { + // Load from localStorage on mount + if (typeof window !== 'undefined') { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + return parsed.slice(-MAX_STORED_MESSAGES); + } + } + } catch (err) { + console.error('[hackbot] Failed to load history', err); + } + } + return []; + }); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Persist messages to localStorage whenever they change + useEffect(() => { + if (typeof window !== 'undefined' && messages.length > 0) { + try { + const toStore = messages.slice(-MAX_STORED_MESSAGES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)); + } catch (err) { + console.error('[hackbot] Failed to save history', err); + } + } + }, [messages]); + + const canSend = + !loading && + input.trim().length > 0 && + input.trim().length <= MAX_USER_MESSAGE_CHARS; + + const clearHistory = () => { + setMessages([]); + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } + }; + + const toggleOpen = () => { + setOpen((prev) => !prev); + setError(null); + }; + + const sendMessage = async () => { + if (!canSend) return; + + const userMessage: HackbotChatMessage = { + role: 'user', + content: input.trim(), + }; + + setMessages((prev) => [...prev, userMessage]); + setInput(''); + setError(null); + setLoading(true); + + // Add placeholder for assistant response + const assistantPlaceholder: HackbotChatMessage = { + role: 'assistant', + content: '', + }; + setMessages((prev) => [...prev, assistantPlaceholder]); + + try { + const response = await fetch('/api/hackbot/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: [ + ...messages.map((m) => ({ role: m.role, content: m.content })), + { role: 'user', content: userMessage.content }, + ], + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Stream failed'); + } + + const reader = response.body?.getReader(); + const decoder = new TextDecoder(); + let accumulatedText = ''; + + if (reader) { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('0:')) { + // Vercel AI SDK data stream format + const content = line.slice(2).trim().replace(/^"(.*)"$/, '$1'); + if (content) { + accumulatedText += content; + + // Update the last message with accumulated text + setMessages((prev) => { + const updated = [...prev]; + updated[updated.length - 1] = { + ...updated[updated.length - 1], + content: accumulatedText, + }; + return updated; + }); + } + } + } + } + } + + setLoading(false); + } catch (err: any) { + console.error('[hackbot] Stream error', err); + setError(err.message || 'Network error. Please try again.'); + // Remove placeholder message on error + setMessages((prev) => prev.slice(0, -1)); + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + await sendMessage(); + }; + + return ( +
+ {open && ( +
+
+
+

HackDavis Helper

+

+ Ask short questions about HackDavis events, schedule, tracks, + judging, or submissions. +

+
+
+ {messages.length > 0 && ( + + )} + +
+
+ +
+ {messages.length === 0 && ( +

+ Try asking: "When does hacking end?" or "Where is the opening + ceremony?" +

+ )} + + {messages.map((m, idx) => ( +
+
+

{m.content}

+ {m.url && ( + + More info + + )} +
+
+ ))} +
+ +
+