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 Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ResearchCopilotPanel } from '@/components/research/research-copilot-panel';
import { AppShell } from '@/components/shell/app-shell';
import { Panel } from '@/components/ui/panel';
import { Button } from '@/components/ui/button';
@@ -31,6 +32,8 @@ import type {
ResearchArtifact,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchCopilotCitation,
ResearchCopilotSession,
ResearchMemo,
ResearchMemoSection,
ResearchWorkspace
@@ -104,6 +107,15 @@ const EMPTY_MEMO_FORM: MemoFormState = {
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) {
const unique = new Set<string>();
@@ -197,6 +209,8 @@ function ResearchPageContent() {
const [uploadTitle, setUploadTitle] = useState('');
const [uploadSummary, setUploadSummary] = 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 deferredSearch = useDeferredValue(searchInput);
@@ -253,6 +267,7 @@ function ResearchPageContent() {
const invalidateResearch = async (symbol: string) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchCopilotSession(symbol) }),
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(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 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 (
<AppShell
title="Research"
@@ -413,6 +788,28 @@ function ResearchPageContent() {
]}
actions={(
<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
variant="secondary"
onClick={() => {
@@ -499,325 +896,75 @@ function ResearchPageContent() {
</Panel>
) : (
<>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]">
<Panel
title="Library Filters"
subtitle="Narrow the evidence set by structure, ownership, and memo linkage."
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
>
<div className="space-y-3">
<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..." />
{researchMode === 'workspace' ? (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.7fr)_minmax(22rem,0.85fr)]">
<div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6">
{renderQuickNotePanel()}
{renderLibraryPanel()}
{renderUploadPanel()}
</div>
{renderMemoPanel()}
</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>
<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="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
<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>
{renderPacketPanel()}
</div>
</Panel>
<div className="space-y-6">
<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" />
<ResearchCopilotPanel
ticker={ticker}
companyName={workspace.companyName}
session={workspace.copilotSession}
targetSection={attachSection}
onSessionChange={handleCopilotSessionChange}
onDraftNote={handleDraftNote}
onDraftMemoSection={handleDraftMemoSection}
onAttachCitation={handleAttachCitation}
onNotice={setNotice}
onError={setError}
/>
</div>
) : (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(22rem,0.95fr)]">
<ResearchCopilotPanel
ticker={ticker}
companyName={workspace.companyName}
session={workspace.copilotSession}
targetSection={attachSection}
variant="focus"
onSessionChange={handleCopilotSessionChange}
onDraftNote={handleDraftNote}
onDraftMemoSection={handleDraftMemoSection}
onAttachCitation={handleAttachCitation}
onNotice={setNotice}
onError={setError}
/>
<div className="space-y-6">
<Panel
title="Research Surfaces"
subtitle="Keep the memo, library, and packet within reach while the copilot stays full-width."
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
>
<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}
{(['library', 'memo', 'packet'] as const).map((tab) => (
<button
key={tab}
type="button"
className={focusTab === tab
? '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)}
>
{tab === 'library' ? 'Library' : tab === 'memo' ? 'Memo' : 'Packet'}
</button>
))}
</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
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>
</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>
</Panel>
{focusTab === 'library' ? renderLibraryPanel() : null}
{focusTab === 'memo' ? renderMemoPanel() : null}
{focusTab === 'packet' ? renderPacketPanel() : null}
</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',
analyze_filing: 'Analyze filing',
portfolio_insights: 'Portfolio insights',
index_search: 'Index search'
index_search: 'Index search',
research_brief: 'Research brief'
};
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`.
- 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
### Font roles
@@ -231,6 +245,53 @@ Panel rules:
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 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,
"tag": "0011_remove_legacy_xbrl_defaults",
"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.getByRole('button', { name: 'Save coverage' }).click();
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
await page.getByLabel('NVDA status').selectOption('active');
await expect(page.getByLabel('NVDA status')).toHaveValue('active');
await page.getByLabel('NVDA priority').selectOption('high');
await expect(page.getByLabel('NVDA priority')).toHaveValue('high');
const coverageTable = page.locator('.data-table-wrap:visible');
const nvdaRow = coverageTable.locator('tbody tr').filter({
has: page.getByText('NVIDIA Corporation')
});
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.getByText('Bull vs Bear')).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 page.getByLabel('Research note title').fill('Own-the-stack moat check');
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.waitForLoadState('networkidle');
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 page.getByLabel('Memo rating').selectOption('buy');

View File

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

View File

@@ -15,6 +15,8 @@ import type {
ResearchArtifact,
ResearchArtifactKind,
ResearchArtifactSource,
ResearchCopilotSession,
ResearchCopilotTurnResponse,
ResearchJournalEntry,
ResearchJournalEntryType,
SearchAnswerResponse,
@@ -226,6 +228,48 @@ export async function getResearchWorkspace(ticker: string) {
}, '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: {
ticker: 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,
report: (accessionNumber: string) => ['report', accessionNumber] as const,
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
researchCopilotSession: (ticker: string) => ['research', 'copilot', 'session', ticker] as const,
researchLibrary: (
ticker: string,
q: string,

View File

@@ -5,6 +5,7 @@ import {
getCompanyFinancialStatements,
getLatestPortfolioInsight,
getPortfolioSummary,
getResearchCopilotSession,
searchKnowledge,
getResearchMemo,
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: {
ticker: string;
q?: string;

View File

@@ -24,7 +24,7 @@ describe('ai config and runtime', () => {
expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('key');
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);
});
@@ -80,7 +80,7 @@ describe('ai config and runtime', () => {
expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('new-key');
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);
return { modelId: config.model };
});
@@ -109,7 +109,7 @@ describe('ai config and runtime', () => {
});
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(createModel).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledTimes(1);

View File

@@ -155,7 +155,7 @@ export function getReportAiConfig(options?: GetAiConfigOptions) {
provider: 'zhipu',
apiKey: envValue('ZHIPU_API_KEY', env),
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))
} 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 { asErrorMessage, jsonError } from '@/lib/server/http';
import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { runResearchCopilotTurn } from '@/lib/server/research-copilot';
import {
defaultFinancialSyncLimit,
getCompanyFinancials
@@ -61,6 +62,7 @@ import {
listResearchJournalEntries,
updateResearchJournalEntryRecord
} from '@/lib/server/repos/research-journal';
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
import {
deleteWatchlistItemRecord,
getWatchlistItemById,
@@ -839,6 +841,116 @@ export const app = new Elysia({ prefix: '/api' })
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 }) => {
const { session, response } = await requireAuthenticatedSession();
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) {
return vectorExtensionStatus.get(client) ?? false;
}
function ensureSearchVirtualTables(client: Database) {
client.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_fts\` USING fts5(

View File

@@ -44,10 +44,12 @@ type ResearchMemoSection =
| 'risks'
| 'disconfirming_evidence'
| 'next_actions';
type SearchSource = 'documents' | 'filings' | 'research';
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
type SearchDocumentScope = 'global' | 'user';
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
type SearchIndexStatus = 'pending' | 'indexed' | 'failed';
type ResearchCopilotMessageRole = 'user' | 'assistant';
type FinancialSurfaceKind =
| 'income_statement'
| 'balance_sheet'
@@ -636,7 +638,7 @@ export const filingLink = sqliteTable('filing_link', {
export const taskRun = sqliteTable('task_run', {
id: text('id').primaryKey().notNull(),
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights' | '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(),
stage: text('stage').notNull(),
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)
}));
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 = {
user,
session,
@@ -855,7 +889,9 @@ export const appSchema = {
searchChunk,
researchArtifact,
researchMemo,
researchMemoEvidence
researchMemoEvidence,
researchCopilotSession,
researchCopilotMessage
};
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 = [
'parser_engine',
'parser_version',
@@ -548,6 +592,7 @@ WHERE resource_key IS NOT NULL AND status IN ('queued', 'running');`);
}
ensureResearchWorkspaceSchema(client);
ensureResearchCopilotSchema(client);
}
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,
researchMemoEvidence
} from '@/lib/server/db/schema';
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
@@ -374,6 +375,26 @@ async function getArtifactByIdForUser(id: number, userId: string) {
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) {
const [row] = await db
.select()
@@ -902,12 +923,13 @@ export async function getResearchPacket(userId: string, ticker: string): Promise
export async function getResearchWorkspace(userId: string, ticker: string): Promise<ResearchWorkspace> {
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),
getResearchMemoByTicker(userId, normalizedTicker),
listResearchArtifacts(userId, { ticker: normalizedTicker, limit: 40 }),
getResearchPacket(userId, normalizedTicker),
listFilingsRecords({ ticker: normalizedTicker, limit: 1 })
listFilingsRecords({ ticker: normalizedTicker, limit: 1 }),
getResearchCopilotSessionByTicker(userId, normalizedTicker)
]);
return {
@@ -918,7 +940,8 @@ export async function getResearchWorkspace(userId: string, ticker: string): Prom
memo,
library: library.artifacts,
packet,
availableTags: library.availableTags
availableTags: library.availableTags,
copilotSession
};
}
@@ -1119,4 +1142,3 @@ export async function getResearchArtifactFileResponse(userId: string, id: number
export function rebuildResearchArtifactIndex() {
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.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;
}
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)) {
@@ -194,6 +201,14 @@ function buildActions(task: TaskCore): TaskNotificationAction[] {
primary: true
});
break;
case 'research_brief':
actions.push({
id: 'open_research',
label: 'Open research',
href: ticker ? `/research?ticker=${encodeURIComponent(ticker)}` : '/research',
primary: true
});
break;
}
actions.push({

View File

@@ -10,6 +10,7 @@ import type {
import { runAiAnalysis } from '@/lib/server/ai';
import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { getQuote } from '@/lib/server/prices';
import { generateResearchBrief } from '@/lib/server/research-copilot';
import { indexSearchDocuments } from '@/lib/server/search';
import {
getFilingByAccession,
@@ -32,7 +33,9 @@ import {
listUserHoldings
} from '@/lib/server/repos/holdings';
import { createPortfolioInsight } from '@/lib/server/repos/insights';
import { createResearchArtifactRecord } from '@/lib/server/repos/research-library';
import { updateTaskStage } from '@/lib/server/repos/tasks';
import { updateWatchlistReviewByTicker } from '@/lib/server/repos/watchlist';
import {
fetchPrimaryFilingText,
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 = {
parseExtractionPayload,
deterministicExtractionFallback,
@@ -1320,6 +1414,8 @@ export async function runTaskProcessor(task: Task) {
return await processPortfolioInsights(task);
case 'index_search':
return await processIndexSearch(task);
case 'research_brief':
return await processResearchBrief(task);
default:
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',
analyze_filing: 'Filing analysis',
portfolio_insights: 'Portfolio insight',
index_search: 'Search indexing'
index_search: 'Search indexing',
research_brief: 'Research brief'
};
const STAGE_LABELS: Record<TaskStage, string> = {
@@ -50,6 +51,9 @@ const STAGE_LABELS: Record<TaskStage, string> = {
'search.chunk': 'Chunk content',
'search.embed': 'Generate embeddings',
'search.persist': 'Persist search index',
'research.retrieve': 'Retrieve evidence',
'research.answer': 'Generate brief',
'research.persist': 'Persist brief',
'insights.load_holdings': 'Load holdings',
'insights.generate': 'Generate insight',
'insights.persist': 'Persist insight'
@@ -97,6 +101,14 @@ const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
'search.persist',
'completed'
],
research_brief: [
'queued',
'running',
'research.retrieve',
'research.answer',
'research.persist',
'completed'
],
portfolio_insights: [
'queued',
'running',

View File

@@ -119,7 +119,8 @@ export type TaskType =
| 'refresh_prices'
| 'analyze_filing'
| 'portfolio_insights'
| 'index_search';
| 'index_search'
| 'research_brief';
export type TaskStage =
| 'queued'
| 'running'
@@ -148,6 +149,9 @@ export type TaskStage =
| 'search.chunk'
| 'search.embed'
| 'search.persist'
| 'research.retrieve'
| 'research.answer'
| 'research.persist'
| 'insights.load_holdings'
| 'insights.generate'
| 'insights.persist';
@@ -178,7 +182,8 @@ export type TaskNotificationAction = {
| 'open_analysis'
| 'open_analysis_report'
| 'open_search'
| 'open_portfolio';
| 'open_portfolio'
| 'open_research';
label: string;
href: string | null;
primary?: boolean;
@@ -318,6 +323,73 @@ export type SearchAnswerResponse = {
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 = {
id: number;
user_id: string;
@@ -403,6 +475,7 @@ export type ResearchWorkspace = {
library: ResearchArtifact[];
packet: ResearchPacket;
availableTags: string[];
copilotSession: ResearchCopilotSession | null;
};
export type CompanyFinancialPoint = {