Compare commits
3 Commits
f4a0014572
...
t3code/res
| Author | SHA1 | Date | |
|---|---|---|---|
| 23932e40c3 | |||
| 73a6d13b69 | |||
| 2ee9a549a3 |
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
444
components/research/research-copilot-panel.tsx
Normal file
444
components/research/research-copilot-panel.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
36
drizzle/0013_research_copilot.sql
Normal file
36
drizzle/0013_research_copilot.sql
Normal 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`);
|
||||
393
drizzle/0014_brave_randall.sql
Normal file
393
drizzle/0014_brave_randall.sql
Normal 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`);
|
||||
4113
drizzle/meta/0014_snapshot.json
Normal file
4113
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
lib/api.ts
44
lib/api.ts
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
219
lib/server/api/research-copilot.e2e.test.ts
Normal file
219
lib/server/api/research-copilot.e2e.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
165
lib/server/repos/research-copilot.test.ts
Normal file
165
lib/server/repos/research-copilot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
229
lib/server/repos/research-copilot.ts
Normal file
229
lib/server/repos/research-copilot.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
69
lib/server/research-copilot-format.test.ts
Normal file
69
lib/server/research-copilot-format.test.ts
Normal 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":[]}');
|
||||
});
|
||||
});
|
||||
225
lib/server/research-copilot-format.ts
Normal file
225
lib/server/research-copilot-format.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
419
lib/server/research-copilot.ts
Normal file
419
lib/server/research-copilot.ts
Normal 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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
77
lib/types.ts
77
lib/types.ts
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user