Add search and RAG workspace flows

This commit is contained in:
2026-03-07 20:34:00 -05:00
parent db01f207a5
commit e20aba998b
35 changed files with 3417 additions and 372 deletions

View File

@@ -8,6 +8,7 @@ import type {
FinancialStatementKind,
FinancialSurfaceKind,
ResearchJournalEntryType,
SearchSource,
TaskStatus
} from '@/lib/types';
import { auth } from '@/lib/auth';
@@ -48,6 +49,7 @@ import {
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
import {
enqueueTask,
findInFlightTask,
@@ -82,6 +84,7 @@ 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 SEARCH_SOURCES: SearchSource[] = ['documents', 'filings', 'research'];
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -205,6 +208,21 @@ function asJournalEntryType(value: unknown) {
: undefined;
}
function asSearchSources(value: unknown) {
const raw = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: [];
const normalized = raw
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim().toLowerCase())
.filter((entry): entry is SearchSource => SEARCH_SOURCES.includes(entry as SearchSource));
return normalized.length > 0 ? [...new Set(normalized)] : undefined;
}
function formatLabel(value: string) {
return value
.split('_')
@@ -763,6 +781,21 @@ export const app = new Elysia({ prefix: '/api' })
});
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
try {
await enqueueTask({
userId: session.user.id,
taskType: 'index_search',
payload: {
ticker: entry.ticker,
journalEntryId: entry.id,
sourceKinds: ['research_note']
},
priority: 52,
resourceKey: `index_search:research_note:${session.user.id}:${entry.id}`
});
} catch (error) {
console.error('[search-index-journal-create] failed:', error);
}
return Response.json({ entry });
} catch (error) {
@@ -800,6 +833,21 @@ export const app = new Elysia({ prefix: '/api' })
}
await updateWatchlistReviewByTicker(session.user.id, entry.ticker, entry.updated_at);
try {
await enqueueTask({
userId: session.user.id,
taskType: 'index_search',
payload: {
ticker: entry.ticker,
journalEntryId: entry.id,
sourceKinds: ['research_note']
},
priority: 52,
resourceKey: `index_search:research_note:${session.user.id}:${entry.id}`
});
} catch (error) {
console.error('[search-index-journal-update] failed:', error);
}
return Response.json({ entry });
} catch (error) {
@@ -822,6 +870,25 @@ export const app = new Elysia({ prefix: '/api' })
return jsonError('Journal entry not found', 404);
}
try {
await enqueueTask({
userId: session.user.id,
taskType: 'index_search',
payload: {
deleteSourceRefs: [{
sourceKind: 'research_note',
sourceRef: String(numericId),
scope: 'user',
userId: session.user.id
}]
},
priority: 52,
resourceKey: `index_search:research_note:${session.user.id}:${numericId}:delete`
});
} catch (error) {
console.error('[search-index-journal-delete] failed:', error);
}
return Response.json({ success: true });
}, {
params: t.Object({
@@ -1124,6 +1191,63 @@ export const app = new Elysia({ prefix: '/api' })
limit: t.Optional(t.Numeric())
})
})
.get('/search', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const q = typeof query.q === 'string' ? query.q.trim() : '';
if (q.length < 2) {
return jsonError('q is required', 400);
}
const results = await searchKnowledgeBase({
userId: session.user.id,
query: q,
ticker: asOptionalString(query.ticker),
sources: asSearchSources(query.sources),
limit: typeof query.limit === 'number' ? query.limit : Number(query.limit)
});
return Response.json({ results });
}, {
query: t.Object({
q: t.String({ minLength: 2 }),
ticker: t.Optional(t.String()),
sources: t.Optional(t.Union([t.String(), t.Array(t.String())])),
limit: t.Optional(t.Numeric())
})
})
.post('/search/answer', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
if (query.length < 2) {
return jsonError('query is required', 400);
}
const answer = await answerSearchQuery({
userId: session.user.id,
query,
ticker: asOptionalString(payload.ticker),
sources: asSearchSources(payload.sources),
limit: asPositiveNumber(payload.limit) ?? undefined
});
return Response.json(answer);
}, {
body: t.Object({
query: t.String({ minLength: 2 }),
ticker: t.Optional(t.String()),
sources: t.Optional(t.Union([t.String(), t.Array(t.String())])),
limit: t.Optional(t.Numeric())
})
})
.post('/filings/sync', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {