3 Commits

Author SHA1 Message Date
23932e40c3 Update AI default model to glm-4.6 2026-03-15 16:30:41 -04:00
73a6d13b69 Harden research e2e selectors 2026-03-15 16:30:41 -04:00
2ee9a549a3 Add hybrid research copilot workspace 2026-03-15 16:30:41 -04:00
31 changed files with 7397 additions and 336 deletions

View File

@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react'; import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { ResearchCopilotPanel } from '@/components/research/research-copilot-panel';
import { AppShell } from '@/components/shell/app-shell'; import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel'; import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -31,6 +32,8 @@ import type {
ResearchArtifact, ResearchArtifact,
ResearchArtifactKind, ResearchArtifactKind,
ResearchArtifactSource, ResearchArtifactSource,
ResearchCopilotCitation,
ResearchCopilotSession,
ResearchMemo, ResearchMemo,
ResearchMemoSection, ResearchMemoSection,
ResearchWorkspace ResearchWorkspace
@@ -104,6 +107,15 @@ const EMPTY_MEMO_FORM: MemoFormState = {
nextActionsMarkdown: '' nextActionsMarkdown: ''
}; };
const MEMO_FORM_FIELD_BY_SECTION: Record<ResearchMemoSection, keyof MemoFormState> = {
thesis: 'thesisMarkdown',
variant_view: 'variantViewMarkdown',
catalysts: 'catalystsMarkdown',
risks: 'risksMarkdown',
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
next_actions: 'nextActionsMarkdown'
};
function parseTags(value: string) { function parseTags(value: string) {
const unique = new Set<string>(); const unique = new Set<string>();
@@ -197,6 +209,8 @@ function ResearchPageContent() {
const [uploadTitle, setUploadTitle] = useState(''); const [uploadTitle, setUploadTitle] = useState('');
const [uploadSummary, setUploadSummary] = useState(''); const [uploadSummary, setUploadSummary] = useState('');
const [uploadTags, setUploadTags] = useState(''); const [uploadTags, setUploadTags] = useState('');
const [researchMode, setResearchMode] = useState<'workspace' | 'copilot'>('workspace');
const [focusTab, setFocusTab] = useState<'library' | 'memo' | 'packet'>('library');
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]); const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
const deferredSearch = useDeferredValue(searchInput); const deferredSearch = useDeferredValue(searchInput);
@@ -253,6 +267,7 @@ function ResearchPageContent() {
const invalidateResearch = async (symbol: string) => { const invalidateResearch = async (symbol: string) => {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }), queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchCopilotSession(symbol) }),
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }), queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }), queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }), queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
@@ -399,9 +414,369 @@ function ResearchPageContent() {
} }
}; };
const handleCopilotSessionChange = (session: ResearchCopilotSession) => {
setWorkspace((current) => current ? { ...current, copilotSession: session } : current);
};
const handleDraftNote = (input: { title: string; summary: string; bodyMarkdown: string }) => {
setNoteForm({
id: null,
title: input.title,
summary: input.summary,
bodyMarkdown: input.bodyMarkdown,
tags: 'copilot'
});
setNotice('Loaded copilot output into the note draft editor for review.');
};
const handleDraftMemoSection = (section: ResearchMemoSection, contentMarkdown: string) => {
const field = MEMO_FORM_FIELD_BY_SECTION[section];
setMemoForm((current) => ({
...current,
[field]: contentMarkdown
}));
setAttachSection(section);
setNotice(`Loaded copilot output into ${MEMO_SECTIONS.find((item) => item.value === section)?.label}.`);
};
const handleAttachCitation = async (citation: ResearchCopilotCitation, section: ResearchMemoSection) => {
if (!citation.artifactId) {
setError('This citation cannot be attached because no research artifact is available for it.');
return;
}
try {
const memoId = await ensureMemo();
await addResearchMemoEvidence({
memoId,
artifactId: citation.artifactId,
section
});
setNotice(`Attached cited evidence to ${MEMO_SECTIONS.find((item) => item.value === section)?.label}.`);
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to attach cited evidence');
}
};
const availableTags = workspace?.availableTags ?? []; const availableTags = workspace?.availableTags ?? [];
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0; const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
const renderQuickNotePanel = () => (
<Panel
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
<textarea
aria-label="Research note body"
value={noteForm.bodyMarkdown}
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
placeholder="Write the actual research note, variant view, or diligence conclusion..."
className="min-h-[160px] 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_var(--focus-ring)]"
/>
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
<div className="flex flex-wrap gap-2">
<Button onClick={() => void saveNote()}>
<FilePlus2 className="size-4" />
{noteForm.id === null ? 'Save note' : 'Update note'}
</Button>
{noteForm.id !== null ? (
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
Cancel edit
</Button>
) : null}
</div>
</div>
</Panel>
);
const renderLibraryPanel = () => (
<Panel
title="Research Library"
subtitle={`${library.length} artifacts match the current filter set.`}
actions={(
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
{MEMO_SECTIONS.map((section) => (
<option key={section.value} value={section.value}>{section.label}</option>
))}
</select>
</div>
)}
>
<div className="space-y-4">
<div className="grid gap-3 rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4 lg:grid-cols-[1.4fr_repeat(3,minmax(0,1fr))]">
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
</div>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
<select aria-label="Artifact type filter" className="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)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
{KIND_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
<select aria-label="Artifact source filter" className="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)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
{SOURCE_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
<select aria-label="Artifact tag filter" className="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)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
<option value="">All tags</option>
{availableTags.map((tag) => (
<option key={tag} value={tag}>{tag}</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
Show memo-linked evidence only
</label>
<div className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-xs text-[color:var(--terminal-muted)]">
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
Private workspace scope
</div>
</div>
<div className="space-y-3">
{library.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
) : (
library.map((artifact) => (
<article key={artifact.id} className="rounded-2xl 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)]">
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{artifact.kind === 'upload' && artifact.storage_path ? (
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
<Download className="size-3" />
File
</a>
) : null}
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
<Link2 className="size-3" />
Attach
</Button>
{artifact.kind === 'note' ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
Edit
</Button>
) : null}
{artifact.source === 'user' || artifact.kind === 'upload' ? (
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchArtifact(artifact.id);
setNotice('Removed artifact from the library.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
) : null}
</div>
</div>
{artifact.summary ? (
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
) : null}
{artifact.body_markdown ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{artifact.linked_to_memo ? (
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
) : null}
{artifact.accession_number ? (
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
) : null}
{artifact.tags.map((tag) => (
<button
key={`${artifact.id}-${tag}`}
type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
onClick={() => setTagFilter(tag)}
>
{tag}
</button>
))}
</div>
</article>
))
)}
</div>
</div>
</Panel>
);
const renderUploadPanel = () => (
<Panel
title="Upload Research"
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
<input
aria-label="Upload file"
type="file"
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
className="block 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)]"
/>
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
<UploadIcon />
Upload file
</Button>
</div>
</Panel>
);
const renderMemoPanel = () => (
<Panel
title="Investment Memo"
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
<option value="">Rating</option>
<option value="strong_buy">Strong Buy</option>
<option value="buy">Buy</option>
<option value="hold">Hold</option>
<option value="sell">Sell</option>
</select>
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
<option value="">Conviction</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
</div>
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
{MEMO_SECTIONS.map((section) => {
const field = MEMO_FORM_FIELD_BY_SECTION[section.value];
return (
<div key={section.value}>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
<textarea
aria-label={`Memo ${section.label}`}
value={memoForm[field]}
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
className="min-h-[108px] 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_var(--focus-ring)]"
placeholder={`Write ${section.label.toLowerCase()}...`}
/>
</div>
);
})}
<Button onClick={() => void saveMemo()}>
<NotebookPen className="size-4" />
Save memo
</Button>
</div>
</Panel>
);
const renderPacketPanel = () => (
<Panel
title="Research Packet"
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-6">
{workspace!.packet.sections.map((section) => (
<section key={section.section} className="rounded-2xl 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)]">Packet Section</p>
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
</div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{section.body_markdown || 'No memo content yet for this section.'}
</p>
{section.evidence.length > 0 ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{section.evidence.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
</div>
{workspace!.memo ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchMemoEvidence(workspace!.memo!.id, item.id);
setNotice('Removed memo evidence.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
}
}}
>
Remove
</Button>
) : null}
</div>
{item.annotation ? (
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
) : null}
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
</article>
))}
</div>
) : null}
</section>
))}
</div>
</Panel>
);
return ( return (
<AppShell <AppShell
title="Research" title="Research"
@@ -413,6 +788,28 @@ function ResearchPageContent() {
]} ]}
actions={( actions={(
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<div className="inline-flex rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-1">
<button
type="button"
className={researchMode === 'workspace'
? 'rounded-lg bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
: 'rounded-lg px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
}
onClick={() => setResearchMode('workspace')}
>
Workspace
</button>
<button
type="button"
className={researchMode === 'copilot'
? 'rounded-lg bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
: 'rounded-lg px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
}
onClick={() => setResearchMode('copilot')}
>
Copilot Focus
</button>
</div>
<Button <Button
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
@@ -499,325 +896,75 @@ function ResearchPageContent() {
</Panel> </Panel>
) : ( ) : (
<> <>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]"> {researchMode === 'workspace' ? (
<Panel <div className="grid gap-6 xl:grid-cols-[minmax(0,1.7fr)_minmax(22rem,0.85fr)]">
title="Library Filters" <div className="space-y-6">
subtitle="Narrow the evidence set by structure, ownership, and memo linkage." <div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
actions={<Filter className="size-4 text-[color:var(--accent)]" />} <div className="space-y-6">
> {renderQuickNotePanel()}
<div className="space-y-3"> {renderLibraryPanel()}
<div> {renderUploadPanel()}
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
</div> </div>
{renderMemoPanel()}
</div> </div>
<div> {renderPacketPanel()}
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
<select aria-label="Artifact type filter" className="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)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
{KIND_OPTIONS.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div> </div>
<div> <ResearchCopilotPanel
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label> ticker={ticker}
<select aria-label="Artifact source filter" className="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)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}> companyName={workspace.companyName}
{SOURCE_OPTIONS.map((option) => ( session={workspace.copilotSession}
<option key={option.label} value={option.value}>{option.label}</option> targetSection={attachSection}
))} onSessionChange={handleCopilotSessionChange}
</select> onDraftNote={handleDraftNote}
onDraftMemoSection={handleDraftMemoSection}
onAttachCitation={handleAttachCitation}
onNotice={setNotice}
onError={setError}
/>
</div> </div>
<div> ) : (
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(22rem,0.95fr)]">
<select aria-label="Artifact tag filter" className="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)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}> <ResearchCopilotPanel
<option value="">All tags</option> ticker={ticker}
{availableTags.map((tag) => ( companyName={workspace.companyName}
<option key={tag} value={tag}>{tag}</option> session={workspace.copilotSession}
))} targetSection={attachSection}
</select> variant="focus"
</div> onSessionChange={handleCopilotSessionChange}
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"> onDraftNote={handleDraftNote}
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} /> onDraftMemoSection={handleDraftMemoSection}
Show memo-linked evidence only onAttachCitation={handleAttachCitation}
</label> onNotice={setNotice}
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4"> onError={setError}
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]"> />
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
Access Model
</div>
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.</p>
</div>
</div>
</Panel>
<div className="space-y-6"> <div className="space-y-6">
<Panel <Panel
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'} title="Research Surfaces"
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library." subtitle="Keep the memo, library, and packet within reach while the copilot stays full-width."
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />} actions={<Filter className="size-4 text-[color:var(--accent)]" />}
> >
<div className="space-y-3">
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
<textarea
aria-label="Research note body"
value={noteForm.bodyMarkdown}
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
placeholder="Write the actual research note, variant view, or diligence conclusion..."
className="min-h-[160px] 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_var(--focus-ring)]"
/>
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={() => void saveNote()}> {(['library', 'memo', 'packet'] as const).map((tab) => (
<FilePlus2 className="size-4" />
{noteForm.id === null ? 'Save note' : 'Update note'}
</Button>
{noteForm.id !== null ? (
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
Cancel edit
</Button>
) : null}
</div>
</div>
</Panel>
<Panel
title="Research Library"
subtitle={`${library.length} artifacts match the current filter set.`}
actions={(
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
{MEMO_SECTIONS.map((section) => (
<option key={section.value} value={section.value}>{section.label}</option>
))}
</select>
</div>
)}
>
<div className="space-y-3">
{library.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
) : (
library.map((artifact) => (
<article key={artifact.id} className="rounded-2xl 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)]">
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{artifact.kind === 'upload' && artifact.storage_path ? (
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
<Download className="size-3" />
File
</a>
) : null}
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
<Link2 className="size-3" />
Attach
</Button>
{artifact.kind === 'note' ? (
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
Edit
</Button>
) : null}
{artifact.source === 'user' || artifact.kind === 'upload' ? (
<Button
variant="danger"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchArtifact(artifact.id);
setNotice('Removed artifact from the library.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
}
}}
>
<Trash2 className="size-3" />
Delete
</Button>
) : null}
</div>
</div>
{artifact.summary ? (
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
) : null}
{artifact.body_markdown ? (
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
) : null}
<div className="mt-4 flex flex-wrap gap-2">
{artifact.linked_to_memo ? (
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
) : null}
{artifact.accession_number ? (
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
) : null}
{artifact.tags.map((tag) => (
<button <button
key={`${artifact.id}-${tag}`} key={tab}
type="button" type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]" className={focusTab === tab
onClick={() => setTagFilter(tag)} ? 'rounded-lg border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
: 'rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
}
onClick={() => setFocusTab(tab)}
> >
{tag} {tab === 'library' ? 'Library' : tab === 'memo' ? 'Memo' : 'Packet'}
</button> </button>
))} ))}
</div> </div>
</article> </Panel>
)) {focusTab === 'library' ? renderLibraryPanel() : null}
{focusTab === 'memo' ? renderMemoPanel() : null}
{focusTab === 'packet' ? renderPacketPanel() : null}
</div>
</div>
)} )}
</div>
</Panel>
<Panel
title="Upload Research"
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
<input
aria-label="Upload file"
type="file"
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
className="block 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)]"
/>
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
<UploadIcon />
Upload file
</Button>
</div>
</Panel>
</div>
<Panel
title="Investment Memo"
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
<option value="">Rating</option>
<option value="strong_buy">Strong Buy</option>
<option value="buy">Buy</option>
<option value="hold">Hold</option>
<option value="sell">Sell</option>
</select>
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
<option value="">Conviction</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
</div>
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
{MEMO_SECTIONS.map((section) => {
const fieldMap: Record<ResearchMemoSection, keyof MemoFormState> = {
thesis: 'thesisMarkdown',
variant_view: 'variantViewMarkdown',
catalysts: 'catalystsMarkdown',
risks: 'risksMarkdown',
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
next_actions: 'nextActionsMarkdown'
};
const field = fieldMap[section.value];
return (
<div key={section.value}>
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
<textarea
aria-label={`Memo ${section.label}`}
value={memoForm[field]}
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
className="min-h-[108px] 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_var(--focus-ring)]"
placeholder={`Write ${section.label.toLowerCase()}...`}
/>
</div>
);
})}
<Button onClick={() => void saveMemo()}>
<NotebookPen className="size-4" />
Save memo
</Button>
</div>
</Panel>
</div>
<Panel
title="Research Packet"
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-6">
{workspace.packet.sections.map((section) => (
<section key={section.section} className="rounded-2xl 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)]">Packet Section</p>
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
</div>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{section.body_markdown || 'No memo content yet for this section.'}
</p>
{section.evidence.length > 0 ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{section.evidence.map((item) => (
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
</div>
{workspace.memo ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={async () => {
try {
await deleteResearchMemoEvidence(workspace.memo!.id, item.id);
setNotice('Removed memo evidence.');
await invalidateResearch(ticker);
await loadWorkspace(ticker);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
}
}}
>
Remove
</Button>
) : null}
</div>
{item.annotation ? (
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
) : null}
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
</article>
))}
</div>
) : null}
</section>
))}
</div>
</Panel>
</> </>
)} )}
</> </>

View File

@@ -11,7 +11,8 @@ const taskLabels: Record<Task['task_type'], string> = {
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' index_search: 'Index search',
research_brief: 'Research brief'
}; };
export function TaskFeed({ tasks }: TaskFeedProps) { export function TaskFeed({ tasks }: TaskFeedProps) {

View File

@@ -0,0 +1,444 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
BrainCircuit,
ExternalLink,
FileText,
Link2,
LoaderCircle,
Pin,
PinOff,
Sparkles
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/ui/panel';
import { Input } from '@/components/ui/input';
import { queueResearchCopilotJob, runResearchCopilotTurn } from '@/lib/api';
import type {
ResearchCopilotCitation,
ResearchCopilotSession,
ResearchCopilotSuggestedAction,
ResearchMemoSection,
SearchSource
} from '@/lib/types';
type ResearchCopilotPanelProps = {
ticker: string;
companyName: string | null;
session: ResearchCopilotSession | null;
targetSection: ResearchMemoSection;
variant?: 'docked' | 'focus';
onSessionChange: (session: ResearchCopilotSession) => void;
onDraftNote: (input: { title: string; summary: string; bodyMarkdown: string }) => void;
onDraftMemoSection: (section: ResearchMemoSection, contentMarkdown: string) => void;
onAttachCitation: (citation: ResearchCopilotCitation, section: ResearchMemoSection) => Promise<void>;
onNotice: (message: string) => void;
onError: (message: string) => void;
};
const DEFAULT_SOURCES: SearchSource[] = ['documents', 'filings', 'research'];
const SOURCE_OPTIONS: Array<{ value: SearchSource; label: string }> = [
{ value: 'documents', label: 'Documents' },
{ value: 'filings', label: 'Filing briefs' },
{ value: 'research', label: 'Research notes' }
];
type OptimisticMessage = {
id: string;
role: 'user';
content_markdown: string;
};
function draftStorageKey(ticker: string) {
return `research-copilot-draft:${ticker}`;
}
function toSummary(markdown: string) {
const normalized = markdown.replace(/\s+/g, ' ').trim();
return normalized.length > 180 ? `${normalized.slice(0, 177).trimEnd()}...` : normalized;
}
function buildSuggestedPrompts(companyName: string | null, ticker: string) {
const label = companyName ?? ticker;
return [
`What changed in the latest evidence set for ${label}?`,
`Draft a thesis update for ${ticker} with the strongest citations.`,
`What evidence most directly challenges the current bull case?`
];
}
export function ResearchCopilotPanel({
ticker,
companyName,
session,
targetSection,
variant = 'docked',
onSessionChange,
onDraftNote,
onDraftMemoSection,
onAttachCitation,
onNotice,
onError
}: ResearchCopilotPanelProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const [prompt, setPrompt] = useState('');
const [selectedSources, setSelectedSources] = useState<SearchSource[]>(session?.selected_sources ?? DEFAULT_SOURCES);
const [pinnedArtifactIds, setPinnedArtifactIds] = useState<number[]>(session?.pinned_artifact_ids ?? []);
const [optimisticMessages, setOptimisticMessages] = useState<OptimisticMessage[]>([]);
const [submitting, setSubmitting] = useState(false);
const [queueing, setQueueing] = useState(false);
useEffect(() => {
setSelectedSources(session?.selected_sources ?? DEFAULT_SOURCES);
setPinnedArtifactIds(session?.pinned_artifact_ids ?? []);
}, [session?.id, session?.updated_at]);
useEffect(() => {
const saved = typeof window === 'undefined' ? null : window.localStorage.getItem(draftStorageKey(ticker));
setPrompt(saved ?? '');
}, [ticker]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const timeout = window.setTimeout(() => {
window.localStorage.setItem(draftStorageKey(ticker), prompt);
}, 250);
return () => window.clearTimeout(timeout);
}, [prompt, ticker]);
useEffect(() => {
if (!scrollRef.current) {
return;
}
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [session?.updated_at, optimisticMessages.length, submitting]);
const messages = useMemo(() => {
const persisted = session?.messages ?? [];
const optimistic = optimisticMessages.map((message) => ({
id: -1,
session_id: session?.id ?? 0,
user_id: '',
role: message.role,
content_markdown: message.content_markdown,
citations: [],
follow_ups: [],
suggested_actions: [],
selected_sources: selectedSources,
pinned_artifact_ids: pinnedArtifactIds,
memo_section: targetSection,
created_at: new Date().toISOString()
}));
return [...persisted, ...optimistic];
}, [optimisticMessages, pinnedArtifactIds, selectedSources, session, targetSection]);
const sendTurn = async () => {
const query = prompt.trim();
if (!query || submitting) {
return;
}
const optimisticMessage: OptimisticMessage = {
id: `${Date.now()}`,
role: 'user',
content_markdown: query
};
setOptimisticMessages((current) => [...current, optimisticMessage]);
setSubmitting(true);
try {
const response = await runResearchCopilotTurn({
ticker,
query,
sources: selectedSources,
pinnedArtifactIds,
memoSection: targetSection
});
setPrompt('');
setOptimisticMessages([]);
if (typeof window !== 'undefined') {
window.localStorage.removeItem(draftStorageKey(ticker));
}
onSessionChange(response.session);
} catch (error) {
setOptimisticMessages([]);
onError(error instanceof Error ? error.message : 'Unable to run copilot turn');
} finally {
setSubmitting(false);
}
};
const queueBrief = async (queryOverride?: string | null) => {
const query = queryOverride?.trim() || prompt.trim();
if (!query || queueing) {
return;
}
setQueueing(true);
try {
await queueResearchCopilotJob({
ticker,
query,
sources: selectedSources
});
onNotice('Queued research brief. Track progress in notifications.');
} catch (error) {
onError(error instanceof Error ? error.message : 'Unable to queue research brief');
} finally {
setQueueing(false);
}
};
const applySuggestedAction = async (action: ResearchCopilotSuggestedAction) => {
if (action.type === 'draft_note' && action.content_markdown) {
onDraftNote({
title: action.title ?? `${ticker} copilot draft`,
summary: toSummary(action.content_markdown),
bodyMarkdown: action.content_markdown
});
return;
}
if (action.type === 'draft_memo_section' && action.section && action.content_markdown) {
onDraftMemoSection(action.section, action.content_markdown);
return;
}
if (action.type === 'queue_research_brief') {
await queueBrief(action.query);
}
};
const suggestedPrompts = buildSuggestedPrompts(companyName, ticker);
const panelClassName = variant === 'focus'
? 'min-h-[70vh]'
: 'xl:sticky xl:top-6 self-start';
return (
<Panel
className={panelClassName}
title={variant === 'focus' ? 'Copilot Focus' : 'Research Copilot'}
subtitle="Cited answers, follow-up questions, and draft actions tied directly to the current company workspace."
actions={<BrainCircuit className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--accent)]">
{ticker}
</span>
<span className="rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
Target: {targetSection.replace('_', ' ')}
</span>
{pinnedArtifactIds.length > 0 ? (
<span className="rounded-full border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{pinnedArtifactIds.length} pinned
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{SOURCE_OPTIONS.map((option) => {
const selected = selectedSources.includes(option.value);
return (
<Button
key={option.value}
type="button"
variant={selected ? 'primary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => {
setSelectedSources((current) => {
if (selected && current.length > 1) {
return current.filter((entry) => entry !== option.value);
}
return selected ? current : [...current, option.value];
});
}}
>
{option.label}
</Button>
);
})}
</div>
<div
ref={scrollRef}
className={variant === 'focus'
? 'max-h-[52vh] overflow-y-auto rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4'
: 'max-h-[36rem] overflow-y-auto rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4'
}
>
{messages.length === 0 ? (
<div className="space-y-3">
<p className="text-sm text-[color:var(--terminal-muted)]">
Ask for cited thesis changes, memo updates, risk summaries, or evidence gaps. This copilot uses the existing search index and current research context.
</p>
<div className="flex flex-wrap gap-2">
{suggestedPrompts.map((suggestion) => (
<button
key={suggestion}
type="button"
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] px-3 py-2 text-left text-xs text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]"
onClick={() => setPrompt(suggestion)}
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<div className="space-y-4">
{messages.map((message, index) => (
<article
key={`${message.role}-${message.created_at}-${index}`}
className={message.role === 'assistant'
? 'rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-4'
: 'rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4'
}
>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{message.role === 'assistant' ? 'Copilot' : 'You'}
</p>
{message.role === 'assistant' ? <Sparkles className="size-4 text-[color:var(--accent)]" /> : null}
</div>
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
{message.content_markdown}
</p>
{message.citations.length > 0 ? (
<div className="mt-4 space-y-2">
{message.citations.map((citation) => {
const pinned = citation.artifactId !== null && pinnedArtifactIds.includes(citation.artifactId);
return (
<div key={`${message.id}-${citation.index}`} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs text-[color:var(--accent)]">[{citation.index}] {citation.label}</p>
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">{citation.excerpt}</p>
</div>
<div className="flex flex-wrap gap-2">
<a
href={citation.href}
className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]"
>
Open
<ExternalLink className="size-3" />
</a>
{citation.artifactId ? (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setPinnedArtifactIds((current) => pinned
? current.filter((entry) => entry !== citation.artifactId)
: [...current, citation.artifactId!]);
}}
>
{pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
{pinned ? 'Unpin' : 'Pin'}
</Button>
) : null}
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
void onAttachCitation(citation, targetSection);
}}
disabled={!citation.artifactId}
>
<Link2 className="size-3" />
Attach
</Button>
</div>
</div>
</div>
);
})}
</div>
) : null}
{message.role === 'assistant' && message.suggested_actions.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{message.suggested_actions.map((action) => (
<Button
key={action.id}
variant={action.type === 'queue_research_brief' ? 'secondary' : 'ghost'}
className="px-2 py-1 text-xs"
onClick={() => {
void applySuggestedAction(action);
}}
>
{action.type === 'draft_note' ? <NotebookIcon /> : action.type === 'draft_memo_section' ? <FileText className="size-3" /> : <Sparkles className="size-3" />}
{action.label}
</Button>
))}
</div>
) : null}
{message.role === 'assistant' && message.follow_ups.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{message.follow_ups.map((followUp) => (
<button
key={followUp}
type="button"
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[11px] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--terminal-bright)]"
onClick={() => setPrompt(followUp)}
>
{followUp}
</button>
))}
</div>
) : null}
</article>
))}
</div>
)}
{submitting ? (
<div className="mt-3 flex items-center gap-2 text-xs text-[color:var(--terminal-muted)]">
<LoaderCircle className="size-3.5 animate-spin" />
Running cited research turn...
</div>
) : null}
</div>
<div className="space-y-3 rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
<Input
aria-label="Research copilot prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Ask for evidence-backed thesis updates, risk summaries, or draft memo language..."
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void sendTurn();
}
}}
/>
<div className="flex flex-wrap gap-2">
<Button onClick={() => void sendTurn()} disabled={!prompt.trim() || submitting}>
<BrainCircuit className="size-4" />
{submitting ? 'Thinking...' : 'Ask copilot'}
</Button>
<Button variant="secondary" onClick={() => void queueBrief()} disabled={!prompt.trim() || queueing}>
<Sparkles className="size-4" />
{queueing ? 'Queueing...' : 'Queue brief'}
</Button>
</div>
</div>
</div>
</Panel>
);
}
function NotebookIcon() {
return <FileText className="size-3" />;
}

View File

@@ -93,6 +93,20 @@ Use this ordering for most product pages:
- Controls typically use `rounded-xl`. - Controls typically use `rounded-xl`.
- Default panel padding is `p-4` on mobile and `sm:p-5` or `sm:p-6` on larger screens. - Default panel padding is `p-4` on mobile and `sm:p-5` or `sm:p-6` on larger screens.
### Research workspace modes
The research product now supports two layout modes for the same ticker-scoped workspace:
- `Workspace`: the default operating mode. Keep memo authoring, library management, and uploads in the primary column with the copilot docked as a sticky secondary rail.
- `Copilot Focus`: a deep-work mode. The conversation becomes the primary surface and memo, library, and packet views move into secondary tabs.
Rules:
- The active ticker must remain visible in both modes.
- Mode switches should preserve the same local state, draft state, and copilot session.
- Secondary surfaces in focus mode should feel adjacent, not modal. Use tabbed panel switching rather than route changes for memo, library, and packet context.
- Avoid introducing a second page shell for focus mode. The user should feel they are still inside the research workspace.
## Typography ## Typography
### Font roles ### Font roles
@@ -231,6 +245,53 @@ Panel rules:
Reference implementation: `components/ui/panel.tsx` Reference implementation: `components/ui/panel.tsx`
### Research copilot panels
Copilot surfaces should feel like analytical instruments, not consumer chat.
Use the docked copilot rail for:
- cited Q&A tied to the current ticker
- short follow-up turns during memo work
- pinning or attaching evidence while staying in the main workspace
Use the focus copilot surface for:
- multi-turn investigative sessions
- long answers with multiple citations
- review-first drafting into memo or notes
Copilot panel rules:
- Keep header copy operational and concise.
- Show context chips for ticker, target memo section, and pinned evidence count.
- Source toggles should be compact and behave like tool filters, not marketing pills.
- Message cards should differentiate user and assistant turns with surface treatment, not bright color.
- Empty states should suggest concrete research questions rather than generic onboarding copy.
### Citation cards
Citation cards are evidence controls, not decorative footnotes.
Rules:
- Lead with the citation index and source label.
- Keep excerpts short and scan-friendly.
- Present actions inline: `Open`, `Pin` or `Unpin`, and `Attach`.
- Use the same card rhythm inside docked and focus copilot modes.
- If a citation cannot be attached because no artifact exists, disable the attach action instead of hiding it.
### Review-first AI write flows
AI-generated content may propose note drafts, memo-section drafts, or background brief jobs, but it must not silently mutate user-authored research state.
Rules:
- Suggested actions should be explicit buttons on assistant messages.
- Drafting into notes or memo sections should populate editable client form state first.
- Persistence should happen only through the existing note or memo save actions.
- Long-running synthesis should route through the task/notification system instead of blocking the conversation thread.
### Status pills and tags ### Status pills and tags
Status pills should be compact, uppercase, and visually coded, but the text label must remain meaningful on its own. Status pills should be compact, uppercase, and visually coded, but the text label must remain meaningful on its own.

View File

@@ -0,0 +1,36 @@
CREATE TABLE `research_copilot_session` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`ticker` text NOT NULL,
`title` text,
`selected_sources` text NOT NULL DEFAULT '["documents","filings","research"]',
`pinned_artifact_ids` text NOT NULL DEFAULT '[]',
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `research_copilot_session_ticker_uidx` ON `research_copilot_session` (`user_id`,`ticker`);
--> statement-breakpoint
CREATE INDEX `research_copilot_session_updated_idx` ON `research_copilot_session` (`user_id`,`updated_at`);
--> statement-breakpoint
CREATE TABLE `research_copilot_message` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`session_id` integer NOT NULL,
`user_id` text NOT NULL,
`role` text NOT NULL,
`content_markdown` text NOT NULL,
`citations` text,
`follow_ups` text,
`suggested_actions` text,
`selected_sources` text,
`pinned_artifact_ids` text,
`memo_section` text,
`created_at` text NOT NULL,
FOREIGN KEY (`session_id`) REFERENCES `research_copilot_session`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `research_copilot_message_session_idx` ON `research_copilot_message` (`session_id`,`created_at`);
--> statement-breakpoint
CREATE INDEX `research_copilot_message_user_idx` ON `research_copilot_message` (`user_id`,`created_at`);

View File

@@ -0,0 +1,393 @@
CREATE TABLE `company_financial_bundle` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ticker` text NOT NULL,
`surface_kind` text NOT NULL,
`cadence` text NOT NULL,
`bundle_version` integer NOT NULL,
`source_snapshot_ids` text NOT NULL,
`source_signature` text NOT NULL,
`payload` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `company_financial_bundle_uidx` ON `company_financial_bundle` (`ticker`,`surface_kind`,`cadence`);--> statement-breakpoint
CREATE INDEX `company_financial_bundle_ticker_idx` ON `company_financial_bundle` (`ticker`,`updated_at`);--> statement-breakpoint
CREATE TABLE `company_overview_cache` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`ticker` text NOT NULL,
`cache_version` integer NOT NULL,
`source_signature` text NOT NULL,
`payload` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `company_overview_cache_uidx` ON `company_overview_cache` (`user_id`,`ticker`);--> statement-breakpoint
CREATE INDEX `company_overview_cache_lookup_idx` ON `company_overview_cache` (`user_id`,`ticker`,`updated_at`);--> statement-breakpoint
CREATE TABLE `filing_statement_snapshot` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`filing_id` integer NOT NULL,
`ticker` text NOT NULL,
`filing_date` text NOT NULL,
`filing_type` text NOT NULL,
`period_end` text,
`statement_bundle` text,
`standardized_bundle` text,
`dimension_bundle` text,
`parse_status` text NOT NULL,
`parse_error` text,
`source` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`filing_id`) REFERENCES `filing`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `filing_stmt_filing_uidx` ON `filing_statement_snapshot` (`filing_id`);--> statement-breakpoint
CREATE INDEX `filing_stmt_ticker_date_idx` ON `filing_statement_snapshot` (`ticker`,`filing_date`);--> statement-breakpoint
CREATE INDEX `filing_stmt_date_idx` ON `filing_statement_snapshot` (`filing_date`);--> statement-breakpoint
CREATE INDEX `filing_stmt_status_idx` ON `filing_statement_snapshot` (`parse_status`);--> statement-breakpoint
CREATE TABLE `filing_taxonomy_asset` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`snapshot_id` integer NOT NULL,
`asset_type` text NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`size_bytes` integer,
`score` numeric,
`is_selected` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`snapshot_id`) REFERENCES `filing_taxonomy_snapshot`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `filing_taxonomy_asset_snapshot_idx` ON `filing_taxonomy_asset` (`snapshot_id`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_asset_type_idx` ON `filing_taxonomy_asset` (`snapshot_id`,`asset_type`);--> statement-breakpoint
CREATE TABLE `filing_taxonomy_concept` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`snapshot_id` integer NOT NULL,
`concept_key` text NOT NULL,
`qname` text NOT NULL,
`namespace_uri` text NOT NULL,
`local_name` text NOT NULL,
`label` text,
`is_extension` integer DEFAULT false NOT NULL,
`balance` text,
`period_type` text,
`data_type` text,
`statement_kind` text,
`role_uri` text,
`authoritative_concept_key` text,
`mapping_method` text,
`surface_key` text,
`detail_parent_surface_key` text,
`kpi_key` text,
`residual_flag` integer DEFAULT false NOT NULL,
`presentation_order` numeric,
`presentation_depth` integer,
`parent_concept_key` text,
`is_abstract` integer DEFAULT false NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`snapshot_id`) REFERENCES `filing_taxonomy_snapshot`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `filing_taxonomy_concept_snapshot_idx` ON `filing_taxonomy_concept` (`snapshot_id`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_concept_statement_idx` ON `filing_taxonomy_concept` (`snapshot_id`,`statement_kind`);--> statement-breakpoint
CREATE UNIQUE INDEX `filing_taxonomy_concept_uidx` ON `filing_taxonomy_concept` (`snapshot_id`,`concept_key`,`role_uri`,`presentation_order`);--> statement-breakpoint
CREATE TABLE `filing_taxonomy_context` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`snapshot_id` integer NOT NULL,
`context_id` text NOT NULL,
`entity_identifier` text,
`entity_scheme` text,
`period_start` text,
`period_end` text,
`period_instant` text,
`segment_json` text,
`scenario_json` text,
`created_at` text NOT NULL,
FOREIGN KEY (`snapshot_id`) REFERENCES `filing_taxonomy_snapshot`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `filing_taxonomy_context_snapshot_idx` ON `filing_taxonomy_context` (`snapshot_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `filing_taxonomy_context_uidx` ON `filing_taxonomy_context` (`snapshot_id`,`context_id`);--> statement-breakpoint
CREATE TABLE `filing_taxonomy_fact` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`snapshot_id` integer NOT NULL,
`concept_key` text NOT NULL,
`qname` text NOT NULL,
`namespace_uri` text NOT NULL,
`local_name` text NOT NULL,
`data_type` text,
`statement_kind` text,
`role_uri` text,
`authoritative_concept_key` text,
`mapping_method` text,
`surface_key` text,
`detail_parent_surface_key` text,
`kpi_key` text,
`residual_flag` integer DEFAULT false NOT NULL,
`context_id` text NOT NULL,
`unit` text,
`decimals` text,
`precision` text,
`nil` integer DEFAULT false NOT NULL,
`value_num` numeric NOT NULL,
`period_start` text,
`period_end` text,
`period_instant` text,
`dimensions` text NOT NULL,
`is_dimensionless` integer DEFAULT true NOT NULL,
`source_file` text,
`created_at` text NOT NULL,
FOREIGN KEY (`snapshot_id`) REFERENCES `filing_taxonomy_snapshot`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `filing_taxonomy_fact_snapshot_idx` ON `filing_taxonomy_fact` (`snapshot_id`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_fact_concept_idx` ON `filing_taxonomy_fact` (`snapshot_id`,`concept_key`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_fact_period_idx` ON `filing_taxonomy_fact` (`snapshot_id`,`period_end`,`period_instant`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_fact_statement_idx` ON `filing_taxonomy_fact` (`snapshot_id`,`statement_kind`);--> statement-breakpoint
CREATE TABLE `filing_taxonomy_metric_validation` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`snapshot_id` integer NOT NULL,
`metric_key` text NOT NULL,
`taxonomy_value` numeric,
`llm_value` numeric,
`absolute_diff` numeric,
`relative_diff` numeric,
`status` text NOT NULL,
`evidence_pages` text NOT NULL,
`pdf_url` text,
`provider` text,
`model` text,
`error` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`snapshot_id`) REFERENCES `filing_taxonomy_snapshot`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `filing_taxonomy_metric_validation_snapshot_idx` ON `filing_taxonomy_metric_validation` (`snapshot_id`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_metric_validation_status_idx` ON `filing_taxonomy_metric_validation` (`snapshot_id`,`status`);--> statement-breakpoint
CREATE UNIQUE INDEX `filing_taxonomy_metric_validation_uidx` ON `filing_taxonomy_metric_validation` (`snapshot_id`,`metric_key`);--> statement-breakpoint
CREATE TABLE `filing_taxonomy_snapshot` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`filing_id` integer NOT NULL,
`ticker` text NOT NULL,
`filing_date` text NOT NULL,
`filing_type` text NOT NULL,
`parse_status` text NOT NULL,
`parse_error` text,
`source` text NOT NULL,
`parser_engine` text DEFAULT 'fiscal-xbrl' NOT NULL,
`parser_version` text DEFAULT 'unknown' NOT NULL,
`taxonomy_regime` text DEFAULT 'unknown' NOT NULL,
`fiscal_pack` text,
`periods` text,
`faithful_rows` text,
`statement_rows` text,
`surface_rows` text,
`detail_rows` text,
`kpi_rows` text,
`derived_metrics` text,
`validation_result` text,
`normalization_summary` text,
`facts_count` integer DEFAULT 0 NOT NULL,
`concepts_count` integer DEFAULT 0 NOT NULL,
`dimensions_count` integer DEFAULT 0 NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`filing_id`) REFERENCES `filing`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `filing_taxonomy_snapshot_filing_uidx` ON `filing_taxonomy_snapshot` (`filing_id`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_snapshot_ticker_date_idx` ON `filing_taxonomy_snapshot` (`ticker`,`filing_date`);--> statement-breakpoint
CREATE INDEX `filing_taxonomy_snapshot_status_idx` ON `filing_taxonomy_snapshot` (`parse_status`);--> statement-breakpoint
CREATE TABLE `research_artifact` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`organization_id` text,
`ticker` text NOT NULL,
`accession_number` text,
`kind` text NOT NULL,
`source` text DEFAULT 'user' NOT NULL,
`subtype` text,
`title` text,
`summary` text,
`body_markdown` text,
`search_text` text,
`visibility_scope` text DEFAULT 'private' NOT NULL,
`tags` text,
`metadata` text,
`file_name` text,
`mime_type` text,
`file_size_bytes` integer,
`storage_path` 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,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `research_artifact_ticker_idx` ON `research_artifact` (`user_id`,`ticker`,`updated_at`);--> statement-breakpoint
CREATE INDEX `research_artifact_kind_idx` ON `research_artifact` (`user_id`,`kind`,`updated_at`);--> statement-breakpoint
CREATE INDEX `research_artifact_accession_idx` ON `research_artifact` (`user_id`,`accession_number`);--> statement-breakpoint
CREATE INDEX `research_artifact_source_idx` ON `research_artifact` (`user_id`,`source`,`updated_at`);--> statement-breakpoint
CREATE TABLE `research_copilot_message` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`session_id` integer NOT NULL,
`user_id` text NOT NULL,
`role` text NOT NULL,
`content_markdown` text NOT NULL,
`citations` text,
`follow_ups` text,
`suggested_actions` text,
`selected_sources` text,
`pinned_artifact_ids` text,
`memo_section` text,
`created_at` text NOT NULL,
FOREIGN KEY (`session_id`) REFERENCES `research_copilot_session`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `research_copilot_message_session_idx` ON `research_copilot_message` (`session_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `research_copilot_message_user_idx` ON `research_copilot_message` (`user_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `research_copilot_session` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`ticker` text NOT NULL,
`title` text,
`selected_sources` text NOT NULL,
`pinned_artifact_ids` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `research_copilot_session_ticker_uidx` ON `research_copilot_session` (`user_id`,`ticker`);--> statement-breakpoint
CREATE INDEX `research_copilot_session_updated_idx` ON `research_copilot_session` (`user_id`,`updated_at`);--> statement-breakpoint
CREATE TABLE `research_journal_entry` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`ticker` text NOT NULL,
`accession_number` text,
`entry_type` text NOT NULL,
`title` text,
`body_markdown` text NOT NULL,
`metadata` 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
);
--> statement-breakpoint
CREATE INDEX `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`,`ticker`,`created_at`);--> statement-breakpoint
CREATE INDEX `research_journal_accession_idx` ON `research_journal_entry` (`user_id`,`accession_number`);--> statement-breakpoint
CREATE TABLE `research_memo` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`organization_id` text,
`ticker` text NOT NULL,
`rating` text,
`conviction` text,
`time_horizon_months` integer,
`packet_title` text,
`packet_subtitle` text,
`thesis_markdown` text DEFAULT '' NOT NULL,
`variant_view_markdown` text DEFAULT '' NOT NULL,
`catalysts_markdown` text DEFAULT '' NOT NULL,
`risks_markdown` text DEFAULT '' NOT NULL,
`disconfirming_evidence_markdown` text DEFAULT '' NOT NULL,
`next_actions_markdown` text DEFAULT '' NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `research_memo_ticker_uidx` ON `research_memo` (`user_id`,`ticker`);--> statement-breakpoint
CREATE INDEX `research_memo_updated_idx` ON `research_memo` (`user_id`,`updated_at`);--> statement-breakpoint
CREATE TABLE `research_memo_evidence` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`memo_id` integer NOT NULL,
`artifact_id` integer NOT NULL,
`section` text NOT NULL,
`annotation` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`memo_id`) REFERENCES `research_memo`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`artifact_id`) REFERENCES `research_artifact`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`,`section`,`sort_order`);--> statement-breakpoint
CREATE INDEX `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`,`artifact_id`,`section`);--> statement-breakpoint
CREATE TABLE `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
);
--> statement-breakpoint
CREATE UNIQUE INDEX `search_chunk_document_chunk_uidx` ON `search_chunk` (`document_id`,`chunk_index`);--> statement-breakpoint
CREATE INDEX `search_chunk_document_idx` ON `search_chunk` (`document_id`);--> statement-breakpoint
CREATE TABLE `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,
`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
);
--> statement-breakpoint
CREATE UNIQUE INDEX `search_document_source_uidx` ON `search_document` (`scope`,`ifnull("user_id"`,` '')`,`source_kind`,`source_ref`);--> statement-breakpoint
CREATE INDEX `search_document_scope_idx` ON `search_document` (`scope`,`source_kind`,`ticker`,`updated_at`);--> statement-breakpoint
CREATE INDEX `search_document_accession_idx` ON `search_document` (`accession_number`,`source_kind`);--> statement-breakpoint
CREATE TABLE `task_stage_event` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`task_id` text NOT NULL,
`user_id` text NOT NULL,
`stage` text NOT NULL,
`stage_detail` text,
`stage_context` text,
`status` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`task_id`) REFERENCES `task_run`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `task_stage_event_task_created_idx` ON `task_stage_event` (`task_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `task_stage_event_user_created_idx` ON `task_stage_event` (`user_id`,`created_at`);--> statement-breakpoint
ALTER TABLE `holding` ADD `company_name` text;--> statement-breakpoint
ALTER TABLE `task_run` ADD `stage` text NOT NULL;--> statement-breakpoint
ALTER TABLE `task_run` ADD `stage_detail` text;--> statement-breakpoint
ALTER TABLE `task_run` ADD `stage_context` text;--> statement-breakpoint
ALTER TABLE `task_run` ADD `resource_key` text;--> statement-breakpoint
ALTER TABLE `task_run` ADD `notification_read_at` text;--> statement-breakpoint
ALTER TABLE `task_run` ADD `notification_silenced_at` text;--> statement-breakpoint
CREATE INDEX `task_user_updated_idx` ON `task_run` (`user_id`,`updated_at`);--> statement-breakpoint
CREATE INDEX `task_user_resource_status_idx` ON `task_run` (`user_id`,`task_type`,`resource_key`,`status`,`created_at`);--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `category` text;--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `tags` text;--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `status` text DEFAULT 'backlog' NOT NULL;--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `priority` text DEFAULT 'medium' NOT NULL;--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `updated_at` text NOT NULL;--> statement-breakpoint
ALTER TABLE `watchlist_item` ADD `last_reviewed_at` text;--> statement-breakpoint
CREATE INDEX `watchlist_user_updated_idx` ON `watchlist_item` (`user_id`,`updated_at`);

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,27 @@
"when": 1773180000000, "when": 1773180000000,
"tag": "0011_remove_legacy_xbrl_defaults", "tag": "0011_remove_legacy_xbrl_defaults",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1773309600000,
"tag": "0012_company_overview_cache",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1773514800000,
"tag": "0013_research_copilot",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1773533245431,
"tag": "0014_brave_randall",
"breakpoints": true
} }
] ]
} }

View File

@@ -176,19 +176,26 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await page.getByLabel('Coverage tags').fill('AI, semis'); await page.getByLabel('Coverage tags').fill('AI, semis');
await page.getByRole('button', { name: 'Save coverage' }).click(); await page.getByRole('button', { name: 'Save coverage' }).click();
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible(); const coverageTable = page.locator('.data-table-wrap:visible');
await page.getByLabel('NVDA status').selectOption('active'); const nvdaRow = coverageTable.locator('tbody tr').filter({
await expect(page.getByLabel('NVDA status')).toHaveValue('active'); has: page.getByText('NVIDIA Corporation')
await page.getByLabel('NVDA priority').selectOption('high'); });
await expect(page.getByLabel('NVDA priority')).toHaveValue('high');
await page.getByRole('link', { name: /^Open overview/ }).first().click(); await expect(coverageTable).toHaveCount(1);
await expect(nvdaRow).toHaveCount(1);
await page.waitForFunction(() => window.innerWidth >= 1024);
await nvdaRow.getByLabel('NVDA status').selectOption('active');
await expect(nvdaRow.getByLabel('NVDA status')).toHaveValue('active');
await nvdaRow.getByLabel('NVDA priority').selectOption('high');
await expect(nvdaRow.getByLabel('NVDA priority')).toHaveValue('high');
await nvdaRow.locator('a[href="/analysis?ticker=NVDA"]').click();
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/); await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
await expect(page.getByText('Bull vs Bear')).toBeVisible(); await expect(page.getByText('Bull vs Bear')).toBeVisible();
await expect(page.getByText('Past 7 Days')).toBeVisible(); await expect(page.getByText('Past 7 Days')).toBeVisible();
await expect(page.getByText('Recent Developments')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Recent Developments' })).toBeVisible();
await page.getByRole('link', { name: 'Research' }).first().click(); await page.locator('a[href="/research?ticker=NVDA"]').first().click();
await expect(page).toHaveURL(/\/research\?ticker=NVDA/); await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await page.getByLabel('Research note title').fill('Own-the-stack moat check'); await page.getByLabel('Research note title').fill('Own-the-stack moat check');
await page.getByLabel('Research note summary').fill('Initial moat checkpoint'); await page.getByLabel('Research note summary').fill('Initial moat checkpoint');
@@ -213,6 +220,16 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
await page.goto('/research?ticker=NVDA'); await page.goto('/research?ticker=NVDA');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/research\?ticker=NVDA/); await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
await expect(page.getByRole('button', { name: 'Workspace' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Copilot Focus' })).toBeVisible();
await expect(page.getByText('Research Copilot')).toBeVisible();
await expect(page.getByLabel('Research copilot prompt')).toBeVisible();
await page.getByRole('button', { name: 'Copilot Focus' }).click();
await expect(page.getByText('Copilot Focus')).toBeVisible();
await expect(page.getByRole('button', { name: 'Library' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Memo' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Packet' })).toBeVisible();
await page.getByRole('button', { name: 'Workspace' }).click();
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible(); await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
await page.getByLabel('Memo rating').selectOption('buy'); await page.getByLabel('Memo rating').selectOption('buy');

View File

@@ -246,6 +246,11 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] }); void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] });
break; break;
} }
case 'research_brief': {
void queryClient.invalidateQueries({ queryKey: ['research'] });
void queryClient.invalidateQueries({ queryKey: ['analysis'] });
break;
}
default: default:
break; break;
} }

View File

@@ -15,6 +15,8 @@ import type {
ResearchArtifact, ResearchArtifact,
ResearchArtifactKind, ResearchArtifactKind,
ResearchArtifactSource, ResearchArtifactSource,
ResearchCopilotSession,
ResearchCopilotTurnResponse,
ResearchJournalEntry, ResearchJournalEntry,
ResearchJournalEntryType, ResearchJournalEntryType,
SearchAnswerResponse, SearchAnswerResponse,
@@ -226,6 +228,48 @@ export async function getResearchWorkspace(ticker: string) {
}, 'Unable to fetch research workspace'); }, 'Unable to fetch research workspace');
} }
export async function getResearchCopilotSession(ticker: string) {
return await requestJson<{ session: ResearchCopilotSession | null }>({
path: `/api/research/copilot/session?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
}, 'Unable to fetch research copilot session');
}
export async function runResearchCopilotTurn(input: {
ticker: string;
query: string;
sources?: SearchSource[];
pinnedArtifactIds?: number[];
memoSection?: ResearchMemoSection | null;
}) {
return await requestJson<ResearchCopilotTurnResponse>({
path: '/api/research/copilot/turn',
method: 'POST',
body: {
ticker: input.ticker.trim().toUpperCase(),
query: input.query.trim(),
...(input.sources && input.sources.length > 0 ? { sources: input.sources } : {}),
...(input.pinnedArtifactIds && input.pinnedArtifactIds.length > 0 ? { pinnedArtifactIds: input.pinnedArtifactIds } : {}),
...(input.memoSection ? { memoSection: input.memoSection } : {})
}
}, 'Unable to run research copilot turn');
}
export async function queueResearchCopilotJob(input: {
ticker: string;
query: string;
sources?: SearchSource[];
}) {
return await requestJson<{ task: Task }>({
path: '/api/research/copilot/job',
method: 'POST',
body: {
ticker: input.ticker.trim().toUpperCase(),
query: input.query.trim(),
...(input.sources && input.sources.length > 0 ? { sources: input.sources } : {})
}
}, 'Unable to queue research brief');
}
export async function listResearchLibrary(input: { export async function listResearchLibrary(input: {
ticker: string; ticker: string;
q?: string; q?: string;

View File

@@ -15,6 +15,7 @@ export const queryKeys = {
search: (query: string, ticker: string | null, sources: string[], limit: number) => ['search', query, ticker ?? '', sources.join(','), 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,
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const, researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
researchCopilotSession: (ticker: string) => ['research', 'copilot', 'session', ticker] as const,
researchLibrary: ( researchLibrary: (
ticker: string, ticker: string,
q: string, q: string,

View File

@@ -5,6 +5,7 @@ import {
getCompanyFinancialStatements, getCompanyFinancialStatements,
getLatestPortfolioInsight, getLatestPortfolioInsight,
getPortfolioSummary, getPortfolioSummary,
getResearchCopilotSession,
searchKnowledge, searchKnowledge,
getResearchMemo, getResearchMemo,
getResearchPacket, getResearchPacket,
@@ -139,6 +140,16 @@ export function researchWorkspaceQueryOptions(ticker: string) {
}); });
} }
export function researchCopilotSessionQueryOptions(ticker: string) {
const normalizedTicker = ticker.trim().toUpperCase();
return queryOptions({
queryKey: queryKeys.researchCopilotSession(normalizedTicker),
queryFn: () => getResearchCopilotSession(normalizedTicker),
staleTime: 10_000
});
}
export function researchLibraryQueryOptions(input: { export function researchLibraryQueryOptions(input: {
ticker: string; ticker: string;
q?: string; q?: string;

View File

@@ -24,7 +24,7 @@ describe('ai config and runtime', () => {
expect(config.provider).toBe('zhipu'); expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('key'); expect(config.apiKey).toBe('key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL); expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-5'); expect(config.model).toBe('glm-4.6');
expect(config.temperature).toBe(0.2); expect(config.temperature).toBe(0.2);
}); });
@@ -80,7 +80,7 @@ describe('ai config and runtime', () => {
expect(config.provider).toBe('zhipu'); expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('new-key'); expect(config.apiKey).toBe('new-key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL); expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-5'); expect(config.model).toBe('glm-4.6');
expect(config.temperature).toBe(0); expect(config.temperature).toBe(0);
return { modelId: config.model }; return { modelId: config.model };
}); });
@@ -109,7 +109,7 @@ describe('ai config and runtime', () => {
}); });
expect(result.provider).toBe('zhipu'); expect(result.provider).toBe('zhipu');
expect(result.model).toBe('glm-5'); expect(result.model).toBe('glm-4.6');
expect(result.text).toBe('{"summary":"ok"}'); expect(result.text).toBe('{"summary":"ok"}');
expect(createModel).toHaveBeenCalledTimes(1); expect(createModel).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledTimes(1);

View File

@@ -155,7 +155,7 @@ export function getReportAiConfig(options?: GetAiConfigOptions) {
provider: 'zhipu', provider: 'zhipu',
apiKey: envValue('ZHIPU_API_KEY', env), apiKey: envValue('ZHIPU_API_KEY', env),
baseUrl: CODING_API_BASE_URL, baseUrl: CODING_API_BASE_URL,
model: envValue('ZHIPU_MODEL', env) ?? 'glm-5', model: envValue('ZHIPU_MODEL', env) ?? 'glm-4.6',
temperature: parseTemperature(envValue('AI_TEMPERATURE', env)) temperature: parseTemperature(envValue('AI_TEMPERATURE', env))
} satisfies AiConfig; } satisfies AiConfig;
} }

View File

@@ -21,6 +21,7 @@ import { requireAuthenticatedSession } from '@/lib/server/auth-session';
import { getLatestFinancialIngestionSchemaStatus } from '@/lib/server/db/financial-ingestion-schema'; import { getLatestFinancialIngestionSchemaStatus } from '@/lib/server/db/financial-ingestion-schema';
import { asErrorMessage, jsonError } from '@/lib/server/http'; import { asErrorMessage, jsonError } from '@/lib/server/http';
import { buildPortfolioSummary } from '@/lib/server/portfolio'; import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { runResearchCopilotTurn } from '@/lib/server/research-copilot';
import { import {
defaultFinancialSyncLimit, defaultFinancialSyncLimit,
getCompanyFinancials getCompanyFinancials
@@ -61,6 +62,7 @@ import {
listResearchJournalEntries, listResearchJournalEntries,
updateResearchJournalEntryRecord updateResearchJournalEntryRecord
} from '@/lib/server/repos/research-journal'; } from '@/lib/server/repos/research-journal';
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
import { import {
deleteWatchlistItemRecord, deleteWatchlistItemRecord,
getWatchlistItemById, getWatchlistItemById,
@@ -839,6 +841,116 @@ export const app = new Elysia({ prefix: '/api' })
ticker: t.String({ minLength: 1 }) ticker: t.String({ minLength: 1 })
}) })
}) })
.get('/research/copilot/session', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
if (!ticker) {
return jsonError('ticker is required');
}
const copilotSession = await getResearchCopilotSessionByTicker(session.user.id, ticker);
return Response.json({ session: copilotSession });
}, {
query: t.Object({
ticker: t.String({ minLength: 1 })
})
})
.post('/research/copilot/turn', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
const memoSection = asResearchMemoSection(payload.memoSection);
if (!ticker) {
return jsonError('ticker is required');
}
if (!query) {
return jsonError('query is required');
}
try {
const result = await runResearchCopilotTurn({
userId: session.user.id,
ticker,
query,
selectedSources: asSearchSources(payload.sources),
pinnedArtifactIds: Array.isArray(payload.pinnedArtifactIds)
? payload.pinnedArtifactIds.map((entry) => Number(entry)).filter((entry) => Number.isInteger(entry) && entry > 0)
: undefined,
memoSection
});
return Response.json(result);
} catch (error) {
return jsonError(asErrorMessage(error, 'Unable to run research copilot turn'));
}
}, {
body: t.Object({
ticker: t.String({ minLength: 1 }),
query: t.String({ minLength: 1 }),
sources: t.Optional(t.Union([t.String(), t.Array(t.String())])),
pinnedArtifactIds: t.Optional(t.Array(t.Numeric())),
memoSection: t.Optional(t.String())
})
})
.post('/research/copilot/job', async ({ body }) => {
const { session, response } = await requireAuthenticatedSession();
if (response) {
return response;
}
const payload = asRecord(body);
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
if (!ticker) {
return jsonError('ticker is required');
}
if (!query) {
return jsonError('query is required');
}
try {
const resourceKey = `research_brief:${ticker}:${query.toLowerCase()}`;
const existing = await findInFlightTask(session.user.id, 'research_brief', resourceKey);
if (existing) {
return Response.json({ task: existing });
}
const task = await enqueueTask({
userId: session.user.id,
taskType: 'research_brief',
payload: {
ticker,
query,
sources: asSearchSources(payload.sources) ?? SEARCH_SOURCES
},
priority: 55,
resourceKey
});
return Response.json({ task });
} catch (error) {
return jsonError(asErrorMessage(error, 'Unable to queue research brief'));
}
}, {
body: t.Object({
ticker: t.String({ minLength: 1 }),
query: t.String({ minLength: 1 }),
sources: t.Optional(t.Union([t.String(), t.Array(t.String())]))
})
})
.get('/research/library', async ({ query }) => { .get('/research/library', async ({ query }) => {
const { session, response } = await requireAuthenticatedSession(); const { session, response } = await requireAuthenticatedSession();
if (response) { if (response) {

View File

@@ -0,0 +1,219 @@
import { beforeAll, describe, expect, it, mock } from 'bun:test';
const TEST_USER_ID = 'copilot-api-user';
const mockGetSession = mock(async () => ({
id: 1,
user_id: TEST_USER_ID,
ticker: 'NVDA',
title: 'NVDA copilot',
selected_sources: ['documents', 'filings', 'research'],
pinned_artifact_ids: [],
created_at: '2026-03-14T00:00:00.000Z',
updated_at: '2026-03-14T00:00:00.000Z',
messages: []
}));
const mockRunTurn = mock(async () => ({
session: {
id: 1,
user_id: TEST_USER_ID,
ticker: 'NVDA',
title: 'NVDA copilot',
selected_sources: ['filings'],
pinned_artifact_ids: [4],
created_at: '2026-03-14T00:00:00.000Z',
updated_at: '2026-03-14T00:00:01.000Z',
messages: []
},
user_message: {
id: 1,
session_id: 1,
user_id: TEST_USER_ID,
role: 'user',
content_markdown: 'What changed?',
citations: [],
follow_ups: [],
suggested_actions: [],
selected_sources: ['filings'],
pinned_artifact_ids: [4],
memo_section: 'thesis',
created_at: '2026-03-14T00:00:00.000Z'
},
assistant_message: {
id: 2,
session_id: 1,
user_id: TEST_USER_ID,
role: 'assistant',
content_markdown: 'Demand stayed strong [1].',
citations: [{
index: 1,
label: 'NVDA · 0001 [1]',
chunkId: 1,
href: '/analysis/reports/NVDA/0001',
source: 'filings',
sourceKind: 'filing_brief',
sourceRef: '0001',
title: '10-K brief',
ticker: 'NVDA',
accessionNumber: '0001',
filingDate: '2026-02-18',
excerpt: 'Demand stayed strong.',
artifactId: 5
}],
follow_ups: ['What changed in risks?'],
suggested_actions: [],
selected_sources: ['filings'],
pinned_artifact_ids: [4],
memo_section: 'thesis',
created_at: '2026-03-14T00:00:01.000Z'
},
results: []
}));
const mockGenerateBrief = mock(async () => ({
provider: 'test',
model: 'test-model',
bodyMarkdown: '# NVDA brief\n\nDemand held up.',
evidence: []
}));
const mockFindInFlightTask = mock(async () => null);
const mockEnqueueTask = mock(async () => ({
id: 'task-1',
user_id: TEST_USER_ID,
task_type: 'research_brief',
status: 'queued',
stage: 'queued',
stage_detail: 'Queued',
stage_context: null,
resource_key: 'research_brief:NVDA:update the thesis',
notification_read_at: null,
notification_silenced_at: null,
priority: 55,
payload: {
ticker: 'NVDA',
query: 'Update the thesis',
sources: ['filings']
},
result: null,
error: null,
attempts: 0,
max_attempts: 3,
workflow_run_id: 'run-1',
created_at: '2026-03-14T00:00:00.000Z',
updated_at: '2026-03-14T00:00:00.000Z',
finished_at: null
}));
function registerMocks() {
mock.module('@/lib/server/auth-session', () => ({
requireAuthenticatedSession: async () => ({
session: {
user: {
id: TEST_USER_ID,
email: 'copilot@example.com',
name: 'Copilot API User',
image: null
}
},
response: null
})
}));
mock.module('@/lib/server/repos/research-copilot', () => ({
getResearchCopilotSessionByTicker: mockGetSession
}));
mock.module('@/lib/server/research-copilot', () => ({
runResearchCopilotTurn: mockRunTurn,
generateResearchBrief: mockGenerateBrief
}));
mock.module('@/lib/server/tasks', () => ({
enqueueTask: mockEnqueueTask,
findInFlightTask: mockFindInFlightTask,
getTaskById: mock(async () => null),
getTaskQueueSnapshot: mock(async () => ({ items: [], stats: { queued: 0, running: 0, failed: 0 } })),
getTaskTimeline: mock(async () => []),
listRecentTasks: mock(async () => []),
updateTaskNotification: mock(async () => null)
}));
}
describe('research copilot api', () => {
let app: { handle: (request: Request) => Promise<Response> };
beforeAll(async () => {
mock.restore();
registerMocks();
({ app } = await import('./app'));
});
it('returns the ticker-scoped session payload', async () => {
const response = await app.handle(new Request('http://localhost/api/research/copilot/session?ticker=nvda'));
expect(response.status).toBe(200);
const payload = await response.json() as { session: { ticker: string } };
expect(payload.session.ticker).toBe('NVDA');
expect(mockGetSession).toHaveBeenCalled();
});
it('returns turn responses with assistant citations', async () => {
const response = await app.handle(new Request('http://localhost/api/research/copilot/turn', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
ticker: 'nvda',
query: 'What changed?',
sources: ['filings'],
pinnedArtifactIds: [4],
memoSection: 'thesis'
})
}));
expect(response.status).toBe(200);
const payload = await response.json() as {
assistant_message: {
citations: Array<{ artifactId: number | null }>;
};
};
expect(payload.assistant_message.citations[0]?.artifactId).toBe(5);
expect(mockRunTurn).toHaveBeenCalled();
});
it('queues research brief jobs with normalized ticker payloads', async () => {
const response = await app.handle(new Request('http://localhost/api/research/copilot/job', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
ticker: 'nvda',
query: 'Update the thesis',
sources: ['filings']
})
}));
expect(response.status).toBe(200);
const payload = await response.json() as {
task: {
task_type: string;
payload: {
ticker: string;
};
};
};
expect(payload.task.task_type).toBe('research_brief');
expect(payload.task.payload.ticker).toBe('NVDA');
expect(mockFindInFlightTask).toHaveBeenCalledWith(
TEST_USER_ID,
'research_brief',
'research_brief:NVDA:update the thesis'
);
expect(mockEnqueueTask).toHaveBeenCalled();
});
});

View File

@@ -77,7 +77,6 @@ function loadSqliteExtensions(client: Database) {
function isVectorExtensionLoaded(client: Database) { function isVectorExtensionLoaded(client: Database) {
return vectorExtensionStatus.get(client) ?? false; return vectorExtensionStatus.get(client) ?? false;
} }
function ensureSearchVirtualTables(client: Database) { function ensureSearchVirtualTables(client: Database) {
client.exec(` client.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_fts\` USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_fts\` USING fts5(

View File

@@ -44,10 +44,12 @@ type ResearchMemoSection =
| 'risks' | 'risks'
| 'disconfirming_evidence' | 'disconfirming_evidence'
| 'next_actions'; | 'next_actions';
type SearchSource = 'documents' | 'filings' | 'research';
type FinancialCadence = 'annual' | 'quarterly' | 'ltm'; type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
type SearchDocumentScope = 'global' | 'user'; type SearchDocumentScope = 'global' | 'user';
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note'; type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
type SearchIndexStatus = 'pending' | 'indexed' | 'failed'; type SearchIndexStatus = 'pending' | 'indexed' | 'failed';
type ResearchCopilotMessageRole = 'user' | 'assistant';
type FinancialSurfaceKind = type FinancialSurfaceKind =
| 'income_statement' | 'income_statement'
| 'balance_sheet' | 'balance_sheet'
@@ -636,7 +638,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' | 'index_search'>().notNull(), task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights' | 'index_search' | 'research_brief'>().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'),
@@ -824,6 +826,38 @@ export const researchMemoEvidence = sqliteTable('research_memo_evidence', {
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section) researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
})); }));
export const researchCopilotSession = sqliteTable('research_copilot_session', {
id: integer('id').primaryKey({ autoIncrement: true }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
ticker: text('ticker').notNull(),
title: text('title'),
selected_sources: text('selected_sources', { mode: 'json' }).$type<SearchSource[]>().notNull(),
pinned_artifact_ids: text('pinned_artifact_ids', { mode: 'json' }).$type<number[]>().notNull(),
created_at: text('created_at').notNull(),
updated_at: text('updated_at').notNull()
}, (table) => ({
researchCopilotSessionTickerUnique: uniqueIndex('research_copilot_session_ticker_uidx').on(table.user_id, table.ticker),
researchCopilotSessionUpdatedIndex: index('research_copilot_session_updated_idx').on(table.user_id, table.updated_at)
}));
export const researchCopilotMessage = sqliteTable('research_copilot_message', {
id: integer('id').primaryKey({ autoIncrement: true }),
session_id: integer('session_id').notNull().references(() => researchCopilotSession.id, { onDelete: 'cascade' }),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
role: text('role').$type<ResearchCopilotMessageRole>().notNull(),
content_markdown: text('content_markdown').notNull(),
citations: text('citations', { mode: 'json' }).$type<Record<string, unknown>[] | null>(),
follow_ups: text('follow_ups', { mode: 'json' }).$type<string[] | null>(),
suggested_actions: text('suggested_actions', { mode: 'json' }).$type<Record<string, unknown>[] | null>(),
selected_sources: text('selected_sources', { mode: 'json' }).$type<SearchSource[] | null>(),
pinned_artifact_ids: text('pinned_artifact_ids', { mode: 'json' }).$type<number[] | null>(),
memo_section: text('memo_section').$type<ResearchMemoSection | null>(),
created_at: text('created_at').notNull()
}, (table) => ({
researchCopilotMessageSessionIndex: index('research_copilot_message_session_idx').on(table.session_id, table.created_at),
researchCopilotMessageUserIndex: index('research_copilot_message_user_idx').on(table.user_id, table.created_at)
}));
export const authSchema = { export const authSchema = {
user, user,
session, session,
@@ -855,7 +889,9 @@ export const appSchema = {
searchChunk, searchChunk,
researchArtifact, researchArtifact,
researchMemo, researchMemo,
researchMemoEvidence researchMemoEvidence,
researchCopilotSession,
researchCopilotMessage
}; };
export const schema = { export const schema = {

View File

@@ -296,6 +296,50 @@ function ensureResearchWorkspaceSchema(client: Database) {
`); `);
} }
function ensureResearchCopilotSchema(client: Database) {
if (!hasTable(client, 'research_copilot_session')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_copilot_session\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`user_id\` text NOT NULL,
\`ticker\` text NOT NULL,
\`title\` text,
\`selected_sources\` text NOT NULL DEFAULT '["documents","filings","research"]',
\`pinned_artifact_ids\` text NOT NULL DEFAULT '[]',
\`created_at\` text NOT NULL,
\`updated_at\` text NOT NULL,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`);
}
if (!hasTable(client, 'research_copilot_message')) {
client.exec(`
CREATE TABLE IF NOT EXISTS \`research_copilot_message\` (
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
\`session_id\` integer NOT NULL,
\`user_id\` text NOT NULL,
\`role\` text NOT NULL,
\`content_markdown\` text NOT NULL,
\`citations\` text,
\`follow_ups\` text,
\`suggested_actions\` text,
\`selected_sources\` text,
\`pinned_artifact_ids\` text,
\`memo_section\` text,
\`created_at\` text NOT NULL,
FOREIGN KEY (\`session_id\`) REFERENCES \`research_copilot_session\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`);
}
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_copilot_session_ticker_uidx` ON `research_copilot_session` (`user_id`, `ticker`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_copilot_session_updated_idx` ON `research_copilot_session` (`user_id`, `updated_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_copilot_message_session_idx` ON `research_copilot_message` (`session_id`, `created_at`);');
client.exec('CREATE INDEX IF NOT EXISTS `research_copilot_message_user_idx` ON `research_copilot_message` (`user_id`, `created_at`);');
}
const TAXONOMY_SNAPSHOT_REQUIRED_COLUMNS = [ const TAXONOMY_SNAPSHOT_REQUIRED_COLUMNS = [
'parser_engine', 'parser_engine',
'parser_version', 'parser_version',
@@ -548,6 +592,7 @@ WHERE resource_key IS NOT NULL AND status IN ('queued', 'running');`);
} }
ensureResearchWorkspaceSchema(client); ensureResearchWorkspaceSchema(client);
ensureResearchCopilotSchema(client);
} }
export const __sqliteSchemaCompatInternals = { export const __sqliteSchemaCompatInternals = {

View File

@@ -0,0 +1,165 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it
} from 'bun:test';
import { mock } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Database } from 'bun:sqlite';
const TEST_USER_ID = 'copilot-user';
let tempDir: string | null = null;
let sqliteClient: Database | null = null;
let copilotRepo: typeof import('./research-copilot') | null = null;
async function loadRepoModule() {
const moduleUrl = new URL(`./research-copilot.ts?test=${Date.now()}`, import.meta.url).href;
return await import(moduleUrl) as typeof import('./research-copilot');
}
function resetDbSingletons() {
const globalState = globalThis as typeof globalThis & {
__fiscalSqliteClient?: Database;
__fiscalDrizzleDb?: unknown;
};
globalState.__fiscalSqliteClient?.close();
globalState.__fiscalSqliteClient = undefined;
globalState.__fiscalDrizzleDb = undefined;
}
function applyMigration(client: Database, fileName: string) {
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
client.exec(sql);
}
function ensureUser(client: Database) {
const now = Date.now();
client.exec(`
INSERT OR REPLACE INTO user (id, name, email, emailVerified, image, createdAt, updatedAt, role, banned, banReason, banExpires)
VALUES ('${TEST_USER_ID}', 'Copilot User', 'copilot@example.com', 1, NULL, ${now}, ${now}, NULL, 0, NULL, NULL);
`);
}
describe('research copilot repo', () => {
beforeAll(async () => {
mock.restore();
tempDir = mkdtempSync(join(tmpdir(), 'fiscal-copilot-repo-'));
process.env.DATABASE_URL = `file:${join(tempDir, 'repo.sqlite')}`;
(process.env as Record<string, string | undefined>).NODE_ENV = 'test';
resetDbSingletons();
sqliteClient = new Database(join(tempDir, 'repo.sqlite'), { create: true });
sqliteClient.exec('PRAGMA foreign_keys = ON;');
applyMigration(sqliteClient, '0000_cold_silver_centurion.sql');
applyMigration(sqliteClient, '0008_research_workspace.sql');
applyMigration(sqliteClient, '0013_research_copilot.sql');
ensureUser(sqliteClient);
const globalState = globalThis as typeof globalThis & {
__fiscalSqliteClient?: Database;
__fiscalDrizzleDb?: unknown;
};
globalState.__fiscalSqliteClient = sqliteClient;
globalState.__fiscalDrizzleDb = undefined;
copilotRepo = await loadRepoModule();
});
afterAll(() => {
mock.restore();
sqliteClient?.close();
resetDbSingletons();
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
}
});
beforeEach(() => {
sqliteClient?.exec('DELETE FROM research_copilot_message;');
sqliteClient?.exec('DELETE FROM research_copilot_session;');
});
it('creates and reloads ticker-scoped sessions', async () => {
if (!copilotRepo) {
throw new Error('repo not initialized');
}
const session = await copilotRepo.getOrCreateResearchCopilotSession({
userId: TEST_USER_ID,
ticker: 'msft',
selectedSources: ['documents', 'research'],
pinnedArtifactIds: [2, 2, 5]
});
const loaded = await copilotRepo.getResearchCopilotSessionByTicker(TEST_USER_ID, 'MSFT');
expect(session.ticker).toBe('MSFT');
expect(session.selected_sources).toEqual(['documents', 'research']);
expect(session.pinned_artifact_ids).toEqual([2, 5]);
expect(loaded?.id).toBe(session.id);
});
it('appends messages and updates session state', async () => {
if (!copilotRepo) {
throw new Error('repo not initialized');
}
const session = await copilotRepo.getOrCreateResearchCopilotSession({
userId: TEST_USER_ID,
ticker: 'NVDA'
});
await copilotRepo.appendResearchCopilotMessage({
userId: TEST_USER_ID,
sessionId: session.id,
role: 'user',
contentMarkdown: 'What changed in the latest filing?',
selectedSources: ['filings'],
pinnedArtifactIds: [7],
memoSection: 'thesis'
});
await copilotRepo.appendResearchCopilotMessage({
userId: TEST_USER_ID,
sessionId: session.id,
role: 'assistant',
contentMarkdown: 'Demand remained strong [1]',
citations: [{
index: 1,
label: 'NVDA 10-K [1]',
chunkId: 1,
href: '/filings?ticker=NVDA',
source: 'filings',
sourceKind: 'filing_brief',
sourceRef: '0001',
title: '10-K brief',
ticker: 'NVDA',
accessionNumber: '0001',
filingDate: '2026-01-01',
excerpt: 'Demand remained strong.',
artifactId: 3
}]
});
const updated = await copilotRepo.upsertResearchCopilotSessionState({
userId: TEST_USER_ID,
ticker: 'NVDA',
title: 'NVDA demand update',
selectedSources: ['filings'],
pinnedArtifactIds: [7]
});
expect(updated.title).toBe('NVDA demand update');
expect(updated.messages).toHaveLength(2);
expect(updated.messages[0]?.selected_sources).toEqual(['filings']);
expect(updated.messages[0]?.memo_section).toBe('thesis');
expect(updated.messages[1]?.citations[0]?.artifactId).toBe(3);
});
});

View File

@@ -0,0 +1,229 @@
import { and, asc, eq } from 'drizzle-orm';
import type {
ResearchCopilotCitation,
ResearchCopilotMessage,
ResearchCopilotSession,
ResearchCopilotSuggestedAction,
ResearchMemoSection,
SearchSource
} from '@/lib/types';
import { db } from '@/lib/server/db';
import {
researchCopilotMessage,
researchCopilotSession
} from '@/lib/server/db/schema';
type ResearchCopilotSessionRow = typeof researchCopilotSession.$inferSelect;
type ResearchCopilotMessageRow = typeof researchCopilotMessage.$inferSelect;
const DEFAULT_SELECTED_SOURCES: SearchSource[] = ['documents', 'filings', 'research'];
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
}
function normalizeSources(value?: SearchSource[] | null) {
const unique = new Set<SearchSource>();
for (const source of value ?? DEFAULT_SELECTED_SOURCES) {
if (source === 'documents' || source === 'filings' || source === 'research') {
unique.add(source);
}
}
return unique.size > 0 ? [...unique] : [...DEFAULT_SELECTED_SOURCES];
}
function normalizePinnedArtifactIds(value?: number[] | null) {
const unique = new Set<number>();
for (const id of value ?? []) {
const normalized = Math.trunc(Number(id));
if (Number.isInteger(normalized) && normalized > 0) {
unique.add(normalized);
}
}
return [...unique];
}
function normalizeOptionalString(value?: string | null) {
const normalized = value?.trim();
return normalized ? normalized : null;
}
function toCitationArray(value: unknown): ResearchCopilotCitation[] {
return Array.isArray(value) ? value as ResearchCopilotCitation[] : [];
}
function toActionArray(value: unknown): ResearchCopilotSuggestedAction[] {
return Array.isArray(value) ? value as ResearchCopilotSuggestedAction[] : [];
}
function toFollowUps(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
: [];
}
function toMessage(row: ResearchCopilotMessageRow): ResearchCopilotMessage {
return {
id: row.id,
session_id: row.session_id,
user_id: row.user_id,
role: row.role,
content_markdown: row.content_markdown,
citations: toCitationArray(row.citations),
follow_ups: toFollowUps(row.follow_ups),
suggested_actions: toActionArray(row.suggested_actions),
selected_sources: normalizeSources(row.selected_sources),
pinned_artifact_ids: normalizePinnedArtifactIds(row.pinned_artifact_ids),
memo_section: row.memo_section ?? null,
created_at: row.created_at
};
}
function toSession(row: ResearchCopilotSessionRow, messages: ResearchCopilotMessage[]): ResearchCopilotSession {
return {
id: row.id,
user_id: row.user_id,
ticker: row.ticker,
title: row.title ?? null,
selected_sources: normalizeSources(row.selected_sources),
pinned_artifact_ids: normalizePinnedArtifactIds(row.pinned_artifact_ids),
created_at: row.created_at,
updated_at: row.updated_at,
messages
};
}
async function listMessagesForSession(sessionId: number) {
const rows = await db
.select()
.from(researchCopilotMessage)
.where(eq(researchCopilotMessage.session_id, sessionId))
.orderBy(asc(researchCopilotMessage.created_at), asc(researchCopilotMessage.id));
return rows.map(toMessage);
}
async function getSessionRowByTicker(userId: string, ticker: string) {
const [row] = await db
.select()
.from(researchCopilotSession)
.where(and(
eq(researchCopilotSession.user_id, userId),
eq(researchCopilotSession.ticker, normalizeTicker(ticker))
))
.limit(1);
return row ?? null;
}
export async function getResearchCopilotSessionByTicker(userId: string, ticker: string) {
const row = await getSessionRowByTicker(userId, ticker);
if (!row) {
return null;
}
return toSession(row, await listMessagesForSession(row.id));
}
export async function getOrCreateResearchCopilotSession(input: {
userId: string;
ticker: string;
title?: string | null;
selectedSources?: SearchSource[] | null;
pinnedArtifactIds?: number[] | null;
}) {
const normalizedTicker = normalizeTicker(input.ticker);
if (!normalizedTicker) {
throw new Error('ticker is required');
}
const existing = await getSessionRowByTicker(input.userId, normalizedTicker);
if (existing) {
const messages = await listMessagesForSession(existing.id);
return toSession(existing, messages);
}
const now = new Date().toISOString();
const [created] = await db
.insert(researchCopilotSession)
.values({
user_id: input.userId,
ticker: normalizedTicker,
title: normalizeOptionalString(input.title),
selected_sources: normalizeSources(input.selectedSources),
pinned_artifact_ids: normalizePinnedArtifactIds(input.pinnedArtifactIds),
created_at: now,
updated_at: now
})
.returning();
return toSession(created, []);
}
export async function upsertResearchCopilotSessionState(input: {
userId: string;
ticker: string;
title?: string | null;
selectedSources?: SearchSource[] | null;
pinnedArtifactIds?: number[] | null;
}) {
const session = await getOrCreateResearchCopilotSession(input);
const [updated] = await db
.update(researchCopilotSession)
.set({
title: input.title === undefined ? session.title : normalizeOptionalString(input.title),
selected_sources: input.selectedSources === undefined
? session.selected_sources
: normalizeSources(input.selectedSources),
pinned_artifact_ids: input.pinnedArtifactIds === undefined
? session.pinned_artifact_ids
: normalizePinnedArtifactIds(input.pinnedArtifactIds),
updated_at: new Date().toISOString()
})
.where(eq(researchCopilotSession.id, session.id))
.returning();
return toSession(updated, await listMessagesForSession(updated.id));
}
export async function appendResearchCopilotMessage(input: {
userId: string;
sessionId: number;
role: ResearchCopilotMessage['role'];
contentMarkdown: string;
citations?: ResearchCopilotCitation[] | null;
followUps?: string[] | null;
suggestedActions?: ResearchCopilotSuggestedAction[] | null;
selectedSources?: SearchSource[] | null;
pinnedArtifactIds?: number[] | null;
memoSection?: ResearchMemoSection | null;
}) {
const now = new Date().toISOString();
const [created] = await db
.insert(researchCopilotMessage)
.values({
session_id: input.sessionId,
user_id: input.userId,
role: input.role,
content_markdown: input.contentMarkdown.trim(),
citations: input.citations ?? [],
follow_ups: input.followUps ?? [],
suggested_actions: input.suggestedActions ?? [],
selected_sources: input.selectedSources ? normalizeSources(input.selectedSources) : null,
pinned_artifact_ids: input.pinnedArtifactIds ? normalizePinnedArtifactIds(input.pinnedArtifactIds) : null,
memo_section: input.memoSection ?? null,
created_at: now
})
.returning();
await db
.update(researchCopilotSession)
.set({ updated_at: now })
.where(eq(researchCopilotSession.id, input.sessionId));
return toMessage(created);
}

View File

@@ -25,6 +25,7 @@ import {
researchMemo, researchMemo,
researchMemoEvidence researchMemoEvidence
} from '@/lib/server/db/schema'; } from '@/lib/server/db/schema';
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings'; import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist'; import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
@@ -374,6 +375,26 @@ async function getArtifactByIdForUser(id: number, userId: string) {
return row ?? null; return row ?? null;
} }
export async function getResearchArtifactsByIdsForUser(userId: string, ids: number[]) {
const normalizedIds = [...new Set(ids.map((id) => Math.trunc(id)).filter((id) => Number.isInteger(id) && id > 0))];
if (normalizedIds.length === 0) {
return [];
}
const rows = await db
.select()
.from(researchArtifact)
.where(and(
eq(researchArtifact.user_id, userId),
sql`${researchArtifact.id} in (${sql.join(normalizedIds.map((id) => sql`${id}`), sql`, `)})`
));
const order = new Map(normalizedIds.map((id, index) => [id, index]));
return rows
.sort((left, right) => (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(right.id) ?? Number.MAX_SAFE_INTEGER))
.map((row) => toResearchArtifact(row));
}
async function getMemoByIdForUser(id: number, userId: string) { async function getMemoByIdForUser(id: number, userId: string) {
const [row] = await db const [row] = await db
.select() .select()
@@ -902,12 +923,13 @@ export async function getResearchPacket(userId: string, ticker: string): Promise
export async function getResearchWorkspace(userId: string, ticker: string): Promise<ResearchWorkspace> { export async function getResearchWorkspace(userId: string, ticker: string): Promise<ResearchWorkspace> {
const normalizedTicker = normalizeTicker(ticker); const normalizedTicker = normalizeTicker(ticker);
const [coverage, memo, library, packet, latestFiling] = await Promise.all([ const [coverage, memo, library, packet, latestFiling, copilotSession] = await Promise.all([
getWatchlistItemByTicker(userId, normalizedTicker), getWatchlistItemByTicker(userId, normalizedTicker),
getResearchMemoByTicker(userId, normalizedTicker), getResearchMemoByTicker(userId, normalizedTicker),
listResearchArtifacts(userId, { ticker: normalizedTicker, limit: 40 }), listResearchArtifacts(userId, { ticker: normalizedTicker, limit: 40 }),
getResearchPacket(userId, normalizedTicker), getResearchPacket(userId, normalizedTicker),
listFilingsRecords({ ticker: normalizedTicker, limit: 1 }) listFilingsRecords({ ticker: normalizedTicker, limit: 1 }),
getResearchCopilotSessionByTicker(userId, normalizedTicker)
]); ]);
return { return {
@@ -918,7 +940,8 @@ export async function getResearchWorkspace(userId: string, ticker: string): Prom
memo, memo,
library: library.artifacts, library: library.artifacts,
packet, packet,
availableTags: library.availableTags availableTags: library.availableTags,
copilotSession
}; };
} }
@@ -1119,4 +1142,3 @@ export async function getResearchArtifactFileResponse(userId: string, id: number
export function rebuildResearchArtifactIndex() { export function rebuildResearchArtifactIndex() {
rebuildArtifactSearchIndex(); rebuildArtifactSearchIndex();
} }

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'bun:test';
import type { SearchResult } from '@/lib/types';
import {
extractJsonObject,
parseCopilotResponse
} from '@/lib/server/research-copilot-format';
function result(overrides: Partial<SearchResult> = {}): SearchResult {
return {
chunkId: 1,
documentId: 1,
source: 'filings',
sourceKind: 'filing_brief',
sourceRef: '0001',
title: '10-K brief',
ticker: 'NVDA',
accessionNumber: '0001',
filingDate: '2026-02-18',
citationLabel: 'NVDA · 0001 [1]',
headingPath: null,
chunkText: 'Demand stayed strong and margins expanded.',
snippet: 'Demand stayed strong and margins expanded.',
score: 0.9,
vectorRank: 1,
lexicalRank: 1,
href: '/analysis/reports/NVDA/0001',
...overrides
};
}
describe('research copilot format helpers', () => {
it('parses strict json responses with suggested actions', () => {
const parsed = parseCopilotResponse(JSON.stringify({
answerMarkdown: 'Demand stayed strong [1]. The setup still looks constructive [2].',
followUps: ['What disconfirms the bull case?', 'Which risks changed most?'],
suggestedActions: [{
type: 'draft_memo_section',
label: 'Use as thesis draft',
section: 'thesis',
contentMarkdown: 'Maintain a constructive stance while monitoring concentration.',
citationIndexes: [1, 2]
}]
}), [result(), result({ chunkId: 2, citationLabel: 'NVDA · 0002 [2]', sourceRef: '0002' })], 'What changed?', 'thesis');
expect(parsed.citationIndexes).toEqual([1, 2]);
expect(parsed.followUps).toHaveLength(2);
expect(parsed.suggestedActions[0]?.type).toBe('draft_memo_section');
expect(parsed.suggestedActions[0]?.section).toBe('thesis');
});
it('falls back to plain text and default actions when json parsing fails', () => {
const parsed = parseCopilotResponse(
'Plain text answer without json wrapper',
[result(), result({ chunkId: 2, citationLabel: 'NVDA · 0002 [2]', sourceRef: '0002' })],
'Summarize the setup',
null
);
expect(parsed.answerMarkdown).toContain('Plain text answer');
expect(parsed.citationIndexes).toEqual([1, 2]);
expect(parsed.suggestedActions.some((action) => action.type === 'draft_note')).toBe(true);
expect(parsed.suggestedActions.some((action) => action.type === 'queue_research_brief')).toBe(true);
});
it('extracts the first json object from fenced responses', () => {
const extracted = extractJsonObject('```json\n{"answerMarkdown":"A [1]","followUps":[],"suggestedActions":[]}\n```');
expect(extracted).toBe('{"answerMarkdown":"A [1]","followUps":[],"suggestedActions":[]}');
});
});

View File

@@ -0,0 +1,225 @@
import { randomUUID } from 'node:crypto';
import type {
ResearchCopilotSuggestedAction,
ResearchMemoSection,
SearchResult
} from '@/lib/types';
type ParsedCopilotPayload = {
answerMarkdown: string;
followUps: string[];
suggestedActions: ResearchCopilotSuggestedAction[];
citationIndexes: number[];
};
const MAX_FOLLOW_UPS = 4;
const MAX_SUGGESTED_ACTIONS = 3;
function truncate(value: string, maxLength: number) {
const normalized = value.trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}`;
}
function buildSessionTitle(query: string) {
return truncate(query, 72);
}
export function extractJsonObject(text: string) {
const fenced = text.match(/```json\s*([\s\S]*?)```/i)?.[1];
if (fenced) {
return fenced.trim();
}
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start >= 0 && end > start) {
return text.slice(start, end + 1).trim();
}
return null;
}
export function parseCitationIndexes(value: string, evidenceLength: number) {
const matches = [...value.matchAll(/\[(\d+)\]/g)];
const seen = new Set<number>();
const indexes: number[] = [];
for (const match of matches) {
const parsed = Number(match[1]);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > evidenceLength || seen.has(parsed)) {
continue;
}
seen.add(parsed);
indexes.push(parsed);
}
return indexes;
}
function parseStringArray(value: unknown, maxItems: number) {
if (!Array.isArray(value)) {
return [];
}
return value
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => truncate(entry, 220))
.slice(0, maxItems);
}
function normalizeSuggestedAction(
value: unknown,
fallbackQuery: string
): ResearchCopilotSuggestedAction | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const candidate = value as Record<string, unknown>;
const type = candidate.type;
if (type !== 'draft_note' && type !== 'draft_memo_section' && type !== 'queue_research_brief') {
return null;
}
const label = typeof candidate.label === 'string' && candidate.label.trim().length > 0
? truncate(candidate.label, 80)
: type === 'draft_note'
? 'Use as note draft'
: type === 'draft_memo_section'
? 'Use as memo draft'
: 'Queue research brief';
const section = candidate.section === 'thesis'
|| candidate.section === 'variant_view'
|| candidate.section === 'catalysts'
|| candidate.section === 'risks'
|| candidate.section === 'disconfirming_evidence'
|| candidate.section === 'next_actions'
? candidate.section
: null;
const description = typeof candidate.description === 'string' && candidate.description.trim().length > 0
? truncate(candidate.description, 180)
: null;
const title = typeof candidate.title === 'string' && candidate.title.trim().length > 0
? truncate(candidate.title, 120)
: null;
const contentMarkdown = typeof candidate.contentMarkdown === 'string' && candidate.contentMarkdown.trim().length > 0
? candidate.contentMarkdown.trim()
: null;
const citationIndexes = Array.isArray(candidate.citationIndexes)
? candidate.citationIndexes
.map((entry) => Math.trunc(Number(entry)))
.filter((entry, index, source) => Number.isInteger(entry) && entry > 0 && source.indexOf(entry) === index)
: [];
const query = typeof candidate.query === 'string' && candidate.query.trim().length > 0
? truncate(candidate.query, 180)
: type === 'queue_research_brief'
? fallbackQuery
: null;
if ((type === 'draft_note' || type === 'draft_memo_section') && !contentMarkdown) {
return null;
}
if (type === 'draft_memo_section' && !section) {
return null;
}
return {
id: randomUUID(),
type,
label,
description,
section,
title,
content_markdown: contentMarkdown,
citation_indexes: citationIndexes,
query
};
}
function buildFallbackActions(query: string, memoSection: ResearchMemoSection | null, answerMarkdown: string) {
return [
{
id: randomUUID(),
type: memoSection ? 'draft_memo_section' : 'draft_note',
label: memoSection ? 'Use as memo draft' : 'Use as note draft',
description: memoSection
? `Populate ${memoSection.replace('_', ' ')} with this answer for review.`
: 'Populate the note draft editor with this answer for review.',
section: memoSection,
title: memoSection ? null : buildSessionTitle(query),
content_markdown: answerMarkdown,
citation_indexes: [],
query: null
},
{
id: randomUUID(),
type: 'queue_research_brief',
label: 'Queue research brief',
description: 'Run a background synthesis job and save a longer-form brief to the library.',
section: null,
title: null,
content_markdown: null,
citation_indexes: [],
query
}
] satisfies ResearchCopilotSuggestedAction[];
}
export function parseCopilotResponse(
rawText: string,
evidence: SearchResult[],
query: string,
memoSection: ResearchMemoSection | null
): ParsedCopilotPayload {
const jsonText = extractJsonObject(rawText);
if (!jsonText) {
const answerMarkdown = rawText.trim() || 'Insufficient evidence to answer from the indexed sources.';
return {
answerMarkdown,
followUps: [],
suggestedActions: buildFallbackActions(query, memoSection, answerMarkdown),
citationIndexes: evidence.slice(0, Math.min(3, evidence.length)).map((_value, index) => index + 1)
};
}
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
const answerMarkdown = typeof parsed.answerMarkdown === 'string' && parsed.answerMarkdown.trim().length > 0
? parsed.answerMarkdown.trim()
: 'Insufficient evidence to answer from the indexed sources.';
const citationIndexes = parseCitationIndexes(answerMarkdown, evidence.length);
const followUps = parseStringArray(parsed.followUps, MAX_FOLLOW_UPS);
const suggestedActions = Array.isArray(parsed.suggestedActions)
? parsed.suggestedActions
.map((entry) => normalizeSuggestedAction(entry, query))
.filter((entry): entry is ResearchCopilotSuggestedAction => Boolean(entry))
.slice(0, MAX_SUGGESTED_ACTIONS)
: [];
return {
answerMarkdown,
followUps,
suggestedActions: suggestedActions.length > 0
? suggestedActions
: buildFallbackActions(query, memoSection, answerMarkdown),
citationIndexes: citationIndexes.length > 0
? citationIndexes
: evidence.slice(0, Math.min(3, evidence.length)).map((_value, index) => index + 1)
};
} catch {
const answerMarkdown = rawText.trim() || 'Insufficient evidence to answer from the indexed sources.';
return {
answerMarkdown,
followUps: [],
suggestedActions: buildFallbackActions(query, memoSection, answerMarkdown),
citationIndexes: evidence.slice(0, Math.min(3, evidence.length)).map((_value, index) => index + 1)
};
}
}

View File

@@ -0,0 +1,419 @@
import type {
ResearchCopilotCitation,
ResearchCopilotTurnResponse,
ResearchMemo,
ResearchMemoSection,
SearchResult,
SearchSource
} from '@/lib/types';
import { runAiAnalysis } from '@/lib/server/ai';
import {
extractJsonObject,
parseCitationIndexes,
parseCopilotResponse
} from '@/lib/server/research-copilot-format';
import {
appendResearchCopilotMessage,
getOrCreateResearchCopilotSession,
upsertResearchCopilotSessionState
} from '@/lib/server/repos/research-copilot';
import {
createAiReportArtifactFromAccession,
createFilingArtifactFromAccession,
getResearchArtifactsByIdsForUser,
getResearchMemoByTicker
} from '@/lib/server/repos/research-library';
import { searchKnowledgeBase } from '@/lib/server/search';
type CopilotTurnInput = {
userId: string;
ticker: string;
query: string;
selectedSources?: SearchSource[];
pinnedArtifactIds?: number[];
memoSection?: ResearchMemoSection | null;
};
const DEFAULT_SELECTED_SOURCES: SearchSource[] = ['documents', 'filings', 'research'];
const MAX_HISTORY_MESSAGES = 6;
const MAX_CONTEXT_RESULTS = 6;
const MAX_CONTEXT_CHARS = 8_000;
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
}
function normalizeSources(value?: SearchSource[] | null) {
const unique = new Set<SearchSource>();
for (const source of value ?? DEFAULT_SELECTED_SOURCES) {
if (source === 'documents' || source === 'filings' || source === 'research') {
unique.add(source);
}
}
return unique.size > 0 ? [...unique] : [...DEFAULT_SELECTED_SOURCES];
}
function normalizePinnedArtifactIds(value?: number[] | null) {
const unique = new Set<number>();
for (const id of value ?? []) {
const normalized = Math.trunc(Number(id));
if (Number.isInteger(normalized) && normalized > 0) {
unique.add(normalized);
}
}
return [...unique];
}
function truncate(value: string, maxLength: number) {
const normalized = value.trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1).trimEnd()}`;
}
function buildSessionTitle(query: string) {
return truncate(query, 72);
}
function summarizeMemoPosture(memo: ResearchMemo | null) {
if (!memo) {
return 'No investment memo exists yet.';
}
return JSON.stringify({
rating: memo.rating,
conviction: memo.conviction,
timeHorizonMonths: memo.time_horizon_months,
packetTitle: memo.packet_title,
packetSubtitle: memo.packet_subtitle
});
}
function buildConversationContext(history: Array<{ role: 'user' | 'assistant'; content_markdown: string }>) {
if (history.length === 0) {
return 'No previous conversation.';
}
return history.map((message) => `${message.role.toUpperCase()}: ${truncate(message.content_markdown, 600)}`).join('\n\n');
}
function buildPinnedArtifactContext(artifacts: Array<{ id: number; title: string | null; summary: string | null; body_markdown: string | null; kind: string }>) {
if (artifacts.length === 0) {
return 'No pinned artifacts.';
}
return artifacts.map((artifact) => JSON.stringify({
id: artifact.id,
kind: artifact.kind,
title: artifact.title,
summary: artifact.summary,
body: artifact.body_markdown ? truncate(artifact.body_markdown, 700) : null
})).join('\n');
}
function buildEvidence(results: SearchResult[]) {
const evidence: SearchResult[] = [];
let totalChars = 0;
for (const result of results) {
if (evidence.length >= MAX_CONTEXT_RESULTS) {
break;
}
if (totalChars + result.chunkText.length > MAX_CONTEXT_CHARS && evidence.length > 0) {
break;
}
evidence.push(result);
totalChars += result.chunkText.length;
}
return evidence;
}
function buildCopilotPrompt(input: {
ticker: string;
query: string;
selectedSources: SearchSource[];
memoSection: ResearchMemoSection | null;
memo: ResearchMemo | null;
history: Array<{ role: 'user' | 'assistant'; content_markdown: string }>;
pinnedArtifacts: Array<{ id: number; title: string | null; summary: string | null; body_markdown: string | null; kind: string }>;
evidence: SearchResult[];
}) {
const evidenceText = input.evidence.map((result, index) => ([
`[${index + 1}] ${result.citationLabel}`,
`Source kind: ${result.sourceKind}`,
`Ticker: ${result.ticker ?? 'n/a'}`,
`Title: ${result.title ?? result.sourceRef}`,
`Excerpt: ${result.chunkText}`
].join('\n'))).join('\n\n');
return [
'You are an embedded buy-side company research copilot.',
'Use only the supplied evidence. Never use outside knowledge.',
'Return strict JSON only with this shape:',
'{"answerMarkdown":"string","followUps":["string"],"suggestedActions":[{"type":"draft_note|draft_memo_section|queue_research_brief","label":"string","description":"string|null","section":"thesis|variant_view|catalysts|risks|disconfirming_evidence|next_actions|null","title":"string|null","contentMarkdown":"string|null","citationIndexes":[1],"query":"string|null"}]}',
'The answerMarkdown should use inline citations like [1] and [2].',
'Suggested actions must be review-first. Never instruct the system to save or mutate automatically.',
`Ticker: ${input.ticker}`,
`Selected sources: ${input.selectedSources.join(', ')}`,
`Target memo section: ${input.memoSection ?? 'none'}`,
`Memo posture: ${summarizeMemoPosture(input.memo)}`,
`Pinned artifacts:\n${buildPinnedArtifactContext(input.pinnedArtifacts)}`,
`Recent conversation:\n${buildConversationContext(input.history)}`,
`User question: ${input.query}`,
'',
'Evidence:',
evidenceText
].join('\n');
}
async function materializeArtifactIdForResult(userId: string, result: SearchResult) {
if (result.sourceKind === 'research_note') {
const artifactId = Math.trunc(Number(result.sourceRef));
return Number.isInteger(artifactId) && artifactId > 0 ? artifactId : null;
}
if (!result.accessionNumber) {
return null;
}
try {
if (result.sourceKind === 'filing_brief') {
return (await createAiReportArtifactFromAccession(userId, result.accessionNumber)).id;
}
return (await createFilingArtifactFromAccession(userId, result.accessionNumber)).id;
} catch {
if (result.sourceKind === 'filing_brief') {
try {
return (await createFilingArtifactFromAccession(userId, result.accessionNumber)).id;
} catch {
return null;
}
}
return null;
}
}
async function buildCopilotCitations(userId: string, evidence: SearchResult[], citationIndexes: number[]) {
const citations: ResearchCopilotCitation[] = [];
for (const index of citationIndexes) {
const result = evidence[index - 1];
if (!result) {
continue;
}
citations.push({
index,
label: result.citationLabel,
chunkId: result.chunkId,
href: result.href,
source: result.source,
sourceKind: result.sourceKind,
sourceRef: result.sourceRef,
title: result.title,
ticker: result.ticker,
accessionNumber: result.accessionNumber,
filingDate: result.filingDate,
excerpt: result.snippet || truncate(result.chunkText, 280),
artifactId: await materializeArtifactIdForResult(userId, result)
});
}
return citations;
}
export async function runResearchCopilotTurn(input: CopilotTurnInput): Promise<ResearchCopilotTurnResponse> {
const ticker = normalizeTicker(input.ticker);
const query = input.query.trim();
if (!ticker) {
throw new Error('ticker is required');
}
if (!query) {
throw new Error('query is required');
}
const selectedSources = normalizeSources(input.selectedSources);
const pinnedArtifactIds = normalizePinnedArtifactIds(input.pinnedArtifactIds);
const existingSession = await getOrCreateResearchCopilotSession({
userId: input.userId,
ticker,
title: buildSessionTitle(query),
selectedSources,
pinnedArtifactIds
});
const memo = await getResearchMemoByTicker(input.userId, ticker);
const history = existingSession.messages.slice(-MAX_HISTORY_MESSAGES).map((message) => ({
role: message.role,
content_markdown: message.content_markdown
}));
const pinnedArtifacts = await getResearchArtifactsByIdsForUser(input.userId, pinnedArtifactIds);
const userMessage = await appendResearchCopilotMessage({
userId: input.userId,
sessionId: existingSession.id,
role: 'user',
contentMarkdown: query,
selectedSources,
pinnedArtifactIds,
memoSection: input.memoSection ?? null
});
const results = await searchKnowledgeBase({
userId: input.userId,
query,
ticker,
sources: selectedSources,
limit: 10
});
const evidence = buildEvidence(results);
if (evidence.length === 0) {
const answerMarkdown = 'Insufficient evidence to answer from the indexed sources.';
const assistantMessage = await appendResearchCopilotMessage({
userId: input.userId,
sessionId: existingSession.id,
role: 'assistant',
contentMarkdown: answerMarkdown,
citations: [],
followUps: [],
suggestedActions: parseCopilotResponse(answerMarkdown, [], query, input.memoSection ?? null).suggestedActions,
selectedSources,
pinnedArtifactIds,
memoSection: input.memoSection ?? null
});
const session = await upsertResearchCopilotSessionState({
userId: input.userId,
ticker,
title: existingSession.title ?? buildSessionTitle(query),
selectedSources,
pinnedArtifactIds
});
return {
session,
user_message: userMessage,
assistant_message: assistantMessage,
results
};
}
const response = await runAiAnalysis(
buildCopilotPrompt({
ticker,
query,
selectedSources,
memoSection: input.memoSection ?? null,
memo,
history,
pinnedArtifacts,
evidence
}),
'Return strict JSON only. Stay concise, factual, and operational.',
{ workload: 'report' }
);
const parsed = parseCopilotResponse(response.text, evidence, query, input.memoSection ?? null);
const citations = await buildCopilotCitations(input.userId, evidence, parsed.citationIndexes);
const assistantMessage = await appendResearchCopilotMessage({
userId: input.userId,
sessionId: existingSession.id,
role: 'assistant',
contentMarkdown: parsed.answerMarkdown,
citations,
followUps: parsed.followUps,
suggestedActions: parsed.suggestedActions,
selectedSources,
pinnedArtifactIds,
memoSection: input.memoSection ?? null
});
const session = await upsertResearchCopilotSessionState({
userId: input.userId,
ticker,
title: existingSession.title ?? buildSessionTitle(query),
selectedSources,
pinnedArtifactIds
});
return {
session,
user_message: userMessage,
assistant_message: assistantMessage,
results
};
}
function buildResearchBriefPrompt(input: {
ticker: string;
query: string;
memo: ResearchMemo | null;
evidence: SearchResult[];
}) {
const evidenceText = input.evidence.map((result, index) => [
`[${index + 1}] ${result.citationLabel}`,
`Title: ${result.title ?? result.sourceRef}`,
`Excerpt: ${result.chunkText}`
].join('\n')).join('\n\n');
return [
'Write a longer-form buy-side research brief grounded only in the evidence below.',
'Use markdown with these sections: Executive Summary, Key Evidence, Memo Implications, Open Questions.',
`Ticker: ${input.ticker}`,
`Brief request: ${input.query}`,
`Memo posture: ${summarizeMemoPosture(input.memo)}`,
'',
'Evidence:',
evidenceText
].join('\n');
}
export async function generateResearchBrief(input: {
userId: string;
ticker: string;
query: string;
selectedSources?: SearchSource[];
}) {
const selectedSources = normalizeSources(input.selectedSources);
const memo = await getResearchMemoByTicker(input.userId, input.ticker);
const results = await searchKnowledgeBase({
userId: input.userId,
query: input.query,
ticker: input.ticker,
sources: selectedSources,
limit: 10
});
const evidence = buildEvidence(results);
const response = await runAiAnalysis(
buildResearchBriefPrompt({
ticker: normalizeTicker(input.ticker),
query: input.query.trim(),
memo,
evidence
}),
'Use neutral analyst prose and cite evidence inline like [1].',
{ workload: 'report' }
);
return {
provider: response.provider,
model: response.model,
bodyMarkdown: response.text.trim(),
evidence
};
}
export const __researchCopilotInternals = {
buildCopilotPrompt,
buildResearchBriefPrompt,
extractJsonObject,
parseCopilotResponse,
parseCitationIndexes
};

View File

@@ -120,4 +120,35 @@ describe('task notification builder', () => {
expect(notification.detailLine).toBe('Could not load the primary filing document.'); expect(notification.detailLine).toBe('Could not load the primary filing document.');
expect(notification.actions.some((action) => action.id === 'open_filings')).toBe(true); expect(notification.actions.some((action) => action.id === 'open_filings')).toBe(true);
}); });
it('adds research navigation for completed research brief jobs', () => {
const notification = buildTaskNotification(baseTask({
task_type: 'research_brief',
status: 'completed',
stage: 'completed',
stage_detail: 'Generated research brief artifact for NVDA.',
stage_context: {
subject: {
ticker: 'NVDA'
}
},
payload: {
ticker: 'NVDA',
query: 'Update the thesis'
},
result: {
ticker: 'NVDA',
artifactId: 12,
model: 'test-model'
},
finished_at: '2026-03-09T10:06:00.000Z'
}));
expect(notification.actions[0]).toMatchObject({
id: 'open_research',
href: '/research?ticker=NVDA',
primary: true
});
expect(notification.stats.some((stat) => stat.label === 'Artifact' && stat.value === '12')).toBe(true);
});
}); });

View File

@@ -117,6 +117,13 @@ function buildStats(task: TaskCore): TaskNotificationStat[] {
); );
break; break;
} }
case 'research_brief':
stats.push(
makeStat('Ticker', asString(result?.ticker) ?? task.stage_context?.subject?.ticker ?? null),
makeStat('Artifact', asNumber(result?.artifactId) ?? null),
makeStat('Model', asString(result?.model) ?? null)
);
break;
} }
if (stats.every((stat) => stat === null)) { if (stats.every((stat) => stat === null)) {
@@ -194,6 +201,14 @@ function buildActions(task: TaskCore): TaskNotificationAction[] {
primary: true primary: true
}); });
break; break;
case 'research_brief':
actions.push({
id: 'open_research',
label: 'Open research',
href: ticker ? `/research?ticker=${encodeURIComponent(ticker)}` : '/research',
primary: true
});
break;
} }
actions.push({ actions.push({

View File

@@ -10,6 +10,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 { generateResearchBrief } from '@/lib/server/research-copilot';
import { indexSearchDocuments } from '@/lib/server/search'; import { indexSearchDocuments } from '@/lib/server/search';
import { import {
getFilingByAccession, getFilingByAccession,
@@ -32,7 +33,9 @@ import {
listUserHoldings listUserHoldings
} from '@/lib/server/repos/holdings'; } from '@/lib/server/repos/holdings';
import { createPortfolioInsight } from '@/lib/server/repos/insights'; import { createPortfolioInsight } from '@/lib/server/repos/insights';
import { createResearchArtifactRecord } from '@/lib/server/repos/research-library';
import { updateTaskStage } from '@/lib/server/repos/tasks'; import { updateTaskStage } from '@/lib/server/repos/tasks';
import { updateWatchlistReviewByTicker } from '@/lib/server/repos/watchlist';
import { import {
fetchPrimaryFilingText, fetchPrimaryFilingText,
fetchRecentFilings fetchRecentFilings
@@ -1302,6 +1305,97 @@ async function processPortfolioInsights(task: Task) {
); );
} }
async function processResearchBrief(task: Task) {
const ticker = typeof task.payload.ticker === 'string' ? task.payload.ticker.trim().toUpperCase() : '';
const query = typeof task.payload.query === 'string' ? task.payload.query.trim() : '';
const sources = Array.isArray(task.payload.sources)
? task.payload.sources.filter((entry): entry is 'documents' | 'filings' | 'research' => entry === 'documents' || entry === 'filings' || entry === 'research')
: undefined;
if (!ticker) {
throw new Error('Research brief task requires a ticker');
}
if (!query) {
throw new Error('Research brief task requires a query');
}
await setProjectionStage(task, 'research.retrieve', `Collecting evidence for ${ticker} research brief`, {
subject: { ticker },
progress: { current: 1, total: 3, unit: 'steps' }
});
const brief = await generateResearchBrief({
userId: task.user_id,
ticker,
query,
selectedSources: sources
});
await setProjectionStage(task, 'research.answer', `Generating research brief for ${ticker}`, {
subject: { ticker },
progress: { current: 2, total: 3, unit: 'steps' },
counters: {
evidence: brief.evidence.length
}
});
const summary = brief.bodyMarkdown
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith('#'))
?? `Generated research brief for ${ticker}.`;
await setProjectionStage(task, 'research.persist', `Saving research brief artifact for ${ticker}`, {
subject: { ticker },
progress: { current: 3, total: 3, unit: 'steps' },
counters: {
evidence: brief.evidence.length
}
});
const artifact = await createResearchArtifactRecord({
userId: task.user_id,
ticker,
kind: 'ai_report',
source: 'system',
subtype: 'research_brief',
title: `Research brief · ${query}`,
summary,
bodyMarkdown: brief.bodyMarkdown,
tags: ['copilot', 'research-brief'],
metadata: {
query,
sources: sources ?? ['documents', 'filings', 'research'],
provider: brief.provider,
model: brief.model,
citations: brief.evidence.map((result, index) => ({
index: index + 1,
label: result.citationLabel,
href: result.href
}))
}
});
await updateWatchlistReviewByTicker(task.user_id, ticker, artifact.updated_at);
return buildTaskOutcome(
{
ticker,
artifactId: artifact.id,
provider: brief.provider,
model: brief.model
},
`Generated research brief artifact for ${ticker}.`,
{
subject: { ticker },
counters: {
evidence: brief.evidence.length
}
}
);
}
export const __taskProcessorInternals = { export const __taskProcessorInternals = {
parseExtractionPayload, parseExtractionPayload,
deterministicExtractionFallback, deterministicExtractionFallback,
@@ -1320,6 +1414,8 @@ export async function runTaskProcessor(task: Task) {
return await processPortfolioInsights(task); return await processPortfolioInsights(task);
case 'index_search': case 'index_search':
return await processIndexSearch(task); return await processIndexSearch(task);
case 'research_brief':
return await processResearchBrief(task);
default: default:
throw new Error(`Unsupported task type: ${task.task_type}`); throw new Error(`Unsupported task type: ${task.task_type}`);
} }

View File

@@ -19,7 +19,8 @@ const TASK_TYPE_LABELS: Record<TaskType, string> = {
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' index_search: 'Search indexing',
research_brief: 'Research brief'
}; };
const STAGE_LABELS: Record<TaskStage, string> = { const STAGE_LABELS: Record<TaskStage, string> = {
@@ -50,6 +51,9 @@ const STAGE_LABELS: Record<TaskStage, string> = {
'search.chunk': 'Chunk content', 'search.chunk': 'Chunk content',
'search.embed': 'Generate embeddings', 'search.embed': 'Generate embeddings',
'search.persist': 'Persist search index', 'search.persist': 'Persist search index',
'research.retrieve': 'Retrieve evidence',
'research.answer': 'Generate brief',
'research.persist': 'Persist brief',
'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'
@@ -97,6 +101,14 @@ const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
'search.persist', 'search.persist',
'completed' 'completed'
], ],
research_brief: [
'queued',
'running',
'research.retrieve',
'research.answer',
'research.persist',
'completed'
],
portfolio_insights: [ portfolio_insights: [
'queued', 'queued',
'running', 'running',

View File

@@ -119,7 +119,8 @@ export type TaskType =
| 'refresh_prices' | 'refresh_prices'
| 'analyze_filing' | 'analyze_filing'
| 'portfolio_insights' | 'portfolio_insights'
| 'index_search'; | 'index_search'
| 'research_brief';
export type TaskStage = export type TaskStage =
| 'queued' | 'queued'
| 'running' | 'running'
@@ -148,6 +149,9 @@ export type TaskStage =
| 'search.chunk' | 'search.chunk'
| 'search.embed' | 'search.embed'
| 'search.persist' | 'search.persist'
| 'research.retrieve'
| 'research.answer'
| 'research.persist'
| 'insights.load_holdings' | 'insights.load_holdings'
| 'insights.generate' | 'insights.generate'
| 'insights.persist'; | 'insights.persist';
@@ -178,7 +182,8 @@ export type TaskNotificationAction = {
| 'open_analysis' | 'open_analysis'
| 'open_analysis_report' | 'open_analysis_report'
| 'open_search' | 'open_search'
| 'open_portfolio'; | 'open_portfolio'
| 'open_research';
label: string; label: string;
href: string | null; href: string | null;
primary?: boolean; primary?: boolean;
@@ -318,6 +323,73 @@ export type SearchAnswerResponse = {
results: SearchResult[]; results: SearchResult[];
}; };
export type ResearchCopilotSuggestedActionType =
| 'draft_note'
| 'draft_memo_section'
| 'queue_research_brief';
export type ResearchCopilotCitation = {
index: number;
label: string;
chunkId: number;
href: string;
source: SearchSource;
sourceKind: SearchResult['sourceKind'];
sourceRef: string;
title: string | null;
ticker: string | null;
accessionNumber: string | null;
filingDate: string | null;
excerpt: string;
artifactId: number | null;
};
export type ResearchCopilotSuggestedAction = {
id: string;
type: ResearchCopilotSuggestedActionType;
label: string;
description: string | null;
section: ResearchMemoSection | null;
title: string | null;
content_markdown: string | null;
citation_indexes: number[];
query: string | null;
};
export type ResearchCopilotMessage = {
id: number;
session_id: number;
user_id: string;
role: 'user' | 'assistant';
content_markdown: string;
citations: ResearchCopilotCitation[];
follow_ups: string[];
suggested_actions: ResearchCopilotSuggestedAction[];
selected_sources: SearchSource[];
pinned_artifact_ids: number[];
memo_section: ResearchMemoSection | null;
created_at: string;
};
export type ResearchCopilotSession = {
id: number;
user_id: string;
ticker: string;
title: string | null;
selected_sources: SearchSource[];
pinned_artifact_ids: number[];
created_at: string;
updated_at: string;
messages: ResearchCopilotMessage[];
};
export type ResearchCopilotTurnResponse = {
session: ResearchCopilotSession;
user_message: ResearchCopilotMessage;
assistant_message: ResearchCopilotMessage;
results: SearchResult[];
};
export type ResearchArtifact = { export type ResearchArtifact = {
id: number; id: number;
user_id: string; user_id: string;
@@ -403,6 +475,7 @@ export type ResearchWorkspace = {
library: ResearchArtifact[]; library: ResearchArtifact[];
packet: ResearchPacket; packet: ResearchPacket;
availableTags: string[]; availableTags: string[];
copilotSession: ResearchCopilotSession | null;
}; };
export type CompanyFinancialPoint = { export type CompanyFinancialPoint = {