Coverage for agentos/rag/citation.py: 0%

118 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 19:15 +0800

1"""Citation tracing for RAG pipeline. 

2 

3Tracks which source documents contributed to generated text, 

4enabling answer provenance and fact-checking. 

5""" 

6 

7from __future__ import annotations 

8 

9import hashlib 

10import re 

11from dataclasses import dataclass, field 

12from typing import Any 

13 

14 

15@dataclass 

16class Citation: 

17 """A single citation linking generated text to a source chunk.""" 

18 

19 chunk_id: str 

20 chunk_text: str 

21 source_doc: str = "" # source document identifier 

22 relevance_score: float = 0.0 

23 context: str = "" # surrounding context window 

24 start_char: int = 0 # position in generated answer 

25 end_char: int = 0 

26 

27 

28@dataclass 

29class CitationReport: 

30 """Complete citation analysis for a generated response.""" 

31 

32 answer: str = "" 

33 citations: list[Citation] = field(default_factory=list) 

34 source_count: int = 0 

35 coverage: float = 0.0 # fraction of answer covered by citations 

36 unused_sources: list[str] = field(default_factory=list) 

37 

38 def to_dict(self) -> dict[str, Any]: 

39 return { 

40 "answer": self.answer, 

41 "num_citations": len(self.citations), 

42 "source_count": self.source_count, 

43 "coverage": self.coverage, 

44 "citations": [ 

45 { 

46 "chunk_id": c.chunk_id, 

47 "source_doc": c.source_doc, 

48 "relevance": c.relevance_score, 

49 "span": f"{c.start_char}-{c.end_char}", 

50 "text_preview": c.chunk_text[:200], 

51 } 

52 for c in self.citations 

53 ], 

54 } 

55 

56 

57class CitationTracer: 

58 """Track which retrieved chunks contributed to an answer. 

59 

60 Two modes: 

61 - token_overlap: Match answer spans to chunk texts by token overlap. 

62 - explicit: Parse answer for explicit citation markers like [1], [doc1]. 

63 """ 

64 

65 def __init__( 

66 self, 

67 mode: str = "token_overlap", 

68 min_overlap: int = 20, # minimum characters of overlap 

69 overlap_ratio: float = 0.3, # minimum overlap ratio 

70 ): 

71 self.mode = mode 

72 self.min_overlap = min_overlap 

73 self.overlap_ratio = overlap_ratio 

74 

75 def trace( 

76 self, 

77 answer: str, 

78 sources: list[dict[str, Any]], 

79 ) -> CitationReport: 

80 """Trace answer back to source chunks. 

81 

82 Args: 

83 answer: Generated text response. 

84 sources: Retrieved chunks with 'text', 'score', 'index' keys. 

85 

86 Returns: 

87 CitationReport with matched citations. 

88 """ 

89 if self.mode == "explicit": 

90 citations = self._trace_explicit(answer, sources) 

91 else: 

92 citations = self._trace_overlap(answer, sources) 

93 

94 # Compute coverage 

95 if answer and citations: 

96 covered_chars = self._compute_covered_chars(answer, citations) 

97 coverage = covered_chars / len(answer) 

98 else: 

99 coverage = 0.0 

100 

101 # Find unused sources 

102 used_ids = {c.chunk_id for c in citations} 

103 unused = [ 

104 f"chunk_{s.get('index', i)}" 

105 for i, s in enumerate(sources) 

106 if f"chunk_{s.get('index', i)}" not in used_ids 

107 ] 

108 

109 return CitationReport( 

110 answer=answer, 

111 citations=citations, 

112 source_count=len(sources), 

113 coverage=round(coverage, 3), 

114 unused_sources=unused, 

115 ) 

116 

117 def _trace_overlap( 

118 self, 

119 answer: str, 

120 sources: list[dict[str, Any]], 

121 ) -> list[Citation]: 

122 """Find answer spans that overlap with source chunks.""" 

123 citations = [] 

124 

125 for i, src in enumerate(sources): 

126 chunk_text = src.get("text", "") 

127 if not chunk_text: 

128 continue 

129 

130 chunk_id = f"chunk_{src.get('index', i)}" 

131 

132 # Find longest common substrings 

133 matches = self._find_substring_matches(answer, chunk_text) 

134 for start, end in matches: 

135 citations.append( 

136 Citation( 

137 chunk_id=chunk_id, 

138 chunk_text=chunk_text, 

139 source_doc=src.get("source", src.get("document", "")), 

140 relevance_score=src.get("score", 0.0), 

141 context=self._get_context(chunk_text, start, end), 

142 start_char=start, 

143 end_char=end, 

144 ) 

145 ) 

146 

147 return citations 

148 

149 def _trace_explicit( 

150 self, 

151 answer: str, 

152 sources: list[dict[str, Any]], 

153 ) -> list[Citation]: 

154 """Parse explicit citation markers like [1], [source1], [doc:1].""" 

155 citations = [] 

156 

157 # Match [N], [docN], [source N] 

158 pattern = r"\[(?:doc|source|ref)?\s*(\d+)\]" 

159 matches = re.finditer(pattern, answer, re.IGNORECASE) 

160 

161 for m in matches: 

162 ref_num = int(m.group(1)) 

163 if 1 <= ref_num <= len(sources): 

164 src = sources[ref_num - 1] 

165 citations.append( 

166 Citation( 

167 chunk_id=f"chunk_{src.get('index', ref_num - 1)}", 

168 chunk_text=src.get("text", ""), 

169 source_doc=src.get("source", ""), 

170 relevance_score=src.get("score", 0.0), 

171 context=src.get("text", "")[:500], 

172 start_char=m.start(), 

173 end_char=m.end(), 

174 ) 

175 ) 

176 

177 return citations 

178 

179 def _find_substring_matches( 

180 self, 

181 answer: str, 

182 chunk: str, 

183 ) -> list[tuple[int, int]]: 

184 """Find spans in answer that match substrings from chunk.""" 

185 matches = [] 

186 min_len = min(self.min_overlap, len(chunk) // 4) 

187 

188 # Use sliding window of sentences/phrases from chunk 

189 sentences = re.split(r"(?<=[.!?。!?])\s+", chunk) 

190 for sent in sentences: 

191 sent = sent.strip() 

192 if len(sent) < min_len: 

193 continue 

194 

195 pos = answer.find(sent) 

196 if pos >= 0: 

197 matches.append((pos, pos + len(sent))) 

198 else: 

199 # Try shorter windows 

200 window = max(min_len, len(sent) // 2) 

201 step = window // 2 

202 for start in range(0, len(sent) - window + 1, step): 

203 sub = sent[start : start + window] 

204 pos = answer.find(sub) 

205 if pos >= 0: 

206 matches.append((pos, pos + len(sub))) 

207 break 

208 

209 return self._merge_overlapping(matches) 

210 

211 def _merge_overlapping( 

212 self, 

213 spans: list[tuple[int, int]], 

214 ) -> list[tuple[int, int]]: 

215 """Merge overlapping citation spans.""" 

216 if not spans: 

217 return [] 

218 

219 sorted_spans = sorted(spans) 

220 merged = [sorted_spans[0]] 

221 

222 for span in sorted_spans[1:]: 

223 last = merged[-1] 

224 if span[0] <= last[1]: 

225 merged[-1] = (last[0], max(last[1], span[1])) 

226 else: 

227 merged.append(span) 

228 

229 return merged 

230 

231 def _get_context(self, chunk_text: str, start: int, end: int) -> str: 

232 """Get surrounding context around a match.""" 

233 ctx_start = max(0, start - 100) 

234 ctx_end = min(len(chunk_text), end + 100) 

235 return chunk_text[ctx_start:ctx_end] 

236 

237 def _compute_covered_chars( 

238 self, 

239 answer: str, 

240 citations: list[Citation], 

241 ) -> int: 

242 """Compute total characters covered by citations.""" 

243 if not citations: 

244 return 0 

245 

246 coverage = [False] * len(answer) 

247 for c in citations: 

248 for i in range(max(0, c.start_char), min(c.end_char, len(answer))): 

249 coverage[i] = True 

250 

251 return sum(coverage) 

252 

253 def build_attribution_map( 

254 self, 

255 answer: str, 

256 sources: list[dict[str, Any]], 

257 ) -> str: 

258 """Build HTML attribution map for the answer. 

259 

260 Wraps cited spans in <cite> tags with source references. 

261 """ 

262 citations = self._trace_overlap(answer, sources) 

263 

264 # Sort citations by start position and apply in reverse to preserve indices 

265 citations.sort(key=lambda c: c.start_char, reverse=True) 

266 

267 result = answer 

268 for c in citations: 

269 prefix = result[: c.start_char] 

270 cited = result[c.start_char : c.end_char] 

271 suffix = result[c.end_char :] 

272 

273 source_id = c.chunk_id.replace("chunk_", "") 

274 cited_wrapped = ( 

275 f'<cite data-source="{c.source_doc}" ' 

276 f'data-chunk="{source_id}" ' 

277 f'data-score="{c.relevance_score:.2f}">' 

278 f"{cited}</cite>" 

279 ) 

280 result = prefix + cited_wrapped + suffix 

281 

282 return result 

283 

284 

285def hash_chunk_id(text: str, index: int) -> str: 

286 """Generate a stable chunk ID from text content.""" 

287 digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12] 

288 return f"chunk_{index}_{digest}"