bmlibrarian-lite 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. bmlibrarian_lite/__init__.py +83 -0
  2. bmlibrarian_lite/__main__.py +8 -0
  3. bmlibrarian_lite/agents/__init__.py +57 -0
  4. bmlibrarian_lite/agents/base.py +183 -0
  5. bmlibrarian_lite/agents/citation_agent.py +247 -0
  6. bmlibrarian_lite/agents/interrogation_agent.py +428 -0
  7. bmlibrarian_lite/agents/reporting_agent.py +292 -0
  8. bmlibrarian_lite/agents/scoring_agent.py +223 -0
  9. bmlibrarian_lite/agents/search_agent.py +353 -0
  10. bmlibrarian_lite/chroma_embeddings.py +169 -0
  11. bmlibrarian_lite/chunking.py +306 -0
  12. bmlibrarian_lite/cli.py +346 -0
  13. bmlibrarian_lite/config.py +754 -0
  14. bmlibrarian_lite/constants.py +590 -0
  15. bmlibrarian_lite/conversation_export.py +200 -0
  16. bmlibrarian_lite/data_models.py +353 -0
  17. bmlibrarian_lite/embeddings.py +231 -0
  18. bmlibrarian_lite/europepmc.py +545 -0
  19. bmlibrarian_lite/exceptions.py +208 -0
  20. bmlibrarian_lite/fulltext_discovery.py +412 -0
  21. bmlibrarian_lite/gui/__init__.py +68 -0
  22. bmlibrarian_lite/gui/app.py +391 -0
  23. bmlibrarian_lite/gui/audit_citations_tab.py +340 -0
  24. bmlibrarian_lite/gui/audit_literature_tab.py +340 -0
  25. bmlibrarian_lite/gui/audit_queries_tab.py +333 -0
  26. bmlibrarian_lite/gui/audit_trail_tab.py +322 -0
  27. bmlibrarian_lite/gui/card_utils.py +283 -0
  28. bmlibrarian_lite/gui/chat_widgets.py +299 -0
  29. bmlibrarian_lite/gui/citation_loader.py +123 -0
  30. bmlibrarian_lite/gui/dialogs.py +492 -0
  31. bmlibrarian_lite/gui/document_card.py +592 -0
  32. bmlibrarian_lite/gui/document_interrogation_tab.py +963 -0
  33. bmlibrarian_lite/gui/document_viewer.py +395 -0
  34. bmlibrarian_lite/gui/quality_badge.py +260 -0
  35. bmlibrarian_lite/gui/quality_filter_panel.py +525 -0
  36. bmlibrarian_lite/gui/quality_summary.py +278 -0
  37. bmlibrarian_lite/gui/report_tab.py +690 -0
  38. bmlibrarian_lite/gui/settings_dialog.py +967 -0
  39. bmlibrarian_lite/gui/systematic_review_tab.py +611 -0
  40. bmlibrarian_lite/gui/text_highlighting.py +255 -0
  41. bmlibrarian_lite/gui/workers.py +452 -0
  42. bmlibrarian_lite/llm/__init__.py +31 -0
  43. bmlibrarian_lite/llm/client.py +321 -0
  44. bmlibrarian_lite/llm/data_types.py +130 -0
  45. bmlibrarian_lite/llm/providers/__init__.py +159 -0
  46. bmlibrarian_lite/llm/providers/anthropic.py +352 -0
  47. bmlibrarian_lite/llm/providers/base.py +284 -0
  48. bmlibrarian_lite/llm/providers/ollama.py +359 -0
  49. bmlibrarian_lite/llm/token_tracker.py +200 -0
  50. bmlibrarian_lite/pdf_discovery.py +1052 -0
  51. bmlibrarian_lite/pdf_utils.py +532 -0
  52. bmlibrarian_lite/pubmed/__init__.py +57 -0
  53. bmlibrarian_lite/pubmed/constants.py +100 -0
  54. bmlibrarian_lite/pubmed/data_types.py +450 -0
  55. bmlibrarian_lite/pubmed/search_client.py +767 -0
  56. bmlibrarian_lite/quality/__init__.py +105 -0
  57. bmlibrarian_lite/quality/data_models.py +593 -0
  58. bmlibrarian_lite/quality/evidence_summary.py +323 -0
  59. bmlibrarian_lite/quality/metadata_filter.py +310 -0
  60. bmlibrarian_lite/quality/quality_agent.py +322 -0
  61. bmlibrarian_lite/quality/quality_manager.py +281 -0
  62. bmlibrarian_lite/quality/report_formatter.py +272 -0
  63. bmlibrarian_lite/quality/study_classifier.py +703 -0
  64. bmlibrarian_lite/query_converter.py +276 -0
  65. bmlibrarian_lite/resources/__init__.py +5 -0
  66. bmlibrarian_lite/resources/styles/__init__.py +85 -0
  67. bmlibrarian_lite/resources/styles/dpi_scale.py +238 -0
  68. bmlibrarian_lite/resources/styles/stylesheet_generator.py +515 -0
  69. bmlibrarian_lite/resources/styles/theme_colors.py +219 -0
  70. bmlibrarian_lite/resources/styles/theme_generator.py +598 -0
  71. bmlibrarian_lite/storage.py +1085 -0
  72. bmlibrarian_lite/utils.py +687 -0
  73. bmlibrarian_lite-0.1.0.dist-info/METADATA +288 -0
  74. bmlibrarian_lite-0.1.0.dist-info/RECORD +77 -0
  75. bmlibrarian_lite-0.1.0.dist-info/WHEEL +4 -0
  76. bmlibrarian_lite-0.1.0.dist-info/entry_points.txt +6 -0
  77. bmlibrarian_lite-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,83 @@
1
+ """
2
+ BMLibrarian Lite - Lightweight biomedical literature research tool.
3
+
4
+ A simplified interface for:
5
+ - Systematic literature review (search, score, extract, report)
6
+ - Document interrogation (Q&A with loaded documents)
7
+
8
+ Features:
9
+ - ChromaDB for vector storage (embedded, no PostgreSQL)
10
+ - SQLite for metadata (embedded)
11
+ - FastEmbed for local embeddings (CPU-optimized, no PyTorch)
12
+ - Anthropic Claude or Ollama for LLM inference
13
+ - NCBI E-utilities for PubMed search (online)
14
+
15
+ No PostgreSQL required.
16
+ """
17
+
18
+ from .config import LiteConfig
19
+ from .storage import LiteStorage
20
+ from .embeddings import LiteEmbedder
21
+ from .data_models import (
22
+ LiteDocument,
23
+ LiteChunk,
24
+ SearchSession,
25
+ ReviewCheckpoint,
26
+ ScoredDocument,
27
+ Citation,
28
+ InterrogationSession,
29
+ )
30
+ from .exceptions import (
31
+ LiteError,
32
+ ConfigurationError,
33
+ LiteStorageError,
34
+ EmbeddingError,
35
+ LLMError,
36
+ )
37
+ from .pdf_discovery import PDFDiscoverer, PDFSource, DiscoveryResult, close_browser_session
38
+
39
+ __version__ = "0.1.0"
40
+
41
+
42
+ def main() -> int:
43
+ """
44
+ Main entry point for BMLibrarian Lite.
45
+
46
+ This is a convenience wrapper that imports and calls the main function
47
+ from the CLI module.
48
+
49
+ Returns:
50
+ Application exit code
51
+ """
52
+ from .cli import main as cli_main
53
+ return cli_main()
54
+
55
+
56
+ __all__ = [
57
+ # Configuration
58
+ "LiteConfig",
59
+ # Storage
60
+ "LiteStorage",
61
+ "LiteEmbedder",
62
+ # Data models
63
+ "LiteDocument",
64
+ "LiteChunk",
65
+ "SearchSession",
66
+ "ReviewCheckpoint",
67
+ "ScoredDocument",
68
+ "Citation",
69
+ "InterrogationSession",
70
+ # PDF Discovery
71
+ "PDFDiscoverer",
72
+ "PDFSource",
73
+ "DiscoveryResult",
74
+ "close_browser_session",
75
+ # Exceptions
76
+ "LiteError",
77
+ "ConfigurationError",
78
+ "LiteStorageError",
79
+ "EmbeddingError",
80
+ "LLMError",
81
+ # Version
82
+ "__version__",
83
+ ]
@@ -0,0 +1,8 @@
1
+ """Allow running as python -m bmlibrarian_lite."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,57 @@
1
+ """
2
+ Lite agents for BMLibrarian Lite.
3
+
4
+ This module provides simplified, stateless agents that work without PostgreSQL,
5
+ using ChromaDB and SQLite for persistence and online LLM providers for inference.
6
+
7
+ Agents:
8
+ LiteBaseAgent: Base class with LLM communication
9
+ LiteSearchAgent: PubMed search and caching
10
+ LiteScoringAgent: Document relevance scoring
11
+ LiteCitationAgent: Citation extraction
12
+ LiteReportingAgent: Report generation
13
+ LiteInterrogationAgent: Document Q&A
14
+
15
+ Usage:
16
+ from bmlibrarian_lite.agents import (
17
+ LiteSearchAgent,
18
+ LiteScoringAgent,
19
+ LiteCitationAgent,
20
+ LiteReportingAgent,
21
+ LiteInterrogationAgent,
22
+ )
23
+
24
+ # Create agents with shared config
25
+ from bmlibrarian_lite import LiteConfig, LiteStorage
26
+
27
+ config = LiteConfig.load()
28
+ storage = LiteStorage(config)
29
+
30
+ search_agent = LiteSearchAgent(storage=storage, config=config)
31
+ scoring_agent = LiteScoringAgent(config=config)
32
+ citation_agent = LiteCitationAgent(config=config)
33
+ reporting_agent = LiteReportingAgent(config=config)
34
+ interrogation_agent = LiteInterrogationAgent(storage=storage, config=config)
35
+
36
+ # Execute a research workflow
37
+ session, documents = search_agent.search("cardiovascular effects of exercise")
38
+ scored = scoring_agent.score_documents("cardiovascular effects", documents)
39
+ citations = citation_agent.extract_all_citations("cardiovascular effects", scored)
40
+ report = reporting_agent.generate_report("cardiovascular effects", citations)
41
+ """
42
+
43
+ from .base import LiteBaseAgent
44
+ from .search_agent import LiteSearchAgent
45
+ from .scoring_agent import LiteScoringAgent
46
+ from .citation_agent import LiteCitationAgent
47
+ from .reporting_agent import LiteReportingAgent
48
+ from .interrogation_agent import LiteInterrogationAgent
49
+
50
+ __all__ = [
51
+ "LiteBaseAgent",
52
+ "LiteSearchAgent",
53
+ "LiteScoringAgent",
54
+ "LiteCitationAgent",
55
+ "LiteReportingAgent",
56
+ "LiteInterrogationAgent",
57
+ ]
@@ -0,0 +1,183 @@
1
+ """
2
+ Base class for BMLibrarian Lite agents.
3
+
4
+ Provides common functionality for LLM communication and configuration.
5
+ All Lite agents inherit from this base class to share the unified LLM client
6
+ interface and configuration management.
7
+ """
8
+
9
+ import logging
10
+ from typing import Optional
11
+
12
+ from bmlibrarian_lite.llm import LLMClient, LLMMessage
13
+ from ..config import LiteConfig, TaskModelConfig
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class LiteBaseAgent:
19
+ """
20
+ Base class for all Lite agents.
21
+
22
+ Provides common functionality for LLM communication and configuration.
23
+ Agents inheriting from this class gain access to:
24
+ - Configured LLM client with provider selection
25
+ - Task-based model configuration
26
+ - Helper methods for creating chat messages
27
+ - Configuration management
28
+
29
+ Subclasses should set TASK_ID to their task identifier for automatic
30
+ model configuration lookup.
31
+
32
+ Attributes:
33
+ config: Lite configuration instance
34
+ TASK_ID: Task identifier for model configuration (override in subclass)
35
+ """
36
+
37
+ # Override in subclasses to set the task type for model configuration
38
+ TASK_ID: str = "default"
39
+
40
+ def __init__(
41
+ self,
42
+ config: Optional[LiteConfig] = None,
43
+ llm_client: Optional[LLMClient] = None,
44
+ ) -> None:
45
+ """
46
+ Initialize the base agent.
47
+
48
+ Args:
49
+ config: Lite configuration (uses defaults if not provided)
50
+ llm_client: Optional pre-configured LLM client
51
+ """
52
+ self.config = config or LiteConfig()
53
+ self._llm_client = llm_client
54
+
55
+ @property
56
+ def llm_client(self) -> LLMClient:
57
+ """
58
+ Get or create LLM client.
59
+
60
+ The client is lazily created on first access.
61
+
62
+ Returns:
63
+ Configured LLMClient instance
64
+ """
65
+ if self._llm_client is None:
66
+ self._llm_client = LLMClient()
67
+ return self._llm_client
68
+
69
+ def _get_task_config(self, task_id: Optional[str] = None) -> TaskModelConfig:
70
+ """
71
+ Get model configuration for a task.
72
+
73
+ Args:
74
+ task_id: Task identifier (uses class TASK_ID if not specified)
75
+
76
+ Returns:
77
+ TaskModelConfig with effective settings for the task
78
+ """
79
+ task_id = task_id or self.TASK_ID
80
+ return self.config.models.get_task_config(task_id)
81
+
82
+ def _get_model(self, task_id: Optional[str] = None) -> str:
83
+ """
84
+ Get the configured model string for a task.
85
+
86
+ Combines provider and model name into the format expected
87
+ by LLMClient (e.g., "anthropic:claude-3-haiku-20240307").
88
+
89
+ Args:
90
+ task_id: Task identifier (uses class TASK_ID if not specified)
91
+
92
+ Returns:
93
+ Model string with provider prefix
94
+ """
95
+ task_id = task_id or self.TASK_ID
96
+ return self.config.models.get_model_string(task_id)
97
+
98
+ def _chat(
99
+ self,
100
+ messages: list[LLMMessage],
101
+ task_id: Optional[str] = None,
102
+ temperature: Optional[float] = None,
103
+ max_tokens: Optional[int] = None,
104
+ json_mode: bool = False,
105
+ ) -> str:
106
+ """
107
+ Send a chat request to the LLM.
108
+
109
+ Uses task-specific configuration if available, otherwise falls back
110
+ to defaults. Temperature and max_tokens can be overridden per-call.
111
+
112
+ Args:
113
+ messages: List of conversation messages
114
+ task_id: Task identifier for config lookup (uses class TASK_ID if None)
115
+ temperature: Optional temperature override
116
+ max_tokens: Optional max tokens override
117
+ json_mode: Request JSON-formatted output
118
+
119
+ Returns:
120
+ Response text from the LLM
121
+ """
122
+ task_config = self._get_task_config(task_id)
123
+ response = self.llm_client.chat(
124
+ messages=messages,
125
+ model=f"{task_config.provider}:{task_config.model}",
126
+ temperature=temperature if temperature is not None else task_config.temperature,
127
+ max_tokens=max_tokens if max_tokens is not None else task_config.max_tokens,
128
+ top_p=task_config.top_p,
129
+ json_mode=json_mode,
130
+ )
131
+ return response.content
132
+
133
+ def _create_system_message(self, content: str) -> LLMMessage:
134
+ """
135
+ Create a system message for the LLM.
136
+
137
+ Args:
138
+ content: System prompt content
139
+
140
+ Returns:
141
+ LLMMessage with role="system"
142
+ """
143
+ return LLMMessage(role="system", content=content)
144
+
145
+ def _create_user_message(self, content: str) -> LLMMessage:
146
+ """
147
+ Create a user message for the LLM.
148
+
149
+ Args:
150
+ content: User message content
151
+
152
+ Returns:
153
+ LLMMessage with role="user"
154
+ """
155
+ return LLMMessage(role="user", content=content)
156
+
157
+ def _create_assistant_message(self, content: str) -> LLMMessage:
158
+ """
159
+ Create an assistant message for conversation history.
160
+
161
+ Args:
162
+ content: Assistant message content
163
+
164
+ Returns:
165
+ LLMMessage with role="assistant"
166
+ """
167
+ return LLMMessage(role="assistant", content=content)
168
+
169
+ def test_connection(self) -> bool:
170
+ """
171
+ Test if the LLM provider is available.
172
+
173
+ Returns:
174
+ True if connection is successful
175
+ """
176
+ try:
177
+ # Simple test message
178
+ messages = [self._create_user_message("Hello")]
179
+ self._chat(messages, max_tokens=10)
180
+ return True
181
+ except Exception as e:
182
+ logger.warning(f"LLM connection test failed: {e}")
183
+ return False
@@ -0,0 +1,247 @@
1
+ """
2
+ Lite citation extraction agent.
3
+
4
+ This agent extracts relevant passages from documents that help answer
5
+ a research question. It identifies specific quotes and findings that
6
+ can be used as citations in a research report.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import re
12
+ from typing import Optional, Callable
13
+
14
+ from ..data_models import Citation, ScoredDocument
15
+ from .base import LiteBaseAgent
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # System prompt for citation extraction
20
+ CITATION_SYSTEM_PROMPT = """You are a medical research citation extractor. Your task is to identify the most relevant passages from a document that help answer a research question.
21
+
22
+ Extract 1-3 key passages that:
23
+ 1. Directly address the research question
24
+ 2. Contain specific findings, data, or conclusions
25
+ 3. Are self-contained and understandable out of context
26
+ 4. Could be quoted in a research summary
27
+
28
+ Guidelines:
29
+ - Extract exact quotes from the abstract when possible
30
+ - Focus on passages with specific numbers, percentages, or outcomes
31
+ - Include key conclusions or recommendations
32
+ - Each passage should add unique value
33
+
34
+ Respond in JSON format:
35
+ {
36
+ "passages": [
37
+ {
38
+ "text": "<exact or close quote from the abstract>",
39
+ "relevance": "<brief explanation of why this passage is relevant>"
40
+ }
41
+ ]
42
+ }"""
43
+
44
+
45
+ class LiteCitationAgent(LiteBaseAgent):
46
+ """
47
+ Stateless citation extraction agent.
48
+
49
+ Extracts relevant passages from documents that answer a research question.
50
+ These passages can be used as citations in a research report.
51
+
52
+ This agent:
53
+ 1. Takes a research question and scored document
54
+ 2. Uses LLM to identify relevant passages
55
+ 3. Returns citations with source attribution
56
+ """
57
+
58
+ TASK_ID = "citation_extraction"
59
+
60
+ def extract_citations(
61
+ self,
62
+ question: str,
63
+ scored_doc: ScoredDocument,
64
+ ) -> list[Citation]:
65
+ """
66
+ Extract citations from a scored document.
67
+
68
+ Args:
69
+ question: Research question
70
+ scored_doc: Document with relevance score
71
+
72
+ Returns:
73
+ List of extracted citations
74
+ """
75
+ doc = scored_doc.document
76
+
77
+ user_prompt = f"""Research Question: {question}
78
+
79
+ Document Title: {doc.title}
80
+ Authors: {doc.formatted_authors}
81
+ Year: {doc.year or 'Unknown'}
82
+ Journal: {doc.journal or 'Unknown'}
83
+ Relevance Score: {scored_doc.score}/5
84
+
85
+ Abstract:
86
+ {doc.abstract}
87
+
88
+ Extract the most relevant passages that help answer the research question."""
89
+
90
+ messages = [
91
+ self._create_system_message(CITATION_SYSTEM_PROMPT),
92
+ self._create_user_message(user_prompt),
93
+ ]
94
+
95
+ try:
96
+ response = self._chat(messages, temperature=0.1, json_mode=True)
97
+ passages = self._parse_citation_response(response)
98
+
99
+ citations = []
100
+ for passage in passages:
101
+ citation = Citation(
102
+ document=doc,
103
+ passage=passage["text"],
104
+ relevance_score=scored_doc.score,
105
+ context=passage.get("relevance", ""),
106
+ )
107
+ citations.append(citation)
108
+
109
+ return citations
110
+
111
+ except Exception as e:
112
+ logger.error(f"Failed to extract citations from {doc.id}: {e}")
113
+ # Return a basic citation using the abstract
114
+ truncated = doc.abstract[:500] if len(doc.abstract) > 500 else doc.abstract
115
+ return [Citation(
116
+ document=doc,
117
+ passage=truncated,
118
+ relevance_score=scored_doc.score,
119
+ context="Full abstract (extraction failed)",
120
+ )]
121
+
122
+ def extract_all_citations(
123
+ self,
124
+ question: str,
125
+ scored_documents: list[ScoredDocument],
126
+ min_score: int = 3,
127
+ progress_callback: Optional[Callable[[int, int], None]] = None,
128
+ ) -> list[Citation]:
129
+ """
130
+ Extract citations from all scored documents.
131
+
132
+ Args:
133
+ question: Research question
134
+ scored_documents: Documents to extract from
135
+ min_score: Minimum score to process
136
+ progress_callback: Optional callback(current, total)
137
+
138
+ Returns:
139
+ List of all extracted citations
140
+ """
141
+ all_citations = []
142
+ eligible = [d for d in scored_documents if d.score >= min_score]
143
+ total = len(eligible)
144
+
145
+ logger.info(f"Extracting citations from {total} documents")
146
+
147
+ for i, scored_doc in enumerate(eligible):
148
+ if progress_callback:
149
+ progress_callback(i + 1, total)
150
+
151
+ citations = self.extract_citations(question, scored_doc)
152
+ all_citations.extend(citations)
153
+
154
+ logger.debug(
155
+ f"Extracted {len(citations)} citations from {scored_doc.document.id}"
156
+ )
157
+
158
+ logger.info(
159
+ f"Extracted {len(all_citations)} total citations from {total} documents"
160
+ )
161
+ return all_citations
162
+
163
+ def _parse_citation_response(self, response: str) -> list[dict]:
164
+ """
165
+ Parse LLM response to extract passages.
166
+
167
+ Args:
168
+ response: LLM response text
169
+
170
+ Returns:
171
+ List of passage dictionaries
172
+ """
173
+ try:
174
+ # Try to find JSON with passages array
175
+ # Handle various JSON formats
176
+ json_match = re.search(
177
+ r'\{\s*"passages"\s*:\s*\[.*?\]\s*\}',
178
+ response,
179
+ re.DOTALL
180
+ )
181
+ if json_match:
182
+ data = json.loads(json_match.group())
183
+ passages = data.get("passages", [])
184
+ # Validate passages
185
+ valid_passages = []
186
+ for p in passages:
187
+ if isinstance(p, dict) and "text" in p:
188
+ valid_passages.append(p)
189
+ return valid_passages
190
+
191
+ # Try parsing the entire response as JSON
192
+ data = json.loads(response)
193
+ if "passages" in data:
194
+ return data["passages"]
195
+
196
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
197
+ logger.debug(f"JSON parsing failed: {e}")
198
+
199
+ # Fallback: return empty list
200
+ logger.warning(f"Could not parse citations from: {response[:100]}...")
201
+ return []
202
+
203
+ def group_citations_by_document(
204
+ self,
205
+ citations: list[Citation],
206
+ ) -> dict[str, list[Citation]]:
207
+ """
208
+ Group citations by their source document.
209
+
210
+ Args:
211
+ citations: List of citations
212
+
213
+ Returns:
214
+ Dictionary mapping document IDs to their citations
215
+ """
216
+ grouped: dict[str, list[Citation]] = {}
217
+ for citation in citations:
218
+ doc_id = citation.document.id
219
+ if doc_id not in grouped:
220
+ grouped[doc_id] = []
221
+ grouped[doc_id].append(citation)
222
+ return grouped
223
+
224
+ def deduplicate_citations(
225
+ self,
226
+ citations: list[Citation],
227
+ ) -> list[Citation]:
228
+ """
229
+ Remove duplicate citations based on passage text.
230
+
231
+ Args:
232
+ citations: List of citations
233
+
234
+ Returns:
235
+ Deduplicated list of citations
236
+ """
237
+ seen_passages: set[str] = set()
238
+ unique_citations = []
239
+
240
+ for citation in citations:
241
+ # Normalize passage for comparison
242
+ normalized = citation.passage.strip().lower()
243
+ if normalized not in seen_passages:
244
+ seen_passages.add(normalized)
245
+ unique_citations.append(citation)
246
+
247
+ return unique_citations