feat(frontend): redesign ResponsePanel for per-sub-question sections with grouped sources
Redesign ResponsePanel with SubQuestionSections component that parses answer markdown on ## Sub-question N: boundaries and renders per-sub-question cards with headers, ReactMarkdown body, and collapsible sources scoped to each section. Extract FlatResponse for backward compatibility when subQuestionSources is null. Add buildCitationLookupForSubq and processCitationsForSubq for per-sub-question citation lookup scope isolation. Add anchor links in ExtractedQuestionsDisplay that smooth-scroll to matching ResponsePanel sections. Pass subQuestionSources through LTTPage. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
098368bb42
commit
7d072e5ea1
|
|
@ -1,11 +1,17 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import type { SubQuestionSources } from '../types'
|
||||||
|
|
||||||
export interface ExtractedQuestionsDisplayProps {
|
export interface ExtractedQuestionsDisplayProps {
|
||||||
extractedQuestions?: string[] | null
|
extractedQuestions?: string[] | null
|
||||||
|
subQuestionSources?: SubQuestionSources[] | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExtractedQuestionsDisplay: React.FC<ExtractedQuestionsDisplayProps> = ({ extractedQuestions, isLoading }) => {
|
export const ExtractedQuestionsDisplay: React.FC<ExtractedQuestionsDisplayProps> = ({
|
||||||
|
extractedQuestions,
|
||||||
|
subQuestionSources,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
if (!isLoading && (!extractedQuestions || extractedQuestions.length === 0)) {
|
if (!isLoading && (!extractedQuestions || extractedQuestions.length === 0)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -27,20 +33,41 @@ export const ExtractedQuestionsDisplay: React.FC<ExtractedQuestionsDisplayProps>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScrollToSubq = (index: number) => {
|
||||||
|
const element = document.getElementById(`subq-${index}`)
|
||||||
|
element?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="extracted-questions-section" className="space-y-2 transition-all duration-300 ease-in-out">
|
<div data-testid="extracted-questions-section" className="space-y-2 transition-all duration-300 ease-in-out">
|
||||||
<span className="text-xs text-gray-500 uppercase tracking-wide">Extracted Questions:</span>
|
<span className="text-xs text-gray-500 uppercase tracking-wide">Extracted Questions:</span>
|
||||||
<ol data-testid="extracted-questions-container" className="list-decimal list-inside space-y-1">
|
<ol data-testid="extracted-questions-container" className="list-decimal list-inside space-y-1">
|
||||||
{extractedQuestions?.map((question, index) => (
|
{extractedQuestions?.map((question, index) => {
|
||||||
<li
|
const displayText = subQuestionSources?.[index]?.sub_question_text ?? question
|
||||||
key={index}
|
return (
|
||||||
role="listitem"
|
<li
|
||||||
data-testid={`extracted-question-${index}`}
|
key={index}
|
||||||
className="text-sm text-gray-700 pl-1"
|
role="listitem"
|
||||||
>
|
data-testid={`extracted-question-${index}`}
|
||||||
{question}
|
className="text-sm text-gray-700 pl-1"
|
||||||
</li>
|
>
|
||||||
))}
|
{subQuestionSources ? (
|
||||||
|
<a
|
||||||
|
href={`#subq-${index}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleScrollToSubq(index)
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
displayText
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,240 @@
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { MessageSquare, AlertCircle, Copy, ChevronDown, ChevronRight } from 'lucide-react'
|
import { MessageSquare, AlertCircle, Copy, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import type { SourceMetadata } from '../types'
|
import type { SourceMetadata, SubQuestionSources } from '../types'
|
||||||
import { getPdfViewerUrl } from '../lib/api'
|
import { getPdfViewerUrl } from '../lib/api'
|
||||||
import { processCitations } from '../utils/citationParser'
|
import { processCitations, processCitationsForSubq } from '../utils/citationParser'
|
||||||
|
|
||||||
interface ResponsePanelProps {
|
interface ResponsePanelProps {
|
||||||
answer: string | null
|
answer: string | null
|
||||||
sources: SourceMetadata[]
|
sources?: SourceMetadata[]
|
||||||
isLoading: boolean
|
subQuestionSources?: SubQuestionSources[] | null
|
||||||
|
isLoading?: boolean
|
||||||
phase?: string
|
phase?: string
|
||||||
error: string | null
|
error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
const CitationLink = ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
|
||||||
|
function parseAnswerSections(answer: string): string[] {
|
||||||
|
const sections = answer.split(/## Sub-question \d+:[^\n]*\n/)
|
||||||
|
if (sections.length <= 1) return [answer]
|
||||||
|
return sections.filter((s) => s.trim().length > 0).map((s) => s.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubQuestionSourceCard({ source, index }: { source: SourceMetadata; index: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-medium text-sm">{source.filename}</div>
|
||||||
|
{source.page_number !== null && (
|
||||||
|
<span className="text-xs text-blue-600 font-medium">
|
||||||
|
Page {source.page_number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{source.upload_date}</div>
|
||||||
|
<div className="text-sm text-gray-600 mt-1">{source.content_summary}</div>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<div className="text-xs text-gray-400">Chunk {source.chunk_index}</div>
|
||||||
|
{source.chunk_file_path && (
|
||||||
|
<a
|
||||||
|
href={getPdfViewerUrl(source.chunk_file_path, source.page_number ?? undefined, source.filename)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
data-testid="view-chunk-pdf-link"
|
||||||
|
>
|
||||||
|
View PDF
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubQuestionSection({
|
||||||
|
index,
|
||||||
|
subQuestion,
|
||||||
|
answerSection,
|
||||||
|
}: {
|
||||||
|
index: number
|
||||||
|
subQuestion: SubQuestionSources
|
||||||
|
answerSection: string
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true)
|
||||||
|
const processedAnswer = processCitationsForSubq(answerSection, [subQuestion], 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`subq-${index}`}
|
||||||
|
data-testid={`subq-section-${index}`}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 text-gray-700 font-medium">
|
||||||
|
<span className="text-gray-400">Sub-question {index + 1}:</span>
|
||||||
|
<span>{subQuestion.sub_question_text}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-sm max-w-none text-gray-800">
|
||||||
|
<ReactMarkdown components={{ a: CitationLink }}>
|
||||||
|
{processedAnswer}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subQuestion.sources.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
data-testid="sources-toggle"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center space-x-2 text-xs uppercase text-gray-500 tracking-wide mb-2 hover:text-gray-700 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span>Sources ({subQuestion.sources.length})</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div data-testid="sources-container" className="grid grid-cols-2 gap-2">
|
||||||
|
{subQuestion.sources.map((source, idx) => (
|
||||||
|
<SubQuestionSourceCard key={idx} source={source} index={idx} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubQuestionSections({
|
||||||
|
answer,
|
||||||
|
subQuestionSources,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
answer: string | null
|
||||||
|
subQuestionSources: SubQuestionSources[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const sections = answer ? parseAnswerSections(answer) : []
|
||||||
|
|
||||||
|
const handleCopyAnswer = async (): Promise<void> => {
|
||||||
|
if (answer) {
|
||||||
|
await navigator.clipboard.writeText(answer)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-gray-500 mb-4">
|
||||||
|
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
|
||||||
|
<span>Generating answers...</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-line"
|
||||||
|
className="h-4 bg-gray-200 rounded animate-pulse w-full"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-line"
|
||||||
|
className="h-4 bg-gray-200 rounded animate-pulse w-11/12"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-line"
|
||||||
|
className="h-4 bg-gray-200 rounded animate-pulse w-4/5"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-line"
|
||||||
|
className="h-4 bg-gray-200 rounded animate-pulse w-3/5"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="skeleton-line"
|
||||||
|
className="h-4 bg-gray-200 rounded animate-pulse w-2/5"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div
|
||||||
|
data-testid="source-skeleton"
|
||||||
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-3/4 mb-2 animate-pulse" />
|
||||||
|
<div className="h-2 bg-gray-200 rounded w-1/2 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="source-skeleton"
|
||||||
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3 mb-2 animate-pulse" />
|
||||||
|
<div className="h-2 bg-gray-200 rounded w-1/3 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
data-testid="copy-answer-btn"
|
||||||
|
onClick={handleCopyAnswer}
|
||||||
|
className="flex items-center space-x-1 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||||
|
title="Copy answer to clipboard"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{copied ? 'Copied!' : 'Copy'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{subQuestionSources.map((subQuestion, index) => (
|
||||||
|
<SubQuestionSection
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
subQuestion={subQuestion}
|
||||||
|
answerSection={sections[index] ?? ''}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlatResponse({
|
||||||
answer,
|
answer,
|
||||||
sources,
|
sources,
|
||||||
isLoading,
|
isLoading,
|
||||||
phase,
|
phase,
|
||||||
error,
|
error,
|
||||||
}) => {
|
}: {
|
||||||
const [sourcesExpanded, setSourcesExpanded] = useState<boolean>(true)
|
answer: string | null
|
||||||
const [copied, setCopied] = useState<boolean>(false)
|
sources?: SourceMetadata[]
|
||||||
|
isLoading?: boolean
|
||||||
|
phase?: string
|
||||||
|
error?: string | null
|
||||||
|
}) {
|
||||||
|
const [sourcesExpanded, setSourcesExpanded] = useState(true)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const safeSources = sources ?? []
|
||||||
|
|
||||||
const processedAnswer = answer ? processCitations(answer, sources) : answer ?? ''
|
const processedAnswer = answer ? processCitations(answer, safeSources) : answer ?? ''
|
||||||
|
|
||||||
const handleCopyAnswer = async (): Promise<void> => {
|
const handleCopyAnswer = async (): Promise<void> => {
|
||||||
if (answer) {
|
if (answer) {
|
||||||
|
|
@ -108,17 +319,6 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CitationLink = ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -143,7 +343,7 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sources.length > 0 && (
|
{safeSources.length > 0 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<button
|
<button
|
||||||
data-testid="sources-toggle"
|
data-testid="sources-toggle"
|
||||||
|
|
@ -155,11 +355,11 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
<span>Sources ({sources.length})</span>
|
<span>Sources ({safeSources.length})</span>
|
||||||
</button>
|
</button>
|
||||||
{sourcesExpanded && (
|
{sourcesExpanded && (
|
||||||
<div data-testid="sources-container" className="grid grid-cols-2 gap-2">
|
<div data-testid="sources-container" className="grid grid-cols-2 gap-2">
|
||||||
{sources.map((source, index) => (
|
{safeSources.map((source, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="border border-gray-200 rounded p-3 bg-gray-50"
|
className="border border-gray-200 rounded p-3 bg-gray-50"
|
||||||
|
|
@ -197,3 +397,32 @@ export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ResponsePanel: React.FC<ResponsePanelProps> = ({
|
||||||
|
answer,
|
||||||
|
sources,
|
||||||
|
subQuestionSources,
|
||||||
|
isLoading,
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
if (subQuestionSources && subQuestionSources.length > 0) {
|
||||||
|
return (
|
||||||
|
<SubQuestionSections
|
||||||
|
answer={answer}
|
||||||
|
subQuestionSources={subQuestionSources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatResponse
|
||||||
|
answer={answer}
|
||||||
|
sources={sources}
|
||||||
|
isLoading={isLoading}
|
||||||
|
phase={phase}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const LTTPage: React.FC = () => {
|
||||||
<QueryInput onSubmit={handleQuerySubmit} isLoading={isLoading} />
|
<QueryInput onSubmit={handleQuerySubmit} isLoading={isLoading} />
|
||||||
<ExtractedQuestionsDisplay
|
<ExtractedQuestionsDisplay
|
||||||
extractedQuestions={queryStream.extractedQuestions}
|
extractedQuestions={queryStream.extractedQuestions}
|
||||||
|
subQuestionSources={queryStream.subQuestionSources}
|
||||||
isLoading={queryStream.phase === 'decomposing'}
|
isLoading={queryStream.phase === 'decomposing'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -58,6 +59,7 @@ export const LTTPage: React.FC = () => {
|
||||||
<ResponsePanel
|
<ResponsePanel
|
||||||
answer={queryStream.answer}
|
answer={queryStream.answer}
|
||||||
sources={queryStream.sources ?? []}
|
sources={queryStream.sources ?? []}
|
||||||
|
subQuestionSources={queryStream.subQuestionSources}
|
||||||
isLoading={queryStream.phase === 'retrieving' || queryStream.phase === 'filtering' || queryStream.phase === 'generating'}
|
isLoading={queryStream.phase === 'retrieving' || queryStream.phase === 'filtering' || queryStream.phase === 'generating'}
|
||||||
phase={queryStream.phase}
|
phase={queryStream.phase}
|
||||||
error={queryStream.error?.message ?? null}
|
error={queryStream.error?.message ?? null}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { SourceMetadata } from '../types'
|
import type { SourceMetadata, SubQuestionSources } from '../types'
|
||||||
import { getPdfViewerUrl } from '../lib/api'
|
import { getPdfViewerUrl } from '../lib/api'
|
||||||
|
|
||||||
function buildCitationLookup(sources: SourceMetadata[]): Map<string, SourceMetadata> {
|
function buildCitationLookup(sources: SourceMetadata[]): Map<string, SourceMetadata> {
|
||||||
|
|
@ -13,23 +13,27 @@ function buildCitationLookup(sources: SourceMetadata[]): Map<string, SourceMetad
|
||||||
return lookup
|
return lookup
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function buildCitationLookupForSubq(
|
||||||
* Parse citation patterns in answer text and replace with markdown links.
|
subQuestionSources: SubQuestionSources[],
|
||||||
*
|
subqIndex: number
|
||||||
* Citation format: [filename, page N] or [filename]
|
): Map<string, SourceMetadata> {
|
||||||
* Only replaces citations that match an actual source in the sources array.
|
const sources = subQuestionSources[subqIndex]?.sources ?? []
|
||||||
* Unmatched citations remain as plain text.
|
return buildCitationLookup(sources)
|
||||||
*
|
}
|
||||||
* @param text - The LLM answer text containing citations
|
|
||||||
* @param sources - Array of source metadata for cross-referencing
|
|
||||||
* @returns Modified text with matched citations converted to markdown links
|
|
||||||
*/
|
|
||||||
export function processCitations(text: string, sources: SourceMetadata[]): string {
|
|
||||||
if (!sources.length) return text
|
|
||||||
|
|
||||||
const lookup = buildCitationLookup(sources)
|
export function processCitationsForSubq(
|
||||||
|
answerSection: string,
|
||||||
|
subQuestionSources: SubQuestionSources[],
|
||||||
|
subqIndex: number
|
||||||
|
): string {
|
||||||
|
const lookup = buildCitationLookupForSubq(subQuestionSources, subqIndex)
|
||||||
|
return replaceCitationPatterns(answerSection, lookup)
|
||||||
|
}
|
||||||
|
|
||||||
// Match [content] that is NOT part of markdown image ![...] or link [...](...)
|
function replaceCitationPatterns(
|
||||||
|
text: string,
|
||||||
|
lookup: Map<string, SourceMetadata>
|
||||||
|
): string {
|
||||||
const citationPattern = /(?<!!)\[([^\]]+)\](?!\()/g
|
const citationPattern = /(?<!!)\[([^\]]+)\](?!\()/g
|
||||||
|
|
||||||
return text.replace(citationPattern, (fullMatch, content: string) => {
|
return text.replace(citationPattern, (fullMatch, content: string) => {
|
||||||
|
|
@ -57,3 +61,21 @@ export function processCitations(text: string, sources: SourceMetadata[]): strin
|
||||||
return fullMatch
|
return fullMatch
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse citation patterns in answer text and replace with markdown links.
|
||||||
|
*
|
||||||
|
* Citation format: [filename, page N] or [filename]
|
||||||
|
* Only replaces citations that match an actual source in the sources array.
|
||||||
|
* Unmatched citations remain as plain text.
|
||||||
|
*
|
||||||
|
* @param text - The LLM answer text containing citations
|
||||||
|
* @param sources - Array of source metadata for cross-referencing
|
||||||
|
* @returns Modified text with matched citations converted to markdown links
|
||||||
|
*/
|
||||||
|
export function processCitations(text: string, sources: SourceMetadata[]): string {
|
||||||
|
if (!sources.length) return text
|
||||||
|
|
||||||
|
const lookup = buildCitationLookup(sources)
|
||||||
|
return replaceCitationPatterns(text, lookup)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue