Add search and RAG workspace flows
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
CartesianGrid,
|
||||
@@ -169,10 +169,12 @@ function AnalysisPageContent() {
|
||||
const [journalLoading, setJournalLoading] = useState(true);
|
||||
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
||||
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
|
||||
const [highlightedJournalId, setHighlightedJournalId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||
const journalEntryRefs = useRef(new Map<number, HTMLElement | null>());
|
||||
|
||||
useEffect(() => {
|
||||
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||
@@ -182,6 +184,8 @@ function AnalysisPageContent() {
|
||||
|
||||
setTickerInput(normalized);
|
||||
setTicker(normalized);
|
||||
const journalId = Number(searchParams.get('journalId'));
|
||||
setHighlightedJournalId(Number.isInteger(journalId) && journalId > 0 ? journalId : null);
|
||||
}, [searchParams]);
|
||||
|
||||
const loadAnalysis = useCallback(async (symbol: string) => {
|
||||
@@ -231,6 +235,26 @@ function AnalysisPageContent() {
|
||||
}
|
||||
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedJournalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = journalEntryRefs.current.get(highlightedJournalId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setHighlightedJournalId((current) => (current === highlightedJournalId ? null : current));
|
||||
}, 2200);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [highlightedJournalId, journalEntries]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||
...point,
|
||||
@@ -349,6 +373,14 @@ function AnalysisPageContent() {
|
||||
subtitle="Research a single ticker across pricing, 10-K/10-Q financials, qualitative filings, and generated AI reports."
|
||||
activeTicker={analysis?.company.ticker ?? ticker}
|
||||
actions={(
|
||||
<>
|
||||
<Link
|
||||
href={`/search?ticker=${encodeURIComponent(activeTicker.trim().toUpperCase())}`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
Ask with RAG
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
@@ -364,11 +396,12 @@ function AnalysisPageContent() {
|
||||
<RefreshCcw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Panel title="Search Company" subtitle="Enter a ticker symbol to load the latest analysis view.">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const normalized = tickerInput.trim().toUpperCase();
|
||||
@@ -383,9 +416,9 @@ function AnalysisPageContent() {
|
||||
aria-label="Analysis ticker"
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="max-w-xs"
|
||||
className="w-full sm:max-w-xs"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Button type="submit" className="w-full sm:w-auto">
|
||||
<Search className="size-4" />
|
||||
Analyze
|
||||
</Button>
|
||||
@@ -462,7 +495,7 @@ function AnalysisPageContent() {
|
||||
<Panel title="Coverage Workflow" subtitle="Coverage metadata shared with the Coverage page.">
|
||||
{analysis?.coverage ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||
<p className="text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.status}</p>
|
||||
@@ -560,7 +593,7 @@ function AnalysisPageContent() {
|
||||
) : priceSeries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No price history available.</p>
|
||||
) : (
|
||||
<div className="h-[320px]">
|
||||
<div className="h-[260px] sm:h-[320px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={priceSeries}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
@@ -638,7 +671,38 @@ function AnalysisPageContent() {
|
||||
) : filteredFinancialSeries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No financial rows match the selected period filter.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{filteredFinancialSeries.map((point, index) => (
|
||||
<article key={`${point.filingDate}-${point.filingType}-${index}`} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{formatLongDate(point.filingDate)}</p>
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">{point.filingType} · {point.periodLabel}</p>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${(point.netIncome ?? 0) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}>
|
||||
{asScaledFinancialCurrency(point.netIncome, financialValueScale)}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Revenue</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(point.revenue, financialValueScale)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Assets</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(point.assets, financialValueScale)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Net Margin</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="data-table min-w-[820px]">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -668,6 +732,7 @@ function AnalysisPageContent() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
@@ -681,7 +746,40 @@ function AnalysisPageContent() {
|
||||
) : periodEndFilings.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No 10-K or 10-Q filings available for this ticker.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{periodEndFilings.map((filing) => (
|
||||
<article key={filing.accession_number} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{format(new Date(filing.filing_date), 'MMM dd, yyyy')}</p>
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">{filing.filing_type} · {filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}</p>
|
||||
</div>
|
||||
{filing.filing_url ? (
|
||||
<a href={filing.filing_url} target="_blank" rel="noreferrer" className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]">
|
||||
SEC filing
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-3">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Revenue</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Net Income</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Assets</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="data-table min-w-[860px]">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -717,6 +815,7 @@ function AnalysisPageContent() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -739,7 +838,7 @@ function AnalysisPageContent() {
|
||||
<BrainCircuit className="size-4 text-[color:var(--accent)]" />
|
||||
</div>
|
||||
<p className="mt-3 line-clamp-6 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{report.summary}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<p className="text-xs text-[color:var(--terminal-muted)]">{report.accessionNumber}</p>
|
||||
<Link
|
||||
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(report.accessionNumber)}`}
|
||||
@@ -792,12 +891,12 @@ function AnalysisPageContent() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<Button type="submit" className="w-full sm:w-auto">
|
||||
<NotebookPen className="size-4" />
|
||||
{editingJournalId === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{editingJournalId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetJournalForm}>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetJournalForm}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -816,7 +915,17 @@ function AnalysisPageContent() {
|
||||
const canEdit = entry.entry_type !== 'status_change';
|
||||
|
||||
return (
|
||||
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<article
|
||||
key={entry.id}
|
||||
ref={(node) => {
|
||||
journalEntryRefs.current.set(entry.id, node);
|
||||
}}
|
||||
className={`rounded-xl border bg-[color:var(--panel-soft)] p-4 transition ${
|
||||
highlightedJournalId === entry.id
|
||||
? 'border-[color:var(--line-strong)] shadow-[0_0_0_1px_rgba(0,255,180,0.14),0_0_28px_rgba(0,255,180,0.16)]'
|
||||
: 'border-[color:var(--line-weak)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
|
||||
@@ -250,6 +250,14 @@ function FilingsPageContent() {
|
||||
subtitle="Sync SEC submissions, keep 10-K/10-Q financial snapshots, and analyze qualitative signals from other forms."
|
||||
activeTicker={searchTicker || null}
|
||||
actions={(
|
||||
<>
|
||||
<Link
|
||||
href={`/search${searchTicker ? `?ticker=${encodeURIComponent(searchTicker)}` : ''}`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
<Search className="size-4" />
|
||||
Ask with RAG
|
||||
</Link>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
@@ -261,6 +269,7 @@ function FilingsPageContent() {
|
||||
<TimerReset className="size-4" />
|
||||
Refresh table
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
|
||||
@@ -334,7 +343,7 @@ function FilingsPageContent() {
|
||||
title="Filing Ledger"
|
||||
subtitle={`${filings.length} records loaded${searchTicker ? ` for ${searchTicker}` : ''}. Values shown in ${selectedFinancialScaleLabel}.`}
|
||||
actions={(
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<div className="flex w-full flex-wrap justify-start gap-2 sm:justify-end">
|
||||
{FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
|
||||
@@ -609,7 +609,7 @@ function FinancialsPageContent() {
|
||||
>
|
||||
<Panel title="Company Selector" subtitle="Load one ticker across statements, ratios, and KPI time series.">
|
||||
<form
|
||||
className="flex flex-wrap items-center gap-3"
|
||||
className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const normalized = tickerInput.trim().toUpperCase();
|
||||
@@ -623,9 +623,9 @@ function FinancialsPageContent() {
|
||||
value={tickerInput}
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker (AAPL)"
|
||||
className="max-w-xs"
|
||||
className="w-full sm:max-w-xs"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Button type="submit" className="w-full sm:w-auto">
|
||||
<Search className="size-4" />
|
||||
Load Financials
|
||||
</Button>
|
||||
@@ -656,12 +656,12 @@ function FinancialsPageContent() {
|
||||
/>
|
||||
|
||||
<Panel title="Search & Filters" subtitle="Filter rows without mutating the source data.">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<Input
|
||||
value={rowSearch}
|
||||
onChange={(event) => setRowSearch(event.target.value)}
|
||||
placeholder="Search rows by label"
|
||||
className="max-w-sm"
|
||||
className="w-full sm:max-w-sm"
|
||||
/>
|
||||
<span className="text-sm text-[color:var(--terminal-muted)]">
|
||||
{filteredRows.length} of {activeRows.length} rows
|
||||
|
||||
@@ -29,6 +29,10 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
[data-sonner-toaster] {
|
||||
--normal-bg: rgba(7, 22, 31, 0.96);
|
||||
--normal-text: #e8fff8;
|
||||
@@ -48,6 +52,7 @@ body {
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
font-family: var(--font-display), sans-serif;
|
||||
color: var(--terminal-bright);
|
||||
background:
|
||||
@@ -92,6 +97,14 @@ body {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -140,3 +153,18 @@ body {
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 24% -4%, rgba(126, 217, 255, 0.2), transparent 36%),
|
||||
radial-gradient(circle at 82% 2%, rgba(104, 255, 213, 0.16), transparent 30%),
|
||||
linear-gradient(155deg, var(--bg-0), var(--bg-1) 54%, var(--bg-2));
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.65rem 0.55rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import './globals.css';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { QueryProvider } from '@/components/providers/query-provider';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -7,6 +7,13 @@ export const metadata: Metadata = {
|
||||
description: 'Futuristic fiscal intelligence terminal with durable tasks and AI SDK integration.'
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: '#05080d'
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
|
||||
@@ -179,16 +179,16 @@ export default function PortfolioPage() {
|
||||
title="Portfolio"
|
||||
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => void queueRefresh()}>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
<Button variant="secondary" className="w-full sm:w-auto" onClick={() => void queueRefresh()}>
|
||||
<RefreshCcw className="size-4" />
|
||||
Queue price refresh
|
||||
</Button>
|
||||
<Button onClick={() => void queueInsights()}>
|
||||
<Button className="w-full sm:w-auto" onClick={() => void queueInsights()}>
|
||||
<BrainCircuit className="size-4" />
|
||||
Generate AI brief
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
@@ -219,7 +219,7 @@ export default function PortfolioPage() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||
) : allocationData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<div className="h-[260px] sm:h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
|
||||
@@ -255,7 +255,7 @@ export default function PortfolioPage() {
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||
) : performanceData.length > 0 ? (
|
||||
<div className="h-[300px]">
|
||||
<div className="h-[260px] sm:h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={performanceData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
@@ -302,7 +302,114 @@ export default function PortfolioPage() {
|
||||
) : holdings.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
||||
) : (
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{holdings.map((holding) => (
|
||||
<article key={holding.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[color:var(--terminal-bright)]">{holding.ticker}</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{holding.company_name ?? 'Company name unavailable'}</p>
|
||||
</div>
|
||||
<p className={`text-sm font-medium ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
|
||||
{formatCurrency(holding.gain_loss)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Shares</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{asNumber(holding.shares).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Avg Cost</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.avg_cost)}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Price</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{holding.current_price ? formatCurrency(holding.current_price) : 'n/a'}</dd>
|
||||
</div>
|
||||
<div className="rounded-md border border-[color:var(--line-weak)] px-2 py-1.5">
|
||||
<dt className="text-[color:var(--terminal-muted)]">Value</dt>
|
||||
<dd className="mt-1 text-sm text-[color:var(--terminal-bright)]">{formatCurrency(holding.market_value)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className={`mt-3 text-xs ${asNumber(holding.gain_loss) >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9898]'}`}>
|
||||
Return {formatPercent(holding.gain_loss_pct)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analysis
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${holding.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(holding.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(holding.ticker)}
|
||||
className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setEditingHoldingId(holding.id);
|
||||
setForm({
|
||||
ticker: holding.ticker,
|
||||
companyName: holding.company_name ?? '',
|
||||
shares: String(asNumber(holding.shares)),
|
||||
avgCost: String(asNumber(holding.avg_cost)),
|
||||
currentPrice: holding.current_price ? String(asNumber(holding.current_price)) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteHolding(holding.id);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.holdings() });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portfolioSummary() });
|
||||
await loadPortfolio();
|
||||
if (editingHoldingId === holding.id) {
|
||||
resetHoldingForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete holding');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden max-w-full overflow-x-auto lg:block">
|
||||
<table className="data-table min-w-[1020px]">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -403,6 +510,7 @@ export default function PortfolioPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -440,12 +548,12 @@ export default function PortfolioPage() {
|
||||
<Input aria-label="Holding current price" type="number" step="0.0001" min="0" value={form.currentPrice} onChange={(event) => setForm((prev) => ({ ...prev, currentPrice: event.target.value }))} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
<Button type="submit" className="w-full sm:flex-1">
|
||||
<Plus className="size-4" />
|
||||
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
||||
</Button>
|
||||
{editingHoldingId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
268
app/search/page.tsx
Normal file
268
app/search/page.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect, useMemo, useState, useTransition } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { BrainCircuit, ExternalLink, Search as SearchIcon } from 'lucide-react';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { getSearchAnswer } from '@/lib/api';
|
||||
import { searchQueryOptions } from '@/lib/query/options';
|
||||
import type { SearchAnswerResponse, SearchResult, SearchSource } from '@/lib/types';
|
||||
|
||||
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
|
||||
{ value: 'documents', label: 'Documents' },
|
||||
{ value: 'filings', label: 'Filing briefs' },
|
||||
{ value: 'research', label: 'Research notes' }
|
||||
];
|
||||
|
||||
function parseSourceParams(value: string | null) {
|
||||
if (!value) {
|
||||
return ['documents', 'filings', 'research'] as SearchSource[];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry): entry is SearchSource => entry === 'documents' || entry === 'filings' || entry === 'research');
|
||||
|
||||
return normalized.length > 0 ? [...new Set(normalized)] : ['documents', 'filings', 'research'] as SearchSource[];
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>}>
|
||||
<SearchPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const initialQuery = searchParams.get('q')?.trim() ?? '';
|
||||
const initialTicker = searchParams.get('ticker')?.trim().toUpperCase() ?? '';
|
||||
const initialSources = useMemo(() => parseSourceParams(searchParams.get('sources')), [searchParams]);
|
||||
|
||||
const [queryInput, setQueryInput] = useState(initialQuery);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||
const [ticker, setTicker] = useState(initialTicker);
|
||||
const [sources, setSources] = useState<SearchSource[]>(initialSources);
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [answer, setAnswer] = useState<SearchAnswerResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [answerLoading, startAnswerTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryInput(initialQuery);
|
||||
setQuery(initialQuery);
|
||||
setTickerInput(initialTicker);
|
||||
setTicker(initialTicker);
|
||||
setSources(initialSources);
|
||||
}, [initialQuery, initialTicker, initialSources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || !isAuthenticated) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
queryClient.fetchQuery(searchQueryOptions({
|
||||
query,
|
||||
ticker: ticker || undefined,
|
||||
sources,
|
||||
limit: 10
|
||||
})).then((response) => {
|
||||
if (!cancelled) {
|
||||
setResults(response.results);
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to search indexed sources');
|
||||
setResults([]);
|
||||
}
|
||||
}).finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAuthenticated, query, queryClient, sources, ticker]);
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>;
|
||||
}
|
||||
|
||||
const runAnswer = () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
startAnswerTransition(() => {
|
||||
setError(null);
|
||||
getSearchAnswer({
|
||||
query,
|
||||
ticker: ticker || undefined,
|
||||
sources,
|
||||
limit: 10
|
||||
}).then((response) => {
|
||||
setAnswer(response);
|
||||
}).catch((err) => {
|
||||
setError(err instanceof Error ? err.message : 'Unable to generate cited answer');
|
||||
setAnswer(null);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Search"
|
||||
subtitle="Hybrid semantic + lexical retrieval across primary filings, filing briefs, and private research notes."
|
||||
activeTicker={ticker || null}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<Panel title="Search Query" subtitle="Run semantic search with an optional ticker filter and source selection.">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setQuery(queryInput.trim());
|
||||
setTicker(tickerInput.trim().toUpperCase());
|
||||
setAnswer(null);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(event) => setQueryInput(event.target.value)}
|
||||
placeholder="Ask about margin drivers, segment commentary, risks, or your notes..."
|
||||
/>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Input
|
||||
value={tickerInput}
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="Ticker filter (optional)"
|
||||
className="sm:max-w-xs"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOURCE_OPTIONS.map((option) => {
|
||||
const selected = sources.includes(option.value);
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={selected ? 'primary' : 'ghost'}
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
setSources((current) => {
|
||||
if (selected && current.length > 1) {
|
||||
return current.filter((entry) => entry !== option.value);
|
||||
}
|
||||
|
||||
return selected ? current : [...current, option.value];
|
||||
});
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="w-full sm:w-auto">
|
||||
<SearchIcon className="size-4" />
|
||||
Search
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" className="w-full sm:w-auto" onClick={runAnswer} disabled={!query.trim() || answerLoading}>
|
||||
<BrainCircuit className="size-4" />
|
||||
{answerLoading ? 'Answering...' : 'Cited answer'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Cited Answer" subtitle="Single-turn answer grounded only in retrieved evidence.">
|
||||
{answer ? (
|
||||
<div className="space-y-3">
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">{answer.answer}</p>
|
||||
{answer.citations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{answer.citations.map((citation) => (
|
||||
<Link
|
||||
key={`${citation.chunkId}-${citation.index}`}
|
||||
href={citation.href}
|
||||
className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
|
||||
>
|
||||
<span>[{citation.index}] {citation.label}</span>
|
||||
<ExternalLink className="size-3.5 text-[color:var(--accent)]" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No supporting citations were strong enough to answer.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Ask a question to synthesize the top retrieved passages into a cited answer.</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Semantic Search"
|
||||
subtitle={query ? `${results.length} results${ticker ? ` for ${ticker}` : ''}.` : 'Search results will appear here.'}
|
||||
>
|
||||
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Searching indexed sources...</p>
|
||||
) : !query ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Enter a question or topic to search the local RAG index.</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No indexed evidence matched this query.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{results.map((result) => (
|
||||
<article key={result.chunkId} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{result.source} {result.ticker ? `· ${result.ticker}` : ''} {result.filingDate ? `· ${result.filingDate}` : ''}
|
||||
</p>
|
||||
<h3 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{result.title ?? result.citationLabel}</h3>
|
||||
<p className="mt-1 text-xs text-[color:var(--accent)]">{result.citationLabel}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={result.href}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open source
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
{result.headingPath ? (
|
||||
<p className="mt-3 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">{result.headingPath}</p>
|
||||
) : null}
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{result.snippet}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ const EMPTY_FORM: FormState = {
|
||||
tags: ''
|
||||
};
|
||||
|
||||
const SELECT_CLASS_NAME = 'w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]';
|
||||
const SELECT_CLASS_NAME = 'min-h-11 w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]';
|
||||
|
||||
function parseTagsInput(input: string) {
|
||||
const unique = new Set<string>();
|
||||
@@ -281,10 +281,11 @@ export default function WatchlistPage() {
|
||||
aria-label="Search coverage"
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search ticker, company, tag, sector..."
|
||||
className="min-w-[18rem]"
|
||||
className="w-full sm:min-w-[18rem]"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
void loadCoverage();
|
||||
@@ -302,7 +303,156 @@ export default function WatchlistPage() {
|
||||
) : filteredItems.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No coverage items match the current search.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{filteredItems.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-[color:var(--terminal-bright)]">{item.ticker}</div>
|
||||
<div className="text-sm text-[color:var(--terminal-bright)]">{item.company_name}</div>
|
||||
<div className="text-xs text-[color:var(--terminal-muted)]">
|
||||
{item.sector ?? 'Unclassified'}
|
||||
{item.category ? ` · ${item.category}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-[color:var(--terminal-muted)]">
|
||||
<p>Last filing</p>
|
||||
<p className="mt-1 text-[color:var(--terminal-bright)]">{formatDateOnly(item.latest_filing_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Status</label>
|
||||
<select
|
||||
aria-label={`${item.ticker} status`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={item.status}
|
||||
onChange={(event) => {
|
||||
void updateCoverageInline(item, {
|
||||
status: event.target.value as CoverageStatus
|
||||
});
|
||||
}}
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">Priority</label>
|
||||
<select
|
||||
aria-label={`${item.ticker} priority`}
|
||||
className={SELECT_CLASS_NAME}
|
||||
value={item.priority}
|
||||
onChange={(event) => {
|
||||
void updateCoverageInline(item, {
|
||||
priority: event.target.value as CoveragePriority
|
||||
});
|
||||
}}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{item.tags.length > 0 ? item.tags.map((tag) => (
|
||||
<span
|
||||
key={`${item.id}-${tag}`}
|
||||
className="rounded border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-1.5 py-0.5 text-[10px] uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
)) : <span className="text-xs text-[color:var(--terminal-muted)]">No tags</span>}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-[color:var(--terminal-muted)]">Last reviewed: {formatDateTime(item.last_reviewed_at)}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/analysis?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Analyze
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
href={`/filings?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(item.ticker)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Filings
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button variant="secondary" className="px-2 py-1 text-xs" onClick={() => void queueSync(item)}>
|
||||
Sync
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void updateCoverageInline(item, {
|
||||
lastReviewedAt: new Date().toISOString()
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock className="size-3" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEdit(item)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteWatchlistItem(item.id);
|
||||
invalidateCoverageQueries(item.ticker);
|
||||
await loadCoverage();
|
||||
if (editingItemId === item.id) {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to remove ${item.ticker}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="data-table min-w-[1120px]">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -456,6 +606,7 @@ export default function WatchlistPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -542,12 +693,12 @@ export default function WatchlistPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" className="flex-1" disabled={saving}>
|
||||
<Button type="submit" className="w-full sm:flex-1" disabled={saving}>
|
||||
<Plus className="size-4" />
|
||||
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
|
||||
</Button>
|
||||
{editingItemId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetForm}>
|
||||
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetForm}>
|
||||
Clear
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
13
bun.lock
13
bun.lock
@@ -23,6 +23,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sqlite-vec": "^0.1.7-alpha.2",
|
||||
"workflow": "^4.1.0-beta.60",
|
||||
"zhipu-ai-provider": "^0.2.2",
|
||||
},
|
||||
@@ -1427,6 +1428,18 @@
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"sqlite-vec": ["sqlite-vec@0.1.7-alpha.2", "", { "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.7-alpha.2", "sqlite-vec-darwin-x64": "0.1.7-alpha.2", "sqlite-vec-linux-arm64": "0.1.7-alpha.2", "sqlite-vec-linux-x64": "0.1.7-alpha.2", "sqlite-vec-windows-x64": "0.1.7-alpha.2" } }, "sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ=="],
|
||||
|
||||
"sqlite-vec-darwin-arm64": ["sqlite-vec-darwin-arm64@0.1.7-alpha.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw=="],
|
||||
|
||||
"sqlite-vec-darwin-x64": ["sqlite-vec-darwin-x64@0.1.7-alpha.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA=="],
|
||||
|
||||
"sqlite-vec-linux-arm64": ["sqlite-vec-linux-arm64@0.1.7-alpha.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA=="],
|
||||
|
||||
"sqlite-vec-linux-x64": ["sqlite-vec-linux-x64@0.1.7-alpha.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg=="],
|
||||
|
||||
"sqlite-vec-windows-x64": ["sqlite-vec-windows-x64@0.1.7-alpha.2", "", { "os": "win32", "cpu": "x64" }, "sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
||||
|
||||
@@ -13,10 +13,10 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
|
||||
<div className="ambient-grid" aria-hidden="true" />
|
||||
<div className="noise-layer" aria-hidden="true" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-5xl flex-col justify-center gap-8 px-4 py-10 md:px-8 lg:flex-row lg:items-center">
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-6 lg:w-[42%]">
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-5xl flex-col justify-center gap-5 px-4 py-6 sm:gap-8 sm:py-10 md:px-8 lg:flex-row lg:items-center">
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 sm:p-6 lg:w-[42%]">
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">Autonomous Analyst Desk</h1>
|
||||
<h1 className="mt-3 text-2xl font-semibold text-[color:var(--terminal-bright)] sm:text-3xl">Autonomous Analyst Desk</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-muted)]">
|
||||
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows powered by AI SDK.
|
||||
</p>
|
||||
@@ -29,8 +29,8 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-6 shadow-[0_20px_60px_rgba(1,4,10,0.55)] lg:w-[58%]">
|
||||
<h2 className="text-2xl font-semibold text-[color:var(--terminal-bright)]">{title}</h2>
|
||||
<section className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_20px_60px_rgba(1,4,10,0.55)] sm:p-6 lg:w-[58%]">
|
||||
<h2 className="text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl">{title}</h2>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
|
||||
<div className="mt-6">{children}</div>
|
||||
|
||||
@@ -10,7 +10,8 @@ const taskLabels: Record<Task['task_type'], string> = {
|
||||
sync_filings: 'Sync filings',
|
||||
refresh_prices: 'Refresh prices',
|
||||
analyze_filing: 'Analyze filing',
|
||||
portfolio_insights: 'Portfolio insights'
|
||||
portfolio_insights: 'Portfolio insights',
|
||||
index_search: 'Index search'
|
||||
};
|
||||
|
||||
export function TaskFeed({ tasks }: TaskFeedProps) {
|
||||
|
||||
@@ -42,8 +42,8 @@ export function FinancialControlBar({
|
||||
}: FinancialControlBarProps) {
|
||||
return (
|
||||
<section className={cn('rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-4 py-3', className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-[color:var(--terminal-bright)]">{title}</h3>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
@@ -51,14 +51,14 @@ export function FinancialControlBar({
|
||||
</div>
|
||||
|
||||
{actions && actions.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div className="grid w-full grid-cols-1 gap-2 sm:flex sm:w-auto sm:flex-wrap sm:items-center sm:justify-end">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
type="button"
|
||||
variant={action.variant ?? 'secondary'}
|
||||
disabled={action.disabled}
|
||||
className="px-2 py-1 text-xs"
|
||||
className="px-2 py-1 text-xs sm:min-h-9"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
@@ -68,22 +68,21 @@ export function FinancialControlBar({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<div className="flex min-w-max flex-wrap gap-2">
|
||||
<div className="mt-3 grid grid-cols-1 gap-2">
|
||||
{sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1.5"
|
||||
className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2"
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</span>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="mb-2 block text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{section.options.map((option) => (
|
||||
<Button
|
||||
key={`${section.id}-${option.value}`}
|
||||
type="button"
|
||||
variant={option.value === section.value ? 'primary' : 'ghost'}
|
||||
disabled={option.disabled}
|
||||
className="px-2 py-1 text-xs"
|
||||
className="px-2 py-1 text-xs sm:min-h-9"
|
||||
onClick={() => section.onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -93,7 +92,6 @@ export function FinancialControlBar({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||
sync_filings: 'Filing sync',
|
||||
refresh_prices: 'Price refresh',
|
||||
analyze_filing: 'Filing analysis',
|
||||
portfolio_insights: 'Portfolio insight'
|
||||
portfolio_insights: 'Portfolio insight',
|
||||
index_search: 'Search indexing'
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<TaskStage, string> = {
|
||||
@@ -38,6 +39,11 @@ const STAGE_LABELS: Record<TaskStage, string> = {
|
||||
'analyze.extract': 'Extract context',
|
||||
'analyze.generate_report': 'Generate report',
|
||||
'analyze.persist_report': 'Persist report',
|
||||
'search.collect_sources': 'Collect sources',
|
||||
'search.fetch_documents': 'Fetch documents',
|
||||
'search.chunk': 'Chunk content',
|
||||
'search.embed': 'Generate embeddings',
|
||||
'search.persist': 'Persist search index',
|
||||
'insights.load_holdings': 'Load holdings',
|
||||
'insights.generate': 'Generate insight',
|
||||
'insights.persist': 'Persist insight'
|
||||
@@ -75,6 +81,16 @@ const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
|
||||
'analyze.persist_report',
|
||||
'completed'
|
||||
],
|
||||
index_search: [
|
||||
'queued',
|
||||
'running',
|
||||
'search.collect_sources',
|
||||
'search.fetch_documents',
|
||||
'search.chunk',
|
||||
'search.embed',
|
||||
'search.persist',
|
||||
'completed'
|
||||
],
|
||||
portfolio_insights: [
|
||||
'queued',
|
||||
'running',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu } from 'lucide-react';
|
||||
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu, Search } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -76,6 +76,16 @@ const NAV_ITEMS: NavConfigItem[] = [
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
href: '/search',
|
||||
label: 'Search',
|
||||
icon: Search,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
preserveTicker: true,
|
||||
mobilePrimary: false
|
||||
},
|
||||
{
|
||||
id: 'portfolio',
|
||||
href: '/portfolio',
|
||||
@@ -167,6 +177,13 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/search')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Search' }
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/portfolio')) {
|
||||
return [{ label: 'Portfolio' }];
|
||||
}
|
||||
@@ -289,6 +306,13 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/search')) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/portfolio')) {
|
||||
void queryClient.prefetchQuery(holdingsQueryOptions());
|
||||
void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
|
||||
@@ -366,7 +390,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
<div className="ambient-grid" aria-hidden="true" />
|
||||
<div className="noise-layer" aria-hidden="true" />
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-[1300px] gap-6 px-4 pb-12 pt-6 md:px-8">
|
||||
<div className="relative z-10 mx-auto flex min-h-screen w-full max-w-[1300px] gap-4 px-3 pb-10 pt-4 sm:px-4 sm:pb-12 sm:pt-6 md:px-8 lg:gap-6">
|
||||
<aside className="hidden w-72 shrink-0 flex-col gap-6 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_0_0_1px_rgba(0,255,180,0.06),0_20px_60px_rgba(1,4,10,0.55)] lg:flex">
|
||||
<div>
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
||||
@@ -421,8 +445,8 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 pb-24 lg:pb-0">
|
||||
<header className="relative mb-4 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-6 py-5 pr-20 shadow-[0_0_0_1px_rgba(0,255,180,0.05),0_14px_40px_rgba(1,4,10,0.5)]">
|
||||
<div className="absolute right-5 top-5 z-10">
|
||||
<header className="relative mb-4 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-4 py-4 pr-16 shadow-[0_0_0_1px_rgba(0,255,180,0.05),0_14px_40px_rgba(1,4,10,0.5)] sm:px-6 sm:py-5 sm:pr-20">
|
||||
<div className="absolute right-4 top-4 z-10 sm:right-5 sm:top-5">
|
||||
<TaskNotificationsTrigger
|
||||
unreadCount={notifications.unreadCount}
|
||||
isPopoverOpen={notifications.isPopoverOpen}
|
||||
@@ -438,17 +462,17 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
markTaskRead={notifications.markTaskRead}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 pr-6 sm:pr-0">
|
||||
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Live System</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)] md:text-3xl">{title}</h2>
|
||||
<h2 className="mt-2 text-xl font-semibold text-[color:var(--terminal-bright)] sm:text-2xl md:text-3xl">{title}</h2>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
{actions}
|
||||
<Button variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||
<Button variant="ghost" className="max-sm:hidden sm:inline-flex lg:hidden" onClick={() => void signOut()} disabled={isSigningOut}>
|
||||
<LogOut className="size-4" />
|
||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||
</Button>
|
||||
@@ -458,9 +482,9 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="mb-6 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2"
|
||||
className="mb-6 overflow-x-auto rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2"
|
||||
>
|
||||
<ol className="flex flex-wrap items-center gap-2 text-xs text-[color:var(--terminal-muted)]">
|
||||
<ol className="flex min-w-max items-center gap-2 text-xs text-[color:var(--terminal-muted)] sm:min-w-0 sm:flex-wrap">
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Button({ className, variant = 'primary', ...props }: ButtonProps
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition duration-200 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition duration-200 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variantMap[variant],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function Input({ className, ...props }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]',
|
||||
'min-h-11 w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,17 +12,17 @@ export function Panel({ title, subtitle, actions, children, className }: PanelPr
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'min-w-0 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-5 shadow-[0_0_0_1px_rgba(0,255,180,0.03),0_12px_30px_rgba(1,4,10,0.45)]',
|
||||
'min-w-0 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-4 shadow-[0_0_0_1px_rgba(0,255,180,0.03),0_12px_30px_rgba(1,4,10,0.45)] sm:p-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{(title || subtitle || actions) ? (
|
||||
<header className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{title ? <h3 className="text-base font-semibold text-[color:var(--terminal-bright)]">{title}</h3> : null}
|
||||
{subtitle ? <p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div>{actions}</div> : null}
|
||||
{actions ? <div className="w-full sm:w-auto">{actions}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
{children}
|
||||
|
||||
45
drizzle/0008_search_rag.sql
Normal file
45
drizzle/0008_search_rag.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS `search_document` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`source_kind` text NOT NULL,
|
||||
`source_ref` text NOT NULL,
|
||||
`scope` text NOT NULL,
|
||||
`user_id` text,
|
||||
`ticker` text,
|
||||
`accession_number` text,
|
||||
`title` text,
|
||||
`content_text` text NOT NULL,
|
||||
`content_hash` text NOT NULL,
|
||||
`metadata` text,
|
||||
`index_status` text NOT NULL DEFAULT 'pending',
|
||||
`indexed_at` text,
|
||||
`last_error` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `search_chunk` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`document_id` integer NOT NULL,
|
||||
`chunk_index` integer NOT NULL,
|
||||
`chunk_text` text NOT NULL,
|
||||
`char_count` integer NOT NULL,
|
||||
`start_offset` integer NOT NULL,
|
||||
`end_offset` integer NOT NULL,
|
||||
`heading_path` text,
|
||||
`citation_label` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`document_id`) REFERENCES `search_document`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `search_document_source_uidx`
|
||||
ON `search_document` (`scope`, ifnull(`user_id`, ''), `source_kind`, `source_ref`);
|
||||
CREATE INDEX IF NOT EXISTS `search_document_scope_idx`
|
||||
ON `search_document` (`scope`, `source_kind`, `ticker`, `updated_at`);
|
||||
CREATE INDEX IF NOT EXISTS `search_document_accession_idx`
|
||||
ON `search_document` (`accession_number`, `source_kind`);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `search_chunk_document_chunk_uidx`
|
||||
ON `search_chunk` (`document_id`, `chunk_index`);
|
||||
CREATE INDEX IF NOT EXISTS `search_chunk_document_idx`
|
||||
ON `search_chunk` (`document_id`);
|
||||
51
lib/api.ts
51
lib/api.ts
@@ -14,6 +14,9 @@ import type {
|
||||
PortfolioSummary,
|
||||
ResearchJournalEntry,
|
||||
ResearchJournalEntryType,
|
||||
SearchAnswerResponse,
|
||||
SearchResult,
|
||||
SearchSource,
|
||||
Task,
|
||||
TaskStatus,
|
||||
TaskTimeline,
|
||||
@@ -295,6 +298,54 @@ export async function listFilings(query?: { ticker?: string; limit?: number }) {
|
||||
return await unwrapData<{ filings: Filing[] }>(result, 'Unable to fetch filings');
|
||||
}
|
||||
|
||||
export async function searchKnowledge(input: {
|
||||
query: string;
|
||||
ticker?: string;
|
||||
sources?: SearchSource[];
|
||||
limit?: number;
|
||||
}) {
|
||||
const result = await client.api.search.get({
|
||||
$query: {
|
||||
q: input.query.trim(),
|
||||
...(input.ticker?.trim()
|
||||
? { ticker: input.ticker.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(input.sources && input.sources.length > 0
|
||||
? { sources: input.sources }
|
||||
: {}),
|
||||
...(typeof input.limit === 'number'
|
||||
? { limit: input.limit }
|
||||
: {})
|
||||
}
|
||||
});
|
||||
|
||||
return await unwrapData<{ results: SearchResult[] }>(result, 'Unable to search indexed sources');
|
||||
}
|
||||
|
||||
export async function getSearchAnswer(input: {
|
||||
query: string;
|
||||
ticker?: string;
|
||||
sources?: SearchSource[];
|
||||
limit?: number;
|
||||
}) {
|
||||
return await requestJson<SearchAnswerResponse>({
|
||||
path: '/api/search/answer',
|
||||
method: 'POST',
|
||||
body: {
|
||||
query: input.query.trim(),
|
||||
...(input.ticker?.trim()
|
||||
? { ticker: input.ticker.trim().toUpperCase() }
|
||||
: {}),
|
||||
...(input.sources && input.sources.length > 0
|
||||
? { sources: input.sources }
|
||||
: {}),
|
||||
...(typeof input.limit === 'number'
|
||||
? { limit: input.limit }
|
||||
: {})
|
||||
}
|
||||
}, 'Unable to generate cited answer');
|
||||
}
|
||||
|
||||
export async function getCompanyAnalysis(ticker: string) {
|
||||
const result = await client.api.analysis.company.get({
|
||||
$query: {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const queryKeys = {
|
||||
limit: number
|
||||
) => ['financials-v3', ticker, surfaceKind, cadence, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const,
|
||||
filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const,
|
||||
search: (query: string, ticker: string | null, sources: string[], limit: number) => ['search', query, ticker ?? '', sources.join(','), limit] as const,
|
||||
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
||||
watchlist: () => ['watchlist'] as const,
|
||||
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getCompanyFinancialStatements,
|
||||
getLatestPortfolioInsight,
|
||||
getPortfolioSummary,
|
||||
searchKnowledge,
|
||||
getTask,
|
||||
getTaskTimeline,
|
||||
listFilings,
|
||||
@@ -16,7 +17,8 @@ import {
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind
|
||||
FinancialSurfaceKind,
|
||||
SearchSource
|
||||
} from '@/lib/types';
|
||||
|
||||
export function companyAnalysisQueryOptions(ticker: string) {
|
||||
@@ -86,6 +88,31 @@ export function filingsQueryOptions(input: { ticker?: string; limit?: number } =
|
||||
});
|
||||
}
|
||||
|
||||
export function searchQueryOptions(input: {
|
||||
query: string;
|
||||
ticker?: string | null;
|
||||
sources?: SearchSource[];
|
||||
limit?: number;
|
||||
}) {
|
||||
const normalizedQuery = input.query.trim();
|
||||
const normalizedTicker = input.ticker?.trim().toUpperCase() ?? null;
|
||||
const sources = input.sources && input.sources.length > 0
|
||||
? [...new Set(input.sources)]
|
||||
: ['documents', 'filings', 'research'] as SearchSource[];
|
||||
const limit = input.limit ?? 10;
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.search(normalizedQuery, normalizedTicker, sources, limit),
|
||||
queryFn: () => searchKnowledge({
|
||||
query: normalizedQuery,
|
||||
ticker: normalizedTicker ?? undefined,
|
||||
sources,
|
||||
limit
|
||||
}),
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
|
||||
export function aiReportQueryOptions(accessionNumber: string) {
|
||||
const normalizedAccession = accessionNumber.trim();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateText } from 'ai';
|
||||
import { embedMany, generateText } from 'ai';
|
||||
import { createZhipu } from 'zhipu-ai-provider';
|
||||
|
||||
type AiWorkload = 'report' | 'extraction';
|
||||
@@ -31,13 +31,35 @@ type AiGenerateOutput = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type AiEmbedOutput = {
|
||||
embeddings: number[][];
|
||||
};
|
||||
|
||||
type RunAiAnalysisOptions = GetAiConfigOptions & {
|
||||
workload?: AiWorkload;
|
||||
createModel?: (config: AiConfig) => unknown;
|
||||
generate?: (input: AiGenerateInput) => Promise<AiGenerateOutput>;
|
||||
};
|
||||
|
||||
type EmbeddingConfig = {
|
||||
provider: AiProvider;
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
model: 'embedding-3';
|
||||
dimensions: 256;
|
||||
};
|
||||
|
||||
type RunAiEmbeddingsOptions = GetAiConfigOptions & {
|
||||
createModel?: (config: EmbeddingConfig) => unknown;
|
||||
embed?: (input: {
|
||||
model: unknown;
|
||||
values: string[];
|
||||
}) => Promise<AiEmbedOutput>;
|
||||
};
|
||||
|
||||
const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
|
||||
const SEARCH_EMBEDDING_MODEL = 'embedding-3';
|
||||
const SEARCH_EMBEDDING_DIMENSIONS = 256;
|
||||
|
||||
let warnedIgnoredZhipuBaseUrl = false;
|
||||
|
||||
@@ -97,6 +119,30 @@ async function defaultGenerate(input: AiGenerateInput): Promise<AiGenerateOutput
|
||||
return { text: result.text };
|
||||
}
|
||||
|
||||
function defaultCreateEmbeddingModel(config: EmbeddingConfig) {
|
||||
const zhipu = createZhipu({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl
|
||||
});
|
||||
|
||||
return zhipu.textEmbeddingModel(config.model, {
|
||||
dimensions: config.dimensions
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultEmbed(input: {
|
||||
model: unknown;
|
||||
values: string[];
|
||||
}): Promise<AiEmbedOutput> {
|
||||
const result = await embedMany({
|
||||
model: input.model as never,
|
||||
values: input.values,
|
||||
maxRetries: 0
|
||||
});
|
||||
|
||||
return { embeddings: result.embeddings as number[][] };
|
||||
}
|
||||
|
||||
export function getAiConfig(options?: GetAiConfigOptions) {
|
||||
return getReportAiConfig(options);
|
||||
}
|
||||
@@ -121,6 +167,19 @@ export function getExtractionAiConfig(options?: GetAiConfigOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmbeddingAiConfig(options?: GetAiConfigOptions) {
|
||||
const env = options?.env ?? process.env;
|
||||
warnIgnoredZhipuBaseUrl(env, options?.warn ?? console.warn);
|
||||
|
||||
return {
|
||||
provider: 'zhipu',
|
||||
apiKey: envValue('ZHIPU_API_KEY', env),
|
||||
baseUrl: CODING_API_BASE_URL,
|
||||
model: SEARCH_EMBEDDING_MODEL,
|
||||
dimensions: SEARCH_EMBEDDING_DIMENSIONS
|
||||
} satisfies EmbeddingConfig;
|
||||
}
|
||||
|
||||
export function isAiConfigured(options?: GetAiConfigOptions) {
|
||||
const config = getReportAiConfig(options);
|
||||
return Boolean(config.apiKey);
|
||||
@@ -160,6 +219,31 @@ export async function runAiAnalysis(prompt: string, systemPrompt?: string, optio
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAiEmbeddings(values: string[], options?: RunAiEmbeddingsOptions) {
|
||||
const sanitizedValues = values
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
|
||||
if (sanitizedValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const config = getEmbeddingAiConfig(options);
|
||||
if (!config.apiKey) {
|
||||
throw new Error('ZHIPU_API_KEY is required for AI workloads');
|
||||
}
|
||||
|
||||
const createModel = options?.createModel ?? defaultCreateEmbeddingModel;
|
||||
const embed = options?.embed ?? defaultEmbed;
|
||||
const model = createModel(config);
|
||||
const result = await embed({
|
||||
model,
|
||||
values: sanitizedValues
|
||||
});
|
||||
|
||||
return result.embeddings.map((embedding) => embedding.map((value) => Number(value)));
|
||||
}
|
||||
|
||||
export function __resetAiWarningsForTests() {
|
||||
warnedIgnoredZhipuBaseUrl = false;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -37,6 +37,14 @@ describe('sqlite schema compatibility bootstrap', () => {
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'search_document')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'search_chunk')).toBe(true);
|
||||
|
||||
__dbInternals.loadSqliteExtensions(client);
|
||||
__dbInternals.ensureSearchVirtualTables(client);
|
||||
|
||||
expect(__dbInternals.hasTable(client, 'search_chunk_fts')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'search_chunk_vec')).toBe(true);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { load as loadSqliteVec } from 'sqlite-vec';
|
||||
import { schema } from './schema';
|
||||
|
||||
type AppDrizzleDb = ReturnType<typeof createDb>;
|
||||
@@ -50,6 +51,45 @@ function applySqlFile(client: Database, fileName: string) {
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
let customSqliteConfigured = false;
|
||||
const vectorExtensionStatus = new WeakMap<Database, boolean>();
|
||||
|
||||
function configureCustomSqliteRuntime() {
|
||||
if (customSqliteConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customSqlitePath = process.env.SQLITE_CUSTOM_LIB_PATH?.trim();
|
||||
if (process.platform === 'darwin' && customSqlitePath) {
|
||||
Database.setCustomSQLite(customSqlitePath);
|
||||
}
|
||||
|
||||
customSqliteConfigured = true;
|
||||
}
|
||||
|
||||
function loadSqliteExtensions(client: Database) {
|
||||
try {
|
||||
const customVectorExtensionPath = process.env.SQLITE_VEC_EXTENSION_PATH?.trim();
|
||||
|
||||
if (customVectorExtensionPath) {
|
||||
client.loadExtension(customVectorExtensionPath);
|
||||
} else {
|
||||
loadSqliteVec(client);
|
||||
}
|
||||
|
||||
vectorExtensionStatus.set(client, true);
|
||||
} catch (error) {
|
||||
vectorExtensionStatus.set(client, false);
|
||||
|
||||
const reason = error instanceof Error ? error.message : 'Unknown sqlite extension error';
|
||||
console.warn(`[sqlite] sqlite-vec unavailable, falling back to table-backed vector storage: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isVectorExtensionLoaded(client: Database) {
|
||||
return vectorExtensionStatus.get(client) ?? false;
|
||||
}
|
||||
|
||||
function ensureLocalSqliteSchema(client: Database) {
|
||||
if (!hasTable(client, 'filing_statement_snapshot')) {
|
||||
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
|
||||
@@ -142,10 +182,70 @@ function ensureLocalSqliteSchema(client: Database) {
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'search_document')) {
|
||||
applySqlFile(client, '0008_search_rag.sql');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSearchVirtualTables(client: Database) {
|
||||
client.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_fts\` USING fts5(
|
||||
\`chunk_text\`,
|
||||
\`citation_label\`,
|
||||
\`heading_path\`,
|
||||
\`chunk_id\` UNINDEXED,
|
||||
\`document_id\` UNINDEXED,
|
||||
\`chunk_index\` UNINDEXED,
|
||||
\`scope\` UNINDEXED,
|
||||
\`user_id\` UNINDEXED,
|
||||
\`source_kind\` UNINDEXED,
|
||||
\`ticker\` UNINDEXED,
|
||||
\`accession_number\` UNINDEXED,
|
||||
\`filing_date\` UNINDEXED
|
||||
);
|
||||
`);
|
||||
|
||||
if (isVectorExtensionLoaded(client)) {
|
||||
client.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_vec\` USING vec0(
|
||||
\`chunk_id\` integer PRIMARY KEY,
|
||||
\`embedding\` float[256],
|
||||
\`scope\` text,
|
||||
\`user_id\` text,
|
||||
\`source_kind\` text,
|
||||
\`ticker\` text,
|
||||
\`accession_number\` text,
|
||||
\`filing_date\` text,
|
||||
+\`document_id\` integer,
|
||||
+\`chunk_index\` integer,
|
||||
+\`citation_label\` text
|
||||
);
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`search_chunk_vec\` (
|
||||
\`chunk_id\` integer PRIMARY KEY NOT NULL,
|
||||
\`embedding\` text NOT NULL,
|
||||
\`scope\` text NOT NULL,
|
||||
\`user_id\` text,
|
||||
\`source_kind\` text NOT NULL,
|
||||
\`ticker\` text,
|
||||
\`accession_number\` text,
|
||||
\`filing_date\` text,
|
||||
\`document_id\` integer NOT NULL,
|
||||
\`chunk_index\` integer NOT NULL,
|
||||
\`citation_label\` text NOT NULL
|
||||
);
|
||||
`);
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `search_chunk_vec_lookup_idx` ON `search_chunk_vec` (`scope`, `user_id`, `source_kind`, `ticker`);');
|
||||
}
|
||||
|
||||
export function getSqliteClient() {
|
||||
if (!globalThis.__fiscalSqliteClient) {
|
||||
configureCustomSqliteRuntime();
|
||||
const databasePath = getDatabasePath();
|
||||
|
||||
if (databasePath !== ':memory:') {
|
||||
@@ -156,7 +256,9 @@ export function getSqliteClient() {
|
||||
client.exec('PRAGMA foreign_keys = ON;');
|
||||
client.exec('PRAGMA journal_mode = WAL;');
|
||||
client.exec('PRAGMA busy_timeout = 5000;');
|
||||
loadSqliteExtensions(client);
|
||||
ensureLocalSqliteSchema(client);
|
||||
ensureSearchVirtualTables(client);
|
||||
|
||||
globalThis.__fiscalSqliteClient = client;
|
||||
}
|
||||
@@ -175,8 +277,12 @@ if (!globalThis.__fiscalDrizzleDb) {
|
||||
}
|
||||
|
||||
export const __dbInternals = {
|
||||
configureCustomSqliteRuntime,
|
||||
ensureLocalSqliteSchema,
|
||||
ensureSearchVirtualTables,
|
||||
getDatabasePath,
|
||||
hasColumn,
|
||||
hasTable
|
||||
hasTable,
|
||||
isVectorExtensionLoaded,
|
||||
loadSqliteExtensions
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
@@ -31,6 +32,9 @@ type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
|
||||
type CoveragePriority = 'low' | 'medium' | 'high';
|
||||
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
||||
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||
type SearchDocumentScope = 'global' | 'user';
|
||||
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
|
||||
type SearchIndexStatus = 'pending' | 'indexed' | 'failed';
|
||||
type FinancialSurfaceKind =
|
||||
| 'income_statement'
|
||||
| 'balance_sheet'
|
||||
@@ -500,7 +504,7 @@ export const filingLink = sqliteTable('filing_link', {
|
||||
export const taskRun = sqliteTable('task_run', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights'>().notNull(),
|
||||
task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights' | 'index_search'>().notNull(),
|
||||
status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(),
|
||||
stage: text('stage').notNull(),
|
||||
stage_detail: text('stage_detail'),
|
||||
@@ -570,6 +574,55 @@ export const researchJournalEntry = sqliteTable('research_journal_entry', {
|
||||
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
|
||||
}));
|
||||
|
||||
export const searchDocument = sqliteTable('search_document', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
source_kind: text('source_kind').$type<SearchDocumentSourceKind>().notNull(),
|
||||
source_ref: text('source_ref').notNull(),
|
||||
scope: text('scope').$type<SearchDocumentScope>().notNull(),
|
||||
user_id: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
|
||||
ticker: text('ticker'),
|
||||
accession_number: text('accession_number'),
|
||||
title: text('title'),
|
||||
content_text: text('content_text').notNull(),
|
||||
content_hash: text('content_hash').notNull(),
|
||||
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
|
||||
index_status: text('index_status').$type<SearchIndexStatus>().notNull(),
|
||||
indexed_at: text('indexed_at'),
|
||||
last_error: text('last_error'),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
searchDocumentSourceUnique: uniqueIndex('search_document_source_uidx').on(
|
||||
table.scope,
|
||||
sql`ifnull(${table.user_id}, '')`,
|
||||
table.source_kind,
|
||||
table.source_ref
|
||||
),
|
||||
searchDocumentScopeIndex: index('search_document_scope_idx').on(
|
||||
table.scope,
|
||||
table.source_kind,
|
||||
table.ticker,
|
||||
table.updated_at
|
||||
),
|
||||
searchDocumentAccessionIndex: index('search_document_accession_idx').on(table.accession_number, table.source_kind)
|
||||
}));
|
||||
|
||||
export const searchChunk = sqliteTable('search_chunk', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
document_id: integer('document_id').notNull().references(() => searchDocument.id, { onDelete: 'cascade' }),
|
||||
chunk_index: integer('chunk_index').notNull(),
|
||||
chunk_text: text('chunk_text').notNull(),
|
||||
char_count: integer('char_count').notNull(),
|
||||
start_offset: integer('start_offset').notNull(),
|
||||
end_offset: integer('end_offset').notNull(),
|
||||
heading_path: text('heading_path'),
|
||||
citation_label: text('citation_label').notNull(),
|
||||
created_at: text('created_at').notNull()
|
||||
}, (table) => ({
|
||||
searchChunkUnique: uniqueIndex('search_chunk_document_chunk_uidx').on(table.document_id, table.chunk_index),
|
||||
searchChunkDocumentIndex: index('search_chunk_document_idx').on(table.document_id)
|
||||
}));
|
||||
|
||||
export const authSchema = {
|
||||
user,
|
||||
session,
|
||||
@@ -595,7 +648,9 @@ export const appSchema = {
|
||||
taskRun,
|
||||
taskStageEvent,
|
||||
portfolioInsight,
|
||||
researchJournalEntry
|
||||
researchJournalEntry,
|
||||
searchDocument,
|
||||
searchChunk
|
||||
};
|
||||
|
||||
export const schema = {
|
||||
|
||||
@@ -62,6 +62,28 @@ export async function listResearchJournalEntries(userId: string, ticker: string,
|
||||
return rows.map(toResearchJournalEntry);
|
||||
}
|
||||
|
||||
export async function listResearchJournalEntriesForUser(userId: string, limit = 250) {
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 500);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(researchJournalEntry)
|
||||
.where(eq(researchJournalEntry.user_id, userId))
|
||||
.orderBy(desc(researchJournalEntry.updated_at), desc(researchJournalEntry.id))
|
||||
.limit(safeLimit);
|
||||
|
||||
return rows.map(toResearchJournalEntry);
|
||||
}
|
||||
|
||||
export async function getResearchJournalEntryRecord(userId: string, id: number) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
|
||||
.limit(1);
|
||||
|
||||
return row ? toResearchJournalEntry(row) : null;
|
||||
}
|
||||
|
||||
export async function createResearchJournalEntryRecord(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
|
||||
217
lib/server/search.test.ts
Normal file
217
lib/server/search.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { __dbInternals } from '@/lib/server/db';
|
||||
import { __searchInternals } from '@/lib/server/search';
|
||||
|
||||
function applyMigration(client: Database, fileName: string) {
|
||||
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function createClient() {
|
||||
const client = new Database(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON;');
|
||||
applyMigration(client, '0000_cold_silver_centurion.sql');
|
||||
applyMigration(client, '0001_glossy_statement_snapshots.sql');
|
||||
applyMigration(client, '0002_workflow_task_projection_metadata.sql');
|
||||
applyMigration(client, '0003_task_stage_event_timeline.sql');
|
||||
__dbInternals.loadSqliteExtensions(client);
|
||||
__dbInternals.ensureLocalSqliteSchema(client);
|
||||
__dbInternals.ensureSearchVirtualTables(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
function insertUser(client: Database, id: string) {
|
||||
client.query(`
|
||||
INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, 1, 0, 0)
|
||||
`).run(id, id, `${id}@example.com`);
|
||||
}
|
||||
|
||||
function vector(first: number, second = 0) {
|
||||
const values = new Array(256).fill(0);
|
||||
values[0] = first;
|
||||
values[1] = second;
|
||||
return values;
|
||||
}
|
||||
|
||||
describe('search internals', () => {
|
||||
it('chunks research notes as a single chunk under the small-note threshold', () => {
|
||||
const chunks = __searchInternals.chunkDocument({
|
||||
sourceKind: 'research_note',
|
||||
sourceRef: '1',
|
||||
scope: 'user',
|
||||
userId: 'user-1',
|
||||
ticker: 'AMD',
|
||||
accessionNumber: null,
|
||||
filingDate: null,
|
||||
title: 'AMD note',
|
||||
contentText: 'A compact note about margins and channel inventory.',
|
||||
metadata: {}
|
||||
});
|
||||
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]?.chunkText).toContain('channel inventory');
|
||||
});
|
||||
|
||||
it('formats insufficient evidence when the answer cites nothing valid', () => {
|
||||
const finalized = __searchInternals.finalizeAnswer('This has no valid citations.', [{
|
||||
chunkId: 1,
|
||||
documentId: 1,
|
||||
source: 'filings',
|
||||
sourceKind: 'filing_brief',
|
||||
sourceRef: '0001',
|
||||
title: 'Brief',
|
||||
ticker: 'AMD',
|
||||
accessionNumber: '0001',
|
||||
filingDate: '2026-01-01',
|
||||
citationLabel: 'AMD · 0001 [1]',
|
||||
headingPath: null,
|
||||
chunkText: 'Revenue grew.',
|
||||
snippet: 'Revenue grew.',
|
||||
score: 0.2,
|
||||
vectorRank: 1,
|
||||
lexicalRank: 1,
|
||||
href: '/filings?ticker=AMD'
|
||||
}]);
|
||||
|
||||
expect(finalized.answer).toBe('Insufficient evidence to answer from the indexed sources.');
|
||||
expect(finalized.citations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('persists vec/fts rows, skips unchanged content, and deletes synced rows together', () => {
|
||||
const client = createClient();
|
||||
const document = {
|
||||
sourceKind: 'filing_brief' as const,
|
||||
sourceRef: '0000320193-26-000001',
|
||||
scope: 'global' as const,
|
||||
userId: null,
|
||||
ticker: 'AAPL',
|
||||
accessionNumber: '0000320193-26-000001',
|
||||
filingDate: '2026-01-30',
|
||||
title: 'AAPL filing brief',
|
||||
contentText: 'Revenue remained resilient across products and services. Services margin expanded.',
|
||||
metadata: {
|
||||
filingDate: '2026-01-30',
|
||||
hasAnalysis: true
|
||||
}
|
||||
};
|
||||
|
||||
const chunks = __searchInternals.chunkDocument(document);
|
||||
const firstPersist = __searchInternals.persistDocumentIndex(
|
||||
client,
|
||||
document,
|
||||
chunks,
|
||||
chunks.map((_chunk, index) => vector(1 - (index * 0.1)))
|
||||
);
|
||||
|
||||
expect(firstPersist.indexed).toBe(true);
|
||||
expect(client.query('SELECT count(*) AS count FROM search_document').get() as { count: number }).toEqual({ count: 1 });
|
||||
expect((client.query('SELECT count(*) AS count FROM search_chunk').get() as { count: number }).count).toBe(chunks.length);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_chunk_fts').get() as { count: number }).count).toBe(chunks.length);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_chunk_vec').get() as { count: number }).count).toBe(chunks.length);
|
||||
|
||||
const secondPersist = __searchInternals.persistDocumentIndex(
|
||||
client,
|
||||
document,
|
||||
chunks,
|
||||
chunks.map((_chunk, index) => vector(1 - (index * 0.1)))
|
||||
);
|
||||
|
||||
expect(secondPersist.skipped).toBe(true);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_document').get() as { count: number }).count).toBe(1);
|
||||
|
||||
const deleted = __searchInternals.deleteSourceRefs(client, [{
|
||||
sourceKind: 'filing_brief',
|
||||
sourceRef: document.sourceRef,
|
||||
scope: 'global'
|
||||
}]);
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_document').get() as { count: number }).count).toBe(0);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_chunk').get() as { count: number }).count).toBe(0);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_chunk_fts').get() as { count: number }).count).toBe(0);
|
||||
expect((client.query('SELECT count(*) AS count FROM search_chunk_vec').get() as { count: number }).count).toBe(0);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
it('keeps user-scoped research notes isolated in lexical and vector search', () => {
|
||||
const client = createClient();
|
||||
insertUser(client, 'user-1');
|
||||
insertUser(client, 'user-2');
|
||||
|
||||
const userOneDoc = {
|
||||
sourceKind: 'research_note' as const,
|
||||
sourceRef: '101',
|
||||
scope: 'user' as const,
|
||||
userId: 'user-1',
|
||||
ticker: 'AMD',
|
||||
accessionNumber: null,
|
||||
filingDate: null,
|
||||
title: 'Durable thesis',
|
||||
contentText: 'Durable pricing power thesis with channel checks.',
|
||||
metadata: {}
|
||||
};
|
||||
const userTwoDoc = {
|
||||
...userOneDoc,
|
||||
sourceRef: '102',
|
||||
userId: 'user-2',
|
||||
contentText: 'Different private note for another user.'
|
||||
};
|
||||
|
||||
const userOneChunks = __searchInternals.chunkDocument(userOneDoc);
|
||||
const userTwoChunks = __searchInternals.chunkDocument(userTwoDoc);
|
||||
|
||||
__searchInternals.persistDocumentIndex(client, userOneDoc, userOneChunks, [vector(1, 0)]);
|
||||
__searchInternals.persistDocumentIndex(client, userTwoDoc, userTwoChunks, [vector(0, 1)]);
|
||||
|
||||
const ftsQuery = __searchInternals.toFtsQuery('durable thesis');
|
||||
expect(ftsQuery).not.toBeNull();
|
||||
|
||||
const lexicalMatches = __searchInternals.lexicalSearch(client, {
|
||||
ftsQuery: ftsQuery!,
|
||||
limit: 5,
|
||||
sourceKind: 'research_note',
|
||||
scope: 'user',
|
||||
userId: 'user-1',
|
||||
ticker: 'AMD'
|
||||
});
|
||||
const hiddenLexicalMatches = __searchInternals.lexicalSearch(client, {
|
||||
ftsQuery: ftsQuery!,
|
||||
limit: 5,
|
||||
sourceKind: 'research_note',
|
||||
scope: 'user',
|
||||
userId: 'user-2',
|
||||
ticker: 'AMD'
|
||||
});
|
||||
|
||||
expect(lexicalMatches).toHaveLength(1);
|
||||
expect(hiddenLexicalMatches).toHaveLength(0);
|
||||
|
||||
const vectorMatches = __searchInternals.vectorSearch(client, {
|
||||
embedding: vector(1, 0),
|
||||
limit: 5,
|
||||
sourceKind: 'research_note',
|
||||
scope: 'user',
|
||||
userId: 'user-1',
|
||||
ticker: 'AMD'
|
||||
});
|
||||
const hiddenVectorMatches = __searchInternals.vectorSearch(client, {
|
||||
embedding: vector(1, 0),
|
||||
limit: 5,
|
||||
sourceKind: 'research_note',
|
||||
scope: 'user',
|
||||
userId: 'user-2',
|
||||
ticker: 'AMD'
|
||||
});
|
||||
|
||||
expect(vectorMatches).toHaveLength(1);
|
||||
expect(hiddenVectorMatches).toHaveLength(1);
|
||||
expect(vectorMatches[0]?.chunk_id).not.toBe(hiddenVectorMatches[0]?.chunk_id);
|
||||
|
||||
client.close();
|
||||
});
|
||||
});
|
||||
1315
lib/server/search.ts
Normal file
1315
lib/server/search.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import type {
|
||||
import { runAiAnalysis } from '@/lib/server/ai';
|
||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||
import { getQuote } from '@/lib/server/prices';
|
||||
import { indexSearchDocuments } from '@/lib/server/search';
|
||||
import {
|
||||
getFilingByAccession,
|
||||
listFilingsRecords,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
fetchPrimaryFilingText,
|
||||
fetchRecentFilings
|
||||
} from '@/lib/server/sec';
|
||||
import { enqueueTask } from '@/lib/server/tasks';
|
||||
import { hydrateFilingTaxonomySnapshot } from '@/lib/server/taxonomy/engine';
|
||||
|
||||
const EXTRACTION_REQUIRED_KEYS = [
|
||||
@@ -167,6 +169,17 @@ function parseOptionalText(raw: unknown) {
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function parseOptionalStringArray(raw: unknown) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function parseTags(raw: unknown) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
@@ -562,6 +575,8 @@ async function processSyncFilings(task: Task) {
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(' | ');
|
||||
|
||||
let searchTaskId: string | null = null;
|
||||
|
||||
await setProjectionStage(
|
||||
task,
|
||||
'sync.fetch_filings',
|
||||
@@ -667,6 +682,22 @@ async function processSyncFilings(task: Task) {
|
||||
await Bun.sleep(STATEMENT_HYDRATION_DELAY_MS);
|
||||
}
|
||||
|
||||
try {
|
||||
const searchTask = await enqueueTask({
|
||||
userId: task.user_id,
|
||||
taskType: 'index_search',
|
||||
payload: {
|
||||
ticker,
|
||||
sourceKinds: ['filing_document', 'filing_brief']
|
||||
},
|
||||
priority: 55,
|
||||
resourceKey: `index_search:ticker:${ticker}`
|
||||
});
|
||||
searchTaskId = searchTask.id;
|
||||
} catch (error) {
|
||||
console.error(`[search-index-sync] failed for ${ticker}:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
ticker,
|
||||
category,
|
||||
@@ -675,7 +706,8 @@ async function processSyncFilings(task: Task) {
|
||||
inserted: saveResult.inserted,
|
||||
updated: saveResult.updated,
|
||||
taxonomySnapshotsHydrated,
|
||||
taxonomySnapshotsFailed
|
||||
taxonomySnapshotsFailed,
|
||||
searchTaskId
|
||||
};
|
||||
}
|
||||
|
||||
@@ -782,12 +814,108 @@ async function processAnalyzeFiling(task: Task) {
|
||||
extractionMeta
|
||||
});
|
||||
|
||||
let searchTaskId: string | null = null;
|
||||
try {
|
||||
const searchTask = await enqueueTask({
|
||||
userId: task.user_id,
|
||||
taskType: 'index_search',
|
||||
payload: {
|
||||
accessionNumber,
|
||||
sourceKinds: ['filing_brief']
|
||||
},
|
||||
priority: 58,
|
||||
resourceKey: `index_search:filing_brief:${accessionNumber}`
|
||||
});
|
||||
searchTaskId = searchTask.id;
|
||||
} catch (error) {
|
||||
console.error(`[search-index-analyze] failed for ${accessionNumber}:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
accessionNumber,
|
||||
provider: analysis.provider,
|
||||
model: analysis.model,
|
||||
extractionProvider: extractionMeta.provider,
|
||||
extractionModel: extractionMeta.model
|
||||
extractionModel: extractionMeta.model,
|
||||
searchTaskId
|
||||
};
|
||||
}
|
||||
|
||||
async function processIndexSearch(task: Task) {
|
||||
await setProjectionStage(task, 'search.collect_sources', 'Collecting source records for search indexing');
|
||||
|
||||
const ticker = parseOptionalText(task.payload.ticker);
|
||||
const accessionNumber = parseOptionalText(task.payload.accessionNumber);
|
||||
const journalEntryId = task.payload.journalEntryId === undefined
|
||||
? null
|
||||
: Number(task.payload.journalEntryId);
|
||||
const deleteSourceRefs = Array.isArray(task.payload.deleteSourceRefs)
|
||||
? task.payload.deleteSourceRefs
|
||||
.filter((entry): entry is {
|
||||
sourceKind: string;
|
||||
sourceRef: string;
|
||||
scope: string;
|
||||
userId?: string | null;
|
||||
} => {
|
||||
return Boolean(
|
||||
entry
|
||||
&& typeof entry === 'object'
|
||||
&& typeof (entry as { sourceKind?: unknown }).sourceKind === 'string'
|
||||
&& typeof (entry as { sourceRef?: unknown }).sourceRef === 'string'
|
||||
&& typeof (entry as { scope?: unknown }).scope === 'string'
|
||||
);
|
||||
})
|
||||
: [];
|
||||
const sourceKinds = parseOptionalStringArray(task.payload.sourceKinds)
|
||||
.filter((sourceKind): sourceKind is 'filing_document' | 'filing_brief' | 'research_note' => {
|
||||
return sourceKind === 'filing_document'
|
||||
|| sourceKind === 'filing_brief'
|
||||
|| sourceKind === 'research_note';
|
||||
});
|
||||
const validatedJournalEntryId = typeof journalEntryId === 'number'
|
||||
&& Number.isInteger(journalEntryId)
|
||||
&& journalEntryId > 0
|
||||
? journalEntryId
|
||||
: null;
|
||||
|
||||
const result = await indexSearchDocuments({
|
||||
userId: task.user_id,
|
||||
ticker,
|
||||
accessionNumber,
|
||||
journalEntryId: validatedJournalEntryId,
|
||||
sourceKinds: sourceKinds.length > 0 ? sourceKinds : undefined,
|
||||
deleteSourceRefs: deleteSourceRefs.map((entry) => ({
|
||||
sourceKind: entry.sourceKind as 'filing_document' | 'filing_brief' | 'research_note',
|
||||
sourceRef: entry.sourceRef,
|
||||
scope: entry.scope === 'user' ? 'user' : 'global',
|
||||
userId: typeof entry.userId === 'string' ? entry.userId : null
|
||||
})),
|
||||
onStage: async (stage, detail) => {
|
||||
switch (stage) {
|
||||
case 'collect':
|
||||
await setProjectionStage(task, 'search.collect_sources', detail);
|
||||
break;
|
||||
case 'fetch':
|
||||
await setProjectionStage(task, 'search.fetch_documents', detail);
|
||||
break;
|
||||
case 'chunk':
|
||||
await setProjectionStage(task, 'search.chunk', detail);
|
||||
break;
|
||||
case 'embed':
|
||||
await setProjectionStage(task, 'search.embed', detail);
|
||||
break;
|
||||
case 'persist':
|
||||
await setProjectionStage(task, 'search.persist', detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ticker,
|
||||
accessionNumber,
|
||||
journalEntryId: validatedJournalEntryId,
|
||||
...result
|
||||
};
|
||||
}
|
||||
|
||||
@@ -858,6 +986,8 @@ export async function runTaskProcessor(task: Task) {
|
||||
return toTaskResult(await processAnalyzeFiling(task));
|
||||
case 'portfolio_insights':
|
||||
return toTaskResult(await processPortfolioInsights(task));
|
||||
case 'index_search':
|
||||
return toTaskResult(await processIndexSearch(task));
|
||||
default:
|
||||
throw new Error(`Unsupported task type: ${task.task_type}`);
|
||||
}
|
||||
|
||||
46
lib/types.ts
46
lib/types.ts
@@ -101,7 +101,12 @@ export type Filing = {
|
||||
};
|
||||
|
||||
export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed';
|
||||
export type TaskType = 'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights';
|
||||
export type TaskType =
|
||||
| 'sync_filings'
|
||||
| 'refresh_prices'
|
||||
| 'analyze_filing'
|
||||
| 'portfolio_insights'
|
||||
| 'index_search';
|
||||
export type TaskStage =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
@@ -125,6 +130,11 @@ export type TaskStage =
|
||||
| 'analyze.extract'
|
||||
| 'analyze.generate_report'
|
||||
| 'analyze.persist_report'
|
||||
| 'search.collect_sources'
|
||||
| 'search.fetch_documents'
|
||||
| 'search.chunk'
|
||||
| 'search.embed'
|
||||
| 'search.persist'
|
||||
| 'insights.load_holdings'
|
||||
| 'insights.generate'
|
||||
| 'insights.persist';
|
||||
@@ -188,6 +198,40 @@ export type ResearchJournalEntry = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type SearchSource = 'documents' | 'filings' | 'research';
|
||||
export type SearchResult = {
|
||||
chunkId: number;
|
||||
documentId: number;
|
||||
source: SearchSource;
|
||||
sourceKind: 'filing_document' | 'filing_brief' | 'research_note';
|
||||
sourceRef: string;
|
||||
title: string | null;
|
||||
ticker: string | null;
|
||||
accessionNumber: string | null;
|
||||
filingDate: string | null;
|
||||
citationLabel: string;
|
||||
headingPath: string | null;
|
||||
chunkText: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
vectorRank: number | null;
|
||||
lexicalRank: number | null;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type SearchCitation = {
|
||||
index: number;
|
||||
label: string;
|
||||
chunkId: number;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export type SearchAnswerResponse = {
|
||||
answer: string;
|
||||
citations: SearchCitation[];
|
||||
results: SearchResult[];
|
||||
};
|
||||
|
||||
export type CompanyFinancialPoint = {
|
||||
filingDate: string;
|
||||
filingType: Filing['filing_type'];
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"workflow:setup": "workflow-postgres-setup",
|
||||
"backfill:filing-metrics": "bun run scripts/backfill-filing-metrics.ts",
|
||||
"backfill:filing-statements": "bun run scripts/backfill-filing-statements.ts",
|
||||
"backfill:search-index": "bun run scripts/backfill-search-index.ts",
|
||||
"backfill:taxonomy-snapshots": "bun run scripts/backfill-taxonomy-snapshots.ts",
|
||||
"db:generate": "bun x drizzle-kit generate",
|
||||
"db:migrate": "bun x drizzle-kit migrate",
|
||||
@@ -42,6 +43,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sqlite-vec": "^0.1.7-alpha.2",
|
||||
"workflow": "^4.1.0-beta.60",
|
||||
"zhipu-ai-provider": "^0.2.2"
|
||||
},
|
||||
|
||||
46
scripts/backfill-search-index.ts
Normal file
46
scripts/backfill-search-index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { indexSearchDocuments } from '@/lib/server/search';
|
||||
|
||||
function getArg(name: string) {
|
||||
const prefix = `--${name}=`;
|
||||
const entry = process.argv.find((value) => value.startsWith(prefix));
|
||||
return entry ? entry.slice(prefix.length).trim() : null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const source = (getArg('source') ?? 'all').toLowerCase();
|
||||
const ticker = getArg('ticker')?.toUpperCase() ?? null;
|
||||
const accessionNumber = getArg('accession') ?? null;
|
||||
const userId = getArg('user') ?? 'system-backfill';
|
||||
|
||||
const sourceKinds: Array<'filing_document' | 'filing_brief' | 'research_note'> | null = source === 'all'
|
||||
? ['filing_document', 'filing_brief', 'research_note'] as const
|
||||
: source === 'documents'
|
||||
? ['filing_document'] as const
|
||||
: source === 'filings'
|
||||
? ['filing_brief'] as const
|
||||
: source === 'research'
|
||||
? ['research_note'] as const
|
||||
: null;
|
||||
|
||||
if (!sourceKinds) {
|
||||
throw new Error('Unsupported --source value. Use all, documents, filings, or research.');
|
||||
}
|
||||
|
||||
if (sourceKinds.includes('research_note') && !userId) {
|
||||
throw new Error('--user is required when backfilling research notes.');
|
||||
}
|
||||
|
||||
const result = await indexSearchDocuments({
|
||||
userId,
|
||||
ticker,
|
||||
accessionNumber,
|
||||
sourceKinds
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -9,7 +9,7 @@ describe('buildLocalDevConfig', () => {
|
||||
expect(config.port).toBe('3000');
|
||||
expect(config.publicOrigin).toBe('http://localhost:3000');
|
||||
expect(config.env.BETTER_AUTH_BASE_URL).toBe('http://localhost:3000');
|
||||
expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('http://localhost:3000');
|
||||
expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('http://localhost:3000,http://127.0.0.1:3000');
|
||||
expect(config.env.BETTER_AUTH_SECRET).toBe(LOCAL_DEV_SECRET);
|
||||
expect(config.env.DATABASE_URL).toBe('file:data/fiscal.sqlite');
|
||||
expect(config.env.NEXT_PUBLIC_API_URL).toBe('');
|
||||
@@ -27,12 +27,23 @@ describe('buildLocalDevConfig', () => {
|
||||
});
|
||||
|
||||
expect(config.env.BETTER_AUTH_SECRET).toBe('real-secret');
|
||||
expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('http://localhost:3000,https://fiscal.b11studio.xyz');
|
||||
expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS)
|
||||
.toBe('http://localhost:3000,http://127.0.0.1:3000,https://fiscal.b11studio.xyz');
|
||||
expect(config.env.DATABASE_URL).toBe('file:data/dev.sqlite');
|
||||
expect(config.overrides.databaseChanged).toBe(false);
|
||||
expect(config.overrides.workflowChanged).toBe(false);
|
||||
});
|
||||
|
||||
it('trusts both localhost and 127.0.0.1 for loopback public origins', () => {
|
||||
const config = buildLocalDevConfig({
|
||||
DEV_PUBLIC_HOST: '127.0.0.1',
|
||||
PORT: '3412'
|
||||
});
|
||||
|
||||
expect(config.publicOrigin).toBe('http://127.0.0.1:3412');
|
||||
expect(config.env.BETTER_AUTH_TRUSTED_ORIGINS).toBe('http://127.0.0.1:3412,http://localhost:3412');
|
||||
});
|
||||
|
||||
it('respects an explicit public origin override', () => {
|
||||
const config = buildLocalDevConfig({
|
||||
DEV_PUBLIC_ORIGIN: 'https://local.fiscal.test:4444/',
|
||||
|
||||
@@ -43,6 +43,34 @@ function toUniqueList(values: string[]) {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string) {
|
||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
||||
}
|
||||
|
||||
function replaceOriginHostname(origin: string, hostname: string) {
|
||||
const url = new URL(origin);
|
||||
url.hostname = hostname;
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
|
||||
const pathName = url.pathname.replace(/\/$/, '');
|
||||
return `${url.origin}${pathName === '/' ? '' : pathName}`;
|
||||
}
|
||||
|
||||
function buildTrustedOrigins(publicOrigin: string, configuredOrigins: string | undefined) {
|
||||
const trustedOrigins = [publicOrigin];
|
||||
const publicOriginUrl = new URL(publicOrigin);
|
||||
|
||||
if (isLoopbackHostname(publicOriginUrl.hostname)) {
|
||||
trustedOrigins.push(replaceOriginHostname(publicOrigin, 'localhost'));
|
||||
trustedOrigins.push(replaceOriginHostname(publicOrigin, '127.0.0.1'));
|
||||
}
|
||||
|
||||
trustedOrigins.push(...parseCsvList(configuredOrigins));
|
||||
|
||||
return toUniqueList(trustedOrigins).join(',');
|
||||
}
|
||||
|
||||
function coercePort(port: string | undefined) {
|
||||
const parsed = Number.parseInt(port ?? '', 10);
|
||||
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
||||
@@ -113,10 +141,7 @@ export function buildLocalDevConfig(sourceEnv: EnvMap = process.env): LocalDevCo
|
||||
? DEFAULT_DATABASE_URL
|
||||
: trim(sourceEnv.DATABASE_URL) ?? DEFAULT_DATABASE_URL;
|
||||
const secret = trim(sourceEnv.BETTER_AUTH_SECRET);
|
||||
const trustedOrigins = toUniqueList([
|
||||
publicOrigin,
|
||||
...parseCsvList(sourceEnv.BETTER_AUTH_TRUSTED_ORIGINS)
|
||||
]).join(',');
|
||||
const trustedOrigins = buildTrustedOrigins(publicOrigin, sourceEnv.BETTER_AUTH_TRUSTED_ORIGINS);
|
||||
|
||||
const env: EnvMap = {
|
||||
...sourceEnv,
|
||||
|
||||
Reference in New Issue
Block a user