Skip to content

Commit fe47692

Browse files
1. HackBot server core (#440)
* Add HackBot server core: API route, actions, utils, types, data, and CI/CD - OpenAI streaming chat endpoint with get_events and provide_links tools - Server actions for knowledge CRUD, reseed, import, usage metrics - Event filtering/formatting utilities with timezone-aware LA time handling - System prompt builder with profile-aware personalization and prefix caching - Vector search context retrieval with retry/backoff - Knowledge base JSON (55 entries: FAQ, tracks, judging, submission, general) - CI/CD seed scripts for hackbot_knowledge to hackbot_docs - Auth session extended with position, is_beginner, name fields - Tailwind hackbot-slide-in animation keyframe - Dependencies: ai@6, @ai-sdk/openai * Harden hackbot data handling and add retrieval tests * Refactor hackbot stream route into modular utilities * Tune hackbot event retrieval and add dev stream test runner * Hackbot Server Core Address Copilot Comments --------- Co-authored-by: michelleyeoh <michellew.yeoh@gmail.com>
1 parent 0f8ca03 commit fe47692

35 files changed

+3570
-40
lines changed

.github/workflows/production.yaml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
steps:
1616
- name: Checkout code
1717
uses: actions/checkout@v4
18-
18+
1919
- name: Install node
2020
uses: actions/setup-node@v4
2121
with:
@@ -24,12 +24,19 @@ jobs:
2424

2525
- name: Install dependencies
2626
run: npm install
27-
27+
2828
- name: Run migrations
2929
run: npx migrate-mongo up
3030
env:
3131
MONGODB_URI: ${{ secrets.MONGODB_URI }}
3232

33+
- name: Seed hackbot documentation
34+
run: npm run hackbot:seed
35+
env:
36+
MONGODB_URI: ${{ secrets.MONGODB_URI }}
37+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
38+
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL }}
39+
3340
- name: Install Vercel CLI
3441
run: npm install --global vercel@latest
3542

@@ -41,13 +48,16 @@ jobs:
4148
printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }}
4249
printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }}
4350
printf "${{ secrets.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN production --force --token=${{ secrets.VERCEL_TOKEN }}
51+
printf "${{ secrets.OPENAI_API_KEY }}" | vercel env add OPENAI_API_KEY production --force --token=${{ secrets.VERCEL_TOKEN }}
4452
4553
printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }}
4654
printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }}
4755
printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }}
4856
printf "${{ vars.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
4957
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
50-
58+
printf "${{ vars.OPENAI_MODEL }}" | vercel env add OPENAI_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
59+
printf "${{ vars.OPENAI_EMBEDDING_MODEL }}" | vercel env add OPENAI_EMBEDDING_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
60+
printf "${{ vars.OPENAI_MAX_TOKENS }}" | vercel env add OPENAI_MAX_TOKENS production --force --token=${{ secrets.VERCEL_TOKEN }}
5161
env:
5262
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
5363
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
@@ -59,4 +69,4 @@ jobs:
5969
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
6070

6171
- name: Success
62-
run: echo "🚀 Deploy successful - BLAST OFF WOO! (woot woot) !!! 🐕 🐕 🐕 🚀 "
72+
run: echo "🚀 Deploy successful - BLAST OFF WOO! (woot woot) !!! 🐕 🐕 🐕 🚀 "

.github/workflows/staging.yaml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
steps:
1818
- name: Checkout code
1919
uses: actions/checkout@v4
20-
20+
2121
- name: Install node
2222
uses: actions/setup-node@v4
2323
with:
@@ -26,12 +26,19 @@ jobs:
2626

2727
- name: Install dependencies
2828
run: npm install
29-
29+
3030
- name: Run migrations
3131
run: npx migrate-mongo up
3232
env:
3333
MONGODB_URI: ${{ secrets.MONGODB_URI }}
3434

35+
- name: Seed hackbot documentation
36+
run: npm run hackbot:seed
37+
env:
38+
MONGODB_URI: ${{ secrets.MONGODB_URI }}
39+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
40+
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL }}
41+
3542
- name: Install Vercel CLI
3643
run: npm install --global vercel@latest
3744

@@ -43,12 +50,16 @@ jobs:
4350
printf "${{ secrets.SENDER_PWD }}" | vercel env add SENDER_PWD production --force --token=${{ secrets.VERCEL_TOKEN }}
4451
printf "${{ secrets.CHECK_IN_CODE }}" | vercel env add CHECK_IN_CODE production --force --token=${{ secrets.VERCEL_TOKEN }}
4552
printf "${{ secrets.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN production --force --token=${{ secrets.VERCEL_TOKEN }}
53+
printf "${{ secrets.OPENAI_API_KEY }}" | vercel env add OPENAI_API_KEY production --force --token=${{ secrets.VERCEL_TOKEN }}
4654
4755
printf "${{ vars.ENV_URL }}" | vercel env add BASE_URL production --force --token=${{ secrets.VERCEL_TOKEN }}
4856
printf "${{ vars.INVITE_DEADLINE }}" | vercel env add INVITE_DEADLINE production --force --token=${{ secrets.VERCEL_TOKEN }}
4957
printf "${{ vars.SENDER_EMAIL }}" | vercel env add SENDER_EMAIL production --force --token=${{ secrets.VERCEL_TOKEN }}
5058
printf "${{ vars.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
5159
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
60+
printf "${{ vars.OPENAI_MODEL }}" | vercel env add OPENAI_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
61+
printf "${{ vars.OPENAI_EMBEDDING_MODEL }}" | vercel env add OPENAI_EMBEDDING_MODEL production --force --token=${{ secrets.VERCEL_TOKEN }}
62+
printf "${{ vars.OPENAI_MAX_TOKENS }}" | vercel env add OPENAI_MAX_TOKENS production --force --token=${{ secrets.VERCEL_TOKEN }}
5263
env:
5364
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
5465
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/** @jest-environment node */
2+
3+
import { retrieveContext } from '@datalib/hackbot/getHackbotContext';
4+
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
5+
import { embedText } from '@utils/hackbot/embedText';
6+
import { retryWithBackoff } from '@utils/hackbot/retryWithBackoff';
7+
8+
jest.mock('@utils/mongodb/mongoClient.mjs', () => ({
9+
getDatabase: jest.fn(),
10+
}));
11+
12+
jest.mock('@utils/hackbot/embedText', () => ({
13+
embedText: jest.fn(),
14+
}));
15+
16+
jest.mock('@utils/hackbot/retryWithBackoff', () => ({
17+
retryWithBackoff: jest.fn(),
18+
}));
19+
20+
const mockGetDatabase = getDatabase as jest.MockedFunction<typeof getDatabase>;
21+
const mockEmbedText = embedText as jest.MockedFunction<typeof embedText>;
22+
const mockRetryWithBackoff = retryWithBackoff as jest.MockedFunction<
23+
typeof retryWithBackoff
24+
>;
25+
26+
describe('retrieveContext', () => {
27+
const aggregateToArray = jest.fn();
28+
const aggregate = jest.fn(() => ({ toArray: aggregateToArray }));
29+
const collection = jest.fn(() => ({ aggregate }));
30+
31+
beforeEach(() => {
32+
jest.clearAllMocks();
33+
34+
mockRetryWithBackoff.mockImplementation(async (operation: any) =>
35+
operation()
36+
);
37+
mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]);
38+
mockGetDatabase.mockResolvedValue({ collection } as any);
39+
aggregateToArray.mockResolvedValue([
40+
{
41+
_id: 'doc-1',
42+
type: 'general',
43+
title: 'Doc 1',
44+
text: 'Some useful context',
45+
url: 'https://example.com',
46+
},
47+
]);
48+
});
49+
50+
it('uses adaptive simple limit for greetings', async () => {
51+
await retrieveContext('hello');
52+
53+
const pipeline = aggregate.mock.calls[0][0];
54+
expect(pipeline[0].$vectorSearch.limit).toBe(5);
55+
});
56+
57+
it('uses adaptive complex limit for schedule/list queries', async () => {
58+
await retrieveContext('show me all events this weekend');
59+
60+
const pipeline = aggregate.mock.calls[0][0];
61+
expect(pipeline[0].$vectorSearch.limit).toBe(30);
62+
});
63+
64+
it('honors explicit limit when provided', async () => {
65+
await retrieveContext('what is hacking', { limit: 7 });
66+
67+
const pipeline = aggregate.mock.calls[0][0];
68+
expect(pipeline[0].$vectorSearch.limit).toBe(7);
69+
});
70+
71+
it('adds preferredTypes filter when provided', async () => {
72+
await retrieveContext('schedule', {
73+
preferredTypes: ['schedule', 'general'] as any,
74+
});
75+
76+
const pipeline = aggregate.mock.calls[0][0];
77+
expect(pipeline[0].$vectorSearch.filter).toEqual({
78+
type: { $in: ['schedule', 'general'] },
79+
});
80+
});
81+
82+
it('projects only fields needed by downstream code', async () => {
83+
await retrieveContext('where is check-in?');
84+
85+
const pipeline = aggregate.mock.calls[0][0];
86+
expect(pipeline[1]).toEqual({
87+
$project: {
88+
_id: 1,
89+
type: 1,
90+
title: 1,
91+
text: 1,
92+
url: 1,
93+
},
94+
});
95+
});
96+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use server';
2+
3+
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
4+
5+
export interface ClearKnowledgeDocsResult {
6+
ok: boolean;
7+
deletedKnowledge: number;
8+
deletedEmbeddings: number;
9+
error?: string;
10+
}
11+
12+
export default async function clearKnowledgeDocs(): Promise<ClearKnowledgeDocsResult> {
13+
try {
14+
const db = await getDatabase();
15+
16+
const knowledgeResult = await db
17+
.collection('hackbot_knowledge')
18+
.deleteMany({});
19+
20+
const embeddingsResult = await db
21+
.collection('hackbot_docs')
22+
.deleteMany({ _id: { $regex: '^knowledge-' } });
23+
24+
return {
25+
ok: true,
26+
deletedKnowledge: knowledgeResult.deletedCount,
27+
deletedEmbeddings: embeddingsResult.deletedCount,
28+
};
29+
} catch (e) {
30+
const msg = e instanceof Error ? e.message : 'Unknown error';
31+
console.error('[clearKnowledgeDocs] Error:', msg);
32+
return {
33+
ok: false,
34+
deletedKnowledge: 0,
35+
deletedEmbeddings: 0,
36+
error: msg,
37+
};
38+
}
39+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use server';
2+
3+
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
4+
import { ObjectId } from 'mongodb';
5+
6+
export interface DeleteKnowledgeDocResult {
7+
ok: boolean;
8+
error?: string;
9+
}
10+
11+
export default async function deleteKnowledgeDoc(
12+
id: string
13+
): Promise<DeleteKnowledgeDocResult> {
14+
try {
15+
const db = await getDatabase();
16+
const objectId = new ObjectId(id);
17+
18+
await db.collection('hackbot_knowledge').deleteOne({ _id: objectId });
19+
await db.collection('hackbot_docs').deleteOne({ _id: `knowledge-${id}` });
20+
21+
return { ok: true };
22+
} catch (e) {
23+
console.error('[deleteKnowledgeDoc] Error', e);
24+
return {
25+
ok: false,
26+
error: e instanceof Error ? e.message : 'Failed to delete document',
27+
};
28+
}
29+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use server';
2+
3+
import { auth } from '@/auth';
4+
import type { HackerProfile } from '@typeDefs/hackbot';
5+
6+
export type { HackerProfile };
7+
8+
export async function getHackerProfile(): Promise<HackerProfile | null> {
9+
const session = await auth();
10+
if (!session?.user) return null;
11+
const user = session.user as any;
12+
return {
13+
name: user.name ?? undefined,
14+
position: user.position ?? undefined,
15+
is_beginner: user.is_beginner ?? undefined,
16+
};
17+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use server';
2+
3+
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
4+
import { HackDocType } from '@typeDefs/hackbot';
5+
6+
export interface KnowledgeDoc {
7+
id: string;
8+
type: HackDocType;
9+
title: string;
10+
content: string;
11+
url: string | null;
12+
createdAt: string | null;
13+
updatedAt: string | null;
14+
}
15+
16+
export interface GetKnowledgeDocsResult {
17+
ok: boolean;
18+
docs: KnowledgeDoc[];
19+
error?: string;
20+
}
21+
22+
export default async function getKnowledgeDocs(): Promise<GetKnowledgeDocsResult> {
23+
try {
24+
const db = await getDatabase();
25+
const raw = await db
26+
.collection('hackbot_knowledge')
27+
.find({})
28+
.sort({ updatedAt: -1 })
29+
.toArray();
30+
31+
const docs: KnowledgeDoc[] = raw.map((d: any) => ({
32+
id: String(d._id),
33+
type: d.type,
34+
title: d.title,
35+
content: d.content,
36+
url: d.url ?? null,
37+
createdAt: d.createdAt?.toISOString?.() ?? null,
38+
updatedAt: d.updatedAt?.toISOString?.() ?? null,
39+
}));
40+
41+
return { ok: true, docs };
42+
} catch (e) {
43+
console.error('[getKnowledgeDocs] Error', e);
44+
return {
45+
ok: false,
46+
docs: [],
47+
error: e instanceof Error ? e.message : 'Failed to load knowledge docs',
48+
};
49+
}
50+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use server';
2+
3+
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
4+
5+
export type UsagePeriod = '24h' | '7d' | '30d';
6+
7+
export interface UsageMetrics {
8+
totalRequests: number;
9+
totalPromptTokens: number;
10+
totalCompletionTokens: number;
11+
totalCachedTokens: number;
12+
/** 0–1 fraction of prompt tokens that were served from cache */
13+
cacheHitRate: number;
14+
}
15+
16+
export async function getUsageMetrics(
17+
period: UsagePeriod = '24h'
18+
): Promise<UsageMetrics> {
19+
const hours = period === '24h' ? 24 : period === '7d' ? 168 : 720;
20+
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
21+
22+
const db = await getDatabase();
23+
const [result] = await db
24+
.collection('hackbot_usage')
25+
.aggregate([
26+
{ $match: { timestamp: { $gte: since } } },
27+
{
28+
$group: {
29+
_id: null,
30+
totalRequests: { $sum: 1 },
31+
totalPromptTokens: { $sum: '$promptTokens' },
32+
totalCompletionTokens: { $sum: '$completionTokens' },
33+
totalCachedTokens: { $sum: '$cachedPromptTokens' },
34+
},
35+
},
36+
])
37+
.toArray();
38+
39+
if (!result) {
40+
return {
41+
totalRequests: 0,
42+
totalPromptTokens: 0,
43+
totalCompletionTokens: 0,
44+
totalCachedTokens: 0,
45+
cacheHitRate: 0,
46+
};
47+
}
48+
49+
return {
50+
totalRequests: result.totalRequests,
51+
totalPromptTokens: result.totalPromptTokens,
52+
totalCompletionTokens: result.totalCompletionTokens,
53+
totalCachedTokens: result.totalCachedTokens,
54+
cacheHitRate:
55+
result.totalPromptTokens > 0
56+
? result.totalCachedTokens / result.totalPromptTokens
57+
: 0,
58+
};
59+
}

0 commit comments

Comments
 (0)