Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/src/docmind/rag/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from docmind.text import best_sentence

TitleLookup = Callable[[str, Sequence[str]], Awaitable[dict[str, str]]]
_CITE = re.compile(r"\[(\d{1,2})\]")
_CITE = re.compile(r"\[(\d{1,2}(?:\s*,\s*\d{1,2})*)\]")


@dataclass(slots=True, frozen=True)
Expand All @@ -40,7 +40,7 @@ class DoneEvent:


def cited_indices(answer: str, available: int) -> list[int]:
found = {int(m) for m in _CITE.findall(answer)}
found = {int(num) for group in _CITE.findall(answer) for num in group.split(",")}
return sorted(i for i in found if 1 <= i <= available)


Expand Down
29 changes: 29 additions & 0 deletions backend/tests/test_citations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Unit tests for parsing citation markers out of an answer."""

from __future__ import annotations

from docmind.rag.service import cited_indices


def test_separate_markers() -> None:
assert cited_indices("See [1] and also [2].", 2) == [1, 2]


def test_repeated_bracket_markers() -> None:
assert cited_indices("Both [1][2] agree.", 2) == [1, 2]


def test_grouped_marker() -> None:
assert cited_indices("Both sources [1, 2] agree.", 2) == [1, 2]


def test_grouped_marker_without_spaces() -> None:
assert cited_indices("See [1,2,3].", 3) == [1, 2, 3]


def test_grouped_marker_drops_out_of_range() -> None:
assert cited_indices("See [1, 5].", 2) == [1]


def test_zero_and_out_of_range_ignored() -> None:
assert cited_indices("Nothing here: [0], [7].", 2) == []
18 changes: 18 additions & 0 deletions frontend/src/components/AnswerText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ describe('AnswerText', () => {
expect(screen.getByText(/See \[0\] and/)).toBeInTheDocument()
})

it('turns a grouped marker into one button per source', () => {
const onCite = vi.fn()
render(<AnswerText text="Both sources [1, 2] agree." available={2} onCite={onCite} />)
expect(citeButtons().map((b) => b.textContent)).toEqual(['1', '2'])
})

it('keeps out-of-range numbers in a group as text', () => {
render(<AnswerText text="See [1, 5]." available={2} />)
expect(citeButtons().map((b) => b.textContent)).toEqual(['1'])
expect(screen.getByText(/5/)).toBeInTheDocument()
})

it('keeps a fully out-of-range group as literal text', () => {
render(<AnswerText text="See [0, 9]." available={2} />)
expect(citeButtons()).toHaveLength(0)
expect(screen.getByText(/See \[0, 9\]\./)).toBeInTheDocument()
})

it('reports the clicked source number', async () => {
const onCite = vi.fn()
render(<AnswerText text="Budget is 1,000 EUR [2]." available={2} onCite={onCite} />)
Expand Down
43 changes: 26 additions & 17 deletions frontend/src/components/AnswerText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface Props {
available?: number
}

const INLINE = /(\[\d{1,2}\])|(\*\*[^*]+\*\*)|(`[^`]+`)|(_[^_\n]+_)/g
const INLINE = /(\[\d{1,2}(?:\s*,\s*\d{1,2})*\])|(\*\*[^*]+\*\*)|(`[^`]+`)|(_[^_\n]+_)/g

/**
* Tiny, safe Markdown subset renderer: paragraphs, lists, bold/italic/code and [n] citations.
Expand Down Expand Up @@ -53,22 +53,31 @@ function renderInline(text: string, onCite: Props['onCite'], available: number):
if (start > last) out.push(text.slice(last, start))
const token = match[0]
if (match[1]) {
const n = Number(token.slice(1, -1))
out.push(
n >= 1 && n <= available ? (
<button
key={start}
type="button"
onClick={() => onCite?.(n)}
className="mx-0.5 inline-flex h-5 min-w-5 items-center justify-center rounded-md bg-indigo-100 px-1 align-text-top text-[11px] font-semibold text-indigo-700 transition hover:bg-indigo-600 hover:text-white dark:bg-indigo-500/20 dark:text-indigo-200"
aria-label={`Show source ${n}`}
>
{n}
</button>
) : (
token
),
)
// A citation can list several sources in one pair of brackets, e.g. [1, 2].
const nums = token.slice(1, -1).split(',').map((part) => Number(part.trim()))
const inRange = (n: number) => n >= 1 && n <= available
if (!nums.some(inRange)) {
out.push(token)
} else {
nums.forEach((n, i) => {
if (i > 0) out.push(', ')
out.push(
inRange(n) ? (
<button
key={`${start}-${i}`}
type="button"
onClick={() => onCite?.(n)}
className="mx-0.5 inline-flex h-5 min-w-5 items-center justify-center rounded-md bg-indigo-100 px-1 align-text-top text-[11px] font-semibold text-indigo-700 transition hover:bg-indigo-600 hover:text-white dark:bg-indigo-500/20 dark:text-indigo-200"
aria-label={`Show source ${n}`}
>
{n}
</button>
) : (
String(n)
),
)
})
}
} else if (match[2]) {
out.push(<strong key={start}>{token.slice(2, -2)}</strong>)
} else if (match[3]) {
Expand Down
Loading