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/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx b/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx new file mode 100644 index 00000000..3537aa3a --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/HackbotUsageMetrics.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + getUsageMetrics, + type UsagePeriod, + type UsageMetrics, +} from '@actions/hackbot/getUsageMetrics'; + +const PERIODS: { value: UsagePeriod; label: string }[] = [ + { value: '24h', label: 'Last 24 h' }, + { value: '7d', label: 'Last 7 days' }, + { value: '30d', label: 'Last 30 days' }, +]; + +function fmt(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + +export default function HackbotUsageMetrics() { + const [period, setPeriod] = useState('24h'); + const [metrics, setMetrics] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async (p: UsagePeriod) => { + setLoading(true); + try { + setMetrics(await getUsageMetrics(p)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(period); + }, [period, load]); + + const uncachedTokens = metrics + ? metrics.totalPromptTokens - metrics.totalCachedTokens + : 0; + const hitPct = metrics ? Math.round(metrics.cacheHitRate * 100) : 0; + + return ( +
+ {/* Header */} +
+

Usage Metrics

+
+ {PERIODS.map(({ value, label }) => ( + + ))} +
+
+ + {/* Metric cards */} +
+ {/* Requests */} +
+

Requests

+

+ {metrics ? fmt(metrics.totalRequests) : 'β€”'} +

+
+ + {/* Cache hit rate */} +
+

Cache hit rate

+

+ {metrics ? `${hitPct}%` : 'β€”'} +

+ {metrics && metrics.totalPromptTokens > 0 && ( +
+
+
+ )} +
+ + {/* Prompt tokens breakdown */} +
+

Prompt tokens

+ {metrics ? ( + <> +

+ {fmt(metrics.totalPromptTokens)} +

+
+ + + {fmt(metrics.totalCachedTokens)} cached + + + + {fmt(uncachedTokens)} uncached + +
+ + ) : ( +

β€”

+ )} +
+ + {/* Completion tokens */} +
+

Completion tokens

+

+ {metrics ? fmt(metrics.totalCompletionTokens) : 'β€”'} +

+
+
+
+ ); +} diff --git a/app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx b/app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx new file mode 100644 index 00000000..d0cdbaa3 --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/KnowledgeBanners.tsx @@ -0,0 +1,111 @@ +'use client'; + +import useHackbotKnowledge from '../../_hooks/useHackbotKnowledge'; + +export default function KnowledgeBanners() { + const { + banner, + setBanner, + reseedResult, + setReseedResult, + clearResult, + setClearResult, + importResult, + setImportResult, + importError, + } = useHackbotKnowledge(); + + return ( + <> + {/* Global banner */} + {banner && ( +
+ {banner.message} + +
+ )} + + {/* Reseed result */} + {reseedResult && ( +
+ + {reseedResult.ok + ? `Reseeded ${reseedResult.successCount} doc${ + reseedResult.successCount !== 1 ? 's' : '' + } successfully.` + : reseedResult.error ?? + `${reseedResult.failureCount} doc(s) failed to reseed.`} + + +
+ )} + + {/* Clear result β€” error only (success goes via banner) */} + {clearResult && !clearResult.ok && ( +
+ Failed to clear: {clearResult.error} + +
+ )} + + {/* Import result β€” error only (success goes via banner) */} + {importResult && !importResult.ok && ( +
+
+ + {importResult.successCount} imported, {importResult.failureCount}{' '} + failed. + + +
+ {importResult.failures.length > 0 && ( +
    + {importResult.failures.map((f, i) => ( +
  • {f}
  • + ))} +
+ )} +
+ )} + + {/* Import parse error */} + {importError && ( +

+ {importError} +

+ )} + + ); +} diff --git a/app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx b/app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx new file mode 100644 index 00000000..3c378a63 --- /dev/null +++ b/app/(pages)/admin/_components/Hackbot/KnowledgeDocModal.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { RxCross1 } from 'react-icons/rx'; +import useHackbotKnowledge from '../../_hooks/useHackbotKnowledge'; +import { DOC_TYPES, TYPE_LABELS } from '../../_constants/hackbotKnowledge'; +import type { HackDocType } from '@typeDefs/hackbot'; + +export default function KnowledgeDocModal() { + const { + modalOpen, + editingDoc, + form, + setForm, + formError, + isSaving, + closeModal, + handleSave, + } = useHackbotKnowledge(); + + if (!modalOpen) return null; + + return ( +
+
+
+

+ {editingDoc ? 'Edit Document' : 'Add Document'} +

+ +
+ +
+ {/* Type */} +
+ + +
+ + {/* Title */} +
+ + + setForm((f) => ({ ...f, title: e.target.value })) + } + placeholder="e.g. Judging Process Overview" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#005271]" + /> +
+ + {/* URL */} +
+ + + setForm((f) => ({ ...f, url: e.target.value || null })) + } + placeholder="e.g. /project-info#judging" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#005271]" + /> +
+ + {/* Content */} +
+ +