Add hybrid research copilot workspace

This commit is contained in:
2026-03-14 19:32:00 -04:00
parent 7a42d73a48
commit 2ee9a549a3
27 changed files with 2864 additions and 323 deletions

View File

@@ -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) {