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.
- bmlibrarian_lite/__init__.py +83 -0
- bmlibrarian_lite/__main__.py +8 -0
- bmlibrarian_lite/agents/__init__.py +57 -0
- bmlibrarian_lite/agents/base.py +183 -0
- bmlibrarian_lite/agents/citation_agent.py +247 -0
- bmlibrarian_lite/agents/interrogation_agent.py +428 -0
- bmlibrarian_lite/agents/reporting_agent.py +292 -0
- bmlibrarian_lite/agents/scoring_agent.py +223 -0
- bmlibrarian_lite/agents/search_agent.py +353 -0
- bmlibrarian_lite/chroma_embeddings.py +169 -0
- bmlibrarian_lite/chunking.py +306 -0
- bmlibrarian_lite/cli.py +346 -0
- bmlibrarian_lite/config.py +754 -0
- bmlibrarian_lite/constants.py +590 -0
- bmlibrarian_lite/conversation_export.py +200 -0
- bmlibrarian_lite/data_models.py +353 -0
- bmlibrarian_lite/embeddings.py +231 -0
- bmlibrarian_lite/europepmc.py +545 -0
- bmlibrarian_lite/exceptions.py +208 -0
- bmlibrarian_lite/fulltext_discovery.py +412 -0
- bmlibrarian_lite/gui/__init__.py +68 -0
- bmlibrarian_lite/gui/app.py +391 -0
- bmlibrarian_lite/gui/audit_citations_tab.py +340 -0
- bmlibrarian_lite/gui/audit_literature_tab.py +340 -0
- bmlibrarian_lite/gui/audit_queries_tab.py +333 -0
- bmlibrarian_lite/gui/audit_trail_tab.py +322 -0
- bmlibrarian_lite/gui/card_utils.py +283 -0
- bmlibrarian_lite/gui/chat_widgets.py +299 -0
- bmlibrarian_lite/gui/citation_loader.py +123 -0
- bmlibrarian_lite/gui/dialogs.py +492 -0
- bmlibrarian_lite/gui/document_card.py +592 -0
- bmlibrarian_lite/gui/document_interrogation_tab.py +963 -0
- bmlibrarian_lite/gui/document_viewer.py +395 -0
- bmlibrarian_lite/gui/quality_badge.py +260 -0
- bmlibrarian_lite/gui/quality_filter_panel.py +525 -0
- bmlibrarian_lite/gui/quality_summary.py +278 -0
- bmlibrarian_lite/gui/report_tab.py +690 -0
- bmlibrarian_lite/gui/settings_dialog.py +967 -0
- bmlibrarian_lite/gui/systematic_review_tab.py +611 -0
- bmlibrarian_lite/gui/text_highlighting.py +255 -0
- bmlibrarian_lite/gui/workers.py +452 -0
- bmlibrarian_lite/llm/__init__.py +31 -0
- bmlibrarian_lite/llm/client.py +321 -0
- bmlibrarian_lite/llm/data_types.py +130 -0
- bmlibrarian_lite/llm/providers/__init__.py +159 -0
- bmlibrarian_lite/llm/providers/anthropic.py +352 -0
- bmlibrarian_lite/llm/providers/base.py +284 -0
- bmlibrarian_lite/llm/providers/ollama.py +359 -0
- bmlibrarian_lite/llm/token_tracker.py +200 -0
- bmlibrarian_lite/pdf_discovery.py +1052 -0
- bmlibrarian_lite/pdf_utils.py +532 -0
- bmlibrarian_lite/pubmed/__init__.py +57 -0
- bmlibrarian_lite/pubmed/constants.py +100 -0
- bmlibrarian_lite/pubmed/data_types.py +450 -0
- bmlibrarian_lite/pubmed/search_client.py +767 -0
- bmlibrarian_lite/quality/__init__.py +105 -0
- bmlibrarian_lite/quality/data_models.py +593 -0
- bmlibrarian_lite/quality/evidence_summary.py +323 -0
- bmlibrarian_lite/quality/metadata_filter.py +310 -0
- bmlibrarian_lite/quality/quality_agent.py +322 -0
- bmlibrarian_lite/quality/quality_manager.py +281 -0
- bmlibrarian_lite/quality/report_formatter.py +272 -0
- bmlibrarian_lite/quality/study_classifier.py +703 -0
- bmlibrarian_lite/query_converter.py +276 -0
- bmlibrarian_lite/resources/__init__.py +5 -0
- bmlibrarian_lite/resources/styles/__init__.py +85 -0
- bmlibrarian_lite/resources/styles/dpi_scale.py +238 -0
- bmlibrarian_lite/resources/styles/stylesheet_generator.py +515 -0
- bmlibrarian_lite/resources/styles/theme_colors.py +219 -0
- bmlibrarian_lite/resources/styles/theme_generator.py +598 -0
- bmlibrarian_lite/storage.py +1085 -0
- bmlibrarian_lite/utils.py +687 -0
- bmlibrarian_lite-0.1.0.dist-info/METADATA +288 -0
- bmlibrarian_lite-0.1.0.dist-info/RECORD +77 -0
- bmlibrarian_lite-0.1.0.dist-info/WHEEL +4 -0
- bmlibrarian_lite-0.1.0.dist-info/entry_points.txt +6 -0
- 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,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
|