Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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