Add hybrid research copilot workspace
This commit is contained in:
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
|
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { ResearchCopilotPanel } from '@/components/research/research-copilot-panel';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
import { Panel } from '@/components/ui/panel';
|
import { Panel } from '@/components/ui/panel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -31,6 +32,8 @@ import type {
|
|||||||
ResearchArtifact,
|
ResearchArtifact,
|
||||||
ResearchArtifactKind,
|
ResearchArtifactKind,
|
||||||
ResearchArtifactSource,
|
ResearchArtifactSource,
|
||||||
|
ResearchCopilotCitation,
|
||||||
|
ResearchCopilotSession,
|
||||||
ResearchMemo,
|
ResearchMemo,
|
||||||
ResearchMemoSection,
|
ResearchMemoSection,
|
||||||
ResearchWorkspace
|
ResearchWorkspace
|
||||||
@@ -104,6 +107,15 @@ const EMPTY_MEMO_FORM: MemoFormState = {
|
|||||||
nextActionsMarkdown: ''
|
nextActionsMarkdown: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MEMO_FORM_FIELD_BY_SECTION: Record<ResearchMemoSection, keyof MemoFormState> = {
|
||||||
|
thesis: 'thesisMarkdown',
|
||||||
|
variant_view: 'variantViewMarkdown',
|
||||||
|
catalysts: 'catalystsMarkdown',
|
||||||
|
risks: 'risksMarkdown',
|
||||||
|
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
|
||||||
|
next_actions: 'nextActionsMarkdown'
|
||||||
|
};
|
||||||
|
|
||||||
function parseTags(value: string) {
|
function parseTags(value: string) {
|
||||||
const unique = new Set<string>();
|
const unique = new Set<string>();
|
||||||
|
|
||||||
@@ -197,6 +209,8 @@ function ResearchPageContent() {
|
|||||||
const [uploadTitle, setUploadTitle] = useState('');
|
const [uploadTitle, setUploadTitle] = useState('');
|
||||||
const [uploadSummary, setUploadSummary] = useState('');
|
const [uploadSummary, setUploadSummary] = useState('');
|
||||||
const [uploadTags, setUploadTags] = useState('');
|
const [uploadTags, setUploadTags] = useState('');
|
||||||
|
const [researchMode, setResearchMode] = useState<'workspace' | 'copilot'>('workspace');
|
||||||
|
const [focusTab, setFocusTab] = useState<'library' | 'memo' | 'packet'>('library');
|
||||||
|
|
||||||
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
|
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
|
||||||
const deferredSearch = useDeferredValue(searchInput);
|
const deferredSearch = useDeferredValue(searchInput);
|
||||||
@@ -253,6 +267,7 @@ function ResearchPageContent() {
|
|||||||
const invalidateResearch = async (symbol: string) => {
|
const invalidateResearch = async (symbol: string) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.researchCopilotSession(symbol) }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
|
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
|
||||||
@@ -399,9 +414,369 @@ function ResearchPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopilotSessionChange = (session: ResearchCopilotSession) => {
|
||||||
|
setWorkspace((current) => current ? { ...current, copilotSession: session } : current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftNote = (input: { title: string; summary: string; bodyMarkdown: string }) => {
|
||||||
|
setNoteForm({
|
||||||
|
id: null,
|
||||||
|
title: input.title,
|
||||||
|
summary: input.summary,
|
||||||
|
bodyMarkdown: input.bodyMarkdown,
|
||||||
|
tags: 'copilot'
|
||||||
|
});
|
||||||
|
setNotice('Loaded copilot output into the note draft editor for review.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftMemoSection = (section: ResearchMemoSection, contentMarkdown: string) => {
|
||||||
|
const field = MEMO_FORM_FIELD_BY_SECTION[section];
|
||||||
|
setMemoForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[field]: contentMarkdown
|
||||||
|
}));
|
||||||
|
setAttachSection(section);
|
||||||
|
setNotice(`Loaded copilot output into ${MEMO_SECTIONS.find((item) => item.value === section)?.label}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachCitation = async (citation: ResearchCopilotCitation, section: ResearchMemoSection) => {
|
||||||
|
if (!citation.artifactId) {
|
||||||
|
setError('This citation cannot be attached because no research artifact is available for it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memoId = await ensureMemo();
|
||||||
|
await addResearchMemoEvidence({
|
||||||
|
memoId,
|
||||||
|
artifactId: citation.artifactId,
|
||||||
|
section
|
||||||
|
});
|
||||||
|
setNotice(`Attached cited evidence to ${MEMO_SECTIONS.find((item) => item.value === section)?.label}.`);
|
||||||
|
await invalidateResearch(ticker);
|
||||||
|
await loadWorkspace(ticker);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to attach cited evidence');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const availableTags = workspace?.availableTags ?? [];
|
const availableTags = workspace?.availableTags ?? [];
|
||||||
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
|
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
|
||||||
|
|
||||||
|
const renderQuickNotePanel = () => (
|
||||||
|
<Panel
|
||||||
|
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
|
||||||
|
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
|
||||||
|
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
|
||||||
|
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
|
||||||
|
<textarea
|
||||||
|
aria-label="Research note body"
|
||||||
|
value={noteForm.bodyMarkdown}
|
||||||
|
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
|
||||||
|
placeholder="Write the actual research note, variant view, or diligence conclusion..."
|
||||||
|
className="min-h-[160px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
||||||
|
/>
|
||||||
|
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={() => void saveNote()}>
|
||||||
|
<FilePlus2 className="size-4" />
|
||||||
|
{noteForm.id === null ? 'Save note' : 'Update note'}
|
||||||
|
</Button>
|
||||||
|
{noteForm.id !== null ? (
|
||||||
|
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
|
||||||
|
Cancel edit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLibraryPanel = () => (
|
||||||
|
<Panel
|
||||||
|
title="Research Library"
|
||||||
|
subtitle={`${library.length} artifacts match the current filter set.`}
|
||||||
|
actions={(
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
|
||||||
|
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
|
||||||
|
{MEMO_SECTIONS.map((section) => (
|
||||||
|
<option key={section.value} value={section.value}>{section.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4 lg:grid-cols-[1.4fr_repeat(3,minmax(0,1fr))]">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
|
||||||
|
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
|
||||||
|
<select aria-label="Artifact type filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
|
||||||
|
{KIND_OPTIONS.map((option) => (
|
||||||
|
<option key={option.label} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
|
||||||
|
<select aria-label="Artifact source filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
|
||||||
|
{SOURCE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.label} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
|
||||||
|
<select aria-label="Artifact tag filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{availableTags.map((tag) => (
|
||||||
|
<option key={tag} value={tag}>{tag}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
|
||||||
|
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
|
||||||
|
Show memo-linked evidence only
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-xs text-[color:var(--terminal-muted)]">
|
||||||
|
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
|
||||||
|
Private workspace scope
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{library.length === 0 ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
|
||||||
|
) : (
|
||||||
|
library.map((artifact) => (
|
||||||
|
<article key={artifact.id} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||||
|
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
|
||||||
|
</p>
|
||||||
|
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
||||||
|
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{artifact.kind === 'upload' && artifact.storage_path ? (
|
||||||
|
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
|
||||||
|
<Download className="size-3" />
|
||||||
|
File
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
|
||||||
|
<Link2 className="size-3" />
|
||||||
|
Attach
|
||||||
|
</Button>
|
||||||
|
{artifact.kind === 'note' ? (
|
||||||
|
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{artifact.source === 'user' || artifact.kind === 'upload' ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteResearchArtifact(artifact.id);
|
||||||
|
setNotice('Removed artifact from the library.');
|
||||||
|
await invalidateResearch(ticker);
|
||||||
|
await loadWorkspace(ticker);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{artifact.summary ? (
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
|
||||||
|
) : null}
|
||||||
|
{artifact.body_markdown ? (
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{artifact.linked_to_memo ? (
|
||||||
|
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
|
||||||
|
) : null}
|
||||||
|
{artifact.accession_number ? (
|
||||||
|
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
|
||||||
|
) : null}
|
||||||
|
{artifact.tags.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={`${artifact.id}-${tag}`}
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
|
||||||
|
onClick={() => setTagFilter(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderUploadPanel = () => (
|
||||||
|
<Panel
|
||||||
|
title="Upload Research"
|
||||||
|
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
|
||||||
|
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
|
||||||
|
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
|
||||||
|
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
|
||||||
|
<input
|
||||||
|
aria-label="Upload file"
|
||||||
|
type="file"
|
||||||
|
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
||||||
|
className="block w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
|
||||||
|
<UploadIcon />
|
||||||
|
Upload file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMemoPanel = () => (
|
||||||
|
<Panel
|
||||||
|
title="Investment Memo"
|
||||||
|
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
|
||||||
|
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
|
||||||
|
<option value="">Rating</option>
|
||||||
|
<option value="strong_buy">Strong Buy</option>
|
||||||
|
<option value="buy">Buy</option>
|
||||||
|
<option value="hold">Hold</option>
|
||||||
|
<option value="sell">Sell</option>
|
||||||
|
</select>
|
||||||
|
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
|
||||||
|
<option value="">Conviction</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
|
||||||
|
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
|
||||||
|
</div>
|
||||||
|
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
|
||||||
|
{MEMO_SECTIONS.map((section) => {
|
||||||
|
const field = MEMO_FORM_FIELD_BY_SECTION[section.value];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={section.value}>
|
||||||
|
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
|
||||||
|
<textarea
|
||||||
|
aria-label={`Memo ${section.label}`}
|
||||||
|
value={memoForm[field]}
|
||||||
|
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
|
||||||
|
className="min-h-[108px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
||||||
|
placeholder={`Write ${section.label.toLowerCase()}...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button onClick={() => void saveMemo()}>
|
||||||
|
<NotebookPen className="size-4" />
|
||||||
|
Save memo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPacketPanel = () => (
|
||||||
|
<Panel
|
||||||
|
title="Research Packet"
|
||||||
|
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
|
||||||
|
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{workspace!.packet.sections.map((section) => (
|
||||||
|
<section key={section.section} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Packet Section</p>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||||
|
{section.body_markdown || 'No memo content yet for this section.'}
|
||||||
|
</p>
|
||||||
|
{section.evidence.length > 0 ? (
|
||||||
|
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||||
|
{section.evidence.map((item) => (
|
||||||
|
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
|
||||||
|
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
|
||||||
|
</div>
|
||||||
|
{workspace!.memo ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await deleteResearchMemoEvidence(workspace!.memo!.id, item.id);
|
||||||
|
setNotice('Removed memo evidence.');
|
||||||
|
await invalidateResearch(ticker);
|
||||||
|
await loadWorkspace(ticker);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.annotation ? (
|
||||||
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
|
||||||
|
) : null}
|
||||||
|
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Research"
|
title="Research"
|
||||||
@@ -413,6 +788,28 @@ function ResearchPageContent() {
|
|||||||
]}
|
]}
|
||||||
actions={(
|
actions={(
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="inline-flex rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={researchMode === 'workspace'
|
||||||
|
? 'rounded-lg bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
|
||||||
|
: 'rounded-lg px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
|
||||||
|
}
|
||||||
|
onClick={() => setResearchMode('workspace')}
|
||||||
|
>
|
||||||
|
Workspace
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={researchMode === 'copilot'
|
||||||
|
? 'rounded-lg bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
|
||||||
|
: 'rounded-lg px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
|
||||||
|
}
|
||||||
|
onClick={() => setResearchMode('copilot')}
|
||||||
|
>
|
||||||
|
Copilot Focus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -499,325 +896,75 @@ function ResearchPageContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]">
|
{researchMode === 'workspace' ? (
|
||||||
<Panel
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.7fr)_minmax(22rem,0.85fr)]">
|
||||||
title="Library Filters"
|
<div className="space-y-6">
|
||||||
subtitle="Narrow the evidence set by structure, ownership, and memo linkage."
|
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
|
<div className="space-y-6">
|
||||||
>
|
{renderQuickNotePanel()}
|
||||||
<div className="space-y-3">
|
{renderLibraryPanel()}
|
||||||
<div>
|
{renderUploadPanel()}
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
|
|
||||||
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
|
|
||||||
</div>
|
</div>
|
||||||
|
{renderMemoPanel()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{renderPacketPanel()}
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
|
|
||||||
<select aria-label="Artifact type filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
|
|
||||||
{KIND_OPTIONS.map((option) => (
|
|
||||||
<option key={option.label} value={option.value}>{option.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<ResearchCopilotPanel
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
|
ticker={ticker}
|
||||||
<select aria-label="Artifact source filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
|
companyName={workspace.companyName}
|
||||||
{SOURCE_OPTIONS.map((option) => (
|
session={workspace.copilotSession}
|
||||||
<option key={option.label} value={option.value}>{option.label}</option>
|
targetSection={attachSection}
|
||||||
))}
|
onSessionChange={handleCopilotSessionChange}
|
||||||
</select>
|
onDraftNote={handleDraftNote}
|
||||||
|
onDraftMemoSection={handleDraftMemoSection}
|
||||||
|
onAttachCitation={handleAttachCitation}
|
||||||
|
onNotice={setNotice}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(22rem,0.95fr)]">
|
||||||
<select aria-label="Artifact tag filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
|
<ResearchCopilotPanel
|
||||||
<option value="">All tags</option>
|
ticker={ticker}
|
||||||
{availableTags.map((tag) => (
|
companyName={workspace.companyName}
|
||||||
<option key={tag} value={tag}>{tag}</option>
|
session={workspace.copilotSession}
|
||||||
))}
|
targetSection={attachSection}
|
||||||
</select>
|
variant="focus"
|
||||||
</div>
|
onSessionChange={handleCopilotSessionChange}
|
||||||
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
|
onDraftNote={handleDraftNote}
|
||||||
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
|
onDraftMemoSection={handleDraftMemoSection}
|
||||||
Show memo-linked evidence only
|
onAttachCitation={handleAttachCitation}
|
||||||
</label>
|
onNotice={setNotice}
|
||||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
|
onError={setError}
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
/>
|
||||||
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
|
|
||||||
Access Model
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Panel
|
<Panel
|
||||||
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
|
title="Research Surfaces"
|
||||||
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
|
subtitle="Keep the memo, library, and packet within reach while the copilot stays full-width."
|
||||||
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
|
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
|
||||||
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
|
|
||||||
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
|
|
||||||
<textarea
|
|
||||||
aria-label="Research note body"
|
|
||||||
value={noteForm.bodyMarkdown}
|
|
||||||
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
|
|
||||||
placeholder="Write the actual research note, variant view, or diligence conclusion..."
|
|
||||||
className="min-h-[160px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
|
||||||
/>
|
|
||||||
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={() => void saveNote()}>
|
{(['library', 'memo', 'packet'] as const).map((tab) => (
|
||||||
<FilePlus2 className="size-4" />
|
|
||||||
{noteForm.id === null ? 'Save note' : 'Update note'}
|
|
||||||
</Button>
|
|
||||||
{noteForm.id !== null ? (
|
|
||||||
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
|
|
||||||
Cancel edit
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel
|
|
||||||
title="Research Library"
|
|
||||||
subtitle={`${library.length} artifacts match the current filter set.`}
|
|
||||||
actions={(
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
|
|
||||||
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
|
|
||||||
{MEMO_SECTIONS.map((section) => (
|
|
||||||
<option key={section.value} value={section.value}>{section.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{library.length === 0 ? (
|
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
|
|
||||||
) : (
|
|
||||||
library.map((artifact) => (
|
|
||||||
<article key={artifact.id} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
|
||||||
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
|
|
||||||
</p>
|
|
||||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
|
||||||
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{artifact.kind === 'upload' && artifact.storage_path ? (
|
|
||||||
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
|
|
||||||
<Download className="size-3" />
|
|
||||||
File
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
|
|
||||||
<Link2 className="size-3" />
|
|
||||||
Attach
|
|
||||||
</Button>
|
|
||||||
{artifact.kind === 'note' ? (
|
|
||||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{artifact.source === 'user' || artifact.kind === 'upload' ? (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await deleteResearchArtifact(artifact.id);
|
|
||||||
setNotice('Removed artifact from the library.');
|
|
||||||
await invalidateResearch(ticker);
|
|
||||||
await loadWorkspace(ticker);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{artifact.summary ? (
|
|
||||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
|
|
||||||
) : null}
|
|
||||||
{artifact.body_markdown ? (
|
|
||||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{artifact.linked_to_memo ? (
|
|
||||||
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
|
|
||||||
) : null}
|
|
||||||
{artifact.accession_number ? (
|
|
||||||
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
|
|
||||||
) : null}
|
|
||||||
{artifact.tags.map((tag) => (
|
|
||||||
<button
|
<button
|
||||||
key={`${artifact.id}-${tag}`}
|
key={tab}
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
|
className={focusTab === tab
|
||||||
onClick={() => setTagFilter(tag)}
|
? 'rounded-lg border border-[color:var(--line-strong)] bg-[color:var(--panel)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]'
|
||||||
|
: 'rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)]'
|
||||||
|
}
|
||||||
|
onClick={() => setFocusTab(tab)}
|
||||||
>
|
>
|
||||||
{tag}
|
{tab === 'library' ? 'Library' : tab === 'memo' ? 'Memo' : 'Packet'}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</Panel>
|
||||||
))
|
{focusTab === 'library' ? renderLibraryPanel() : null}
|
||||||
|
{focusTab === 'memo' ? renderMemoPanel() : null}
|
||||||
|
{focusTab === 'packet' ? renderPacketPanel() : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel
|
|
||||||
title="Upload Research"
|
|
||||||
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
|
|
||||||
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
|
|
||||||
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
|
|
||||||
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
|
|
||||||
<input
|
|
||||||
aria-label="Upload file"
|
|
||||||
type="file"
|
|
||||||
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
|
||||||
className="block w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
|
|
||||||
<UploadIcon />
|
|
||||||
Upload file
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Panel
|
|
||||||
title="Investment Memo"
|
|
||||||
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
|
|
||||||
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
|
|
||||||
<option value="">Rating</option>
|
|
||||||
<option value="strong_buy">Strong Buy</option>
|
|
||||||
<option value="buy">Buy</option>
|
|
||||||
<option value="hold">Hold</option>
|
|
||||||
<option value="sell">Sell</option>
|
|
||||||
</select>
|
|
||||||
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
|
|
||||||
<option value="">Conviction</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
|
|
||||||
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
|
|
||||||
</div>
|
|
||||||
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
|
|
||||||
{MEMO_SECTIONS.map((section) => {
|
|
||||||
const fieldMap: Record<ResearchMemoSection, keyof MemoFormState> = {
|
|
||||||
thesis: 'thesisMarkdown',
|
|
||||||
variant_view: 'variantViewMarkdown',
|
|
||||||
catalysts: 'catalystsMarkdown',
|
|
||||||
risks: 'risksMarkdown',
|
|
||||||
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
|
|
||||||
next_actions: 'nextActionsMarkdown'
|
|
||||||
};
|
|
||||||
const field = fieldMap[section.value];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={section.value}>
|
|
||||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
|
|
||||||
<textarea
|
|
||||||
aria-label={`Memo ${section.label}`}
|
|
||||||
value={memoForm[field]}
|
|
||||||
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
|
|
||||||
className="min-h-[108px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_var(--focus-ring)]"
|
|
||||||
placeholder={`Write ${section.label.toLowerCase()}...`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<Button onClick={() => void saveMemo()}>
|
|
||||||
<NotebookPen className="size-4" />
|
|
||||||
Save memo
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Panel
|
|
||||||
title="Research Packet"
|
|
||||||
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
|
|
||||||
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{workspace.packet.sections.map((section) => (
|
|
||||||
<section key={section.section} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Packet Section</p>
|
|
||||||
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
|
||||||
{section.body_markdown || 'No memo content yet for this section.'}
|
|
||||||
</p>
|
|
||||||
{section.evidence.length > 0 ? (
|
|
||||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
|
||||||
{section.evidence.map((item) => (
|
|
||||||
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
|
|
||||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
|
|
||||||
</div>
|
|
||||||
{workspace.memo ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-1 text-xs"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await deleteResearchMemoEvidence(workspace.memo!.id, item.id);
|
|
||||||
setNotice('Removed memo evidence.');
|
|
||||||
await invalidateResearch(ticker);
|
|
||||||
await loadWorkspace(ticker);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{item.annotation ? (
|
|
||||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
|
|
||||||
) : null}
|
|
||||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const taskLabels: Record<Task['task_type'], string> = {
|
|||||||
refresh_prices: 'Refresh prices',
|
refresh_prices: 'Refresh prices',
|
||||||
analyze_filing: 'Analyze filing',
|
analyze_filing: 'Analyze filing',
|
||||||
portfolio_insights: 'Portfolio insights',
|
portfolio_insights: 'Portfolio insights',
|
||||||
index_search: 'Index search'
|
index_search: 'Index search',
|
||||||
|
research_brief: 'Research brief'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TaskFeed({ tasks }: TaskFeedProps) {
|
export function TaskFeed({ tasks }: TaskFeedProps) {
|
||||||
|
|||||||
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`.
|
- Controls typically use `rounded-xl`.
|
||||||
- Default panel padding is `p-4` on mobile and `sm:p-5` or `sm:p-6` on larger screens.
|
- Default panel padding is `p-4` on mobile and `sm:p-5` or `sm:p-6` on larger screens.
|
||||||
|
|
||||||
|
### Research workspace modes
|
||||||
|
|
||||||
|
The research product now supports two layout modes for the same ticker-scoped workspace:
|
||||||
|
|
||||||
|
- `Workspace`: the default operating mode. Keep memo authoring, library management, and uploads in the primary column with the copilot docked as a sticky secondary rail.
|
||||||
|
- `Copilot Focus`: a deep-work mode. The conversation becomes the primary surface and memo, library, and packet views move into secondary tabs.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- The active ticker must remain visible in both modes.
|
||||||
|
- Mode switches should preserve the same local state, draft state, and copilot session.
|
||||||
|
- Secondary surfaces in focus mode should feel adjacent, not modal. Use tabbed panel switching rather than route changes for memo, library, and packet context.
|
||||||
|
- Avoid introducing a second page shell for focus mode. The user should feel they are still inside the research workspace.
|
||||||
|
|
||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
### Font roles
|
### Font roles
|
||||||
@@ -231,6 +245,53 @@ Panel rules:
|
|||||||
|
|
||||||
Reference implementation: `components/ui/panel.tsx`
|
Reference implementation: `components/ui/panel.tsx`
|
||||||
|
|
||||||
|
### Research copilot panels
|
||||||
|
|
||||||
|
Copilot surfaces should feel like analytical instruments, not consumer chat.
|
||||||
|
|
||||||
|
Use the docked copilot rail for:
|
||||||
|
|
||||||
|
- cited Q&A tied to the current ticker
|
||||||
|
- short follow-up turns during memo work
|
||||||
|
- pinning or attaching evidence while staying in the main workspace
|
||||||
|
|
||||||
|
Use the focus copilot surface for:
|
||||||
|
|
||||||
|
- multi-turn investigative sessions
|
||||||
|
- long answers with multiple citations
|
||||||
|
- review-first drafting into memo or notes
|
||||||
|
|
||||||
|
Copilot panel rules:
|
||||||
|
|
||||||
|
- Keep header copy operational and concise.
|
||||||
|
- Show context chips for ticker, target memo section, and pinned evidence count.
|
||||||
|
- Source toggles should be compact and behave like tool filters, not marketing pills.
|
||||||
|
- Message cards should differentiate user and assistant turns with surface treatment, not bright color.
|
||||||
|
- Empty states should suggest concrete research questions rather than generic onboarding copy.
|
||||||
|
|
||||||
|
### Citation cards
|
||||||
|
|
||||||
|
Citation cards are evidence controls, not decorative footnotes.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Lead with the citation index and source label.
|
||||||
|
- Keep excerpts short and scan-friendly.
|
||||||
|
- Present actions inline: `Open`, `Pin` or `Unpin`, and `Attach`.
|
||||||
|
- Use the same card rhythm inside docked and focus copilot modes.
|
||||||
|
- If a citation cannot be attached because no artifact exists, disable the attach action instead of hiding it.
|
||||||
|
|
||||||
|
### Review-first AI write flows
|
||||||
|
|
||||||
|
AI-generated content may propose note drafts, memo-section drafts, or background brief jobs, but it must not silently mutate user-authored research state.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Suggested actions should be explicit buttons on assistant messages.
|
||||||
|
- Drafting into notes or memo sections should populate editable client form state first.
|
||||||
|
- Persistence should happen only through the existing note or memo save actions.
|
||||||
|
- Long-running synthesis should route through the task/notification system instead of blocking the conversation thread.
|
||||||
|
|
||||||
### Status pills and tags
|
### Status pills and tags
|
||||||
|
|
||||||
Status pills should be compact, uppercase, and visually coded, but the text label must remain meaningful on its own.
|
Status pills should be compact, uppercase, and visually coded, but the text label must remain meaningful on its own.
|
||||||
|
|||||||
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`);
|
||||||
@@ -85,6 +85,20 @@
|
|||||||
"when": 1773180000000,
|
"when": 1773180000000,
|
||||||
"tag": "0011_remove_legacy_xbrl_defaults",
|
"tag": "0011_remove_legacy_xbrl_defaults",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773309600000,
|
||||||
|
"tag": "0012_company_overview_cache",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773514800000,
|
||||||
|
"tag": "0013_research_copilot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,16 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
|
|||||||
await page.goto('/research?ticker=NVDA');
|
await page.goto('/research?ticker=NVDA');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
|
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
|
||||||
|
await expect(page.getByRole('button', { name: 'Workspace' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Copilot Focus' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Research Copilot')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Research copilot prompt')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Copilot Focus' }).click();
|
||||||
|
await expect(page.getByText('Copilot Focus')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Library' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Memo' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Packet' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Workspace' }).click();
|
||||||
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
|
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Memo rating').selectOption('buy');
|
await page.getByLabel('Memo rating').selectOption('buy');
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] });
|
void queryClient.invalidateQueries({ queryKey: ['portfolio', 'insights', 'latest'] });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'research_brief': {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['research'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['analysis'] });
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
44
lib/api.ts
44
lib/api.ts
@@ -15,6 +15,8 @@ import type {
|
|||||||
ResearchArtifact,
|
ResearchArtifact,
|
||||||
ResearchArtifactKind,
|
ResearchArtifactKind,
|
||||||
ResearchArtifactSource,
|
ResearchArtifactSource,
|
||||||
|
ResearchCopilotSession,
|
||||||
|
ResearchCopilotTurnResponse,
|
||||||
ResearchJournalEntry,
|
ResearchJournalEntry,
|
||||||
ResearchJournalEntryType,
|
ResearchJournalEntryType,
|
||||||
SearchAnswerResponse,
|
SearchAnswerResponse,
|
||||||
@@ -226,6 +228,48 @@ export async function getResearchWorkspace(ticker: string) {
|
|||||||
}, 'Unable to fetch research workspace');
|
}, 'Unable to fetch research workspace');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getResearchCopilotSession(ticker: string) {
|
||||||
|
return await requestJson<{ session: ResearchCopilotSession | null }>({
|
||||||
|
path: `/api/research/copilot/session?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||||
|
}, 'Unable to fetch research copilot session');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResearchCopilotTurn(input: {
|
||||||
|
ticker: string;
|
||||||
|
query: string;
|
||||||
|
sources?: SearchSource[];
|
||||||
|
pinnedArtifactIds?: number[];
|
||||||
|
memoSection?: ResearchMemoSection | null;
|
||||||
|
}) {
|
||||||
|
return await requestJson<ResearchCopilotTurnResponse>({
|
||||||
|
path: '/api/research/copilot/turn',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
ticker: input.ticker.trim().toUpperCase(),
|
||||||
|
query: input.query.trim(),
|
||||||
|
...(input.sources && input.sources.length > 0 ? { sources: input.sources } : {}),
|
||||||
|
...(input.pinnedArtifactIds && input.pinnedArtifactIds.length > 0 ? { pinnedArtifactIds: input.pinnedArtifactIds } : {}),
|
||||||
|
...(input.memoSection ? { memoSection: input.memoSection } : {})
|
||||||
|
}
|
||||||
|
}, 'Unable to run research copilot turn');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueResearchCopilotJob(input: {
|
||||||
|
ticker: string;
|
||||||
|
query: string;
|
||||||
|
sources?: SearchSource[];
|
||||||
|
}) {
|
||||||
|
return await requestJson<{ task: Task }>({
|
||||||
|
path: '/api/research/copilot/job',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
ticker: input.ticker.trim().toUpperCase(),
|
||||||
|
query: input.query.trim(),
|
||||||
|
...(input.sources && input.sources.length > 0 ? { sources: input.sources } : {})
|
||||||
|
}
|
||||||
|
}, 'Unable to queue research brief');
|
||||||
|
}
|
||||||
|
|
||||||
export async function listResearchLibrary(input: {
|
export async function listResearchLibrary(input: {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const queryKeys = {
|
|||||||
search: (query: string, ticker: string | null, sources: string[], limit: number) => ['search', query, ticker ?? '', sources.join(','), limit] as const,
|
search: (query: string, ticker: string | null, sources: string[], limit: number) => ['search', query, ticker ?? '', sources.join(','), limit] as const,
|
||||||
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
||||||
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
|
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
|
||||||
|
researchCopilotSession: (ticker: string) => ['research', 'copilot', 'session', ticker] as const,
|
||||||
researchLibrary: (
|
researchLibrary: (
|
||||||
ticker: string,
|
ticker: string,
|
||||||
q: string,
|
q: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getCompanyFinancialStatements,
|
getCompanyFinancialStatements,
|
||||||
getLatestPortfolioInsight,
|
getLatestPortfolioInsight,
|
||||||
getPortfolioSummary,
|
getPortfolioSummary,
|
||||||
|
getResearchCopilotSession,
|
||||||
searchKnowledge,
|
searchKnowledge,
|
||||||
getResearchMemo,
|
getResearchMemo,
|
||||||
getResearchPacket,
|
getResearchPacket,
|
||||||
@@ -139,6 +140,16 @@ export function researchWorkspaceQueryOptions(ticker: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function researchCopilotSessionQueryOptions(ticker: string) {
|
||||||
|
const normalizedTicker = ticker.trim().toUpperCase();
|
||||||
|
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: queryKeys.researchCopilotSession(normalizedTicker),
|
||||||
|
queryFn: () => getResearchCopilotSession(normalizedTicker),
|
||||||
|
staleTime: 10_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function researchLibraryQueryOptions(input: {
|
export function researchLibraryQueryOptions(input: {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { requireAuthenticatedSession } from '@/lib/server/auth-session';
|
|||||||
import { getLatestFinancialIngestionSchemaStatus } from '@/lib/server/db/financial-ingestion-schema';
|
import { getLatestFinancialIngestionSchemaStatus } from '@/lib/server/db/financial-ingestion-schema';
|
||||||
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||||
|
import { runResearchCopilotTurn } from '@/lib/server/research-copilot';
|
||||||
import {
|
import {
|
||||||
defaultFinancialSyncLimit,
|
defaultFinancialSyncLimit,
|
||||||
getCompanyFinancials
|
getCompanyFinancials
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
listResearchJournalEntries,
|
listResearchJournalEntries,
|
||||||
updateResearchJournalEntryRecord
|
updateResearchJournalEntryRecord
|
||||||
} from '@/lib/server/repos/research-journal';
|
} from '@/lib/server/repos/research-journal';
|
||||||
|
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
|
||||||
import {
|
import {
|
||||||
deleteWatchlistItemRecord,
|
deleteWatchlistItemRecord,
|
||||||
getWatchlistItemById,
|
getWatchlistItemById,
|
||||||
@@ -839,6 +841,116 @@ export const app = new Elysia({ prefix: '/api' })
|
|||||||
ticker: t.String({ minLength: 1 })
|
ticker: t.String({ minLength: 1 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.get('/research/copilot/session', async ({ query }) => {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||||
|
if (!ticker) {
|
||||||
|
return jsonError('ticker is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const copilotSession = await getResearchCopilotSessionByTicker(session.user.id, ticker);
|
||||||
|
return Response.json({ session: copilotSession });
|
||||||
|
}, {
|
||||||
|
query: t.Object({
|
||||||
|
ticker: t.String({ minLength: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/research/copilot/turn', async ({ body }) => {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(body);
|
||||||
|
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||||
|
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
|
||||||
|
const memoSection = asResearchMemoSection(payload.memoSection);
|
||||||
|
|
||||||
|
if (!ticker) {
|
||||||
|
return jsonError('ticker is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return jsonError('query is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runResearchCopilotTurn({
|
||||||
|
userId: session.user.id,
|
||||||
|
ticker,
|
||||||
|
query,
|
||||||
|
selectedSources: asSearchSources(payload.sources),
|
||||||
|
pinnedArtifactIds: Array.isArray(payload.pinnedArtifactIds)
|
||||||
|
? payload.pinnedArtifactIds.map((entry) => Number(entry)).filter((entry) => Number.isInteger(entry) && entry > 0)
|
||||||
|
: undefined,
|
||||||
|
memoSection
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
return jsonError(asErrorMessage(error, 'Unable to run research copilot turn'));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
ticker: t.String({ minLength: 1 }),
|
||||||
|
query: t.String({ minLength: 1 }),
|
||||||
|
sources: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||||
|
pinnedArtifactIds: t.Optional(t.Array(t.Numeric())),
|
||||||
|
memoSection: t.Optional(t.String())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post('/research/copilot/job', async ({ body }) => {
|
||||||
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = asRecord(body);
|
||||||
|
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||||
|
const query = typeof payload.query === 'string' ? payload.query.trim() : '';
|
||||||
|
|
||||||
|
if (!ticker) {
|
||||||
|
return jsonError('ticker is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return jsonError('query is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resourceKey = `research_brief:${ticker}:${query.toLowerCase()}`;
|
||||||
|
const existing = await findInFlightTask(session.user.id, 'research_brief', resourceKey);
|
||||||
|
if (existing) {
|
||||||
|
return Response.json({ task: existing });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await enqueueTask({
|
||||||
|
userId: session.user.id,
|
||||||
|
taskType: 'research_brief',
|
||||||
|
payload: {
|
||||||
|
ticker,
|
||||||
|
query,
|
||||||
|
sources: asSearchSources(payload.sources) ?? SEARCH_SOURCES
|
||||||
|
},
|
||||||
|
priority: 55,
|
||||||
|
resourceKey
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ task });
|
||||||
|
} catch (error) {
|
||||||
|
return jsonError(asErrorMessage(error, 'Unable to queue research brief'));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
ticker: t.String({ minLength: 1 }),
|
||||||
|
query: t.String({ minLength: 1 }),
|
||||||
|
sources: t.Optional(t.Union([t.String(), t.Array(t.String())]))
|
||||||
|
})
|
||||||
|
})
|
||||||
.get('/research/library', async ({ query }) => {
|
.get('/research/library', async ({ query }) => {
|
||||||
const { session, response } = await requireAuthenticatedSession();
|
const { session, response } = await requireAuthenticatedSession();
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
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) {
|
function isVectorExtensionLoaded(client: Database) {
|
||||||
return vectorExtensionStatus.get(client) ?? false;
|
return vectorExtensionStatus.get(client) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureSearchVirtualTables(client: Database) {
|
function ensureSearchVirtualTables(client: Database) {
|
||||||
client.exec(`
|
client.exec(`
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_fts\` USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS \`search_chunk_fts\` USING fts5(
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ type ResearchMemoSection =
|
|||||||
| 'risks'
|
| 'risks'
|
||||||
| 'disconfirming_evidence'
|
| 'disconfirming_evidence'
|
||||||
| 'next_actions';
|
| 'next_actions';
|
||||||
|
type SearchSource = 'documents' | 'filings' | 'research';
|
||||||
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||||
type SearchDocumentScope = 'global' | 'user';
|
type SearchDocumentScope = 'global' | 'user';
|
||||||
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
|
type SearchDocumentSourceKind = 'filing_document' | 'filing_brief' | 'research_note';
|
||||||
type SearchIndexStatus = 'pending' | 'indexed' | 'failed';
|
type SearchIndexStatus = 'pending' | 'indexed' | 'failed';
|
||||||
|
type ResearchCopilotMessageRole = 'user' | 'assistant';
|
||||||
type FinancialSurfaceKind =
|
type FinancialSurfaceKind =
|
||||||
| 'income_statement'
|
| 'income_statement'
|
||||||
| 'balance_sheet'
|
| 'balance_sheet'
|
||||||
@@ -636,7 +638,7 @@ export const filingLink = sqliteTable('filing_link', {
|
|||||||
export const taskRun = sqliteTable('task_run', {
|
export const taskRun = sqliteTable('task_run', {
|
||||||
id: text('id').primaryKey().notNull(),
|
id: text('id').primaryKey().notNull(),
|
||||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights' | 'index_search'>().notNull(),
|
task_type: text('task_type').$type<'sync_filings' | 'refresh_prices' | 'analyze_filing' | 'portfolio_insights' | 'index_search' | 'research_brief'>().notNull(),
|
||||||
status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(),
|
status: text('status').$type<'queued' | 'running' | 'completed' | 'failed'>().notNull(),
|
||||||
stage: text('stage').notNull(),
|
stage: text('stage').notNull(),
|
||||||
stage_detail: text('stage_detail'),
|
stage_detail: text('stage_detail'),
|
||||||
@@ -824,6 +826,38 @@ export const researchMemoEvidence = sqliteTable('research_memo_evidence', {
|
|||||||
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
|
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const researchCopilotSession = sqliteTable('research_copilot_session', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
ticker: text('ticker').notNull(),
|
||||||
|
title: text('title'),
|
||||||
|
selected_sources: text('selected_sources', { mode: 'json' }).$type<SearchSource[]>().notNull(),
|
||||||
|
pinned_artifact_ids: text('pinned_artifact_ids', { mode: 'json' }).$type<number[]>().notNull(),
|
||||||
|
created_at: text('created_at').notNull(),
|
||||||
|
updated_at: text('updated_at').notNull()
|
||||||
|
}, (table) => ({
|
||||||
|
researchCopilotSessionTickerUnique: uniqueIndex('research_copilot_session_ticker_uidx').on(table.user_id, table.ticker),
|
||||||
|
researchCopilotSessionUpdatedIndex: index('research_copilot_session_updated_idx').on(table.user_id, table.updated_at)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const researchCopilotMessage = sqliteTable('research_copilot_message', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
session_id: integer('session_id').notNull().references(() => researchCopilotSession.id, { onDelete: 'cascade' }),
|
||||||
|
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
role: text('role').$type<ResearchCopilotMessageRole>().notNull(),
|
||||||
|
content_markdown: text('content_markdown').notNull(),
|
||||||
|
citations: text('citations', { mode: 'json' }).$type<Record<string, unknown>[] | null>(),
|
||||||
|
follow_ups: text('follow_ups', { mode: 'json' }).$type<string[] | null>(),
|
||||||
|
suggested_actions: text('suggested_actions', { mode: 'json' }).$type<Record<string, unknown>[] | null>(),
|
||||||
|
selected_sources: text('selected_sources', { mode: 'json' }).$type<SearchSource[] | null>(),
|
||||||
|
pinned_artifact_ids: text('pinned_artifact_ids', { mode: 'json' }).$type<number[] | null>(),
|
||||||
|
memo_section: text('memo_section').$type<ResearchMemoSection | null>(),
|
||||||
|
created_at: text('created_at').notNull()
|
||||||
|
}, (table) => ({
|
||||||
|
researchCopilotMessageSessionIndex: index('research_copilot_message_session_idx').on(table.session_id, table.created_at),
|
||||||
|
researchCopilotMessageUserIndex: index('research_copilot_message_user_idx').on(table.user_id, table.created_at)
|
||||||
|
}));
|
||||||
|
|
||||||
export const authSchema = {
|
export const authSchema = {
|
||||||
user,
|
user,
|
||||||
session,
|
session,
|
||||||
@@ -855,7 +889,9 @@ export const appSchema = {
|
|||||||
searchChunk,
|
searchChunk,
|
||||||
researchArtifact,
|
researchArtifact,
|
||||||
researchMemo,
|
researchMemo,
|
||||||
researchMemoEvidence
|
researchMemoEvidence,
|
||||||
|
researchCopilotSession,
|
||||||
|
researchCopilotMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
export const schema = {
|
export const schema = {
|
||||||
|
|||||||
@@ -296,6 +296,50 @@ function ensureResearchWorkspaceSchema(client: Database) {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureResearchCopilotSchema(client: Database) {
|
||||||
|
if (!hasTable(client, 'research_copilot_session')) {
|
||||||
|
client.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`research_copilot_session\` (
|
||||||
|
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
\`user_id\` text NOT NULL,
|
||||||
|
\`ticker\` text NOT NULL,
|
||||||
|
\`title\` text,
|
||||||
|
\`selected_sources\` text NOT NULL DEFAULT '["documents","filings","research"]',
|
||||||
|
\`pinned_artifact_ids\` text NOT NULL DEFAULT '[]',
|
||||||
|
\`created_at\` text NOT NULL,
|
||||||
|
\`updated_at\` text NOT NULL,
|
||||||
|
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTable(client, 'research_copilot_message')) {
|
||||||
|
client.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`research_copilot_message\` (
|
||||||
|
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
\`session_id\` integer NOT NULL,
|
||||||
|
\`user_id\` text NOT NULL,
|
||||||
|
\`role\` text NOT NULL,
|
||||||
|
\`content_markdown\` text NOT NULL,
|
||||||
|
\`citations\` text,
|
||||||
|
\`follow_ups\` text,
|
||||||
|
\`suggested_actions\` text,
|
||||||
|
\`selected_sources\` text,
|
||||||
|
\`pinned_artifact_ids\` text,
|
||||||
|
\`memo_section\` text,
|
||||||
|
\`created_at\` text NOT NULL,
|
||||||
|
FOREIGN KEY (\`session_id\`) REFERENCES \`research_copilot_session\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_copilot_session_ticker_uidx` ON `research_copilot_session` (`user_id`, `ticker`);');
|
||||||
|
client.exec('CREATE INDEX IF NOT EXISTS `research_copilot_session_updated_idx` ON `research_copilot_session` (`user_id`, `updated_at`);');
|
||||||
|
client.exec('CREATE INDEX IF NOT EXISTS `research_copilot_message_session_idx` ON `research_copilot_message` (`session_id`, `created_at`);');
|
||||||
|
client.exec('CREATE INDEX IF NOT EXISTS `research_copilot_message_user_idx` ON `research_copilot_message` (`user_id`, `created_at`);');
|
||||||
|
}
|
||||||
|
|
||||||
const TAXONOMY_SNAPSHOT_REQUIRED_COLUMNS = [
|
const TAXONOMY_SNAPSHOT_REQUIRED_COLUMNS = [
|
||||||
'parser_engine',
|
'parser_engine',
|
||||||
'parser_version',
|
'parser_version',
|
||||||
@@ -548,6 +592,7 @@ WHERE resource_key IS NOT NULL AND status IN ('queued', 'running');`);
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureResearchWorkspaceSchema(client);
|
ensureResearchWorkspaceSchema(client);
|
||||||
|
ensureResearchCopilotSchema(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __sqliteSchemaCompatInternals = {
|
export const __sqliteSchemaCompatInternals = {
|
||||||
|
|||||||
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,
|
researchMemo,
|
||||||
researchMemoEvidence
|
researchMemoEvidence
|
||||||
} from '@/lib/server/db/schema';
|
} from '@/lib/server/db/schema';
|
||||||
|
import { getResearchCopilotSessionByTicker } from '@/lib/server/repos/research-copilot';
|
||||||
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
|
import { getFilingByAccession, listFilingsRecords } from '@/lib/server/repos/filings';
|
||||||
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
|
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
|
||||||
|
|
||||||
@@ -374,6 +375,26 @@ async function getArtifactByIdForUser(id: number, userId: string) {
|
|||||||
return row ?? null;
|
return row ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getResearchArtifactsByIdsForUser(userId: string, ids: number[]) {
|
||||||
|
const normalizedIds = [...new Set(ids.map((id) => Math.trunc(id)).filter((id) => Number.isInteger(id) && id > 0))];
|
||||||
|
if (normalizedIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(researchArtifact)
|
||||||
|
.where(and(
|
||||||
|
eq(researchArtifact.user_id, userId),
|
||||||
|
sql`${researchArtifact.id} in (${sql.join(normalizedIds.map((id) => sql`${id}`), sql`, `)})`
|
||||||
|
));
|
||||||
|
|
||||||
|
const order = new Map(normalizedIds.map((id, index) => [id, index]));
|
||||||
|
return rows
|
||||||
|
.sort((left, right) => (order.get(left.id) ?? Number.MAX_SAFE_INTEGER) - (order.get(right.id) ?? Number.MAX_SAFE_INTEGER))
|
||||||
|
.map((row) => toResearchArtifact(row));
|
||||||
|
}
|
||||||
|
|
||||||
async function getMemoByIdForUser(id: number, userId: string) {
|
async function getMemoByIdForUser(id: number, userId: string) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -902,12 +923,13 @@ export async function getResearchPacket(userId: string, ticker: string): Promise
|
|||||||
|
|
||||||
export async function getResearchWorkspace(userId: string, ticker: string): Promise<ResearchWorkspace> {
|
export async function getResearchWorkspace(userId: string, ticker: string): Promise<ResearchWorkspace> {
|
||||||
const normalizedTicker = normalizeTicker(ticker);
|
const normalizedTicker = normalizeTicker(ticker);
|
||||||
const [coverage, memo, library, packet, latestFiling] = await Promise.all([
|
const [coverage, memo, library, packet, latestFiling, copilotSession] = await Promise.all([
|
||||||
getWatchlistItemByTicker(userId, normalizedTicker),
|
getWatchlistItemByTicker(userId, normalizedTicker),
|
||||||
getResearchMemoByTicker(userId, normalizedTicker),
|
getResearchMemoByTicker(userId, normalizedTicker),
|
||||||
listResearchArtifacts(userId, { ticker: normalizedTicker, limit: 40 }),
|
listResearchArtifacts(userId, { ticker: normalizedTicker, limit: 40 }),
|
||||||
getResearchPacket(userId, normalizedTicker),
|
getResearchPacket(userId, normalizedTicker),
|
||||||
listFilingsRecords({ ticker: normalizedTicker, limit: 1 })
|
listFilingsRecords({ ticker: normalizedTicker, limit: 1 }),
|
||||||
|
getResearchCopilotSessionByTicker(userId, normalizedTicker)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -918,7 +940,8 @@ export async function getResearchWorkspace(userId: string, ticker: string): Prom
|
|||||||
memo,
|
memo,
|
||||||
library: library.artifacts,
|
library: library.artifacts,
|
||||||
packet,
|
packet,
|
||||||
availableTags: library.availableTags
|
availableTags: library.availableTags,
|
||||||
|
copilotSession
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,4 +1142,3 @@ export async function getResearchArtifactFileResponse(userId: string, id: number
|
|||||||
export function rebuildResearchArtifactIndex() {
|
export function rebuildResearchArtifactIndex() {
|
||||||
rebuildArtifactSearchIndex();
|
rebuildArtifactSearchIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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.detailLine).toBe('Could not load the primary filing document.');
|
||||||
expect(notification.actions.some((action) => action.id === 'open_filings')).toBe(true);
|
expect(notification.actions.some((action) => action.id === 'open_filings')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds research navigation for completed research brief jobs', () => {
|
||||||
|
const notification = buildTaskNotification(baseTask({
|
||||||
|
task_type: 'research_brief',
|
||||||
|
status: 'completed',
|
||||||
|
stage: 'completed',
|
||||||
|
stage_detail: 'Generated research brief artifact for NVDA.',
|
||||||
|
stage_context: {
|
||||||
|
subject: {
|
||||||
|
ticker: 'NVDA'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
ticker: 'NVDA',
|
||||||
|
query: 'Update the thesis'
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
ticker: 'NVDA',
|
||||||
|
artifactId: 12,
|
||||||
|
model: 'test-model'
|
||||||
|
},
|
||||||
|
finished_at: '2026-03-09T10:06:00.000Z'
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(notification.actions[0]).toMatchObject({
|
||||||
|
id: 'open_research',
|
||||||
|
href: '/research?ticker=NVDA',
|
||||||
|
primary: true
|
||||||
|
});
|
||||||
|
expect(notification.stats.some((stat) => stat.label === 'Artifact' && stat.value === '12')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ function buildStats(task: TaskCore): TaskNotificationStat[] {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'research_brief':
|
||||||
|
stats.push(
|
||||||
|
makeStat('Ticker', asString(result?.ticker) ?? task.stage_context?.subject?.ticker ?? null),
|
||||||
|
makeStat('Artifact', asNumber(result?.artifactId) ?? null),
|
||||||
|
makeStat('Model', asString(result?.model) ?? null)
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stats.every((stat) => stat === null)) {
|
if (stats.every((stat) => stat === null)) {
|
||||||
@@ -194,6 +201,14 @@ function buildActions(task: TaskCore): TaskNotificationAction[] {
|
|||||||
primary: true
|
primary: true
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'research_brief':
|
||||||
|
actions.push({
|
||||||
|
id: 'open_research',
|
||||||
|
label: 'Open research',
|
||||||
|
href: ticker ? `/research?ticker=${encodeURIComponent(ticker)}` : '/research',
|
||||||
|
primary: true
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push({
|
actions.push({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
import { runAiAnalysis } from '@/lib/server/ai';
|
import { runAiAnalysis } from '@/lib/server/ai';
|
||||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||||
import { getQuote } from '@/lib/server/prices';
|
import { getQuote } from '@/lib/server/prices';
|
||||||
|
import { generateResearchBrief } from '@/lib/server/research-copilot';
|
||||||
import { indexSearchDocuments } from '@/lib/server/search';
|
import { indexSearchDocuments } from '@/lib/server/search';
|
||||||
import {
|
import {
|
||||||
getFilingByAccession,
|
getFilingByAccession,
|
||||||
@@ -32,7 +33,9 @@ import {
|
|||||||
listUserHoldings
|
listUserHoldings
|
||||||
} from '@/lib/server/repos/holdings';
|
} from '@/lib/server/repos/holdings';
|
||||||
import { createPortfolioInsight } from '@/lib/server/repos/insights';
|
import { createPortfolioInsight } from '@/lib/server/repos/insights';
|
||||||
|
import { createResearchArtifactRecord } from '@/lib/server/repos/research-library';
|
||||||
import { updateTaskStage } from '@/lib/server/repos/tasks';
|
import { updateTaskStage } from '@/lib/server/repos/tasks';
|
||||||
|
import { updateWatchlistReviewByTicker } from '@/lib/server/repos/watchlist';
|
||||||
import {
|
import {
|
||||||
fetchPrimaryFilingText,
|
fetchPrimaryFilingText,
|
||||||
fetchRecentFilings
|
fetchRecentFilings
|
||||||
@@ -1302,6 +1305,97 @@ async function processPortfolioInsights(task: Task) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function processResearchBrief(task: Task) {
|
||||||
|
const ticker = typeof task.payload.ticker === 'string' ? task.payload.ticker.trim().toUpperCase() : '';
|
||||||
|
const query = typeof task.payload.query === 'string' ? task.payload.query.trim() : '';
|
||||||
|
const sources = Array.isArray(task.payload.sources)
|
||||||
|
? task.payload.sources.filter((entry): entry is 'documents' | 'filings' | 'research' => entry === 'documents' || entry === 'filings' || entry === 'research')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!ticker) {
|
||||||
|
throw new Error('Research brief task requires a ticker');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
throw new Error('Research brief task requires a query');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setProjectionStage(task, 'research.retrieve', `Collecting evidence for ${ticker} research brief`, {
|
||||||
|
subject: { ticker },
|
||||||
|
progress: { current: 1, total: 3, unit: 'steps' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const brief = await generateResearchBrief({
|
||||||
|
userId: task.user_id,
|
||||||
|
ticker,
|
||||||
|
query,
|
||||||
|
selectedSources: sources
|
||||||
|
});
|
||||||
|
|
||||||
|
await setProjectionStage(task, 'research.answer', `Generating research brief for ${ticker}`, {
|
||||||
|
subject: { ticker },
|
||||||
|
progress: { current: 2, total: 3, unit: 'steps' },
|
||||||
|
counters: {
|
||||||
|
evidence: brief.evidence.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = brief.bodyMarkdown
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0 && !line.startsWith('#'))
|
||||||
|
?? `Generated research brief for ${ticker}.`;
|
||||||
|
|
||||||
|
await setProjectionStage(task, 'research.persist', `Saving research brief artifact for ${ticker}`, {
|
||||||
|
subject: { ticker },
|
||||||
|
progress: { current: 3, total: 3, unit: 'steps' },
|
||||||
|
counters: {
|
||||||
|
evidence: brief.evidence.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const artifact = await createResearchArtifactRecord({
|
||||||
|
userId: task.user_id,
|
||||||
|
ticker,
|
||||||
|
kind: 'ai_report',
|
||||||
|
source: 'system',
|
||||||
|
subtype: 'research_brief',
|
||||||
|
title: `Research brief · ${query}`,
|
||||||
|
summary,
|
||||||
|
bodyMarkdown: brief.bodyMarkdown,
|
||||||
|
tags: ['copilot', 'research-brief'],
|
||||||
|
metadata: {
|
||||||
|
query,
|
||||||
|
sources: sources ?? ['documents', 'filings', 'research'],
|
||||||
|
provider: brief.provider,
|
||||||
|
model: brief.model,
|
||||||
|
citations: brief.evidence.map((result, index) => ({
|
||||||
|
index: index + 1,
|
||||||
|
label: result.citationLabel,
|
||||||
|
href: result.href
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateWatchlistReviewByTicker(task.user_id, ticker, artifact.updated_at);
|
||||||
|
|
||||||
|
return buildTaskOutcome(
|
||||||
|
{
|
||||||
|
ticker,
|
||||||
|
artifactId: artifact.id,
|
||||||
|
provider: brief.provider,
|
||||||
|
model: brief.model
|
||||||
|
},
|
||||||
|
`Generated research brief artifact for ${ticker}.`,
|
||||||
|
{
|
||||||
|
subject: { ticker },
|
||||||
|
counters: {
|
||||||
|
evidence: brief.evidence.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const __taskProcessorInternals = {
|
export const __taskProcessorInternals = {
|
||||||
parseExtractionPayload,
|
parseExtractionPayload,
|
||||||
deterministicExtractionFallback,
|
deterministicExtractionFallback,
|
||||||
@@ -1320,6 +1414,8 @@ export async function runTaskProcessor(task: Task) {
|
|||||||
return await processPortfolioInsights(task);
|
return await processPortfolioInsights(task);
|
||||||
case 'index_search':
|
case 'index_search':
|
||||||
return await processIndexSearch(task);
|
return await processIndexSearch(task);
|
||||||
|
case 'research_brief':
|
||||||
|
return await processResearchBrief(task);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported task type: ${task.task_type}`);
|
throw new Error(`Unsupported task type: ${task.task_type}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
|||||||
refresh_prices: 'Price refresh',
|
refresh_prices: 'Price refresh',
|
||||||
analyze_filing: 'Filing analysis',
|
analyze_filing: 'Filing analysis',
|
||||||
portfolio_insights: 'Portfolio insight',
|
portfolio_insights: 'Portfolio insight',
|
||||||
index_search: 'Search indexing'
|
index_search: 'Search indexing',
|
||||||
|
research_brief: 'Research brief'
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_LABELS: Record<TaskStage, string> = {
|
const STAGE_LABELS: Record<TaskStage, string> = {
|
||||||
@@ -50,6 +51,9 @@ const STAGE_LABELS: Record<TaskStage, string> = {
|
|||||||
'search.chunk': 'Chunk content',
|
'search.chunk': 'Chunk content',
|
||||||
'search.embed': 'Generate embeddings',
|
'search.embed': 'Generate embeddings',
|
||||||
'search.persist': 'Persist search index',
|
'search.persist': 'Persist search index',
|
||||||
|
'research.retrieve': 'Retrieve evidence',
|
||||||
|
'research.answer': 'Generate brief',
|
||||||
|
'research.persist': 'Persist brief',
|
||||||
'insights.load_holdings': 'Load holdings',
|
'insights.load_holdings': 'Load holdings',
|
||||||
'insights.generate': 'Generate insight',
|
'insights.generate': 'Generate insight',
|
||||||
'insights.persist': 'Persist insight'
|
'insights.persist': 'Persist insight'
|
||||||
@@ -97,6 +101,14 @@ const TASK_STAGE_ORDER: Record<TaskType, TaskStage[]> = {
|
|||||||
'search.persist',
|
'search.persist',
|
||||||
'completed'
|
'completed'
|
||||||
],
|
],
|
||||||
|
research_brief: [
|
||||||
|
'queued',
|
||||||
|
'running',
|
||||||
|
'research.retrieve',
|
||||||
|
'research.answer',
|
||||||
|
'research.persist',
|
||||||
|
'completed'
|
||||||
|
],
|
||||||
portfolio_insights: [
|
portfolio_insights: [
|
||||||
'queued',
|
'queued',
|
||||||
'running',
|
'running',
|
||||||
|
|||||||
77
lib/types.ts
77
lib/types.ts
@@ -119,7 +119,8 @@ export type TaskType =
|
|||||||
| 'refresh_prices'
|
| 'refresh_prices'
|
||||||
| 'analyze_filing'
|
| 'analyze_filing'
|
||||||
| 'portfolio_insights'
|
| 'portfolio_insights'
|
||||||
| 'index_search';
|
| 'index_search'
|
||||||
|
| 'research_brief';
|
||||||
export type TaskStage =
|
export type TaskStage =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
| 'running'
|
| 'running'
|
||||||
@@ -148,6 +149,9 @@ export type TaskStage =
|
|||||||
| 'search.chunk'
|
| 'search.chunk'
|
||||||
| 'search.embed'
|
| 'search.embed'
|
||||||
| 'search.persist'
|
| 'search.persist'
|
||||||
|
| 'research.retrieve'
|
||||||
|
| 'research.answer'
|
||||||
|
| 'research.persist'
|
||||||
| 'insights.load_holdings'
|
| 'insights.load_holdings'
|
||||||
| 'insights.generate'
|
| 'insights.generate'
|
||||||
| 'insights.persist';
|
| 'insights.persist';
|
||||||
@@ -178,7 +182,8 @@ export type TaskNotificationAction = {
|
|||||||
| 'open_analysis'
|
| 'open_analysis'
|
||||||
| 'open_analysis_report'
|
| 'open_analysis_report'
|
||||||
| 'open_search'
|
| 'open_search'
|
||||||
| 'open_portfolio';
|
| 'open_portfolio'
|
||||||
|
| 'open_research';
|
||||||
label: string;
|
label: string;
|
||||||
href: string | null;
|
href: string | null;
|
||||||
primary?: boolean;
|
primary?: boolean;
|
||||||
@@ -318,6 +323,73 @@ export type SearchAnswerResponse = {
|
|||||||
results: SearchResult[];
|
results: SearchResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResearchCopilotSuggestedActionType =
|
||||||
|
| 'draft_note'
|
||||||
|
| 'draft_memo_section'
|
||||||
|
| 'queue_research_brief';
|
||||||
|
|
||||||
|
export type ResearchCopilotCitation = {
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
chunkId: number;
|
||||||
|
href: string;
|
||||||
|
source: SearchSource;
|
||||||
|
sourceKind: SearchResult['sourceKind'];
|
||||||
|
sourceRef: string;
|
||||||
|
title: string | null;
|
||||||
|
ticker: string | null;
|
||||||
|
accessionNumber: string | null;
|
||||||
|
filingDate: string | null;
|
||||||
|
excerpt: string;
|
||||||
|
artifactId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchCopilotSuggestedAction = {
|
||||||
|
id: string;
|
||||||
|
type: ResearchCopilotSuggestedActionType;
|
||||||
|
label: string;
|
||||||
|
description: string | null;
|
||||||
|
section: ResearchMemoSection | null;
|
||||||
|
title: string | null;
|
||||||
|
content_markdown: string | null;
|
||||||
|
citation_indexes: number[];
|
||||||
|
query: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchCopilotMessage = {
|
||||||
|
id: number;
|
||||||
|
session_id: number;
|
||||||
|
user_id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content_markdown: string;
|
||||||
|
citations: ResearchCopilotCitation[];
|
||||||
|
follow_ups: string[];
|
||||||
|
suggested_actions: ResearchCopilotSuggestedAction[];
|
||||||
|
selected_sources: SearchSource[];
|
||||||
|
pinned_artifact_ids: number[];
|
||||||
|
memo_section: ResearchMemoSection | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchCopilotSession = {
|
||||||
|
id: number;
|
||||||
|
user_id: string;
|
||||||
|
ticker: string;
|
||||||
|
title: string | null;
|
||||||
|
selected_sources: SearchSource[];
|
||||||
|
pinned_artifact_ids: number[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
messages: ResearchCopilotMessage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResearchCopilotTurnResponse = {
|
||||||
|
session: ResearchCopilotSession;
|
||||||
|
user_message: ResearchCopilotMessage;
|
||||||
|
assistant_message: ResearchCopilotMessage;
|
||||||
|
results: SearchResult[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ResearchArtifact = {
|
export type ResearchArtifact = {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -403,6 +475,7 @@ export type ResearchWorkspace = {
|
|||||||
library: ResearchArtifact[];
|
library: ResearchArtifact[];
|
||||||
packet: ResearchPacket;
|
packet: ResearchPacket;
|
||||||
availableTags: string[];
|
availableTags: string[];
|
||||||
|
copilotSession: ResearchCopilotSession | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CompanyFinancialPoint = {
|
export type CompanyFinancialPoint = {
|
||||||
|
|||||||
Reference in New Issue
Block a user