Add search and RAG workspace flows

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

View File

@@ -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)]">

View File

@@ -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}

View File

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

View File

@@ -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;
}
}

View File

@@ -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">

View File

@@ -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
View 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>
);
}

View File

@@ -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}

View File

@@ -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=="],

View File

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

View File

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

View File

@@ -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>
);
}

View File

@@ -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',

View File

@@ -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;

View File

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

View File

@@ -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}

View File

@@ -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}

View 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`);

View File

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

View File

@@ -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,

View File

@@ -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();

View File

@@ -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;
}

View File

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

View File

@@ -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();
});

View File

@@ -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
};

View File

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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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}`);
}

View File

@@ -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'];

View File

@@ -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"
},

View 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;
});

View File

@@ -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/',

View File

@@ -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,