Add research workspace and graphing flows
This commit is contained in:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user