Add hybrid research copilot workspace
This commit is contained in:
@@ -21,6 +21,7 @@ import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
||||
import { getLatestFinancialIngestionSchemaStatus } from '@/lib/server/db/financial-ingestion-schema';
|
||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||
import { runResearchCopilotTurn } from '@/lib/server/research-copilot';
|
||||
import {
|
||||
defaultFinancialSyncLimit,
|
||||
getCompanyFinancials
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
listResearchJournalEntries,
|
||||
updateResearchJournalEntryRecord
|
||||
} from '@/lib/server/repos/research-journal';
|
||||
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
|
||||
import {
|
||||
deleteWatchlistItemRecord,
|
||||
getWatchlistItemById,
|
||||
@@ -839,6 +841,116 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/copilot/session', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const copilotSession = await getResearchCopilotSessionByTicker(session.user.id, ticker);
|
||||
return Response.json({ session: copilotSession });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.post('/research/copilot/turn', async ({ body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
|
||||
const memoSection = asResearchMemoSection(payload.memoSection);
|
||||
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return jsonError('query is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runResearchCopilotTurn({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
query,
|
||||
selectedSources: asSearchSources(payload.sources),
|
||||
pinnedArtifactIds: Array.isArray(payload.pinnedArtifactIds)
|
||||
? payload.pinnedArtifactIds.map((entry) => Number(entry)).filter((entry) => Number.isInteger(entry) && entry > 0)
|
||||
: undefined,
|
||||
memoSection
|
||||
});
|
||||
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Unable to run research copilot turn'));
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
query: t.String({ minLength: 1 }),
|
||||
sources: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
pinnedArtifactIds: t.Optional(t.Array(t.Numeric())),
|
||||
memoSection: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.post('/research/copilot/job', async ({ body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
|
||||
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return jsonError('query is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceKey = `research_brief:${ticker}:${query.toLowerCase()}`;
|
||||
const existing = await findInFlightTask(session.user.id, 'research_brief', resourceKey);
|
||||
if (existing) {
|
||||
return Response.json({ task: existing });
|
||||
}
|
||||
|
||||
const task = await enqueueTask({
|
||||
userId: session.user.id,
|
||||
taskType: 'research_brief',
|
||||
payload: {
|
||||
ticker,
|
||||
query,
|
||||
sources: asSearchSources(payload.sources) ?? SEARCH_SOURCES
|
||||
},
|
||||
priority: 55,
|
||||
resourceKey
|
||||
});
|
||||
|
||||
return Response.json({ task });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Unable to queue research brief'));
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
query: t.String({ minLength: 1 }),
|
||||
sources: t.Optional(t.Union([t.String(), t.Array(t.String())]))
|
||||
})
|
||||
})
|
||||
.get('/research/library', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
|
||||
219
lib/server/api/research-copilot.e2e.test.ts
Normal file
219
lib/server/api/research-copilot.e2e.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { beforeAll, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
const TEST_USER_ID = 'copilot-api-user';
|
||||
|
||||
const mockGetSession = mock(async () => ({
|
||||
id: 1,
|
||||
user_id: TEST_USER_ID,
|
||||
ticker: 'NVDA',
|
||||
title: 'NVDA copilot',
|
||||
selected_sources: ['documents', 'filings', 'research'],
|
||||
pinned_artifact_ids: [],
|
||||
created_at: '2026-03-14T00:00:00.000Z',
|
||||
updated_at: '2026-03-14T00:00:00.000Z',
|
||||
messages: []
|
||||
}));
|
||||
|
||||
const mockRunTurn = mock(async () => ({
|
||||
session: {
|
||||
id: 1,
|
||||
user_id: TEST_USER_ID,
|
||||
ticker: 'NVDA',
|
||||
title: 'NVDA copilot',
|
||||
selected_sources: ['filings'],
|
||||
pinned_artifact_ids: [4],
|
||||
created_at: '2026-03-14T00:00:00.000Z',
|
||||
updated_at: '2026-03-14T00:00:01.000Z',
|
||||
messages: []
|
||||
},
|
||||
user_message: {
|
||||
id: 1,
|
||||
session_id: 1,
|
||||
user_id: TEST_USER_ID,
|
||||
role: 'user',
|
||||
content_markdown: 'What changed?',
|
||||
citations: [],
|
||||
follow_ups: [],
|
||||
suggested_actions: [],
|
||||
selected_sources: ['filings'],
|
||||
pinned_artifact_ids: [4],
|
||||
memo_section: 'thesis',
|
||||
created_at: '2026-03-14T00:00:00.000Z'
|
||||
},
|
||||
assistant_message: {
|
||||
id: 2,
|
||||
session_id: 1,
|
||||
user_id: TEST_USER_ID,
|
||||
role: 'assistant',
|
||||
content_markdown: 'Demand stayed strong [1].',
|
||||
citations: [{
|
||||
index: 1,
|
||||
label: 'NVDA · 0001 [1]',
|
||||
chunkId: 1,
|
||||
href: '/analysis/reports/NVDA/0001',
|
||||
source: 'filings',
|
||||
sourceKind: 'filing_brief',
|
||||
sourceRef: '0001',
|
||||
title: '10-K brief',
|
||||
ticker: 'NVDA',
|
||||
accessionNumber: '0001',
|
||||
filingDate: '2026-02-18',
|
||||
excerpt: 'Demand stayed strong.',
|
||||
artifactId: 5
|
||||
}],
|
||||
follow_ups: ['What changed in risks?'],
|
||||
suggested_actions: [],
|
||||
selected_sources: ['filings'],
|
||||
pinned_artifact_ids: [4],
|
||||
memo_section: 'thesis',
|
||||
created_at: '2026-03-14T00:00:01.000Z'
|
||||
},
|
||||
results: []
|
||||
}));
|
||||
|
||||
const mockGenerateBrief = mock(async () => ({
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
bodyMarkdown: '# NVDA brief\n\nDemand held up.',
|
||||
evidence: []
|
||||
}));
|
||||
|
||||
const mockFindInFlightTask = mock(async () => null);
|
||||
const mockEnqueueTask = mock(async () => ({
|
||||
id: 'task-1',
|
||||
user_id: TEST_USER_ID,
|
||||
task_type: 'research_brief',
|
||||
status: 'queued',
|
||||
stage: 'queued',
|
||||
stage_detail: 'Queued',
|
||||
stage_context: null,
|
||||
resource_key: 'research_brief:NVDA:update the thesis',
|
||||
notification_read_at: null,
|
||||
notification_silenced_at: null,
|
||||
priority: 55,
|
||||
payload: {
|
||||
ticker: 'NVDA',
|
||||
query: 'Update the thesis',
|
||||
sources: ['filings']
|
||||
},
|
||||
result: null,
|
||||
error: null,
|
||||
attempts: 0,
|
||||
max_attempts: 3,
|
||||
workflow_run_id: 'run-1',
|
||||
created_at: '2026-03-14T00:00:00.000Z',
|
||||
updated_at: '2026-03-14T00:00:00.000Z',
|
||||
finished_at: null
|
||||
}));
|
||||
|
||||
function registerMocks() {
|
||||
mock.module('@/lib/server/auth-session', () => ({
|
||||
requireAuthenticatedSession: async () => ({
|
||||
session: {
|
||||
user: {
|
||||
id: TEST_USER_ID,
|
||||
email: 'copilot@example.com',
|
||||
name: 'Copilot API User',
|
||||
image: null
|
||||
}
|
||||
},
|
||||
response: null
|
||||
})
|
||||
}));
|
||||
|
||||
mock.module('@/lib/server/repos/research-copilot', () => ({
|
||||
getResearchCopilotSessionByTicker: mockGetSession
|
||||
}));
|
||||
|
||||
mock.module('@/lib/server/research-copilot', () => ({
|
||||
runResearchCopilotTurn: mockRunTurn,
|
||||
generateResearchBrief: mockGenerateBrief
|
||||
}));
|
||||
|
||||
mock.module('@/lib/server/tasks', () => ({
|
||||
enqueueTask: mockEnqueueTask,
|
||||
findInFlightTask: mockFindInFlightTask,
|
||||
getTaskById: mock(async () => null),
|
||||
getTaskQueueSnapshot: mock(async () => ({ items: [], stats: { queued: 0, running: 0, failed: 0 } })),
|
||||
getTaskTimeline: mock(async () => []),
|
||||
listRecentTasks: mock(async () => []),
|
||||
updateTaskNotification: mock(async () => null)
|
||||
}));
|
||||
}
|
||||
|
||||
describe('research copilot api', () => {
|
||||
let app: { handle: (request: Request) => Promise<Response> };
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.restore();
|
||||
registerMocks();
|
||||
({ app } = await import('./app'));
|
||||
});
|
||||
|
||||
it('returns the ticker-scoped session payload', async () => {
|
||||
const response = await app.handle(new Request('http://localhost/api/research/copilot/session?ticker=nvda'));
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json() as { session: { ticker: string } };
|
||||
expect(payload.session.ticker).toBe('NVDA');
|
||||
expect(mockGetSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns turn responses with assistant citations', async () => {
|
||||
const response = await app.handle(new Request('http://localhost/api/research/copilot/turn', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticker: 'nvda',
|
||||
query: 'What changed?',
|
||||
sources: ['filings'],
|
||||
pinnedArtifactIds: [4],
|
||||
memoSection: 'thesis'
|
||||
})
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json() as {
|
||||
assistant_message: {
|
||||
citations: Array<{ artifactId: number | null }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(payload.assistant_message.citations[0]?.artifactId).toBe(5);
|
||||
expect(mockRunTurn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues research brief jobs with normalized ticker payloads', async () => {
|
||||
const response = await app.handle(new Request('http://localhost/api/research/copilot/job', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ticker: 'nvda',
|
||||
query: 'Update the thesis',
|
||||
sources: ['filings']
|
||||
})
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json() as {
|
||||
task: {
|
||||
task_type: string;
|
||||
payload: {
|
||||
ticker: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(payload.task.task_type).toBe('research_brief');
|
||||
expect(payload.task.payload.ticker).toBe('NVDA');
|
||||
expect(mockFindInFlightTask).toHaveBeenCalledWith(
|
||||
TEST_USER_ID,
|
||||
'research_brief',
|
||||
'research_brief:NVDA:update the thesis'
|
||||
);
|
||||
expect(mockEnqueueTask).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user