Add search and RAG workspace flows
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
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 { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
@@ -169,10 +169,12 @@ function AnalysisPageContent() {
|
|||||||
const [journalLoading, setJournalLoading] = useState(true);
|
const [journalLoading, setJournalLoading] = useState(true);
|
||||||
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
const [journalForm, setJournalForm] = useState<JournalFormState>(EMPTY_JOURNAL_FORM);
|
||||||
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
|
const [editingJournalId, setEditingJournalId] = useState<number | null>(null);
|
||||||
|
const [highlightedJournalId, setHighlightedJournalId] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
const [financialPeriodFilter, setFinancialPeriodFilter] = useState<FinancialPeriodFilter>('quarterlyAndFiscalYearEnd');
|
||||||
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
const [financialValueScale, setFinancialValueScale] = useState<NumberScaleUnit>('millions');
|
||||||
|
const journalEntryRefs = useRef(new Map<number, HTMLElement | null>());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
const normalized = normalizeTickerInput(searchParams.get('ticker'));
|
||||||
@@ -182,6 +184,8 @@ function AnalysisPageContent() {
|
|||||||
|
|
||||||
setTickerInput(normalized);
|
setTickerInput(normalized);
|
||||||
setTicker(normalized);
|
setTicker(normalized);
|
||||||
|
const journalId = Number(searchParams.get('journalId'));
|
||||||
|
setHighlightedJournalId(Number.isInteger(journalId) && journalId > 0 ? journalId : null);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const loadAnalysis = useCallback(async (symbol: string) => {
|
const loadAnalysis = useCallback(async (symbol: string) => {
|
||||||
@@ -231,6 +235,26 @@ function AnalysisPageContent() {
|
|||||||
}
|
}
|
||||||
}, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]);
|
}, [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(() => {
|
const priceSeries = useMemo(() => {
|
||||||
return (analysis?.priceHistory ?? []).map((point) => ({
|
return (analysis?.priceHistory ?? []).map((point) => ({
|
||||||
...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."
|
subtitle="Research a single ticker across pricing, 10-K/10-Q financials, qualitative filings, and generated AI reports."
|
||||||
activeTicker={analysis?.company.ticker ?? ticker}
|
activeTicker={analysis?.company.ticker ?? ticker}
|
||||||
actions={(
|
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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -364,11 +396,12 @@ function AnalysisPageContent() {
|
|||||||
<RefreshCcw className="size-4" />
|
<RefreshCcw className="size-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Panel title="Search Company" subtitle="Enter a ticker symbol to load the latest analysis view.">
|
<Panel title="Search Company" subtitle="Enter a ticker symbol to load the latest analysis view.">
|
||||||
<form
|
<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) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const normalized = tickerInput.trim().toUpperCase();
|
const normalized = tickerInput.trim().toUpperCase();
|
||||||
@@ -383,9 +416,9 @@ function AnalysisPageContent() {
|
|||||||
aria-label="Analysis ticker"
|
aria-label="Analysis ticker"
|
||||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||||
placeholder="Ticker (AAPL)"
|
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" />
|
<Search className="size-4" />
|
||||||
Analyze
|
Analyze
|
||||||
</Button>
|
</Button>
|
||||||
@@ -462,7 +495,7 @@ function AnalysisPageContent() {
|
|||||||
<Panel title="Coverage Workflow" subtitle="Coverage metadata shared with the Coverage page.">
|
<Panel title="Coverage Workflow" subtitle="Coverage metadata shared with the Coverage page.">
|
||||||
{analysis?.coverage ? (
|
{analysis?.coverage ? (
|
||||||
<div className="space-y-3 text-sm">
|
<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">
|
<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="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>
|
<p className="mt-1 text-[color:var(--terminal-bright)]">{analysis.coverage.status}</p>
|
||||||
@@ -560,7 +593,7 @@ function AnalysisPageContent() {
|
|||||||
) : priceSeries.length === 0 ? (
|
) : priceSeries.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No price history available.</p>
|
<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%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={priceSeries}>
|
<LineChart data={priceSeries}>
|
||||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||||
@@ -638,7 +671,38 @@ function AnalysisPageContent() {
|
|||||||
) : filteredFinancialSeries.length === 0 ? (
|
) : filteredFinancialSeries.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No financial rows match the selected period filter.</p>
|
<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]">
|
<table className="data-table min-w-[820px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -668,6 +732,7 @@ function AnalysisPageContent() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
@@ -681,7 +746,40 @@ function AnalysisPageContent() {
|
|||||||
) : periodEndFilings.length === 0 ? (
|
) : periodEndFilings.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No 10-K or 10-Q filings available for this ticker.</p>
|
<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]">
|
<table className="data-table min-w-[860px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -717,6 +815,7 @@ function AnalysisPageContent() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -739,7 +838,7 @@ function AnalysisPageContent() {
|
|||||||
<BrainCircuit className="size-4 text-[color:var(--accent)]" />
|
<BrainCircuit className="size-4 text-[color:var(--accent)]" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 line-clamp-6 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{report.summary}</p>
|
<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>
|
<p className="text-xs text-[color:var(--terminal-muted)]">{report.accessionNumber}</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(report.accessionNumber)}`}
|
href={`/analysis/reports/${analysis.company.ticker}/${encodeURIComponent(report.accessionNumber)}`}
|
||||||
@@ -792,12 +891,12 @@ function AnalysisPageContent() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="submit">
|
<Button type="submit" className="w-full sm:w-auto">
|
||||||
<NotebookPen className="size-4" />
|
<NotebookPen className="size-4" />
|
||||||
{editingJournalId === null ? 'Save note' : 'Update note'}
|
{editingJournalId === null ? 'Save note' : 'Update note'}
|
||||||
</Button>
|
</Button>
|
||||||
{editingJournalId !== null ? (
|
{editingJournalId !== null ? (
|
||||||
<Button type="button" variant="ghost" onClick={resetJournalForm}>
|
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetJournalForm}>
|
||||||
Cancel edit
|
Cancel edit
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -816,7 +915,17 @@ function AnalysisPageContent() {
|
|||||||
const canEdit = entry.entry_type !== 'status_change';
|
const canEdit = entry.entry_type !== 'status_change';
|
||||||
|
|
||||||
return (
|
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 className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ function FilingsPageContent() {
|
|||||||
subtitle="Sync SEC submissions, keep 10-K/10-Q financial snapshots, and analyze qualitative signals from other forms."
|
subtitle="Sync SEC submissions, keep 10-K/10-Q financial snapshots, and analyze qualitative signals from other forms."
|
||||||
activeTicker={searchTicker || null}
|
activeTicker={searchTicker || null}
|
||||||
actions={(
|
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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
@@ -261,6 +269,7 @@ function FilingsPageContent() {
|
|||||||
<TimerReset className="size-4" />
|
<TimerReset className="size-4" />
|
||||||
Refresh table
|
Refresh table
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1.2fr_1fr]">
|
||||||
@@ -334,7 +343,7 @@ function FilingsPageContent() {
|
|||||||
title="Filing Ledger"
|
title="Filing Ledger"
|
||||||
subtitle={`${filings.length} records loaded${searchTicker ? ` for ${searchTicker}` : ''}. Values shown in ${selectedFinancialScaleLabel}.`}
|
subtitle={`${filings.length} records loaded${searchTicker ? ` for ${searchTicker}` : ''}. Values shown in ${selectedFinancialScaleLabel}.`}
|
||||||
actions={(
|
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) => (
|
{FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => (
|
||||||
<Button
|
<Button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ function FinancialsPageContent() {
|
|||||||
>
|
>
|
||||||
<Panel title="Company Selector" subtitle="Load one ticker across statements, ratios, and KPI time series.">
|
<Panel title="Company Selector" subtitle="Load one ticker across statements, ratios, and KPI time series.">
|
||||||
<form
|
<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) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const normalized = tickerInput.trim().toUpperCase();
|
const normalized = tickerInput.trim().toUpperCase();
|
||||||
@@ -623,9 +623,9 @@ function FinancialsPageContent() {
|
|||||||
value={tickerInput}
|
value={tickerInput}
|
||||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||||
placeholder="Ticker (AAPL)"
|
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" />
|
<Search className="size-4" />
|
||||||
Load Financials
|
Load Financials
|
||||||
</Button>
|
</Button>
|
||||||
@@ -656,12 +656,12 @@ function FinancialsPageContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Panel title="Search & Filters" subtitle="Filter rows without mutating the source data.">
|
<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
|
<Input
|
||||||
value={rowSearch}
|
value={rowSearch}
|
||||||
onChange={(event) => setRowSearch(event.target.value)}
|
onChange={(event) => setRowSearch(event.target.value)}
|
||||||
placeholder="Search rows by label"
|
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)]">
|
<span className="text-sm text-[color:var(--terminal-muted)]">
|
||||||
{filteredRows.length} of {activeRows.length} rows
|
{filteredRows.length} of {activeRows.length} rows
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
[data-sonner-toaster] {
|
[data-sonner-toaster] {
|
||||||
--normal-bg: rgba(7, 22, 31, 0.96);
|
--normal-bg: rgba(7, 22, 31, 0.96);
|
||||||
--normal-text: #e8fff8;
|
--normal-text: #e8fff8;
|
||||||
@@ -48,6 +52,7 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
font-family: var(--font-display), sans-serif;
|
font-family: var(--font-display), sans-serif;
|
||||||
color: var(--terminal-bright);
|
color: var(--terminal-bright);
|
||||||
background:
|
background:
|
||||||
@@ -92,6 +97,14 @@ body {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table {
|
.data-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -140,3 +153,18 @@ body {
|
|||||||
background-size: 26px 26px;
|
background-size: 26px 26px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% -4%, rgba(126, 217, 255, 0.2), transparent 36%),
|
||||||
|
radial-gradient(circle at 82% 2%, rgba(104, 255, 213, 0.16), transparent 30%),
|
||||||
|
linear-gradient(155deg, var(--bg-0), var(--bg-1) 54%, var(--bg-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 0.65rem 0.55rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import './globals.css';
|
import './globals.css';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { QueryProvider } from '@/components/providers/query-provider';
|
import { QueryProvider } from '@/components/providers/query-provider';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -7,6 +7,13 @@ export const metadata: Metadata = {
|
|||||||
description: 'Futuristic fiscal intelligence terminal with durable tasks and AI SDK integration.'
|
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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|||||||
@@ -179,16 +179,16 @@ export default function PortfolioPage() {
|
|||||||
title="Portfolio"
|
title="Portfolio"
|
||||||
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
subtitle="Position management, market valuation, and AI generated portfolio commentary."
|
||||||
actions={(
|
actions={(
|
||||||
<>
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">
|
||||||
<Button variant="secondary" onClick={() => void queueRefresh()}>
|
<Button variant="secondary" className="w-full sm:w-auto" onClick={() => void queueRefresh()}>
|
||||||
<RefreshCcw className="size-4" />
|
<RefreshCcw className="size-4" />
|
||||||
Queue price refresh
|
Queue price refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void queueInsights()}>
|
<Button className="w-full sm:w-auto" onClick={() => void queueInsights()}>
|
||||||
<BrainCircuit className="size-4" />
|
<BrainCircuit className="size-4" />
|
||||||
Generate AI brief
|
Generate AI brief
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -219,7 +219,7 @@ export default function PortfolioPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||||
) : allocationData.length > 0 ? (
|
) : allocationData.length > 0 ? (
|
||||||
<div className="h-[300px]">
|
<div className="h-[260px] sm:h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
|
<Pie data={allocationData} dataKey="value" nameKey="name" innerRadius={56} outerRadius={104} paddingAngle={2}>
|
||||||
@@ -255,7 +255,7 @@ export default function PortfolioPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading chart...</p>
|
||||||
) : performanceData.length > 0 ? (
|
) : performanceData.length > 0 ? (
|
||||||
<div className="h-[300px]">
|
<div className="h-[260px] sm:h-[300px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={performanceData}>
|
<BarChart data={performanceData}>
|
||||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||||
@@ -302,7 +302,114 @@ export default function PortfolioPage() {
|
|||||||
) : holdings.length === 0 ? (
|
) : holdings.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No holdings added yet.</p>
|
<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]">
|
<table className="data-table min-w-[1020px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -403,6 +510,7 @@ export default function PortfolioPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</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 }))} />
|
<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>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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" />
|
<Plus className="size-4" />
|
||||||
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
{editingHoldingId === null ? 'Save holding' : 'Update holding'}
|
||||||
</Button>
|
</Button>
|
||||||
{editingHoldingId !== null ? (
|
{editingHoldingId !== null ? (
|
||||||
<Button type="button" variant="ghost" onClick={resetHoldingForm}>
|
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetHoldingForm}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
268
app/search/page.tsx
Normal file
268
app/search/page.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Suspense, useEffect, useMemo, useState, useTransition } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { BrainCircuit, ExternalLink, Search as SearchIcon } from 'lucide-react';
|
||||||
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Panel } from '@/components/ui/panel';
|
||||||
|
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||||
|
import { getSearchAnswer } from '@/lib/api';
|
||||||
|
import { searchQueryOptions } from '@/lib/query/options';
|
||||||
|
import type { SearchAnswerResponse, SearchResult, SearchSource } from '@/lib/types';
|
||||||
|
|
||||||
|
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
|
||||||
|
{ value: 'documents', label: 'Documents' },
|
||||||
|
{ value: 'filings', label: 'Filing briefs' },
|
||||||
|
{ value: 'research', label: 'Research notes' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseSourceParams(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return ['documents', 'filings', 'research'] as SearchSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim().toLowerCase())
|
||||||
|
.filter((entry): entry is SearchSource => entry === 'documents' || entry === 'filings' || entry === 'research');
|
||||||
|
|
||||||
|
return normalized.length > 0 ? [...new Set(normalized)] : ['documents', 'filings', 'research'] as SearchSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>}>
|
||||||
|
<SearchPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchPageContent() {
|
||||||
|
const { isPending, isAuthenticated } = useAuthGuard();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const initialQuery = searchParams.get('q')?.trim() ?? '';
|
||||||
|
const initialTicker = searchParams.get('ticker')?.trim().toUpperCase() ?? '';
|
||||||
|
const initialSources = useMemo(() => parseSourceParams(searchParams.get('sources')), [searchParams]);
|
||||||
|
|
||||||
|
const [queryInput, setQueryInput] = useState(initialQuery);
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [tickerInput, setTickerInput] = useState(initialTicker);
|
||||||
|
const [ticker, setTicker] = useState(initialTicker);
|
||||||
|
const [sources, setSources] = useState<SearchSource[]>(initialSources);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [answer, setAnswer] = useState<SearchAnswerResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [answerLoading, startAnswerTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setQueryInput(initialQuery);
|
||||||
|
setQuery(initialQuery);
|
||||||
|
setTickerInput(initialTicker);
|
||||||
|
setTicker(initialTicker);
|
||||||
|
setSources(initialSources);
|
||||||
|
}, [initialQuery, initialTicker, initialSources]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim() || !isAuthenticated) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
queryClient.fetchQuery(searchQueryOptions({
|
||||||
|
query,
|
||||||
|
ticker: ticker || undefined,
|
||||||
|
sources,
|
||||||
|
limit: 10
|
||||||
|
})).then((response) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults(response.results);
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to search indexed sources');
|
||||||
|
setResults([]);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, query, queryClient, sources, ticker]);
|
||||||
|
|
||||||
|
if (isPending || !isAuthenticated) {
|
||||||
|
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading search desk...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runAnswer = () => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startAnswerTransition(() => {
|
||||||
|
setError(null);
|
||||||
|
getSearchAnswer({
|
||||||
|
query,
|
||||||
|
ticker: ticker || undefined,
|
||||||
|
sources,
|
||||||
|
limit: 10
|
||||||
|
}).then((response) => {
|
||||||
|
setAnswer(response);
|
||||||
|
}).catch((err) => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to generate cited answer');
|
||||||
|
setAnswer(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
title="Search"
|
||||||
|
subtitle="Hybrid semantic + lexical retrieval across primary filings, filing briefs, and private research notes."
|
||||||
|
activeTicker={ticker || null}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<Panel title="Search Query" subtitle="Run semantic search with an optional ticker filter and source selection.">
|
||||||
|
<form
|
||||||
|
className="space-y-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setQuery(queryInput.trim());
|
||||||
|
setTicker(tickerInput.trim().toUpperCase());
|
||||||
|
setAnswer(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={queryInput}
|
||||||
|
onChange={(event) => setQueryInput(event.target.value)}
|
||||||
|
placeholder="Ask about margin drivers, segment commentary, risks, or your notes..."
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={tickerInput}
|
||||||
|
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||||
|
placeholder="Ticker filter (optional)"
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SOURCE_OPTIONS.map((option) => {
|
||||||
|
const selected = sources.includes(option.value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={selected ? 'primary' : 'ghost'}
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setSources((current) => {
|
||||||
|
if (selected && current.length > 1) {
|
||||||
|
return current.filter((entry) => entry !== option.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected ? current : [...current, option.value];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" className="w-full sm:w-auto">
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" className="w-full sm:w-auto" onClick={runAnswer} disabled={!query.trim() || answerLoading}>
|
||||||
|
<BrainCircuit className="size-4" />
|
||||||
|
{answerLoading ? 'Answering...' : 'Cited answer'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel title="Cited Answer" subtitle="Single-turn answer grounded only in retrieved evidence.">
|
||||||
|
{answer ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">{answer.answer}</p>
|
||||||
|
{answer.citations.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{answer.citations.map((citation) => (
|
||||||
|
<Link
|
||||||
|
key={`${citation.chunkId}-${citation.index}`}
|
||||||
|
href={citation.href}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
|
||||||
|
>
|
||||||
|
<span>[{citation.index}] {citation.label}</span>
|
||||||
|
<ExternalLink className="size-3.5 text-[color:var(--accent)]" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">No supporting citations were strong enough to answer.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">Ask a question to synthesize the top retrieved passages into a cited answer.</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel
|
||||||
|
title="Semantic Search"
|
||||||
|
subtitle={query ? `${results.length} results${ticker ? ` for ${ticker}` : ''}.` : 'Search results will appear here.'}
|
||||||
|
>
|
||||||
|
{error ? <p className="text-sm text-[#ffb5b5]">{error}</p> : null}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">Searching indexed sources...</p>
|
||||||
|
) : !query ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">Enter a question or topic to search the local RAG index.</p>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">No indexed evidence matched this query.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{results.map((result) => (
|
||||||
|
<article key={result.chunkId} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||||
|
{result.source} {result.ticker ? `· ${result.ticker}` : ''} {result.filingDate ? `· ${result.filingDate}` : ''}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{result.title ?? result.citationLabel}</h3>
|
||||||
|
<p className="mt-1 text-xs text-[color:var(--accent)]">{result.citationLabel}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={result.href}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]"
|
||||||
|
>
|
||||||
|
Open source
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{result.headingPath ? (
|
||||||
|
<p className="mt-3 text-xs uppercase tracking-[0.12em] text-[color:var(--terminal-muted)]">{result.headingPath}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{result.snippet}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ const EMPTY_FORM: FormState = {
|
|||||||
tags: ''
|
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) {
|
function parseTagsInput(input: string) {
|
||||||
const unique = new Set<string>();
|
const unique = new Set<string>();
|
||||||
@@ -281,10 +281,11 @@ export default function WatchlistPage() {
|
|||||||
aria-label="Search coverage"
|
aria-label="Search coverage"
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
placeholder="Search ticker, company, tag, sector..."
|
placeholder="Search ticker, company, tag, sector..."
|
||||||
className="min-w-[18rem]"
|
className="w-full sm:min-w-[18rem]"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||||
void loadCoverage();
|
void loadCoverage();
|
||||||
@@ -302,7 +303,156 @@ export default function WatchlistPage() {
|
|||||||
) : filteredItems.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No coverage items match the current search.</p>
|
<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]">
|
<table className="data-table min-w-[1120px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -456,6 +606,7 @@ export default function WatchlistPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -542,12 +693,12 @@ export default function WatchlistPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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" />
|
<Plus className="size-4" />
|
||||||
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
|
{saving ? 'Saving...' : editingItemId === null ? 'Save coverage' : 'Update coverage'}
|
||||||
</Button>
|
</Button>
|
||||||
{editingItemId !== null ? (
|
{editingItemId !== null ? (
|
||||||
<Button type="button" variant="ghost" onClick={resetForm}>
|
<Button type="button" variant="ghost" className="w-full sm:w-auto" onClick={resetForm}>
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
13
bun.lock
13
bun.lock
@@ -23,6 +23,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sqlite-vec": "^0.1.7-alpha.2",
|
||||||
"workflow": "^4.1.0-beta.60",
|
"workflow": "^4.1.0-beta.60",
|
||||||
"zhipu-ai-provider": "^0.2.2",
|
"zhipu-ai-provider": "^0.2.2",
|
||||||
},
|
},
|
||||||
@@ -1427,6 +1428,18 @@
|
|||||||
|
|
||||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
"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=="],
|
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
|
|||||||
<div className="ambient-grid" aria-hidden="true" />
|
<div className="ambient-grid" aria-hidden="true" />
|
||||||
<div className="noise-layer" 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">
|
<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-6 lg:w-[42%]">
|
<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>
|
<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)]">
|
<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.
|
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows powered by AI SDK.
|
||||||
</p>
|
</p>
|
||||||
@@ -29,8 +29,8 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
|
|||||||
</Link>
|
</Link>
|
||||||
</section>
|
</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%]">
|
<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-2xl font-semibold text-[color:var(--terminal-bright)]">{title}</h2>
|
<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>
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||||
|
|
||||||
<div className="mt-6">{children}</div>
|
<div className="mt-6">{children}</div>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const taskLabels: Record<Task['task_type'], string> = {
|
|||||||
sync_filings: 'Sync filings',
|
sync_filings: 'Sync filings',
|
||||||
refresh_prices: 'Refresh prices',
|
refresh_prices: 'Refresh prices',
|
||||||
analyze_filing: 'Analyze filing',
|
analyze_filing: 'Analyze filing',
|
||||||
portfolio_insights: 'Portfolio insights'
|
portfolio_insights: 'Portfolio insights',
|
||||||
|
index_search: 'Index search'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaskFeed({ tasks }: TaskFeedProps) {
|
export function TaskFeed({ tasks }: TaskFeedProps) {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export function FinancialControlBar({
|
|||||||
}: FinancialControlBarProps) {
|
}: FinancialControlBarProps) {
|
||||||
return (
|
return (
|
||||||
<section className={cn('rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-4 py-3', className)}>
|
<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 className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-[color:var(--terminal-bright)]">{title}</h3>
|
<h3 className="text-sm font-semibold text-[color:var(--terminal-bright)]">{title}</h3>
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{subtitle}</p>
|
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||||
@@ -51,14 +51,14 @@ export function FinancialControlBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actions && actions.length > 0 ? (
|
{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) => (
|
{actions.map((action) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.id}
|
key={action.id}
|
||||||
type="button"
|
type="button"
|
||||||
variant={action.variant ?? 'secondary'}
|
variant={action.variant ?? 'secondary'}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
className="px-2 py-1 text-xs"
|
className="px-2 py-1 text-xs sm:min-h-9"
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
>
|
>
|
||||||
{action.label}
|
{action.label}
|
||||||
@@ -68,22 +68,21 @@ export function FinancialControlBar({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 overflow-x-auto">
|
<div className="mt-3 grid grid-cols-1 gap-2">
|
||||||
<div className="flex min-w-max flex-wrap gap-2">
|
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<div
|
<div
|
||||||
key={section.id}
|
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>
|
<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">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{section.options.map((option) => (
|
{section.options.map((option) => (
|
||||||
<Button
|
<Button
|
||||||
key={`${section.id}-${option.value}`}
|
key={`${section.id}-${option.value}`}
|
||||||
type="button"
|
type="button"
|
||||||
variant={option.value === section.value ? 'primary' : 'ghost'}
|
variant={option.value === section.value ? 'primary' : 'ghost'}
|
||||||
disabled={option.disabled}
|
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)}
|
onClick={() => section.onChange(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -93,7 +92,6 @@ export function FinancialControlBar({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
|||||||
sync_filings: 'Filing sync',
|
sync_filings: 'Filing sync',
|
||||||
refresh_prices: 'Price refresh',
|
refresh_prices: 'Price refresh',
|
||||||
analyze_filing: 'Filing analysis',
|
analyze_filing: 'Filing analysis',
|
||||||
portfolio_insights: 'Portfolio insight'
|
portfolio_insights: 'Portfolio insight',
|
||||||
|
index_search: 'Search indexing'
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_LABELS: Record<TaskStage, string> = {
|
const STAGE_LABELS: Record<TaskStage, string> = {
|
||||||
@@ -38,6 +39,11 @@ const STAGE_LABELS: Record<TaskStage, string> = {
|
|||||||
'analyze.extract': 'Extract context',
|
'analyze.extract': 'Extract context',
|
||||||
'analyze.generate_report': 'Generate report',
|
'analyze.generate_report': 'Generate report',
|
||||||
'analyze.persist_report': 'Persist 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.load_holdings': 'Load holdings',
|
||||||
'insights.generate': 'Generate insight',
|
'insights.generate': 'Generate insight',
|
||||||
'insights.persist': 'Persist insight'
|
'insights.persist': 'Persist insight'
|
||||||
@@ -75,6 +81,16 @@ const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
|
|||||||
'analyze.persist_report',
|
'analyze.persist_report',
|
||||||
'completed'
|
'completed'
|
||||||
],
|
],
|
||||||
|
index_search: [
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'search.collect_sources',
|
||||||
|
'search.fetch_documents',
|
||||||
|
'search.chunk',
|
||||||
|
'search.embed',
|
||||||
|
'search.persist',
|
||||||
|
'completed'
|
||||||
|
],
|
||||||
portfolio_insights: [
|
portfolio_insights: [
|
||||||
'queued',
|
'queued',
|
||||||
'running',
|
'running',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
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 Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -76,6 +76,16 @@ const NAV_ITEMS: NavConfigItem[] = [
|
|||||||
preserveTicker: true,
|
preserveTicker: true,
|
||||||
mobilePrimary: true
|
mobilePrimary: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
href: '/search',
|
||||||
|
label: 'Search',
|
||||||
|
icon: Search,
|
||||||
|
group: 'research',
|
||||||
|
matchMode: 'exact',
|
||||||
|
preserveTicker: true,
|
||||||
|
mobilePrimary: false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'portfolio',
|
id: 'portfolio',
|
||||||
href: '/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')) {
|
if (pathname.startsWith('/portfolio')) {
|
||||||
return [{ label: 'Portfolio' }];
|
return [{ label: 'Portfolio' }];
|
||||||
}
|
}
|
||||||
@@ -289,6 +306,13 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (href.startsWith('/search')) {
|
||||||
|
if (context.activeTicker) {
|
||||||
|
void queryClient.prefetchQuery(companyAnalysisQueryOptions(context.activeTicker));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (href.startsWith('/portfolio')) {
|
if (href.startsWith('/portfolio')) {
|
||||||
void queryClient.prefetchQuery(holdingsQueryOptions());
|
void queryClient.prefetchQuery(holdingsQueryOptions());
|
||||||
void queryClient.prefetchQuery(portfolioSummaryQueryOptions());
|
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="ambient-grid" aria-hidden="true" />
|
||||||
<div className="noise-layer" 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">
|
<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>
|
<div>
|
||||||
<p className="terminal-caption text-xs uppercase tracking-[0.25em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
|
<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>
|
</aside>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 pb-24 lg:pb-0">
|
<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)]">
|
<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-5 top-5 z-10">
|
<div className="absolute right-4 top-4 z-10 sm:right-5 sm:top-5">
|
||||||
<TaskNotificationsTrigger
|
<TaskNotificationsTrigger
|
||||||
unreadCount={notifications.unreadCount}
|
unreadCount={notifications.unreadCount}
|
||||||
isPopoverOpen={notifications.isPopoverOpen}
|
isPopoverOpen={notifications.isPopoverOpen}
|
||||||
@@ -438,17 +462,17 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
|||||||
markTaskRead={notifications.markTaskRead}
|
markTaskRead={notifications.markTaskRead}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||||
<div>
|
<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>
|
<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 ? (
|
{subtitle ? (
|
||||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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}
|
{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" />
|
<LogOut className="size-4" />
|
||||||
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
{isSigningOut ? 'Signing out...' : 'Sign out'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -458,9 +482,9 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
|||||||
|
|
||||||
<nav
|
<nav
|
||||||
aria-label="Breadcrumb"
|
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) => {
|
{breadcrumbItems.map((item, index) => {
|
||||||
const isLast = index === breadcrumbItems.length - 1;
|
const isLast = index === breadcrumbItems.length - 1;
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function Button({ className, variant = 'primary', ...props }: ButtonProps
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
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],
|
variantMap[variant],
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function Input({ className, ...props }: InputProps) {
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -12,17 +12,17 @@ export function Panel({ title, subtitle, actions, children, className }: PanelPr
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(title || subtitle || actions) ? (
|
{(title || subtitle || actions) ? (
|
||||||
<header className="mb-4 flex items-start justify-between gap-3">
|
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
{title ? <h3 className="text-base font-semibold text-[color:var(--terminal-bright)]">{title}</h3> : null}
|
{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}
|
{subtitle ? <p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{subtitle}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
{actions ? <div>{actions}</div> : null}
|
{actions ? <div className="w-full sm:w-auto">{actions}</div> : null}
|
||||||
</header>
|
</header>
|
||||||
) : null}
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
45
drizzle/0008_search_rag.sql
Normal file
45
drizzle/0008_search_rag.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS `search_document` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`source_kind` text NOT NULL,
|
||||||
|
`source_ref` text NOT NULL,
|
||||||
|
`scope` text NOT NULL,
|
||||||
|
`user_id` text,
|
||||||
|
`ticker` text,
|
||||||
|
`accession_number` text,
|
||||||
|
`title` text,
|
||||||
|
`content_text` text NOT NULL,
|
||||||
|
`content_hash` text NOT NULL,
|
||||||
|
`metadata` text,
|
||||||
|
`index_status` text NOT NULL DEFAULT 'pending',
|
||||||
|
`indexed_at` text,
|
||||||
|
`last_error` text,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
`updated_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `search_chunk` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`document_id` integer NOT NULL,
|
||||||
|
`chunk_index` integer NOT NULL,
|
||||||
|
`chunk_text` text NOT NULL,
|
||||||
|
`char_count` integer NOT NULL,
|
||||||
|
`start_offset` integer NOT NULL,
|
||||||
|
`end_offset` integer NOT NULL,
|
||||||
|
`heading_path` text,
|
||||||
|
`citation_label` text NOT NULL,
|
||||||
|
`created_at` text NOT NULL,
|
||||||
|
FOREIGN KEY (`document_id`) REFERENCES `search_document`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `search_document_source_uidx`
|
||||||
|
ON `search_document` (`scope`, ifnull(`user_id`, ''), `source_kind`, `source_ref`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `search_document_scope_idx`
|
||||||
|
ON `search_document` (`scope`, `source_kind`, `ticker`, `updated_at`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `search_document_accession_idx`
|
||||||
|
ON `search_document` (`accession_number`, `source_kind`);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS `search_chunk_document_chunk_uidx`
|
||||||
|
ON `search_chunk` (`document_id`, `chunk_index`);
|
||||||
|
CREATE INDEX IF NOT EXISTS `search_chunk_document_idx`
|
||||||
|
ON `search_chunk` (`document_id`);
|
||||||
51
lib/api.ts
51
lib/api.ts
@@ -14,6 +14,9 @@ import type {
|
|||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
ResearchJournalEntry,
|
ResearchJournalEntry,
|
||||||
ResearchJournalEntryType,
|
ResearchJournalEntryType,
|
||||||
|
SearchAnswerResponse,
|
||||||
|
SearchResult,
|
||||||
|
SearchSource,
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
TaskTimeline,
|
TaskTimeline,
|
||||||
@@ -295,6 +298,54 @@ export async function listFilings(query?: { ticker?: string; limit?: number }) {
|
|||||||
return await unwrapData<{ filings: Filing[] }>(result, 'Unable to fetch filings');
|
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) {
|
export async function getCompanyAnalysis(ticker: string) {
|
||||||
const result = await client.api.analysis.company.get({
|
const result = await client.api.analysis.company.get({
|
||||||
$query: {
|
$query: {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const queryKeys = {
|
|||||||
limit: number
|
limit: number
|
||||||
) => ['financials-v3', ticker, surfaceKind, cadence, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const,
|
) => ['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,
|
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,
|
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
||||||
watchlist: () => ['watchlist'] as const,
|
watchlist: () => ['watchlist'] as const,
|
||||||
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
|
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getCompanyFinancialStatements,
|
getCompanyFinancialStatements,
|
||||||
getLatestPortfolioInsight,
|
getLatestPortfolioInsight,
|
||||||
getPortfolioSummary,
|
getPortfolioSummary,
|
||||||
|
searchKnowledge,
|
||||||
getTask,
|
getTask,
|
||||||
getTaskTimeline,
|
getTaskTimeline,
|
||||||
listFilings,
|
listFilings,
|
||||||
@@ -16,7 +17,8 @@ import {
|
|||||||
import { queryKeys } from '@/lib/query/keys';
|
import { queryKeys } from '@/lib/query/keys';
|
||||||
import type {
|
import type {
|
||||||
FinancialCadence,
|
FinancialCadence,
|
||||||
FinancialSurfaceKind
|
FinancialSurfaceKind,
|
||||||
|
SearchSource
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
export function companyAnalysisQueryOptions(ticker: string) {
|
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) {
|
export function aiReportQueryOptions(accessionNumber: string) {
|
||||||
const normalizedAccession = accessionNumber.trim();
|
const normalizedAccession = accessionNumber.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { generateText } from 'ai';
|
import { embedMany, generateText } from 'ai';
|
||||||
import { createZhipu } from 'zhipu-ai-provider';
|
import { createZhipu } from 'zhipu-ai-provider';
|
||||||
|
|
||||||
type AiWorkload = 'report' | 'extraction';
|
type AiWorkload = 'report' | 'extraction';
|
||||||
@@ -31,13 +31,35 @@ type AiGenerateOutput = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiEmbedOutput = {
|
||||||
|
embeddings: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
type RunAiAnalysisOptions = GetAiConfigOptions & {
|
type RunAiAnalysisOptions = GetAiConfigOptions & {
|
||||||
workload?: AiWorkload;
|
workload?: AiWorkload;
|
||||||
createModel?: (config: AiConfig) => unknown;
|
createModel?: (config: AiConfig) => unknown;
|
||||||
generate?: (input: AiGenerateInput) => Promise<AiGenerateOutput>;
|
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 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;
|
let warnedIgnoredZhipuBaseUrl = false;
|
||||||
|
|
||||||
@@ -97,6 +119,30 @@ async function defaultGenerate(input: AiGenerateInput): Promise<AiGenerateOutput
|
|||||||
return { text: result.text };
|
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) {
|
export function getAiConfig(options?: GetAiConfigOptions) {
|
||||||
return getReportAiConfig(options);
|
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) {
|
export function isAiConfigured(options?: GetAiConfigOptions) {
|
||||||
const config = getReportAiConfig(options);
|
const config = getReportAiConfig(options);
|
||||||
return Boolean(config.apiKey);
|
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() {
|
export function __resetAiWarningsForTests() {
|
||||||
warnedIgnoredZhipuBaseUrl = false;
|
warnedIgnoredZhipuBaseUrl = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
FinancialSurfaceKind,
|
FinancialSurfaceKind,
|
||||||
ResearchJournalEntryType,
|
ResearchJournalEntryType,
|
||||||
|
SearchSource,
|
||||||
TaskStatus
|
TaskStatus
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
upsertWatchlistItemRecord
|
upsertWatchlistItemRecord
|
||||||
} from '@/lib/server/repos/watchlist';
|
} from '@/lib/server/repos/watchlist';
|
||||||
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
import { getPriceHistory, getQuote } from '@/lib/server/prices';
|
||||||
|
import { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
|
||||||
import {
|
import {
|
||||||
enqueueTask,
|
enqueueTask,
|
||||||
findInFlightTask,
|
findInFlightTask,
|
||||||
@@ -82,6 +84,7 @@ const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
|
|||||||
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
|
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
|
||||||
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
|
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
|
||||||
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
|
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
|
||||||
|
const SEARCH_SOURCES: SearchSource[] = ['documents', 'filings', 'research'];
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
@@ -205,6 +208,21 @@ function asJournalEntryType(value: unknown) {
|
|||||||
: undefined;
|
: 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) {
|
function formatLabel(value: string) {
|
||||||
return value
|
return value
|
||||||
.split('_')
|
.split('_')
|
||||||
@@ -763,6 +781,21 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
});
|
});
|
||||||
|
|
||||||
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
|
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 });
|
return Response.json({ entry });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -800,6 +833,21 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateWatchlistReviewByTicker(session.user.id, entry.ticker, entry.updated_at);
|
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 });
|
return Response.json({ entry });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -822,6 +870,25 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
return jsonError('Journal entry not found', 404);
|
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 });
|
return Response.json({ success: true });
|
||||||
}, {
|
}, {
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
@@ -1124,6 +1191,63 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
limit: t.Optional(t.Numeric())
|
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 }) => {
|
.post('/filings/sync', async ({ body }) => {
|
||||||
const { session, response } = await requireAuthenticatedSession();
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ describe('sqlite schema compatibility bootstrap', () => {
|
|||||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
|
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
|
||||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
||||||
expect(__dbInternals.hasTable(client, 'research_journal_entry')).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();
|
client.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
|
import { load as loadSqliteVec } from 'sqlite-vec';
|
||||||
import { schema } from './schema';
|
import { schema } from './schema';
|
||||||
|
|
||||||
type AppDrizzleDb = ReturnType<typeof createDb>;
|
type AppDrizzleDb = ReturnType<typeof createDb>;
|
||||||
@@ -50,6 +51,45 @@ function applySqlFile(client: Database, fileName: string) {
|
|||||||
client.exec(sql);
|
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) {
|
function ensureLocalSqliteSchema(client: Database) {
|
||||||
if (!hasTable(client, 'filing_statement_snapshot')) {
|
if (!hasTable(client, 'filing_statement_snapshot')) {
|
||||||
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
|
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_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`);');
|
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() {
|
export function getSqliteClient() {
|
||||||
if (!globalThis.__fiscalSqliteClient) {
|
if (!globalThis.__fiscalSqliteClient) {
|
||||||
|
configureCustomSqliteRuntime();
|
||||||
const databasePath = getDatabasePath();
|
const databasePath = getDatabasePath();
|
||||||
|
|
||||||
if (databasePath !== ':memory:') {
|
if (databasePath !== ':memory:') {
|
||||||
@@ -156,7 +256,9 @@ export function getSqliteClient() {
|
|||||||
client.exec('PRAGMA foreign_keys = ON;');
|
client.exec('PRAGMA foreign_keys = ON;');
|
||||||
client.exec('PRAGMA journal_mode = WAL;');
|
client.exec('PRAGMA journal_mode = WAL;');
|
||||||
client.exec('PRAGMA busy_timeout = 5000;');
|
client.exec('PRAGMA busy_timeout = 5000;');
|
||||||
|
loadSqliteExtensions(client);
|
||||||
ensureLocalSqliteSchema(client);
|
ensureLocalSqliteSchema(client);
|
||||||
|
ensureSearchVirtualTables(client);
|
||||||
|
|
||||||
globalThis.__fiscalSqliteClient = client;
|
globalThis.__fiscalSqliteClient = client;
|
||||||
}
|
}
|
||||||
@@ -175,8 +277,12 @@ if (!globalThis.__fiscalDrizzleDb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const __dbInternals = {
|
export const __dbInternals = {
|
||||||
|
configureCustomSqliteRuntime,
|
||||||
ensureLocalSqliteSchema,
|
ensureLocalSqliteSchema,
|
||||||
|
ensureSearchVirtualTables,
|
||||||
getDatabasePath,
|
getDatabasePath,
|
||||||
hasColumn,
|
hasColumn,
|
||||||
hasTable
|
hasTable,
|
||||||
|
isVectorExtensionLoaded,
|
||||||
|
loadSqliteExtensions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
@@ -31,6 +32,9 @@ type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
|
|||||||
type CoveragePriority = 'low' | 'medium' | 'high';
|
type CoveragePriority = 'low' | 'medium' | 'high';
|
||||||
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
||||||
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||||
|
type SearchDocumentScope = 'global' | 'user';
|
||||||
|
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
|
||||||
|
type SearchIndexStatus = 'pending' | 'indexed' | 'failed';
|
||||||
type FinancialSurfaceKind =
|
type FinancialSurfaceKind =
|
||||||
| 'income_statement'
|
| 'income_statement'
|
||||||
| 'balance_sheet'
|
| 'balance_sheet'
|
||||||
@@ -500,7 +504,7 @@ export const filingLink = sqliteTable('filing_link', {
|
|||||||
export const taskRun = sqliteTable('task_run', {
|
export const taskRun = sqliteTable('task_run', {
|
||||||
id: text('id').primaryKey().notNull(),
|
id: text('id').primaryKey().notNull(),
|
||||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
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(),
|
status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(),
|
||||||
stage: text('stage').notNull(),
|
stage: text('stage').notNull(),
|
||||||
stage_detail: text('stage_detail'),
|
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)
|
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 = {
|
export const authSchema = {
|
||||||
user,
|
user,
|
||||||
session,
|
session,
|
||||||
@@ -595,7 +648,9 @@ export const appSchema = {
|
|||||||
taskRun,
|
taskRun,
|
||||||
taskStageEvent,
|
taskStageEvent,
|
||||||
portfolioInsight,
|
portfolioInsight,
|
||||||
researchJournalEntry
|
researchJournalEntry,
|
||||||
|
searchDocument,
|
||||||
|
searchChunk
|
||||||
};
|
};
|
||||||
|
|
||||||
export const schema = {
|
export const schema = {
|
||||||
|
|||||||
@@ -62,6 +62,28 @@ export async function listResearchJournalEntries(userId: string, ticker: string,
|
|||||||
return rows.map(toResearchJournalEntry);
|
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: {
|
export async function createResearchJournalEntryRecord(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
ticker: string;
|
ticker: string;
|
||||||
|
|||||||
217
lib/server/search.test.ts
Normal file
217
lib/server/search.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { __dbInternals } from '@/lib/server/db';
|
||||||
|
import { __searchInternals } from '@/lib/server/search';
|
||||||
|
|
||||||
|
function applyMigration(client: Database, fileName: string) {
|
||||||
|
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||||
|
client.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient() {
|
||||||
|
const client = new Database(':memory:');
|
||||||
|
client.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
applyMigration(client, '0000_cold_silver_centurion.sql');
|
||||||
|
applyMigration(client, '0001_glossy_statement_snapshots.sql');
|
||||||
|
applyMigration(client, '0002_workflow_task_projection_metadata.sql');
|
||||||
|
applyMigration(client, '0003_task_stage_event_timeline.sql');
|
||||||
|
__dbInternals.loadSqliteExtensions(client);
|
||||||
|
__dbInternals.ensureLocalSqliteSchema(client);
|
||||||
|
__dbInternals.ensureSearchVirtualTables(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertUser(client: Database, id: string) {
|
||||||
|
client.query(`
|
||||||
|
INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, 1, 0, 0)
|
||||||
|
`).run(id, id, `${id}@example.com`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function vector(first: number, second = 0) {
|
||||||
|
const values = new Array(256).fill(0);
|
||||||
|
values[0] = first;
|
||||||
|
values[1] = second;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('search internals', () => {
|
||||||
|
it('chunks research notes as a single chunk under the small-note threshold', () => {
|
||||||
|
const chunks = __searchInternals.chunkDocument({
|
||||||
|
sourceKind: 'research_note',
|
||||||
|
sourceRef: '1',
|
||||||
|
scope: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'AMD',
|
||||||
|
accessionNumber: null,
|
||||||
|
filingDate: null,
|
||||||
|
title: 'AMD note',
|
||||||
|
contentText: 'A compact note about margins and channel inventory.',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chunks).toHaveLength(1);
|
||||||
|
expect(chunks[0]?.chunkText).toContain('channel inventory');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats insufficient evidence when the answer cites nothing valid', () => {
|
||||||
|
const finalized = __searchInternals.finalizeAnswer('This has no valid citations.', [{
|
||||||
|
chunkId: 1,
|
||||||
|
documentId: 1,
|
||||||
|
source: 'filings',
|
||||||
|
sourceKind: 'filing_brief',
|
||||||
|
sourceRef: '0001',
|
||||||
|
title: 'Brief',
|
||||||
|
ticker: 'AMD',
|
||||||
|
accessionNumber: '0001',
|
||||||
|
filingDate: '2026-01-01',
|
||||||
|
citationLabel: 'AMD · 0001 [1]',
|
||||||
|
headingPath: null,
|
||||||
|
chunkText: 'Revenue grew.',
|
||||||
|
snippet: 'Revenue grew.',
|
||||||
|
score: 0.2,
|
||||||
|
vectorRank: 1,
|
||||||
|
lexicalRank: 1,
|
||||||
|
href: '/filings?ticker=AMD'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(finalized.answer).toBe('Insufficient evidence to answer from the indexed sources.');
|
||||||
|
expect(finalized.citations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists vec/fts rows, skips unchanged content, and deletes synced rows together', () => {
|
||||||
|
const client = createClient();
|
||||||
|
const document = {
|
||||||
|
sourceKind: 'filing_brief' as const,
|
||||||
|
sourceRef: '0000320193-26-000001',
|
||||||
|
scope: 'global' as const,
|
||||||
|
userId: null,
|
||||||
|
ticker: 'AAPL',
|
||||||
|
accessionNumber: '0000320193-26-000001',
|
||||||
|
filingDate: '2026-01-30',
|
||||||
|
title: 'AAPL filing brief',
|
||||||
|
contentText: 'Revenue remained resilient across products and services. Services margin expanded.',
|
||||||
|
metadata: {
|
||||||
|
filingDate: '2026-01-30',
|
||||||
|
hasAnalysis: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const chunks = __searchInternals.chunkDocument(document);
|
||||||
|
const firstPersist = __searchInternals.persistDocumentIndex(
|
||||||
|
client,
|
||||||
|
document,
|
||||||
|
chunks,
|
||||||
|
chunks.map((_chunk, index) => vector(1 - (index * 0.1)))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstPersist.indexed).toBe(true);
|
||||||
|
expect(client.query('SELECT count(*) AS count FROM search_document').get() as { count: number }).toEqual({ count: 1 });
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_chunk').get() as { count: number }).count).toBe(chunks.length);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_chunk_fts').get() as { count: number }).count).toBe(chunks.length);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_chunk_vec').get() as { count: number }).count).toBe(chunks.length);
|
||||||
|
|
||||||
|
const secondPersist = __searchInternals.persistDocumentIndex(
|
||||||
|
client,
|
||||||
|
document,
|
||||||
|
chunks,
|
||||||
|
chunks.map((_chunk, index) => vector(1 - (index * 0.1)))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(secondPersist.skipped).toBe(true);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_document').get() as { count: number }).count).toBe(1);
|
||||||
|
|
||||||
|
const deleted = __searchInternals.deleteSourceRefs(client, [{
|
||||||
|
sourceKind: 'filing_brief',
|
||||||
|
sourceRef: document.sourceRef,
|
||||||
|
scope: 'global'
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_document').get() as { count: number }).count).toBe(0);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_chunk').get() as { count: number }).count).toBe(0);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_chunk_fts').get() as { count: number }).count).toBe(0);
|
||||||
|
expect((client.query('SELECT count(*) AS count FROM search_chunk_vec').get() as { count: number }).count).toBe(0);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps user-scoped research notes isolated in lexical and vector search', () => {
|
||||||
|
const client = createClient();
|
||||||
|
insertUser(client, 'user-1');
|
||||||
|
insertUser(client, 'user-2');
|
||||||
|
|
||||||
|
const userOneDoc = {
|
||||||
|
sourceKind: 'research_note' as const,
|
||||||
|
sourceRef: '101',
|
||||||
|
scope: 'user' as const,
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'AMD',
|
||||||
|
accessionNumber: null,
|
||||||
|
filingDate: null,
|
||||||
|
title: 'Durable thesis',
|
||||||
|
contentText: 'Durable pricing power thesis with channel checks.',
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
|
const userTwoDoc = {
|
||||||
|
...userOneDoc,
|
||||||
|
sourceRef: '102',
|
||||||
|
userId: 'user-2',
|
||||||
|
contentText: 'Different private note for another user.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const userOneChunks = __searchInternals.chunkDocument(userOneDoc);
|
||||||
|
const userTwoChunks = __searchInternals.chunkDocument(userTwoDoc);
|
||||||
|
|
||||||
|
__searchInternals.persistDocumentIndex(client, userOneDoc, userOneChunks, [vector(1, 0)]);
|
||||||
|
__searchInternals.persistDocumentIndex(client, userTwoDoc, userTwoChunks, [vector(0, 1)]);
|
||||||
|
|
||||||
|
const ftsQuery = __searchInternals.toFtsQuery('durable thesis');
|
||||||
|
expect(ftsQuery).not.toBeNull();
|
||||||
|
|
||||||
|
const lexicalMatches = __searchInternals.lexicalSearch(client, {
|
||||||
|
ftsQuery: ftsQuery!,
|
||||||
|
limit: 5,
|
||||||
|
sourceKind: 'research_note',
|
||||||
|
scope: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'AMD'
|
||||||
|
});
|
||||||
|
const hiddenLexicalMatches = __searchInternals.lexicalSearch(client, {
|
||||||
|
ftsQuery: ftsQuery!,
|
||||||
|
limit: 5,
|
||||||
|
sourceKind: 'research_note',
|
||||||
|
scope: 'user',
|
||||||
|
userId: 'user-2',
|
||||||
|
ticker: 'AMD'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lexicalMatches).toHaveLength(1);
|
||||||
|
expect(hiddenLexicalMatches).toHaveLength(0);
|
||||||
|
|
||||||
|
const vectorMatches = __searchInternals.vectorSearch(client, {
|
||||||
|
embedding: vector(1, 0),
|
||||||
|
limit: 5,
|
||||||
|
sourceKind: 'research_note',
|
||||||
|
scope: 'user',
|
||||||
|
userId: 'user-1',
|
||||||
|
ticker: 'AMD'
|
||||||
|
});
|
||||||
|
const hiddenVectorMatches = __searchInternals.vectorSearch(client, {
|
||||||
|
embedding: vector(1, 0),
|
||||||
|
limit: 5,
|
||||||
|
sourceKind: 'research_note',
|
||||||
|
scope: 'user',
|
||||||
|
userId: 'user-2',
|
||||||
|
ticker: 'AMD'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(vectorMatches).toHaveLength(1);
|
||||||
|
expect(hiddenVectorMatches).toHaveLength(1);
|
||||||
|
expect(vectorMatches[0]?.chunk_id).not.toBe(hiddenVectorMatches[0]?.chunk_id);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
1315
lib/server/search.ts
Normal file
1315
lib/server/search.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import type {
|
|||||||
import { runAiAnalysis } from '@/lib/server/ai';
|
import { runAiAnalysis } from '@/lib/server/ai';
|
||||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||||
import { getQuote } from '@/lib/server/prices';
|
import { getQuote } from '@/lib/server/prices';
|
||||||
|
import { indexSearchDocuments } from '@/lib/server/search';
|
||||||
import {
|
import {
|
||||||
getFilingByAccession,
|
getFilingByAccession,
|
||||||
listFilingsRecords,
|
listFilingsRecords,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
fetchPrimaryFilingText,
|
fetchPrimaryFilingText,
|
||||||
fetchRecentFilings
|
fetchRecentFilings
|
||||||
} from '@/lib/server/sec';
|
} from '@/lib/server/sec';
|
||||||
|
import { enqueueTask } from '@/lib/server/tasks';
|
||||||
import { hydrateFilingTaxonomySnapshot } from '@/lib/server/taxonomy/engine';
|
import { hydrateFilingTaxonomySnapshot } from '@/lib/server/taxonomy/engine';
|
||||||
|
|
||||||
const EXTRACTION_REQUIRED_KEYS = [
|
const EXTRACTION_REQUIRED_KEYS = [
|
||||||
@@ -167,6 +169,17 @@ function parseOptionalText(raw: unknown) {
|
|||||||
return normalized.length > 0 ? normalized : null;
|
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) {
|
function parseTags(raw: unknown) {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -562,6 +575,8 @@ async function processSyncFilings(task: Task) {
|
|||||||
.filter((entry): entry is string => Boolean(entry))
|
.filter((entry): entry is string => Boolean(entry))
|
||||||
.join(' | ');
|
.join(' | ');
|
||||||
|
|
||||||
|
let searchTaskId: string | null = null;
|
||||||
|
|
||||||
await setProjectionStage(
|
await setProjectionStage(
|
||||||
task,
|
task,
|
||||||
'sync.fetch_filings',
|
'sync.fetch_filings',
|
||||||
@@ -667,6 +682,22 @@ async function processSyncFilings(task: Task) {
|
|||||||
await Bun.sleep(STATEMENT_HYDRATION_DELAY_MS);
|
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 {
|
return {
|
||||||
ticker,
|
ticker,
|
||||||
category,
|
category,
|
||||||
@@ -675,7 +706,8 @@ async function processSyncFilings(task: Task) {
|
|||||||
inserted: saveResult.inserted,
|
inserted: saveResult.inserted,
|
||||||
updated: saveResult.updated,
|
updated: saveResult.updated,
|
||||||
taxonomySnapshotsHydrated,
|
taxonomySnapshotsHydrated,
|
||||||
taxonomySnapshotsFailed
|
taxonomySnapshotsFailed,
|
||||||
|
searchTaskId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,12 +814,108 @@ async function processAnalyzeFiling(task: Task) {
|
|||||||
extractionMeta
|
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 {
|
return {
|
||||||
accessionNumber,
|
accessionNumber,
|
||||||
provider: analysis.provider,
|
provider: analysis.provider,
|
||||||
model: analysis.model,
|
model: analysis.model,
|
||||||
extractionProvider: extractionMeta.provider,
|
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));
|
return toTaskResult(await processAnalyzeFiling(task));
|
||||||
case 'portfolio_insights':
|
case 'portfolio_insights':
|
||||||
return toTaskResult(await processPortfolioInsights(task));
|
return toTaskResult(await processPortfolioInsights(task));
|
||||||
|
case 'index_search':
|
||||||
|
return toTaskResult(await processIndexSearch(task));
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported task type: ${task.task_type}`);
|
throw new Error(`Unsupported task type: ${task.task_type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
46
lib/types.ts
46
lib/types.ts
@@ -101,7 +101,12 @@ export type Filing = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TaskStatus = 'queued' | 'running' | 'completed' | 'failed';
|
export type 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 =
|
export type TaskStage =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
| 'running'
|
| 'running'
|
||||||
@@ -125,6 +130,11 @@ export type TaskStage =
|
|||||||
| 'analyze.extract'
|
| 'analyze.extract'
|
||||||
| 'analyze.generate_report'
|
| 'analyze.generate_report'
|
||||||
| 'analyze.persist_report'
|
| 'analyze.persist_report'
|
||||||
|
| 'search.collect_sources'
|
||||||
|
| 'search.fetch_documents'
|
||||||
|
| 'search.chunk'
|
||||||
|
| 'search.embed'
|
||||||
|
| 'search.persist'
|
||||||
| 'insights.load_holdings'
|
| 'insights.load_holdings'
|
||||||
| 'insights.generate'
|
| 'insights.generate'
|
||||||
| 'insights.persist';
|
| 'insights.persist';
|
||||||
@@ -188,6 +198,40 @@ export type ResearchJournalEntry = {
|
|||||||
updated_at: string;
|
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 = {
|
export type CompanyFinancialPoint = {
|
||||||
filingDate: string;
|
filingDate: string;
|
||||||
filingType: Filing['filing_type'];
|
filingType: Filing['filing_type'];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"workflow:setup": "workflow-postgres-setup",
|
"workflow:setup": "workflow-postgres-setup",
|
||||||
"backfill:filing-metrics": "bun run scripts/backfill-filing-metrics.ts",
|
"backfill:filing-metrics": "bun run scripts/backfill-filing-metrics.ts",
|
||||||
"backfill:filing-statements": "bun run scripts/backfill-filing-statements.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",
|
"backfill:taxonomy-snapshots": "bun run scripts/backfill-taxonomy-snapshots.ts",
|
||||||
"db:generate": "bun x drizzle-kit generate",
|
"db:generate": "bun x drizzle-kit generate",
|
||||||
"db:migrate": "bun x drizzle-kit migrate",
|
"db:migrate": "bun x drizzle-kit migrate",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"sqlite-vec": "^0.1.7-alpha.2",
|
||||||
"workflow": "^4.1.0-beta.60",
|
"workflow": "^4.1.0-beta.60",
|
||||||
"zhipu-ai-provider": "^0.2.2"
|
"zhipu-ai-provider": "^0.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
46
scripts/backfill-search-index.ts
Normal file
46
scripts/backfill-search-index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { indexSearchDocuments } from '@/lib/server/search';
|
||||||
|
|
||||||
|
function getArg(name: string) {
|
||||||
|
const prefix = `--${name}=`;
|
||||||
|
const entry = process.argv.find((value) => value.startsWith(prefix));
|
||||||
|
return entry ? entry.slice(prefix.length).trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const source = (getArg('source') ?? 'all').toLowerCase();
|
||||||
|
const ticker = getArg('ticker')?.toUpperCase() ?? null;
|
||||||
|
const accessionNumber = getArg('accession') ?? null;
|
||||||
|
const userId = getArg('user') ?? 'system-backfill';
|
||||||
|
|
||||||
|
const sourceKinds: Array<'filing_document' | 'filing_brief' | 'research_note'> | null = source === 'all'
|
||||||
|
? ['filing_document', 'filing_brief', 'research_note'] as const
|
||||||
|
: source === 'documents'
|
||||||
|
? ['filing_document'] as const
|
||||||
|
: source === 'filings'
|
||||||
|
? ['filing_brief'] as const
|
||||||
|
: source === 'research'
|
||||||
|
? ['research_note'] as const
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!sourceKinds) {
|
||||||
|
throw new Error('Unsupported --source value. Use all, documents, filings, or research.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceKinds.includes('research_note') && !userId) {
|
||||||
|
throw new Error('--user is required when backfilling research notes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await indexSearchDocuments({
|
||||||
|
userId,
|
||||||
|
ticker,
|
||||||
|
accessionNumber,
|
||||||
|
sourceKinds
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ describe('buildLocalDevConfig', () => {
|
|||||||
expect(config.port).toBe('3000');
|
expect(config.port).toBe('3000');
|
||||||
expect(config.publicOrigin).toBe('http://localhost:3000');
|
expect(config.publicOrigin).toBe('http://localhost:3000');
|
||||||
expect(config.env.BETTER_AUTH_BASE_URL).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.BETTER_AUTH_SECRET).toBe(LOCAL_DEV_SECRET);
|
||||||
expect(config.env.DATABASE_URL).toBe('file:data/fiscal.sqlite');
|
expect(config.env.DATABASE_URL).toBe('file:data/fiscal.sqlite');
|
||||||
expect(config.env.NEXT_PUBLIC_API_URL).toBe('');
|
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_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.env.DATABASE_URL).toBe('file:data/dev.sqlite');
|
||||||
expect(config.overrides.databaseChanged).toBe(false);
|
expect(config.overrides.databaseChanged).toBe(false);
|
||||||
expect(config.overrides.workflowChanged).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', () => {
|
it('respects an explicit public origin override', () => {
|
||||||
const config = buildLocalDevConfig({
|
const config = buildLocalDevConfig({
|
||||||
DEV_PUBLIC_ORIGIN: 'https://local.fiscal.test:4444/',
|
DEV_PUBLIC_ORIGIN: 'https://local.fiscal.test:4444/',
|
||||||
|
|||||||
@@ -43,6 +43,34 @@ function toUniqueList(values: string[]) {
|
|||||||
return Array.from(new Set(values));
|
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) {
|
function coercePort(port: string | undefined) {
|
||||||
const parsed = Number.parseInt(port ?? '', 10);
|
const parsed = Number.parseInt(port ?? '', 10);
|
||||||
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) {
|
||||||
@@ -113,10 +141,7 @@ export function buildLocalDevConfig(sourceEnv: EnvMap = process.env): LocalDevCo
|
|||||||
? DEFAULT_DATABASE_URL
|
? DEFAULT_DATABASE_URL
|
||||||
: trim(sourceEnv.DATABASE_URL) ?? DEFAULT_DATABASE_URL;
|
: trim(sourceEnv.DATABASE_URL) ?? DEFAULT_DATABASE_URL;
|
||||||
const secret = trim(sourceEnv.BETTER_AUTH_SECRET);
|
const secret = trim(sourceEnv.BETTER_AUTH_SECRET);
|
||||||
const trustedOrigins = toUniqueList([
|
const trustedOrigins = buildTrustedOrigins(publicOrigin, sourceEnv.BETTER_AUTH_TRUSTED_ORIGINS);
|
||||||
publicOrigin,
|
|
||||||
...parseCsvList(sourceEnv.BETTER_AUTH_TRUSTED_ORIGINS)
|
|
||||||
]).join(',');
|
|
||||||
|
|
||||||
const env: EnvMap = {
|
const env: EnvMap = {
|
||||||
...sourceEnv,
|
...sourceEnv,
|
||||||
|
|||||||
Reference in New Issue
Block a user