Skip to content
Merged
18 changes: 14 additions & 4 deletions .github/workflows/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install node
uses: actions/setup-node@v4
with:
Expand All @@ -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

Expand All @@ -41,13 +48,16 @@ jobs:
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.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN 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.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG 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 }}
Expand All @@ -59,4 +69,4 @@ jobs:
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

- name: Success
run: echo "🚀 Deploy successful - BLAST OFF WOO! (woot woot) !!! 🐕 🐕 🐕 🚀 "
run: echo "🚀 Deploy successful - BLAST OFF WOO! (woot woot) !!! 🐕 🐕 🐕 🚀 "
15 changes: 13 additions & 2 deletions .github/workflows/staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install node
uses: actions/setup-node@v4
with:
Expand All @@ -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

Expand All @@ -43,12 +50,16 @@ jobs:
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.TITO_API_TOKEN }}" | vercel env add TITO_API_TOKEN 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.TITO_ACCOUNT_SLUG }}" | vercel env add TITO_ACCOUNT_SLUG production --force --token=${{ secrets.VERCEL_TOKEN }}
printf "${{ vars.TITO_EVENT_SLUG }}" | vercel env add TITO_EVENT_SLUG 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 }}
Expand Down
96 changes: 96 additions & 0 deletions __tests__/datalib/getHackbotContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/** @jest-environment node */

import { retrieveContext } from '@datalib/hackbot/getHackbotContext';
import { getDatabase } from '@utils/mongodb/mongoClient.mjs';
import { embedText } from '@utils/hackbot/embedText';
import { retryWithBackoff } from '@utils/hackbot/retryWithBackoff';

jest.mock('@utils/mongodb/mongoClient.mjs', () => ({
getDatabase: jest.fn(),
}));

jest.mock('@utils/hackbot/embedText', () => ({
embedText: jest.fn(),
}));

jest.mock('@utils/hackbot/retryWithBackoff', () => ({
retryWithBackoff: jest.fn(),
}));

const mockGetDatabase = getDatabase as jest.MockedFunction<typeof getDatabase>;
const mockEmbedText = embedText as jest.MockedFunction<typeof embedText>;
const mockRetryWithBackoff = retryWithBackoff as jest.MockedFunction<
typeof retryWithBackoff
>;

describe('retrieveContext', () => {
const aggregateToArray = jest.fn();
const aggregate = jest.fn(() => ({ toArray: aggregateToArray }));
const collection = jest.fn(() => ({ aggregate }));

beforeEach(() => {
jest.clearAllMocks();

mockRetryWithBackoff.mockImplementation(async (operation: any) =>
operation()
);
mockEmbedText.mockResolvedValue([0.1, 0.2, 0.3]);
mockGetDatabase.mockResolvedValue({ collection } as any);
aggregateToArray.mockResolvedValue([
{
_id: 'doc-1',
type: 'general',
title: 'Doc 1',
text: 'Some useful context',
url: 'https://example.com',
},
]);
});

it('uses adaptive simple limit for greetings', async () => {
await retrieveContext('hello');

const pipeline = aggregate.mock.calls[0][0];
expect(pipeline[0].$vectorSearch.limit).toBe(5);
});

it('uses adaptive complex limit for schedule/list queries', async () => {
await retrieveContext('show me all events this weekend');

const pipeline = aggregate.mock.calls[0][0];
expect(pipeline[0].$vectorSearch.limit).toBe(30);
});

it('honors explicit limit when provided', async () => {
await retrieveContext('what is hacking', { limit: 7 });

const pipeline = aggregate.mock.calls[0][0];
expect(pipeline[0].$vectorSearch.limit).toBe(7);
});

it('adds preferredTypes filter when provided', async () => {
await retrieveContext('schedule', {
preferredTypes: ['schedule', 'general'] as any,
});

const pipeline = aggregate.mock.calls[0][0];
expect(pipeline[0].$vectorSearch.filter).toEqual({
type: { $in: ['schedule', 'general'] },
});
});

it('projects only fields needed by downstream code', async () => {
await retrieveContext('where is check-in?');

const pipeline = aggregate.mock.calls[0][0];
expect(pipeline[1]).toEqual({
$project: {
_id: 1,
type: 1,
title: 1,
text: 1,
url: 1,
},
});
});
});
39 changes: 39 additions & 0 deletions app/(api)/_actions/hackbot/clearKnowledgeDocs.ts
Original file line number Diff line number Diff line change
@@ -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<ClearKnowledgeDocsResult> {
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,
};
}
}
29 changes: 29 additions & 0 deletions app/(api)/_actions/hackbot/deleteKnowledgeDoc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'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<DeleteKnowledgeDocResult> {
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}` });

return { ok: true };
} catch (e) {
console.error('[deleteKnowledgeDoc] Error', e);
return {
ok: false,
error: e instanceof Error ? e.message : 'Failed to delete document',
};
}
}
17 changes: 17 additions & 0 deletions app/(api)/_actions/hackbot/getHackerProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use server';

import { auth } from '@/auth';
import type { HackerProfile } from '@typeDefs/hackbot';

export type { HackerProfile };

export async function getHackerProfile(): Promise<HackerProfile | null> {
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,
};
}
50 changes: 50 additions & 0 deletions app/(api)/_actions/hackbot/getKnowledgeDocs.ts
Original file line number Diff line number Diff line change
@@ -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 | null;
updatedAt: string | null;
}

export interface GetKnowledgeDocsResult {
ok: boolean;
docs: KnowledgeDoc[];
error?: string;
}

export default async function getKnowledgeDocs(): Promise<GetKnowledgeDocsResult> {
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?.() ?? null,
updatedAt: d.updatedAt?.toISOString?.() ?? null,
}));

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',
};
}
}
59 changes: 59 additions & 0 deletions app/(api)/_actions/hackbot/getUsageMetrics.ts
Original file line number Diff line number Diff line change
@@ -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<UsageMetrics> {
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,
};
}
Loading
Loading