Files
Neon-Desk/lib/server/research-copilot-format.ts

226 lines
7.0 KiB
TypeScript

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)
};
}
}