Add research workspace and graphing flows

This commit is contained in:
2026-03-07 16:52:35 -05:00
parent db01f207a5
commit 62bacdf104
37 changed files with 5494 additions and 434 deletions

View File

@@ -7,7 +7,12 @@ import type {
FinancialCadence,
FinancialStatementKind,
FinancialSurfaceKind,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchJournalEntryType,
ResearchMemoConviction,
ResearchMemoRating,
ResearchMemoSection,
TaskStatus
} from '@/lib/types';
import { auth } from '@/lib/auth';
@@ -32,6 +37,22 @@ import {
upsertHoldingRecord
} from '@/lib/server/repos/holdings';
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
import {
addResearchMemoEvidenceLink,
createAiReportArtifactFromAccession,
createFilingArtifactFromAccession,
createResearchArtifactRecord,
deleteResearchArtifactRecord,
deleteResearchMemoEvidenceLink,
getResearchArtifactFileResponse,
getResearchMemoByTicker,
getResearchPacket,
getResearchWorkspace,
listResearchArtifacts,
storeResearchUpload,
updateResearchArtifactRecord,
upsertResearchMemoRecord
} from '@/lib/server/repos/research-library';
import {
createResearchJournalEntryRecord,
deleteResearchJournalEntryRecord,
@@ -82,6 +103,18 @@ const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
const RESEARCH_ARTIFACT_KINDS: ResearchArtifactKind[] = ['filing', 'ai_report', 'note', 'upload', 'memo_snapshot', 'status_change'];
const RESEARCH_ARTIFACT_SOURCES: ResearchArtifactSource[] = ['system', 'user'];
const RESEARCH_MEMO_RATINGS: ResearchMemoRating[] = ['strong_buy', 'buy', 'hold', 'sell'];
const RESEARCH_MEMO_CONVICTIONS: ResearchMemoConviction[] = ['low', 'medium', 'high'];
const RESEARCH_MEMO_SECTIONS: ResearchMemoSection[] = [
'thesis',
'variant_view',
'catalysts',
'risks',
'disconfirming_evidence',
'next_actions'
];
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -205,6 +238,44 @@ function asJournalEntryType(value: unknown) {
: undefined;
}
function asResearchArtifactKind(value: unknown) {
return RESEARCH_ARTIFACT_KINDS.includes(value as ResearchArtifactKind)
? value as ResearchArtifactKind
: undefined;
}
function asResearchArtifactSource(value: unknown) {
return RESEARCH_ARTIFACT_SOURCES.includes(value as ResearchArtifactSource)
? value as ResearchArtifactSource
: undefined;
}
function asResearchMemoRating(value: unknown) {
if (value === null) {
return null;
}
return RESEARCH_MEMO_RATINGS.includes(value as ResearchMemoRating)
? value as ResearchMemoRating
: undefined;
}
function asResearchMemoConviction(value: unknown) {
if (value === null) {
return null;
}
return RESEARCH_MEMO_CONVICTIONS.includes(value as ResearchMemoConviction)
? value as ResearchMemoConviction
: undefined;
}
function asResearchMemoSection(value: unknown) {
return RESEARCH_MEMO_SECTIONS.includes(value as ResearchMemoSection)
? value as ResearchMemoSection
: undefined;
}
function formatLabel(value: string) {
return value
.split('_')
@@ -212,6 +283,10 @@ function formatLabel(value: string) {
.join(' ');
}
function normalizeTicker(value: unknown) {
return typeof value === 'string' ? value.trim().toUpperCase() : '';
}
function withFinancialMetricsPolicy(filing: Filing): Filing {
if (FINANCIAL_FORMS.has(filing.filing_type)) {
return filing;
@@ -707,6 +782,383 @@ export const app = new Elysia({ prefix: '/api' })
return Response.json({ insight });
})
.get('/research/workspace', 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 workspace = await getResearchWorkspace(session.user.id, ticker);
return Response.json({ workspace });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.get('/research/library', 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 linkedToMemo = query.linkedToMemo === undefined
? null
: asBoolean(query.linkedToMemo, false);
const library = await listResearchArtifacts(session.user.id, {
ticker,
q: asOptionalString(query.q),
kind: asResearchArtifactKind(query.kind) ?? null,
tag: asOptionalString(query.tag),
source: asResearchArtifactSource(query.source) ?? null,
linkedToMemo,
limit: typeof query.limit === 'number' ? query.limit : Number(query.limit ?? 100)
});
return Response.json(library);
}, {
query: t.Object({
ticker: t.String({ minLength: 1 }),
q: t.Optional(t.String()),
kind: t.Optional(t.String()),
tag: t.Optional(t.String()),
source: t.Optional(t.String()),
linkedToMemo: t.Optional(t.String()),
limit: t.Optional(t.Numeric())
})
})
.post('/research/library', 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 kind = asResearchArtifactKind(payload.kind);
const source = asResearchArtifactSource(payload.source);
const title = asOptionalString(payload.title);
const summary = asOptionalString(payload.summary);
const bodyMarkdown = asOptionalString(payload.bodyMarkdown);
if (!ticker) {
return jsonError('ticker is required');
}
if (!kind) {
return jsonError('kind is required');
}
if (kind === 'upload') {
return jsonError('Use /api/research/library/upload for file uploads');
}
if (!title && !summary && !bodyMarkdown) {
return jsonError('title, summary, or bodyMarkdown is required');
}
try {
const artifact = await createResearchArtifactRecord({
userId: session.user.id,
ticker,
accessionNumber: asOptionalString(payload.accessionNumber),
kind,
source: source ?? 'user',
subtype: asOptionalString(payload.subtype),
title,
summary,
bodyMarkdown,
tags: asTags(payload.tags),
metadata: asOptionalRecord(payload.metadata)
});
await updateWatchlistReviewByTicker(session.user.id, ticker, artifact.updated_at);
return Response.json({ artifact });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to create research artifact'));
}
})
.post('/research/library/upload', async ({ request }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
try {
const form = await request.formData();
const ticker = normalizeTicker(String(form.get('ticker') ?? ''));
const title = asOptionalString(String(form.get('title') ?? ''));
const summary = asOptionalString(String(form.get('summary') ?? ''));
const tags = asTags(String(form.get('tags') ?? ''));
const file = form.get('file');
if (!ticker) {
return jsonError('ticker is required');
}
if (!(file instanceof File)) {
return jsonError('file is required');
}
const artifact = await storeResearchUpload({
userId: session.user.id,
ticker,
file,
title,
summary,
tags
});
return Response.json({ artifact });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to upload research file'));
}
})
.patch('/research/library/:id', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid artifact id', 400);
}
const payload = asRecord(body);
try {
const artifact = await updateResearchArtifactRecord({
userId: session.user.id,
id: numericId,
title: payload.title === undefined ? undefined : asOptionalString(payload.title),
summary: payload.summary === undefined ? undefined : asOptionalString(payload.summary),
bodyMarkdown: payload.bodyMarkdown === undefined
? undefined
: (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : ''),
tags: payload.tags === undefined ? undefined : asTags(payload.tags),
metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata)
});
if (!artifact) {
return jsonError('Research artifact not found', 404);
}
return Response.json({ artifact });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to update research artifact'));
}
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.delete('/research/library/:id', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid artifact id', 400);
}
const removed = await deleteResearchArtifactRecord(session.user.id, numericId);
if (!removed) {
return jsonError('Research artifact not found', 404);
}
return Response.json({ success: true });
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.get('/research/library/:id/file', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid artifact id', 400);
}
const fileResponse = await getResearchArtifactFileResponse(session.user.id, numericId);
if (!fileResponse) {
return jsonError('Research upload not found', 404);
}
return fileResponse;
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.get('/research/memo', 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 memo = await getResearchMemoByTicker(session.user.id, ticker);
return Response.json({ memo });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.put('/research/memo', 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() : '';
if (!ticker) {
return jsonError('ticker is required');
}
const rating = asResearchMemoRating(payload.rating);
const conviction = asResearchMemoConviction(payload.conviction);
if (payload.rating !== undefined && rating === undefined) {
return jsonError('Invalid memo rating', 400);
}
if (payload.conviction !== undefined && conviction === undefined) {
return jsonError('Invalid memo conviction', 400);
}
try {
const memo = await upsertResearchMemoRecord({
userId: session.user.id,
ticker,
rating,
conviction,
timeHorizonMonths: payload.timeHorizonMonths === undefined
? undefined
: (typeof payload.timeHorizonMonths === 'number' ? payload.timeHorizonMonths : Number(payload.timeHorizonMonths)),
packetTitle: payload.packetTitle === undefined ? undefined : asOptionalString(payload.packetTitle),
packetSubtitle: payload.packetSubtitle === undefined ? undefined : asOptionalString(payload.packetSubtitle),
thesisMarkdown: payload.thesisMarkdown === undefined ? undefined : String(payload.thesisMarkdown),
variantViewMarkdown: payload.variantViewMarkdown === undefined ? undefined : String(payload.variantViewMarkdown),
catalystsMarkdown: payload.catalystsMarkdown === undefined ? undefined : String(payload.catalystsMarkdown),
risksMarkdown: payload.risksMarkdown === undefined ? undefined : String(payload.risksMarkdown),
disconfirmingEvidenceMarkdown: payload.disconfirmingEvidenceMarkdown === undefined ? undefined : String(payload.disconfirmingEvidenceMarkdown),
nextActionsMarkdown: payload.nextActionsMarkdown === undefined ? undefined : String(payload.nextActionsMarkdown)
});
return Response.json({ memo });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to save research memo'));
}
})
.post('/research/memo/:id/evidence', async ({ params, body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const numericId = Number(params.id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return jsonError('Invalid memo id', 400);
}
const payload = asRecord(body);
const section = asResearchMemoSection(payload.section);
const artifactId = typeof payload.artifactId === 'number' ? payload.artifactId : Number(payload.artifactId);
if (!section) {
return jsonError('section is required', 400);
}
if (!Number.isInteger(artifactId) || artifactId <= 0) {
return jsonError('artifactId is required', 400);
}
try {
const evidence = await addResearchMemoEvidenceLink({
userId: session.user.id,
memoId: numericId,
artifactId,
section,
annotation: asOptionalString(payload.annotation),
sortOrder: payload.sortOrder === undefined
? undefined
: (typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder))
});
return Response.json({ evidence });
} catch (error) {
return jsonError(asErrorMessage(error, 'Failed to attach memo evidence'));
}
}, {
params: t.Object({
id: t.String({ minLength: 1 })
})
})
.delete('/research/memo/:id/evidence/:linkId', async ({ params }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const memoId = Number(params.id);
const linkId = Number(params.linkId);
if (!Number.isInteger(memoId) || memoId <= 0 || !Number.isInteger(linkId) || linkId <= 0) {
return jsonError('Invalid memo evidence id', 400);
}
const removed = await deleteResearchMemoEvidenceLink(session.user.id, memoId, linkId);
if (!removed) {
return jsonError('Memo evidence not found', 404);
}
return Response.json({ success: true });
}, {
params: t.Object({
id: t.String({ minLength: 1 }),
linkId: t.String({ minLength: 1 })
})
})
.get('/research/packet', 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 packet = await getResearchPacket(session.user.id, ticker);
return Response.json({ packet });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.get('/research/journal', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
@@ -762,6 +1214,10 @@ export const app = new Elysia({ prefix: '/api' })
metadata
});
if (!entry) {
return jsonError('Failed to create journal entry', 500);
}
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
return Response.json({ entry });