Coverage for src/local_deep_research/citation_handlers/standard_citation_handler.py: 100%

49 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Standard citation handler - the original implementation. 

3""" 

4 

5from datetime import datetime, timezone 

6from typing import Any, Dict, List, Union 

7 

8from langchain_core.documents import Document 

9 

10from ..security.log_sanitizer import sanitize_error_message, strip_control_chars 

11from .base_citation_handler import BaseCitationHandler 

12 

13 

14def _identity_text(value: Any) -> str: 

15 """Render scalar metadata; containers could hide secrets from the scrubber.""" 

16 if value is None: 

17 return "" 

18 if isinstance(value, (str, int, float)): 

19 return str(value) 

20 return "[non-text value omitted]" 

21 

22 

23class StandardCitationHandler(BaseCitationHandler): 

24 """Standard citation handler with detailed analysis.""" 

25 

26 def _format_sources(self, documents: List[Document]) -> str: 

27 """Keep source identities alongside the text used for synthesis.""" 

28 sources = [] 

29 for doc in documents: 

30 # Bound the title and URL and keep each on one line so they cannot 

31 # add identity lines; page_content passes through unchanged, as in 

32 # the base formatter. Preserve the original metadata. 

33 title = strip_control_chars( 

34 _identity_text(doc.metadata.get("title")) 

35 ) 

36 title = sanitize_error_message(" ".join(title.split()))[:200] 

37 # Unlike the log-sink scrubber, this preserves query parameters 

38 # that identify sources (e.g. a video's ?v=), redacting credentials. 

39 url = strip_control_chars( 

40 _identity_text(doc.metadata.get("source")) 

41 ) 

42 url = sanitize_error_message(" ".join(url.split())) 

43 # Scrub the whole URL first so userinfo containing "#" is caught, 

44 # then apply the query-parameter scrubber to fragment parameters. 

45 url, separator, fragment = url.partition("#") 

46 if separator: 

47 url += "#" + sanitize_error_message("?" + fragment)[1:] 

48 # Sanitize before truncating so a cut cannot hide a credential's 

49 # identifying suffix (e.g. the @ in URL userinfo). 

50 url = url[:2000] 

51 sources.append( 

52 f"[{doc.metadata['index']}] Title: {title}\n" 

53 f"URL: {url}\nContent: {doc.page_content}" 

54 ) 

55 return "\n\n".join(sources) 

56 

57 def analyze_initial( 

58 self, query: str, search_results: Union[str, List[Dict]] 

59 ) -> Dict[str, Any]: 

60 documents = self._create_documents(search_results) 

61 if not documents: 

62 return self._no_sources_response(query) 

63 formatted_sources = self._format_sources(documents) 

64 current_timestamp = datetime.now(timezone.utc).strftime( 

65 "%Y-%m-%d %H:%M" 

66 ) 

67 

68 output_prefix = self._get_output_instruction_prefix() 

69 

70 prompt = f"""{output_prefix}Analyze the following information concerning the question and include citations using numbers in square brackets [1], [2], etc. When citing, use the source number provided at the start of each source. 

71The Title and URL lines identify each source for your reasoning; cite by number only — links are added automatically. 

72 

73Question: {query} 

74 

75Sources: 

76{formatted_sources} 

77 

78Current time is {current_timestamp} UTC for verifying temporal references in sources. 

79 

80Provide a detailed analysis with inline citations. Citations must only appear inline within your sentences (e.g. "According to the research [1], ..."). Do NOT create a bibliography, sources list, or list of citations at the end, as sources will be provided automatically. Never make up sources. Only write text relevant to the question. 

81""" 

82 

83 response = self._invoke_with_streaming(prompt) 

84 return {"content": response, "documents": documents} 

85 

86 def analyze_followup( 

87 self, 

88 question: str, 

89 search_results: Union[str, List[Dict]], 

90 previous_knowledge: str, 

91 nr_of_links: int, 

92 ) -> Dict[str, Any]: 

93 """Process follow-up analysis with citations.""" 

94 documents = self._create_documents( 

95 search_results, nr_of_links=nr_of_links 

96 ) 

97 # With previous knowledge present the LLM can still cite prior 

98 # sources legitimately; with neither, refuse to synthesize. 

99 if not documents and not (previous_knowledge or "").strip(): 

100 return self._no_sources_response(question) 

101 formatted_sources = self._format_sources(documents) 

102 # Add fact-checking step 

103 fact_check_prompt = f"""Analyze these sources for factual consistency: 

104The Title and URL lines identify each source for your reasoning; cite by number only — links are added automatically. 

105 

1061. Cross-reference major claims between sources 

1072. Identify and flag any contradictions 

1083. Verify basic facts (dates, company names, ownership) 

1094. Note when sources disagree 

110 

111Previous Knowledge: 

112{previous_knowledge} 

113 

114New Sources: 

115{formatted_sources} 

116 

117 Return any inconsistencies or conflicts found.""" 

118 if self.is_fact_checking_enabled(): 

119 fact_check_response = self._invoke_text(fact_check_prompt) 

120 else: 

121 fact_check_response = "" 

122 

123 current_timestamp = datetime.now(timezone.utc).strftime( 

124 "%Y-%m-%d %H:%M" 

125 ) 

126 

127 output_prefix = self._get_output_instruction_prefix() 

128 

129 prompt = f"""{output_prefix}Using the previous knowledge and new sources, answer the question. Include citations using numbers in square brackets [1], [2], etc. When citing, use the source number provided at the start of each source. Reflect information from sources critically. 

130The Title and URL lines identify each source for your reasoning; cite by number only — links are added automatically. 

131 

132Previous Knowledge: 

133{previous_knowledge} 

134 

135Question: {question} 

136 

137New Sources: 

138{formatted_sources} 

139 

140Current time is {current_timestamp} UTC for verifying temporal references in sources. 

141 

142Reflect information from sources critically based on: {fact_check_response}. Never invent sources. 

143Provide a detailed answer with inline citations. Citations must only appear inline within your sentences (e.g. "According to [1], ..."). Do NOT create a bibliography, sources list, or list of citations at the end, as sources will be provided automatically.""" 

144 

145 response = self._invoke_with_streaming(prompt) 

146 

147 return {"content": response, "documents": documents}