Skip to content

Commit d8f41cd

Browse files
authored
add more metadata fields and metadata search (#185)
added authors, source and research area fields in chunks metadata
1 parent 3c5eee7 commit d8f41cd

6 files changed

Lines changed: 218 additions & 28 deletions

File tree

ChemCoScientist/paper_analysis/chroma_db_operations.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import uuid
55
from pathlib import Path
6+
from typing import Literal
67

78
import chromadb
89
import numpy as np
@@ -18,6 +19,7 @@
1819
import requests
1920

2021
from ChemCoScientist.chemical_utils.openchemie_functions import extract_molecules_from_figure, extract_reactions_from_figure
22+
from ChemCoScientist.paper_analysis.constants import ResearchArea
2123
from ChemCoScientist.paper_analysis.prompts import summarisation_prompt
2224
from ChemCoScientist.paper_analysis.settings import allowed_providers
2325
from ChemCoScientist.paper_analysis.settings import settings as default_settings
@@ -53,10 +55,18 @@ class ExpandedSummary(BaseModel):
5355
description="Title of the paper. If the title is not explicitly specified, use the default value - 'NO TITLE'"
5456
)
5557
publication_year: int = Field(
56-
description=(
57-
"Year of publication of the paper. If the publication year is not explicitly specified, use the default"
58+
description="Year of publication of the paper. If the publication year is not explicitly specified, use the default"
5859
" value - 9999."
59-
)
60+
)
61+
paper_authors: str = Field(
62+
description="Authors of the paper. If the authors are not explicitly specified, use the default value - 'NO AUTHORS'"
63+
)
64+
publication_source: str = Field(
65+
description="Source where the paper was published. If the source is not explicitly specified, use the default value - 'NO SOURCE'"
66+
)
67+
research_area: ResearchArea = Field(
68+
description="Area or field of chemistry the paper is about. Must be one of the predefined values."
69+
" If the area has no match in the predefined list or is hard to determine, use the default value - 'OTHER'"
6070
)
6171

6272

@@ -455,7 +465,7 @@ def search_for_papers(self,
455465
return res
456466

457467
def retrieve_context(
458-
self, query: str, relevant_papers: dict = None
468+
self, query: str, relevant_papers: dict = None, meta_filter: dict = None
459469
) -> tuple[list, dict, dict]:
460470
"""
461471
Retrieves relevant information from text and images associated with scientific papers based on a user query.
@@ -468,14 +478,15 @@ def retrieve_context(
468478
query (str): The search query used to identify relevant information.
469479
relevant_papers (list, optional): A list of pre-identified relevant papers. Defaults to None, in which case
470480
a search for relevant papers is initiated.
481+
meta_filter (dict, optional): A dictionary of metadata filters to apply during the search.
471482
472483
Returns:
473484
tuple[list, dict]: A tuple containing the retrieved text and image context.
474485
- text_context (list): A list of text chunks deemed most relevant to the query.
475486
- image_context (dict): A dictionary containing image data associated with the query.
476487
"""
477488
if not relevant_papers:
478-
relevant_papers = self.search_for_papers(query)
489+
relevant_papers = self.search_for_papers(query, meta_filter=meta_filter)
479490

480491
raw_text_context = self.client.query_chromadb(
481492
self.txt_collection,
@@ -558,7 +569,24 @@ def search_with_reranker(
558569

559570
return scored_docs[:top_k]
560571

561-
def add_paper_summary_to_db(self, paper_name: str, parsed_paper: str, llm) -> None:
572+
def _generate_expanded_summary(self, parsed_paper: str, llm) -> ExpandedSummary:
573+
"""
574+
Generates an expanded summary of a paper using a language model.
575+
576+
This method takes parsed paper content and uses an LLM to extract and structure
577+
key information including summary, title, authors, publication year, and source.
578+
579+
Args:
580+
parsed_paper (str): The text content of the parsed paper.
581+
llm: The language model used to generate the summary.
582+
583+
Returns:
584+
ExpandedSummary: An object containing the paper's structured summary information.
585+
"""
586+
expanded_summary: ExpandedSummary = llm.invoke([HumanMessage(content=summarisation_prompt + parsed_paper)])
587+
return expanded_summary
588+
589+
def add_paper_summary_to_db(self, paper_name: str, parsed_paper: str, expanded_summary: ExpandedSummary) -> None:
562590
"""
563591
Adds a paper summary to the document collection for efficient information retrieval.
564592
@@ -574,13 +602,15 @@ def add_paper_summary_to_db(self, paper_name: str, parsed_paper: str, llm) -> No
574602
Returns:
575603
None
576604
"""
577-
expanded_summary: ExpandedSummary = llm.invoke([HumanMessage(content=summarisation_prompt + parsed_paper)])
578605
doc = Document(
579606
page_content=expanded_summary.paper_summary,
580607
metadata={
581608
"source": paper_name,
582609
"paper_title": expanded_summary.paper_title,
583-
"publication_year": expanded_summary.publication_year
610+
"publication_year": expanded_summary.publication_year,
611+
"paper_authors": expanded_summary.paper_authors,
612+
"publication_source": expanded_summary.publication_source,
613+
"research_area": expanded_summary.research_area
584614
}
585615
)
586616
embedding = self.get_embeddings([doc.page_content])
@@ -771,13 +801,15 @@ def process_single_document(folder_path: Path, s3_service: S3BucketService, s3_p
771801
else:
772802
parsed_paper, mapping = clean_up_html(folder_path, paper_name, text)
773803
print(f"Finished post-processing paper: {paper_name}")
774-
documents = html_chunking(parsed_paper, paper_name)
775804

776805
llm = create_llm_connector(SUMMARY_LLM_URL, extra_body={"provider": {"only": allowed_providers}})
777806
struct_llm = llm.with_structured_output(schema=ExpandedSummary)
807+
paper_summary = process_local_store._generate_expanded_summary(parsed_paper, struct_llm)
808+
809+
documents = html_chunking(parsed_paper, paper_name, paper_summary)
778810

779811
print(f"Starting loading paper: {paper_name}")
780-
process_local_store.add_paper_summary_to_db(str(paper_name_to_load), parsed_paper, struct_llm)
812+
process_local_store.add_paper_summary_to_db(str(paper_name_to_load), parsed_paper, paper_summary)
781813
process_local_store.store_text_chunks_in_chromadb(documents)
782814
process_local_store.store_images_in_chromadb_txt_format(str(folder_path), str(paper_name_to_load), mapping)
783815
print(f"Finished loading paper: {paper_name}")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import Literal, TypeAlias
2+
3+
4+
ResearchArea: TypeAlias = Literal[
5+
"Polymer Chemistry",
6+
"Organic Chemistry",
7+
"Nanomaterials",
8+
"Molecular Dynamics",
9+
"Membrane Chemistry",
10+
"Electrochemistry",
11+
"DFT",
12+
"Biological Macromolecules",
13+
"Biological Chemistry",
14+
"Analytical Chemistry",
15+
]

ChemCoScientist/paper_analysis/prompts.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
" list separately all tables with its names, all images with its names, and all main"
5656
" substances that are in the article. Keywords/terms, as well as lists of tables, images, and"
5757
" substances are also part of the summary.\n"
58-
" Also try to determine the title of the article and the year of its publication.\n\n"
58+
" Also try to determine the title of the article, the year of its publication, authors,"
59+
" its publication source (journal name, venue name, preprint server name etc.)"
60+
" and the research area of the paper.\n\n"
5961
"Article in Markdown markup:\n"
6062
)
6163

@@ -107,3 +109,30 @@
107109
"Do not include molecules where the required property is not mentioned."
108110
"Output only the CSV data (no explanations, no markdown, no additional text)."
109111
)
112+
113+
extract_query_filters_prompt = (
114+
"You are an assistant that extracts metadata filters from user questions about scientific papers. "
115+
"Your task is to analyze the USER QUESTION and identify any mentions of:"
116+
"\n1. Author names (e.g., 'What did Smith say', 'According to John Doe', 'research by Dr. Jane')"
117+
"\n2. Publication year or year range (e.g., 'papers from 2020', 'research since 2018', 'recent studies')"
118+
"\n3. Publication source/journal (e.g., 'papers in Nature', 'from ACS Catalysis', 'published in Science')"
119+
"\n4. Research area (e.g., 'polymer chemistry papers', 'nanomaterials research', 'DFT studies')"
120+
"\n\nFor year filters:"
121+
"\n- 'recent' or 'latest' should translate to publication_year_min = current_year - 2"
122+
"\n- 'since YEAR' should translate to publication_year_min = YEAR"
123+
"\n- 'in YEAR' or 'from YEAR' should translate to publication_year_exact = YEAR"
124+
"\n- 'between YEAR1 and YEAR2' should translate to publication_year_min = YEAR1, publication_year_max = YEAR2"
125+
"\n\nFor research areas, use only these values if detected:"
126+
"\n'Polymer Chemistry', 'Organic Chemistry', 'Nanomaterials', 'Molecular Dynamics', 'Membrane Chemistry', "
127+
"'Electrochemistry', 'DFT', 'Biological Macromolecules', 'Biological Chemistry', 'Analytical Chemistry'"
128+
"\n\nIf no specific filter is mentioned for a field, leave it as null."
129+
"\n\nExamples:"
130+
"\nQ: 'What did Sam Smith say about catalysis?'"
131+
"\nA: {\"paper_authors\": \"Sam Smith\", \"publication_year_min\": null, \"publication_year_max\": null, \"publication_year_exact\": null, \"publication_source\": null, \"research_area\": null}"
132+
"\n\nQ: 'What are recent advances in polymer chemistry?'"
133+
"\nA: {\"paper_authors\": null, \"publication_year_min\": 2024, \"publication_year_max\": null, \"publication_year_exact\": null, \"publication_source\": null, \"research_area\": \"Polymer Chemistry\"}"
134+
"\n\nQ: 'Show me DFT studies from Nature Chemistry published in 2023'"
135+
"\nA: {\"paper_authors\": null, \"publication_year_min\": null, \"publication_year_max\": null, \"publication_year_exact\": 2023, \"publication_source\": \"Nature Chemistry\", \"research_area\": \"DFT\"}"
136+
"\n\nQ: 'What synthesis methods are used for nanoparticles?'"
137+
"\nA: {\"paper_authors\": null, \"publication_year_min\": null, \"publication_year_max\": null, \"publication_year_exact\": null, \"publication_source\": null, \"research_area\": null}"
138+
)

ChemCoScientist/paper_analysis/question_processing.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from io import BytesIO
1313

1414
from ChemCoScientist.paper_analysis.chroma_db_operations import ChromaDBPaperStore
15-
from ChemCoScientist.paper_analysis.prompts import sys_prompt, explore_my_papers_prompt
15+
from ChemCoScientist.paper_analysis.constants import ResearchArea
16+
from ChemCoScientist.paper_analysis.prompts import sys_prompt, explore_my_papers_prompt, extract_query_filters_prompt
1617
from ChemCoScientist.paper_analysis.settings import allowed_providers
1718
from CoScientist.paper_parser.utils import convert_to_base64, prompt_func, load_image_as_binary
1819
from ChemCoScientist.chemical_utils.openchemie_functions import *
@@ -22,6 +23,111 @@
2223

2324
VISION_LLM_URL = os.environ["VISION_LLM_URL"]
2425

26+
27+
class QueryFilters(BaseModel):
28+
"""Metadata filters extracted from user question."""
29+
paper_authors: str | None = Field(
30+
description="Author name(s) mentioned in the question",
31+
default=None
32+
)
33+
publication_year_min: int | None = Field(
34+
description="Minimum publication year for filtering",
35+
default=None
36+
)
37+
publication_year_max: int | None = Field(
38+
description="Maximum publication year for filtering",
39+
default=None
40+
)
41+
publication_year_exact: int | None = Field(
42+
description="Exact publication year when specified",
43+
default=None
44+
)
45+
publication_source: str | None = Field(
46+
description="Journal or publication source name",
47+
default=None
48+
)
49+
research_area: ResearchArea | None = Field(
50+
description="Research area/field of chemistry",
51+
default=None
52+
)
53+
54+
55+
def extract_metadata_filters(question: str) -> QueryFilters:
56+
"""
57+
Uses LLM to extract metadata filters from user question.
58+
59+
Args:
60+
question: The user's question string
61+
62+
Returns:
63+
QueryFilters: Structured filters including authors, year, source, and research area
64+
"""
65+
llm = create_llm_connector(
66+
VISION_LLM_URL,
67+
extra_body={"provider": {"only": allowed_providers}},
68+
temperature=0.0
69+
)
70+
71+
struct_llm = llm.with_structured_output(schema=QueryFilters)
72+
73+
prompt = extract_query_filters_prompt + f"\n\nUSER QUESTION: {question}"
74+
75+
from langchain_core.messages import HumanMessage
76+
filters: QueryFilters = struct_llm.invoke([HumanMessage(content=prompt)])
77+
78+
return filters
79+
80+
81+
def build_chroma_where_filter(filters: QueryFilters) -> dict | None:
82+
"""
83+
Converts QueryFilters to ChromaDB where clause format.
84+
85+
Args:
86+
filters: QueryFilters object with extracted metadata
87+
88+
Returns:
89+
dict: ChromaDB where clause ready for collection.query(), or None if no filters
90+
91+
Example output:
92+
{"paper_authors": {"$eq": "Smith"}}
93+
{
94+
"$and": [
95+
{"paper_authors": {"$eq": "Smith"}},
96+
{"publication_year": {"$gte": 2020}}
97+
]
98+
}
99+
"""
100+
conditions = []
101+
102+
if filters.paper_authors:
103+
conditions.append({"paper_authors": {"$eq": filters.paper_authors}})
104+
105+
if filters.publication_year_exact:
106+
conditions.append({"publication_year": {"$eq": filters.publication_year_exact}})
107+
elif filters.publication_year_min or filters.publication_year_max:
108+
year_condition = {}
109+
if filters.publication_year_min:
110+
year_condition["$gte"] = filters.publication_year_min
111+
if filters.publication_year_max:
112+
year_condition["$lte"] = filters.publication_year_max
113+
if year_condition:
114+
conditions.append({"publication_year": year_condition})
115+
116+
if filters.publication_source:
117+
conditions.append({"publication_source": {"$eq": filters.publication_source}})
118+
119+
if filters.research_area and filters.research_area != "OTHER":
120+
conditions.append({"research_area": {"$eq": filters.research_area}})
121+
122+
if not conditions:
123+
return None
124+
125+
if len(conditions) == 1:
126+
return conditions[0]
127+
128+
return {"$and": conditions}
129+
130+
25131
def query_llm(
26132
model_url: str, question: str, txt_context: str, img_paths: list[str]
27133
) -> tuple:
@@ -159,7 +265,10 @@ def process_question(question: str, store: ChromaDBPaperStore) -> dict:
159265
'image_context' - the set of image paths identified as relevant to the question;
160266
'metadata' - Additional metadata returned by the LLM query.
161267
"""
162-
txt_data, img_data = store.retrieve_context(question)
268+
meta_filter = extract_metadata_filters(question)
269+
meta_filter_chroma = build_chroma_where_filter(meta_filter)
270+
271+
txt_data, img_data = store.retrieve_context(question, meta_filter=meta_filter_chroma)
163272
txt_context = ""
164273
relevant_txt_context = []
165274
img_paths = []
@@ -276,7 +385,7 @@ def process_question(question: str, store: ChromaDBPaperStore) -> dict:
276385
#######################################################
277386

278387
paper_store = ChromaDBPaperStore()
279-
question = 'What is the title of an article?'
388+
question = 'What are papers since 2023 about analytical chemistry are focused on?'
280389
# question = 'What components are involved in the synthesis of BASHY dyes, and what are the uses of these dyes?'
281390
# question = 'What IC50 values do weakly active and highly active Bruton\'s tyrosine kinase inhibitors have?'
282391
# question = 'How does the synthesis of Glionitrin A/B happen?'

CoScientist/paper_parser/parse_and_split.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def clean_up_html(
178178
return soup.prettify(), image_url_mapping
179179

180180

181-
def html_chunking(html_string: str, paper_name: str) -> list:
181+
def html_chunking(html_string: str, paper_name: str, paper_summary) -> list:
182182
"""
183183
Chunks an HTML string into semantic passages for efficient information retrieval.
184184
@@ -216,8 +216,15 @@ def custom_table_extractor(table_tag):
216216
documents = splitter.split_text(html_string)
217217
for doc in documents:
218218
doc.page_content = "passage: " + doc.page_content # Maybe delete "passage: " addition
219-
doc.metadata["imgs_in_chunk"] = str(extract_img_url(doc.page_content, paper_name))
220-
doc.metadata["source"] = paper_name + ".pdf"
219+
doc.metadata.update({
220+
"imgs_in_chunk": str(extract_img_url(doc.page_content, paper_name)),
221+
"source": f"{paper_name}.pdf",
222+
"paper_title": paper_summary.paper_title,
223+
"publication_year": paper_summary.publication_year,
224+
"paper_authors": paper_summary.paper_authors,
225+
"publication_source": paper_summary.publication_source,
226+
"research_area": paper_summary.research_area,
227+
})
221228

222229
return documents
223230

CoScientist/paper_parser/s3_connection.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,23 +92,22 @@ def list_objects(self, prefix: str) -> list[str]:
9292
Lists all objects in the S3 bucket with the given prefix.
9393
9494
Args:
95-
prefix: The prefix to filter objects in the S3 bucket
95+
prefix: The prefix to filter objects in the S3 bucket. If empty, lists all objects.
9696
9797
Returns:
9898
A list of object keys (file paths) that match the prefix
9999
"""
100100
client = self.create_s3_client()
101101

102-
response = client.list_objects_v2(Bucket=self.bucket_name, Prefix=prefix)
103-
storage_content: list[str] = []
102+
paginator = client.get_paginator("list_objects_v2")
103+
page_iterator = paginator.paginate(Bucket=self.bucket_name, Prefix=prefix)
104104

105-
try:
106-
contents = response["Contents"]
107-
except KeyError:
108-
return storage_content
105+
storage_content: list[str] = []
109106

110-
for item in contents:
111-
storage_content.append(item["Key"])
107+
for page in page_iterator:
108+
contents = page.get("Contents", [])
109+
for item in contents:
110+
storage_content.append(item["Key"])
112111

113112
return storage_content
114113

@@ -248,5 +247,4 @@ def clean_up_by_prefix(self, prefix_to_delete: str):
248247
# for bucket in buckets["Buckets"]:
249248
# print(bucket["Name"], bucket["CreationDate"])
250249
# objects = s3_service.list_objects(prefix="")
251-
# print(objects)
252-
250+
# print(objects)

0 commit comments

Comments
 (0)