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
108 changes: 5 additions & 103 deletions src/main/services/citationCandidates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CitationCandidate } from '../../shared/types/citation'
import { splitSentences, type TextRange } from '../../shared/utils/sentenceSegmentation'
import type { EvidenceLocator } from './retrieval'

/**
Expand All @@ -16,6 +17,10 @@ import type { EvidenceLocator } from './retrieval'
* spans that already exist* removes the guess entirely.
*
* The chunk stays the retrieval unit. Candidates are the citation unit.
*
* Sentence boundaries come from `shared/utils/sentenceSegmentation`, the same
* splitter #156 uses to check an answer sentence by sentence — the two must agree
* or a candidate and a coverage statement would be about different spans.
*/

/**
Expand All @@ -32,115 +37,12 @@ export interface CitationCandidateSource {
content?: string
}

/** 相对传入字符串的字符区间。 */
export interface TextRange {
start: number
end: number
}

/** 一个 candidate 大致的最小字符数:短句会与相邻句合并,而不是各自成条。 */
const MIN_CANDIDATE_CHARS = 80

/** 一个 candidate 最多合并多少句。 */
const MAX_CANDIDATE_SENTENCES = 3

/**
* 常见的缩写。它们后面的句点不是句末 —— 误判成句末会把句子切碎,而碎 candidate
* 比略大的 candidate 更糟:模型拿不到能支撑 claim 的完整片段。
*/
const ABBREVIATIONS = new Set([
'al',
'approx',
'cf',
'dr',
'etc',
'fig',
'inc',
'jr',
'ltd',
'mr',
'mrs',
'ms',
'prof',
'ref',
'sec',
'sr',
'st',
'vol',
'vs'
])

/** 形如 `U.S` / `e.g` / `i.e` 的点分隔缩写。 */
const DOTTED_INITIALS = /^(?:[a-z]\.)+[a-z]?$/

/** 可以紧跟句末标点而不影响断句的收尾符号。 */
const CLOSERS = /["')\]}»”’】」』]/

const isCjk = (char: string): boolean => /[\u3000-\u9fff\uff00-\uffef]/.test(char)

/**
* `text[i]` 是不是一个句末标点。
*
* 刻意保守:`3.14`、`U.S.`、`Fig. 3`、`et al.` 里面的点都不断句。宁可把两句话合成
* 一个 candidate,也不要把一句话切成两半。
*/
function isSentenceBoundary(text: string, i: number): boolean {
const char = text[i]

if (char === '。' || char === '!' || char === '?') return true
if (char === '!' || char === '?') return true
if (char !== '.') return false

const prev = text[i - 1]
const next = text[i + 1]

// 3.14 —— 数字之间的小数点
if (prev !== undefined && next !== undefined && /\d/.test(prev) && /\d/.test(next)) return false

// 点前面的 token
let start = i
while (start > 0 && /[A-Za-z.\u00c0-\u024f]/.test(text[start - 1])) start--
const token = text.slice(start, i).toLowerCase()
if (token.length === 1) return false // 缩写首字母:"J. Smith"
if (DOTTED_INITIALS.test(token)) return false
if (ABBREVIATIONS.has(token)) return false

// 点后面粘连着一个普通字母("word.Foo")不是句末;CJK 与收尾符号可以。
if (next !== undefined && !/\s/.test(next) && !isCjk(next) && !CLOSERS.test(next)) return false

return true
}

/** 把 `[start, end)` 去空白后加入区间列表。 */
function pushRange(ranges: TextRange[], text: string, start: number, end: number): void {
let from = start
let to = end
while (from < to && /\s/.test(text[from])) from++
while (to > from && /\s/.test(text[to - 1])) to--
if (to > from) ranges.push({ start: from, end: to })
}

/**
* 按句子/换行切分文本,返回相对 `text` 的区间。
*
* 纯函数:只有偏移,不产出字符串,调用方自己切片,因此 `quote` 与被引用的原文
* 一定逐字一致(reader 的高亮就靠这个)。
*/
export function splitSentences(text: string): TextRange[] {
const ranges: TextRange[] = []
let start = 0

for (let i = 0; i < text.length; i++) {
const boundary = text[i] === '\n' || isSentenceBoundary(text, i)
if (!boundary) continue
pushRange(ranges, text, start, i + 1)
start = i + 1
}

pushRange(ranges, text, start, text.length)
return ranges
}

/**
* 句子区间 → candidate 区间。
*
Expand Down
5 changes: 4 additions & 1 deletion src/main/services/citations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ export function buildRAGContext(searchResults: SearchResult[]): RAGContext {

${promptParts.join('\n\n---\n\n')}

请基于以上片段回答用户的问题。引用某一个片段时,请在该句子后使用 [n] 标注片段编号(n 为上面的编号,例如 [1])。如果这些片段不足以回答问题,请说明并尽力提供有帮助的回答。`
请基于以上片段回答用户的问题:
- 每一句来自片段的陈述,都请在该句后使用 [n] 标注片段编号(n 为上面的编号,例如 [1])。
- 如果某句话是你自己的推断、并不来自这些片段,请在该句后标注 [inference],不要给它加 [n]。
- 如果这些片段不足以回答问题,请说明并尽力提供有帮助的回答。`

return { context, sources, citations, citationContexts }
}
30 changes: 30 additions & 0 deletions src/renderer/src/components/notebook/chat/AnswerCoverage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ReactElement } from 'react'
import { useTranslation } from 'react-i18next'
import type { ClaimSupportReport } from '../../../../../shared/utils/claimSupport'

interface AnswerCoverageProps {
coverage: ClaimSupportReport
}

/**
* 这条回答的 citation coverage(#156)。
*
* 只报告确定的事情:哪些句子没有引用、哪些引用指向了不存在的来源、哪些句子被模型
* 明确标注为自己的推断。它**不**声称某条引用在语义上证明了这个句子 —— 那是 NLI,
* 不是一个规则能回答的问题,假装能回答只会给出读者会相信的错误结论。
*
* 没有任何未支撑陈述时不渲染:正常的回答不需要一句「一切正常」。
*/
export default function AnswerCoverage({ coverage }: AnswerCoverageProps): ReactElement | null {
const { t } = useTranslation('chat')

const notices: string[] = []
if (coverage.invalid > 0) notices.push(t('answerCoverageInvalid', { count: coverage.invalid }))
if (coverage.uncited > 0) notices.push(t('answerCoverageUncited', { count: coverage.uncited }))
if (coverage.inference > 0)
notices.push(t('answerCoverageInference', { count: coverage.inference }))

if (notices.length === 0) return null

return <p className="px-2 text-xs text-subtle-foreground">{notices.join(' ')}</p>
}
10 changes: 10 additions & 0 deletions src/renderer/src/components/notebook/chat/MessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import type { ChatMessage } from '../../../types/notebook'
import ReasoningContent from './ReasoningContent'
import AnswerSources from './AnswerSources'
import AnswerCoverage from './AnswerCoverage'
import CitationChip from './CitationChip'
import remarkCitationMarkers, { parseCitationHref } from './citationMarkers'
import { useItemStore } from '../../../store/itemStore'
Expand All @@ -18,6 +19,7 @@ import { useSourceAnchorNavigation } from '../../../hooks/useSourceAnchorNavigat
import { useUIStore } from '../../../store/uiStore'
import { parseRetrievalStatus, sourcesForDisplay } from '../../../../../shared/utils/answerSources'
import { parseCitations, sourceDocumentExists } from '../../../../../shared/utils/citations'
import { classifyClaimSupport } from '../../../../../shared/utils/claimSupport'
import {
citationToSourceAnchor,
sourceAnchorsEqual
Expand Down Expand Up @@ -68,6 +70,11 @@ export default function MessageItem({ message }: MessageItemProps): ReactElement
const citations = parseCitations(message.metadata)
const citationByIndex = new Map(citations.map((citation) => [citation.index, citation]))

// Citation coverage (#156): which sentences carry a resolvable citation. Computed
// from the answer and the persisted citations with the same pure function the
// tests exercise, so "grounded" means one thing in the UI and in a test.
const coverage = message.content ? classifyClaimSupport(message.content, citations) : null

// A chip is disabled only once the library has been read and the document is
// provably absent. Before that (or after a failed load) the list is "unknown",
// not "deleted", so the chip stays clickable and the reader falls back.
Expand Down Expand Up @@ -271,6 +278,9 @@ export default function MessageItem({ message }: MessageItemProps): ReactElement
}
/>
)}
{/* Citation coverage (#156). Stated only when something is not backed by a
source; a normal answer does not need an "all good" line. */}
{coverage && !isLive && <AnswerCoverage coverage={coverage} />}
{/* Why the answer stops where it does, when the model did not decide to stop
there itself. Beside the answer, never instead of it: the part that
arrived is what the reader was left with. */}
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/components/notebook/chat/citationMarkers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export const CITATION_HREF_PREFIX = '#citation-'
/** 只认 1-3 位数字,避免把 `[2024]` 这种正文误判成引用。 */
const MARKER_PATTERN = /\[(\d{1,3})\]/g

/**
* 模型标注「这是自己的推断,不是来自来源」的标记(#156)。它不会变成链接,也不该
* 出现在正文里 —— 逐句的判定由 `classifyClaimSupport` 完成,正文只显示干净的句子。
*/
const INFERENCE_MARKER = /\[inference\]/gi

/** 从 link 的 `href` 里取回标记编号;不是 citation 链接时返回 null。 */
export function parseCitationHref(href: string | undefined): number | null {
if (!href || !href.startsWith(CITATION_HREF_PREFIX)) return null
Expand All @@ -37,6 +43,11 @@ interface MarkdownNode {

/** 文本节点按标记切开,标记本身变成 link。 */
function splitText(value: string): MarkdownNode[] {
// The inference marker is a machine annotation, not prose: drop it before the
// answer is rendered. The sentence's status still reaches the reader through the
// coverage notice.
value = value.replace(INFERENCE_MARKER, '')

const parts: MarkdownNode[] = []
let lastIndex = 0
MARKER_PATTERN.lastIndex = 0
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/locales/en-US/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"citationUnavailable": "Source no longer available",
"answerNotGrounded": "No passages from your sources were used in this answer.",
"answerRetrievalFailed": "Searching your sources failed, so this answer is not based on them.",
"answerCoverageUncited": "{{count}} statement(s) here are not backed by a source.",
"answerCoverageInvalid": "{{count}} citation(s) point to a source that does not exist.",
"answerCoverageInference": "{{count}} statement(s) are the model's own inference.",
"answerTruncated": "The answer hit the model's output length limit, so it is cut off here.",
"answerBlocked": "The provider refused this answer on content policy grounds.",
"answerStopped": "The answer was stopped; this is the part that had been written.",
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/locales/zh-CN/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"citationUnavailable": "来源已删除",
"answerNotGrounded": "这条回答没有使用你来源中的任何段落。",
"answerRetrievalFailed": "检索来源失败,这条回答没有依据你的来源。",
"answerCoverageUncited": "有 {{count}} 句陈述没有标注来源。",
"answerCoverageInvalid": "有 {{count}} 处引用指向不存在的来源。",
"answerCoverageInference": "有 {{count}} 句是模型自己的推断。",
"answerTruncated": "回答达到模型输出长度上限,因此在这里被截断。",
"answerBlocked": "提供方以内容策略为由拒绝了这次回答。",
"answerStopped": "回答已停止,上面是已生成的部分。",
Expand Down
Loading
Loading