@lzhzzzzwill/cofos 1.0.0 → 1.0.2
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.
- package/README.md +104 -83
- package/bin/cofos.js +119 -41
- package/package.json +3 -2
- package/requirements.txt +8 -0
- package/runtime/config/config.yaml +313 -0
- package/runtime/config/prompt_templates.yaml +403 -0
- package/runtime/config/qwen3_5_config.md +46 -0
- package/runtime/src/data_processing/__init__.py +31 -0
- package/runtime/src/data_processing/clean_json_records.py +387 -0
- package/runtime/src/data_processing/parse_pdf_text.py +332 -0
- package/runtime/src/data_processing/parse_wos_xlsx.py +255 -0
- package/runtime/src/data_processing/schema_utils.py +56 -0
- package/runtime/src/data_processing/utils.py +74 -0
- package/runtime/src/inference/__init__.py +8 -0
- package/runtime/src/inference/query_router.py +33 -0
- package/runtime/src/inference/run_kg_grounded_inference.py +370 -0
- package/runtime/src/inference/student_adapter_inference.py +520 -0
- package/runtime/src/retrieval/__init__.py +10 -0
- package/runtime/src/retrieval/build_bm25_index.py +382 -0
- package/runtime/src/retrieval/build_faiss_index.py +328 -0
- package/runtime/src/retrieval/retrieve_evidence.py +859 -0
- package/runtime/src/retrieval/text_search.py +60 -0
- package/scripts/chat.py +479 -61
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
COF-ROS-Reasoner: 检索证据脚本
|
|
4
|
+
|
|
5
|
+
目标:
|
|
6
|
+
- 使用 FAISS + BM25 进行混合检索
|
|
7
|
+
- 合并结果并重排
|
|
8
|
+
- 返回证据列表
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import json
|
|
13
|
+
import yaml
|
|
14
|
+
import logging
|
|
15
|
+
import pickle
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
# 添加 src 路径
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from sentence_transformers import SentenceTransformer
|
|
28
|
+
except ImportError:
|
|
29
|
+
SentenceTransformer = None
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import faiss
|
|
33
|
+
except ImportError:
|
|
34
|
+
faiss = None
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
from rank_bm25 import BM25Okapi
|
|
38
|
+
except ImportError:
|
|
39
|
+
BM25Okapi = None
|
|
40
|
+
|
|
41
|
+
from data_processing.utils import setup_logging, ensure_directory
|
|
42
|
+
from retrieval.text_search import tokenize_search_text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EvidenceRetriever:
|
|
46
|
+
"""检索证据"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: Dict[str, Any]):
|
|
49
|
+
self.config = config
|
|
50
|
+
self.faiss_dir = Path(config['paths']['retrieval_store'])
|
|
51
|
+
self.bm25_dir = Path(config['paths']['bm25_store'])
|
|
52
|
+
|
|
53
|
+
# 设置日志
|
|
54
|
+
setup_logging(config)
|
|
55
|
+
|
|
56
|
+
# 检索配置
|
|
57
|
+
self.vector_top_k = config.get('retrieval', {}).get('vector_top_k', 20)
|
|
58
|
+
self.bm25_top_k = config.get('retrieval', {}).get('bm25_top_k', 20)
|
|
59
|
+
self.rerank_top_k = config.get('retrieval', {}).get('rerank_top_k', 5)
|
|
60
|
+
self.enable_faiss = config.get('retrieval', {}).get('enable_faiss', False)
|
|
61
|
+
self.enable_bm25 = config.get('retrieval', {}).get('enable_bm25', True)
|
|
62
|
+
self.min_bm25_score = float(
|
|
63
|
+
config.get('retrieval', {}).get('min_bm25_score', 7.0)
|
|
64
|
+
)
|
|
65
|
+
self.min_vector_score = float(
|
|
66
|
+
config.get('retrieval', {}).get('min_vector_score', 0.35)
|
|
67
|
+
)
|
|
68
|
+
self.min_kg_confidence = float(
|
|
69
|
+
config.get('retrieval', {}).get('min_kg_confidence', 2.0)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self.source_priority = config.get('retrieval', {}).get('source_priority', {
|
|
73
|
+
'gold_json': 4,
|
|
74
|
+
'silver_json': 3,
|
|
75
|
+
'pdf_fulltext': 2,
|
|
76
|
+
'wos_abstract': 1
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
# ROS 标准化
|
|
80
|
+
self.ros_normalization = config.get('data_processing', {}).get('ros_normalization', {})
|
|
81
|
+
|
|
82
|
+
# 加载索引与 KG 三元组
|
|
83
|
+
self._load_indices()
|
|
84
|
+
self._load_kg_triples()
|
|
85
|
+
|
|
86
|
+
def _load_indices(self):
|
|
87
|
+
"""加载 FAISS 和 BM25 索引"""
|
|
88
|
+
# 加载 FAISS 索引
|
|
89
|
+
self.faiss_index = None
|
|
90
|
+
self.faiss_metadata = []
|
|
91
|
+
|
|
92
|
+
if self.enable_faiss and self.faiss_dir.exists():
|
|
93
|
+
faiss_path = self.faiss_dir / 'faiss_index.bin'
|
|
94
|
+
metadata_path = self.faiss_dir / 'faiss_metadata.jsonl'
|
|
95
|
+
|
|
96
|
+
if faiss is not None and faiss_path.exists() and metadata_path.exists():
|
|
97
|
+
self.faiss_index = faiss.read_index(str(faiss_path))
|
|
98
|
+
|
|
99
|
+
with open(metadata_path, 'r', encoding='utf-8') as f:
|
|
100
|
+
for line in f:
|
|
101
|
+
if line.strip():
|
|
102
|
+
self.faiss_metadata.append(json.loads(line))
|
|
103
|
+
|
|
104
|
+
logging.info(f"Loaded FAISS index with {len(self.faiss_metadata)} vectors")
|
|
105
|
+
|
|
106
|
+
# 加载 BM25 索引
|
|
107
|
+
self.bm25_index = None
|
|
108
|
+
self.bm25_metadata = []
|
|
109
|
+
|
|
110
|
+
if self.enable_bm25 and self.bm25_dir.exists():
|
|
111
|
+
metadata_path = self.bm25_dir / 'bm25_metadata.jsonl'
|
|
112
|
+
index_path = self.bm25_dir / 'bm25_index.pkl'
|
|
113
|
+
|
|
114
|
+
if metadata_path.exists():
|
|
115
|
+
with open(metadata_path, 'r', encoding='utf-8') as f:
|
|
116
|
+
for line in f:
|
|
117
|
+
if line.strip():
|
|
118
|
+
self.bm25_metadata.append(json.loads(line))
|
|
119
|
+
|
|
120
|
+
logging.info(f"Loaded BM25 index with {len(self.bm25_metadata)} documents")
|
|
121
|
+
|
|
122
|
+
if not index_path.exists():
|
|
123
|
+
raise FileNotFoundError(
|
|
124
|
+
f"BM25 index not found: {index_path}. Run scripts/build_retrieval.py."
|
|
125
|
+
)
|
|
126
|
+
with open(index_path, 'rb') as f:
|
|
127
|
+
self.bm25_index = pickle.load(f)
|
|
128
|
+
logging.info(f"Loaded persisted BM25 index from {index_path}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _load_kg_triples(self) -> None:
|
|
132
|
+
"""Load real KG triples and build lightweight in-memory indexes."""
|
|
133
|
+
self.kg_triples = []
|
|
134
|
+
self.kg_by_head = defaultdict(list)
|
|
135
|
+
self.kg_by_tail = defaultdict(list)
|
|
136
|
+
|
|
137
|
+
kg_path = Path(self.config['paths']['kg_dir']) / 'cofros_kg_triples.jsonl'
|
|
138
|
+
if not kg_path.exists():
|
|
139
|
+
logging.warning("KG triples not found: %s", kg_path)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
with open(kg_path, 'r', encoding='utf-8') as handle:
|
|
144
|
+
for line in handle:
|
|
145
|
+
if not line.strip():
|
|
146
|
+
continue
|
|
147
|
+
triple = json.loads(line)
|
|
148
|
+
head = triple.get('head', '')
|
|
149
|
+
tail = triple.get('tail', '')
|
|
150
|
+
if not head or not tail:
|
|
151
|
+
continue
|
|
152
|
+
self.kg_triples.append(triple)
|
|
153
|
+
self.kg_by_head[head].append(triple)
|
|
154
|
+
self.kg_by_tail[tail].append(triple)
|
|
155
|
+
logging.info("Loaded %s KG triples for graph retrieval", len(self.kg_triples))
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
logging.error("Failed to load KG triples from %s: %s", kg_path, exc)
|
|
158
|
+
self.kg_triples = []
|
|
159
|
+
self.kg_by_head = defaultdict(list)
|
|
160
|
+
self.kg_by_tail = defaultdict(list)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _kg_label(node_id: str) -> str:
|
|
164
|
+
"""Convert a KG node id into a readable label."""
|
|
165
|
+
label = str(node_id or '')
|
|
166
|
+
if ':' in label:
|
|
167
|
+
label = label.split(':', 1)[1]
|
|
168
|
+
return label.replace('_', ' ').strip()
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _kg_search_text(text: str) -> str:
|
|
172
|
+
"""Normalize labels for fuzzy query matching without changing displayed chemistry."""
|
|
173
|
+
text = str(text or '').lower().replace('_', ' ')
|
|
174
|
+
text = text.replace('−', '-').replace('⋅', '·')
|
|
175
|
+
text = re.sub(r"[^a-z0-9\u4e00-\u9fff·*+\-\s]", " ", text)
|
|
176
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _kg_tokens(text: str) -> set:
|
|
180
|
+
"""Extract informative tokens for KG node matching.
|
|
181
|
+
|
|
182
|
+
Only generic English stopwords are removed. Domain terms like *cof*,
|
|
183
|
+
*ros*, *photocatalysis* are deliberately KEPT — they are the very
|
|
184
|
+
terms users query with and must contribute to KG node scoring.
|
|
185
|
+
"""
|
|
186
|
+
tokens = set(re.findall(r"[a-z0-9]+", text.lower()))
|
|
187
|
+
return tokens - {
|
|
188
|
+
'the', 'and', 'with', 'under', 'what', 'which',
|
|
189
|
+
'does', 'do', 'is', 'are', 'a', 'an', 'of', 'in',
|
|
190
|
+
'on', 'or', 'to', 'for', 'from', 'at', 'by', 'as',
|
|
191
|
+
'it', 'be', 'how', 'not', 'no', 'but', 'its',
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def _kg_label_score(self, label: str, query: str) -> float:
|
|
195
|
+
label_text = self._kg_search_text(label)
|
|
196
|
+
query_text = self._kg_search_text(query)
|
|
197
|
+
if not label_text:
|
|
198
|
+
return 0.0
|
|
199
|
+
if label_text in query_text:
|
|
200
|
+
return 100.0 + len(label_text) / 100.0
|
|
201
|
+
|
|
202
|
+
label_tokens = self._kg_tokens(label_text)
|
|
203
|
+
query_tokens = self._kg_tokens(query_text)
|
|
204
|
+
if not label_tokens or not query_tokens:
|
|
205
|
+
return 0.0
|
|
206
|
+
|
|
207
|
+
overlap = label_tokens & query_tokens
|
|
208
|
+
if not overlap:
|
|
209
|
+
return 0.0
|
|
210
|
+
|
|
211
|
+
generic_tokens = {
|
|
212
|
+
'cof', 'cofs', 'ros', 'light', 'visible', 'photocatalysis',
|
|
213
|
+
'photocatalytic', 'phenol', 'degradation', 'generate', 'generates',
|
|
214
|
+
'dominate', 'dominates', 'dominant', 'which', 'what', 'under',
|
|
215
|
+
}
|
|
216
|
+
specific_overlap = overlap - generic_tokens
|
|
217
|
+
if not specific_overlap:
|
|
218
|
+
return 0.0
|
|
219
|
+
|
|
220
|
+
# Specific material or condition tokens such as fe3o4, tpma, tt, tempo,
|
|
221
|
+
# h2o2, and pms should outrank short generic matches such as phenol/light.
|
|
222
|
+
digit_or_formula_hits = sum(
|
|
223
|
+
1 for token in specific_overlap if any(ch.isdigit() for ch in token)
|
|
224
|
+
)
|
|
225
|
+
score = (len(specific_overlap) * 3.0) + (len(overlap) * 0.5) + (digit_or_formula_hits * 2.0)
|
|
226
|
+
return score / max(len(label_tokens) ** 0.25, 1.0)
|
|
227
|
+
|
|
228
|
+
def _find_kg_nodes(self, query: str, node_prefix: str) -> List[str]:
|
|
229
|
+
return [node for _, node in self._find_scored_kg_nodes(query, node_prefix)]
|
|
230
|
+
|
|
231
|
+
def _find_scored_kg_nodes(self, query: str, node_prefix: str) -> List[Tuple[float, str]]:
|
|
232
|
+
nodes = set()
|
|
233
|
+
for triple in self.kg_triples:
|
|
234
|
+
for field in ('head', 'tail'):
|
|
235
|
+
node = triple.get(field, '')
|
|
236
|
+
if node.startswith(node_prefix):
|
|
237
|
+
nodes.add(node)
|
|
238
|
+
|
|
239
|
+
scored = []
|
|
240
|
+
for node in nodes:
|
|
241
|
+
score = self._kg_label_score(self._kg_label(node), query)
|
|
242
|
+
if score > 0:
|
|
243
|
+
scored.append((score, node))
|
|
244
|
+
scored.sort(key=lambda item: item[0], reverse=True)
|
|
245
|
+
return scored
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def is_domain_query(query: str) -> bool:
|
|
249
|
+
"""Return whether a query is specific enough for COF/ROS retrieval."""
|
|
250
|
+
return bool(re.search(
|
|
251
|
+
r"\b(cof|cofs|ros|h2o2|o2|orr|oxygen|radical|peroxide|hydroxyl|"
|
|
252
|
+
r"superoxide|singlet|photocatal|photocatalysis|photocatalytic|"
|
|
253
|
+
r"redox|oxidation|reduction|electron|radicals?)\b",
|
|
254
|
+
query,
|
|
255
|
+
flags=re.IGNORECASE,
|
|
256
|
+
))
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def _looks_like_kg_query(query: str) -> bool:
|
|
260
|
+
return EvidenceRetriever.is_domain_query(query)
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def _kg_fact_priority(fact: Dict[str, Any]) -> float:
|
|
264
|
+
relation_weight = {
|
|
265
|
+
'dominantly_generates': 5.0,
|
|
266
|
+
'secondarily_generates': 4.0,
|
|
267
|
+
'has_intermediate': 3.0,
|
|
268
|
+
'under_condition': 2.0,
|
|
269
|
+
'reported_in': 0.5,
|
|
270
|
+
}.get(fact.get('relation', ''), 1.0)
|
|
271
|
+
evidence = fact.get('evidence') or {}
|
|
272
|
+
if not isinstance(evidence, dict):
|
|
273
|
+
evidence = {'evidence_level': str(evidence)}
|
|
274
|
+
support_weight = {
|
|
275
|
+
'supported': 3.0,
|
|
276
|
+
'partially_supported': 2.0,
|
|
277
|
+
'unsupported': -2.0,
|
|
278
|
+
}.get(evidence.get('support_status') or fact.get('support_status', ''), 0.0)
|
|
279
|
+
level_weight = {
|
|
280
|
+
'high': 2.0,
|
|
281
|
+
'medium': 1.0,
|
|
282
|
+
'low': 0.0,
|
|
283
|
+
}.get(evidence.get('evidence_level') or fact.get('evidence_level', ''), 0.0)
|
|
284
|
+
return relation_weight + support_weight + level_weight
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _kg_fact_support_status(fact: Dict[str, Any]) -> str:
|
|
288
|
+
evidence = fact.get('evidence') or {}
|
|
289
|
+
if isinstance(evidence, dict):
|
|
290
|
+
return str(evidence.get('support_status') or fact.get('support_status') or '').lower()
|
|
291
|
+
return str(fact.get('support_status') or '').lower()
|
|
292
|
+
|
|
293
|
+
def is_confident_kg_fact(
|
|
294
|
+
self,
|
|
295
|
+
fact: Dict[str, Any],
|
|
296
|
+
min_confidence: Optional[float] = None,
|
|
297
|
+
) -> bool:
|
|
298
|
+
"""Return whether a KG fact is suitable for RAG context injection."""
|
|
299
|
+
if self._kg_fact_support_status(fact) == 'unsupported':
|
|
300
|
+
return False
|
|
301
|
+
threshold = getattr(self, 'min_kg_confidence', 2.0) if min_confidence is None else float(min_confidence)
|
|
302
|
+
return self._kg_fact_priority(fact) >= threshold
|
|
303
|
+
|
|
304
|
+
def _filter_confident_kg_facts(
|
|
305
|
+
self,
|
|
306
|
+
facts: List[Dict[str, Any]],
|
|
307
|
+
min_confidence: Optional[float] = None,
|
|
308
|
+
) -> List[Dict[str, Any]]:
|
|
309
|
+
if min_confidence is None:
|
|
310
|
+
return facts
|
|
311
|
+
return [
|
|
312
|
+
fact for fact in facts
|
|
313
|
+
if self.is_confident_kg_fact(fact, min_confidence=min_confidence)
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
def _format_kg_fact(self, triple: Dict[str, Any]) -> Dict[str, Any]:
|
|
317
|
+
fact = dict(triple)
|
|
318
|
+
fact['head'] = self._kg_label(triple.get('head', ''))
|
|
319
|
+
fact['tail'] = self._kg_label(triple.get('tail', ''))
|
|
320
|
+
fact['source'] = 'kg_triples'
|
|
321
|
+
return fact
|
|
322
|
+
|
|
323
|
+
def _dedupe_kg_facts(self, facts: List[Dict[str, Any]], top_k: int) -> List[Dict[str, Any]]:
|
|
324
|
+
seen = set()
|
|
325
|
+
unique = []
|
|
326
|
+
for fact in sorted(facts, key=self._kg_fact_priority, reverse=True):
|
|
327
|
+
key = (
|
|
328
|
+
fact.get('head', ''),
|
|
329
|
+
fact.get('relation', ''),
|
|
330
|
+
fact.get('tail', ''),
|
|
331
|
+
json.dumps(fact.get('condition', {}), sort_keys=True, ensure_ascii=False),
|
|
332
|
+
)
|
|
333
|
+
if key in seen:
|
|
334
|
+
continue
|
|
335
|
+
seen.add(key)
|
|
336
|
+
unique.append(fact)
|
|
337
|
+
if len(unique) >= top_k:
|
|
338
|
+
break
|
|
339
|
+
return unique
|
|
340
|
+
|
|
341
|
+
def _retrieve_graph_kg_facts(
|
|
342
|
+
self,
|
|
343
|
+
query: str,
|
|
344
|
+
top_k: int,
|
|
345
|
+
min_confidence: Optional[float] = None,
|
|
346
|
+
) -> List[Dict[str, Any]]:
|
|
347
|
+
if not self.kg_triples or not self._looks_like_kg_query(query):
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
facts = []
|
|
351
|
+
generation_relations = {
|
|
352
|
+
'dominantly_generates',
|
|
353
|
+
'secondarily_generates',
|
|
354
|
+
'has_intermediate',
|
|
355
|
+
'under_condition',
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
reverse_species_query = bool(re.search(
|
|
359
|
+
r"\bwhich\b.*\b(?:cof|cofs|materials?)\b.*\b(?:generate|generates|produce|produces|yield|yields)\b",
|
|
360
|
+
query,
|
|
361
|
+
flags=re.IGNORECASE,
|
|
362
|
+
))
|
|
363
|
+
|
|
364
|
+
if not reverse_species_query:
|
|
365
|
+
scored_material_nodes = self._find_scored_kg_nodes(query, 'Material:')
|
|
366
|
+
if scored_material_nodes:
|
|
367
|
+
top_score = scored_material_nodes[0][0]
|
|
368
|
+
# If a query clearly names a material, use that material neighborhood
|
|
369
|
+
# instead of mixing in weaker generic material matches.
|
|
370
|
+
selected_nodes = [scored_material_nodes[0][1]] if top_score >= 2.5 else [
|
|
371
|
+
node for _, node in scored_material_nodes[:5]
|
|
372
|
+
]
|
|
373
|
+
for material_node in selected_nodes:
|
|
374
|
+
for triple in self.kg_by_head.get(material_node, []):
|
|
375
|
+
if triple.get('relation') in generation_relations:
|
|
376
|
+
facts.append(self._format_kg_fact(triple))
|
|
377
|
+
|
|
378
|
+
# A concrete material mention should drive the graph neighborhood. Only use
|
|
379
|
+
# reverse species lookup when no material node was found.
|
|
380
|
+
if facts:
|
|
381
|
+
facts = self._filter_confident_kg_facts(facts, min_confidence=min_confidence)
|
|
382
|
+
return self._dedupe_kg_facts(facts, top_k)
|
|
383
|
+
|
|
384
|
+
for species_node in self._find_kg_nodes(query, 'ROS_or_Product:')[:5]:
|
|
385
|
+
for triple in self.kg_by_tail.get(species_node, []):
|
|
386
|
+
if triple.get('relation') in generation_relations:
|
|
387
|
+
facts.append(self._format_kg_fact(triple))
|
|
388
|
+
|
|
389
|
+
for species_node in self._find_kg_nodes(query, 'Intermediate:')[:5]:
|
|
390
|
+
for triple in self.kg_by_tail.get(species_node, []):
|
|
391
|
+
if triple.get('relation') in generation_relations:
|
|
392
|
+
facts.append(self._format_kg_fact(triple))
|
|
393
|
+
|
|
394
|
+
facts = self._filter_confident_kg_facts(facts, min_confidence=min_confidence)
|
|
395
|
+
return self._dedupe_kg_facts(facts, top_k)
|
|
396
|
+
|
|
397
|
+
def get_schema_record(self, record: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
398
|
+
"""Return Gold schema record or validated Silver extracted payload."""
|
|
399
|
+
extracted = record.get('extracted_json')
|
|
400
|
+
if isinstance(extracted, dict):
|
|
401
|
+
if extracted.get('status') == 'insufficient_evidence' or extracted.get('error'):
|
|
402
|
+
return None
|
|
403
|
+
payload = dict(extracted)
|
|
404
|
+
payload['source_chunk_id'] = record.get('chunk_id', '')
|
|
405
|
+
payload['evidence_level'] = record.get('evidence_level', 'low')
|
|
406
|
+
payload['support_status'] = record.get('support_status', 'unsupported')
|
|
407
|
+
return payload
|
|
408
|
+
|
|
409
|
+
if 'material_context' in record or 'dominant_ros_or_product' in record:
|
|
410
|
+
return record
|
|
411
|
+
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def _load_text_metadata(self) -> Dict[str, Dict[str, Any]]:
|
|
415
|
+
"""加载所有元数据以便检索文本"""
|
|
416
|
+
metadata = {}
|
|
417
|
+
|
|
418
|
+
# 加载 Gold/Silver JSON 元数据
|
|
419
|
+
processed_dir = Path(self.config['paths']['processed_dir'])
|
|
420
|
+
jsonl_paths = [
|
|
421
|
+
processed_dir / 'json_records_cleaned.jsonl',
|
|
422
|
+
processed_dir / 'silver_json_validated.jsonl',
|
|
423
|
+
]
|
|
424
|
+
for jsonl_path in jsonl_paths:
|
|
425
|
+
if not jsonl_path.exists():
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
with open(jsonl_path, 'r', encoding='utf-8') as f:
|
|
429
|
+
for line in f:
|
|
430
|
+
if line.strip():
|
|
431
|
+
raw_record = json.loads(line)
|
|
432
|
+
record = self.get_schema_record(raw_record)
|
|
433
|
+
if not record:
|
|
434
|
+
continue
|
|
435
|
+
doc_id = record.get('source_chunk_id', raw_record.get('chunk_id', ''))
|
|
436
|
+
if doc_id:
|
|
437
|
+
metadata[doc_id] = {
|
|
438
|
+
'source_type': 'gold_silver_json',
|
|
439
|
+
'text': self._construct_json_text(record),
|
|
440
|
+
'evidence_level': record.get('evidence_level', 'low'),
|
|
441
|
+
**record
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# 加载 PDF 元数据
|
|
445
|
+
pdf_path = processed_dir / 'pdf_chunks.jsonl'
|
|
446
|
+
if pdf_path.exists():
|
|
447
|
+
with open(pdf_path, 'r', encoding='utf-8') as f:
|
|
448
|
+
for line in f:
|
|
449
|
+
if line.strip():
|
|
450
|
+
record = json.loads(line)
|
|
451
|
+
doc_id = record.get('chunk_id', '')
|
|
452
|
+
if doc_id:
|
|
453
|
+
metadata[doc_id] = {
|
|
454
|
+
'source_type': 'pdf_fulltext',
|
|
455
|
+
'text': record.get('text', ''),
|
|
456
|
+
**record
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
# 加载 WOS 元数据
|
|
460
|
+
wos_path = processed_dir / 'wos_abstract_chunks.jsonl'
|
|
461
|
+
if wos_path.exists():
|
|
462
|
+
with open(wos_path, 'r', encoding='utf-8') as f:
|
|
463
|
+
for line in f:
|
|
464
|
+
if line.strip():
|
|
465
|
+
record = json.loads(line)
|
|
466
|
+
doc_id = record.get('doc_id', '')
|
|
467
|
+
if doc_id:
|
|
468
|
+
metadata[doc_id] = {
|
|
469
|
+
'source_type': 'wos_abstract',
|
|
470
|
+
'text': record.get('text', ''),
|
|
471
|
+
**record
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return metadata
|
|
475
|
+
|
|
476
|
+
def _construct_json_text(self, record: Dict[str, Any]) -> str:
|
|
477
|
+
"""构造 JSON 记录的文本表示"""
|
|
478
|
+
parts = []
|
|
479
|
+
|
|
480
|
+
material = record.get('material_context', {})
|
|
481
|
+
if material.get('material_type'):
|
|
482
|
+
parts.append(f"Material: {material['material_type']}")
|
|
483
|
+
if material.get('active_site'):
|
|
484
|
+
parts.append(f"Active site: {material['active_site']}")
|
|
485
|
+
|
|
486
|
+
env = record.get('reaction_environment', {})
|
|
487
|
+
if env.get('phase'):
|
|
488
|
+
parts.append(f"Phase: {env['phase']}")
|
|
489
|
+
if env.get('oxidant_source'):
|
|
490
|
+
parts.append(f"Oxidant: {env['oxidant_source']}")
|
|
491
|
+
if env.get('driving_force'):
|
|
492
|
+
parts.append(f"Driving: {env['driving_force']}")
|
|
493
|
+
|
|
494
|
+
for item in record.get('dominant_ros_or_product', []):
|
|
495
|
+
if item.get('species'):
|
|
496
|
+
parts.append(f"ROS: {item['species']}")
|
|
497
|
+
|
|
498
|
+
for step in record.get('mechanism', []):
|
|
499
|
+
if step:
|
|
500
|
+
parts.append(f"Mechanism: {step}")
|
|
501
|
+
|
|
502
|
+
return ' '.join(parts)
|
|
503
|
+
|
|
504
|
+
def _load_metadata_dict(self) -> Dict[str, Dict[str, Any]]:
|
|
505
|
+
"""加载所有元数据为字典"""
|
|
506
|
+
if hasattr(self, '_metadata_dict_cache'):
|
|
507
|
+
return self._metadata_dict_cache
|
|
508
|
+
|
|
509
|
+
metadata = self._load_text_metadata()
|
|
510
|
+
|
|
511
|
+
self._metadata_dict_cache = metadata
|
|
512
|
+
return metadata
|
|
513
|
+
|
|
514
|
+
@staticmethod
|
|
515
|
+
def _truncate_at_sentence(text: str, max_chars: int = 500) -> str:
|
|
516
|
+
"""Truncate *text* at the last sentence boundary before *max_chars*."""
|
|
517
|
+
if len(text) <= max_chars:
|
|
518
|
+
return text
|
|
519
|
+
# Find the last sentence terminator within the limit.
|
|
520
|
+
snippet = text[:max_chars]
|
|
521
|
+
for sep in ('. ', '.\n', '? ', '?\n', '! ', '!\n', '。', ';'):
|
|
522
|
+
idx = snippet.rfind(sep)
|
|
523
|
+
if idx > max_chars * 0.5: # don't cut too early
|
|
524
|
+
return snippet[:idx + len(sep.rstrip())]
|
|
525
|
+
# Fallback: truncate at the last space.
|
|
526
|
+
last_space = snippet.rfind(' ')
|
|
527
|
+
if last_space > max_chars * 0.5:
|
|
528
|
+
return snippet[:last_space]
|
|
529
|
+
return snippet
|
|
530
|
+
|
|
531
|
+
def normalize_ros(self, name: str) -> str:
|
|
532
|
+
"""标准化 ROS 名称"""
|
|
533
|
+
if not name:
|
|
534
|
+
return ""
|
|
535
|
+
name = name.strip()
|
|
536
|
+
return self.ros_normalization.get(name, self.ros_normalization.get(name.lower(), name))
|
|
537
|
+
|
|
538
|
+
def tokenize(self, text: str) -> List[str]:
|
|
539
|
+
"""Use the same tokenizer as index construction."""
|
|
540
|
+
return tokenize_search_text(text)
|
|
541
|
+
|
|
542
|
+
def is_confident_result(self, result: Dict[str, Any]) -> bool:
|
|
543
|
+
"""Return whether a text result clears at least one retrieval confidence gate."""
|
|
544
|
+
return (
|
|
545
|
+
result.get('bm25_score', 0.0) >= self.min_bm25_score
|
|
546
|
+
or result.get('vector_score', 0.0) >= self.min_vector_score
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def _is_confident_result(self, result: Dict[str, Any]) -> bool:
|
|
550
|
+
"""Backward-compatible alias for older callers/tests."""
|
|
551
|
+
return self.is_confident_result(result)
|
|
552
|
+
|
|
553
|
+
def hybrid_retrieve(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
|
|
554
|
+
"""混合检索"""
|
|
555
|
+
if not self.is_domain_query(query):
|
|
556
|
+
return []
|
|
557
|
+
|
|
558
|
+
results = []
|
|
559
|
+
|
|
560
|
+
# 1. 向量检索
|
|
561
|
+
vector_results = self._vector_search(query, self.vector_top_k)
|
|
562
|
+
|
|
563
|
+
# 2. BM25 检索
|
|
564
|
+
bm25_results = self._bm25_search(query, self.bm25_top_k)
|
|
565
|
+
|
|
566
|
+
# 3. 合并结果
|
|
567
|
+
combined = self._merge_results(vector_results, bm25_results)
|
|
568
|
+
|
|
569
|
+
# 4. 重排
|
|
570
|
+
reranked = self._rerank_results(combined, query)
|
|
571
|
+
|
|
572
|
+
# 5. 丢弃低置信度结果,避免无关问题也被强制注入 RAG 上下文
|
|
573
|
+
confident = [result for result in reranked if self.is_confident_result(result)]
|
|
574
|
+
return confident[:top_k]
|
|
575
|
+
|
|
576
|
+
def _vector_search(self, query: str, top_k: int) -> List[Tuple[int, float]]:
|
|
577
|
+
"""向量搜索"""
|
|
578
|
+
if self.faiss_index is None or SentenceTransformer is None:
|
|
579
|
+
return []
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
# 嵌入查询
|
|
583
|
+
embedding_model = self.config.get('models', {}).get('embedding_model', '')
|
|
584
|
+
if not embedding_model:
|
|
585
|
+
return []
|
|
586
|
+
|
|
587
|
+
model = SentenceTransformer(embedding_model)
|
|
588
|
+
query_vec = model.encode([query], normalize_embeddings=True).astype('float32')
|
|
589
|
+
|
|
590
|
+
# 搜索
|
|
591
|
+
D, I = self.faiss_index.search(query_vec, top_k)
|
|
592
|
+
|
|
593
|
+
results = []
|
|
594
|
+
for i, idx in enumerate(I[0]):
|
|
595
|
+
if idx >= 0 and idx < len(self.faiss_metadata):
|
|
596
|
+
results.append((idx, float(D[0][i])))
|
|
597
|
+
|
|
598
|
+
return results
|
|
599
|
+
|
|
600
|
+
except Exception as e:
|
|
601
|
+
logging.error(f"Vector search error: {e}")
|
|
602
|
+
return []
|
|
603
|
+
|
|
604
|
+
def _bm25_search(self, query: str, top_k: int) -> List[Tuple[int, float]]:
|
|
605
|
+
"""BM25 搜索"""
|
|
606
|
+
if BM25Okapi is None or not self.bm25_metadata:
|
|
607
|
+
return []
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
# 分词查询
|
|
611
|
+
query_tokens = self.tokenize(query)
|
|
612
|
+
|
|
613
|
+
# 计算 BM25 得分
|
|
614
|
+
scores = self.bm25_index.get_scores(query_tokens)
|
|
615
|
+
|
|
616
|
+
# 排序
|
|
617
|
+
results = [(i, float(score)) for i, score in enumerate(scores)]
|
|
618
|
+
results.sort(key=lambda x: x[1], reverse=True)
|
|
619
|
+
|
|
620
|
+
return results[:top_k]
|
|
621
|
+
|
|
622
|
+
except Exception as e:
|
|
623
|
+
logging.error(f"BM25 search error: {e}")
|
|
624
|
+
return []
|
|
625
|
+
|
|
626
|
+
def _merge_results(self, vector_results: List[Tuple[int, float]],
|
|
627
|
+
bm25_results: List[Tuple[int, float]]) -> List[Dict[str, Any]]:
|
|
628
|
+
"""合并检索结果,对 BM25 分数做归一化后再组合。"""
|
|
629
|
+
merged = {}
|
|
630
|
+
metadata_dict = self._load_metadata_dict()
|
|
631
|
+
|
|
632
|
+
# Normalise BM25 scores to [0, 1] so they combine fairly with vector scores.
|
|
633
|
+
bm25_max = max((s for _, s in bm25_results), default=1.0)
|
|
634
|
+
bm25_max = max(bm25_max, 1.0) # avoid division by zero
|
|
635
|
+
|
|
636
|
+
# 处理向量结果
|
|
637
|
+
for idx, score in vector_results:
|
|
638
|
+
if idx < len(self.faiss_metadata):
|
|
639
|
+
meta = self.faiss_metadata[idx]
|
|
640
|
+
doc_id = meta.get('doc_id') or f"faiss_{idx}"
|
|
641
|
+
|
|
642
|
+
if doc_id not in merged:
|
|
643
|
+
merged[doc_id] = {
|
|
644
|
+
'doc_id': doc_id,
|
|
645
|
+
'vector_score': score,
|
|
646
|
+
'bm25_score': 0.0,
|
|
647
|
+
'combined_score': score,
|
|
648
|
+
'metadata': meta
|
|
649
|
+
}
|
|
650
|
+
else:
|
|
651
|
+
merged[doc_id]['vector_score'] = score
|
|
652
|
+
merged[doc_id]['combined_score'] += score
|
|
653
|
+
|
|
654
|
+
# 处理 BM25 结果
|
|
655
|
+
for idx, score in bm25_results:
|
|
656
|
+
if idx < len(self.bm25_metadata):
|
|
657
|
+
meta = self.bm25_metadata[idx]
|
|
658
|
+
doc_id = meta.get('doc_id') or f"bm25_{idx}"
|
|
659
|
+
norm_score = score / bm25_max
|
|
660
|
+
|
|
661
|
+
if doc_id not in merged:
|
|
662
|
+
merged[doc_id] = {
|
|
663
|
+
'doc_id': doc_id,
|
|
664
|
+
'vector_score': 0.0,
|
|
665
|
+
'bm25_score': score,
|
|
666
|
+
'combined_score': norm_score,
|
|
667
|
+
'metadata': meta
|
|
668
|
+
}
|
|
669
|
+
else:
|
|
670
|
+
merged[doc_id]['bm25_score'] = score
|
|
671
|
+
merged[doc_id]['combined_score'] += norm_score
|
|
672
|
+
|
|
673
|
+
# 添加来自 metadata_dict 的完整数据
|
|
674
|
+
for doc_id, result in merged.items():
|
|
675
|
+
if doc_id in metadata_dict:
|
|
676
|
+
result['full_data'] = metadata_dict[doc_id]
|
|
677
|
+
|
|
678
|
+
return list(merged.values())
|
|
679
|
+
|
|
680
|
+
def _rerank_results(self, results: List[Dict[str, Any]], query: str) -> List[Dict[str, Any]]:
|
|
681
|
+
"""重排结果"""
|
|
682
|
+
query_lower = query.lower()
|
|
683
|
+
query_tokens = set(self.tokenize(query))
|
|
684
|
+
|
|
685
|
+
# 提取查询中的关键词
|
|
686
|
+
ros_keywords = ['·oh', 'o2·-', '1o2', 'h2o2', 'so4·-', 'hydroxyl', 'superoxide', 'singlet oxygen', 'hydrogen peroxide', 'sulfate radical']
|
|
687
|
+
condition_keywords = [' photocatalysis', 'light', 'pms', 'pds', '2e-', 'orr', 'electrochemical']
|
|
688
|
+
|
|
689
|
+
# 重排逻辑
|
|
690
|
+
def score_result(result: Dict[str, Any]) -> float:
|
|
691
|
+
score = result['combined_score']
|
|
692
|
+
|
|
693
|
+
# 来源优先级
|
|
694
|
+
metadata = result.get('metadata', {})
|
|
695
|
+
source_type = metadata.get('source_type', '')
|
|
696
|
+
|
|
697
|
+
source_bonus = self.source_priority.get(source_type, 1)
|
|
698
|
+
score += 0.1 * source_bonus
|
|
699
|
+
|
|
700
|
+
# 材料匹配
|
|
701
|
+
if 'full_data' in result:
|
|
702
|
+
full_data = result['full_data']
|
|
703
|
+
|
|
704
|
+
# 检查 ROS 匹配
|
|
705
|
+
requested_ros = [ros for ros in ros_keywords if ros in query_lower]
|
|
706
|
+
if requested_ros:
|
|
707
|
+
for item in full_data.get('dominant_ros_or_product', []):
|
|
708
|
+
species = item.get('species', '').lower()
|
|
709
|
+
if any(ros in species or species in ros for ros in requested_ros):
|
|
710
|
+
score += 2.0
|
|
711
|
+
break
|
|
712
|
+
|
|
713
|
+
# 检查条件匹配
|
|
714
|
+
env = full_data.get('reaction_environment', {})
|
|
715
|
+
if any(kw in query_lower for kw in condition_keywords):
|
|
716
|
+
if env.get('phase') or env.get('driving_force'):
|
|
717
|
+
score += 1.0
|
|
718
|
+
|
|
719
|
+
# 检查材料匹配
|
|
720
|
+
material = full_data.get('material_context', {})
|
|
721
|
+
material_text = material.get('material_type', '').lower()
|
|
722
|
+
material_tokens = set(self.tokenize(material_text))
|
|
723
|
+
specific_query_tokens = query_tokens - {'cof', 'cofs'}
|
|
724
|
+
if specific_query_tokens & material_tokens:
|
|
725
|
+
score += 1.5
|
|
726
|
+
|
|
727
|
+
result['rerank_score'] = score
|
|
728
|
+
return score
|
|
729
|
+
|
|
730
|
+
# 排序
|
|
731
|
+
results.sort(key=score_result, reverse=True)
|
|
732
|
+
|
|
733
|
+
return results
|
|
734
|
+
|
|
735
|
+
def retrieve_with_sources(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
|
|
736
|
+
"""检索并返回带完整来源信息的结果"""
|
|
737
|
+
results = self.hybrid_retrieve(query, top_k * 2) # 检索更多结果用于重排
|
|
738
|
+
|
|
739
|
+
# 构建返回结果
|
|
740
|
+
output = []
|
|
741
|
+
for result in results:
|
|
742
|
+
full_data = result.get('full_data') or result.get('metadata', {})
|
|
743
|
+
|
|
744
|
+
output.append({
|
|
745
|
+
'doc_id': result['doc_id'],
|
|
746
|
+
'score': result.get('rerank_score', result['combined_score']),
|
|
747
|
+
'vector_score': result['vector_score'],
|
|
748
|
+
'bm25_score': result['bm25_score'],
|
|
749
|
+
'source_type': result['metadata'].get('source_type', 'unknown'),
|
|
750
|
+
'evidence_level': full_data.get('evidence_level', 'low'),
|
|
751
|
+
'material_context': full_data.get('material_context', {}),
|
|
752
|
+
'reaction_environment': full_data.get('reaction_environment', {}),
|
|
753
|
+
'dominant_ros_or_product': full_data.get('dominant_ros_or_product', []),
|
|
754
|
+
'secondary_or_intermediate_ros': full_data.get('secondary_or_intermediate_ros', []),
|
|
755
|
+
'mechanism': full_data.get('mechanism', []),
|
|
756
|
+
'text': full_data.get('text', ''),
|
|
757
|
+
'source_chunk_id': full_data.get('source_chunk_id', '')
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
return output[:top_k]
|
|
761
|
+
|
|
762
|
+
def _retrieve_bm25_kg_facts(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
|
|
763
|
+
"""Fallback: derive compact facts from confident text retrieval results."""
|
|
764
|
+
results = self.hybrid_retrieve(query, top_k)
|
|
765
|
+
|
|
766
|
+
kg_facts = []
|
|
767
|
+
|
|
768
|
+
for result in results:
|
|
769
|
+
full_data = result.get('full_data', {})
|
|
770
|
+
if str(full_data.get('support_status', '')).lower() == 'unsupported':
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
material = full_data.get('material_context', {})
|
|
774
|
+
if material.get('material_type'):
|
|
775
|
+
kg_facts.append({
|
|
776
|
+
'head': material.get('material_type', ''),
|
|
777
|
+
'relation': 'has_condition',
|
|
778
|
+
'tail': ', '.join(filter(None, [
|
|
779
|
+
full_data.get('reaction_environment', {}).get('phase', ''),
|
|
780
|
+
full_data.get('reaction_environment', {}).get('driving_force', '')
|
|
781
|
+
])),
|
|
782
|
+
'evidence': full_data.get('evidence_level', 'low'),
|
|
783
|
+
'source': 'bm25_fallback',
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
for item in full_data.get('dominant_ros_or_product', []):
|
|
787
|
+
if item.get('species'):
|
|
788
|
+
kg_facts.append({
|
|
789
|
+
'head': material.get('material_type', ''),
|
|
790
|
+
'relation': 'generates',
|
|
791
|
+
'tail': item.get('species', ''),
|
|
792
|
+
'category': item.get('category', ''),
|
|
793
|
+
'evidence': full_data.get('evidence_level', 'low'),
|
|
794
|
+
'source': 'bm25_fallback',
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
return kg_facts
|
|
798
|
+
|
|
799
|
+
def retrieve_kg_facts(
|
|
800
|
+
self,
|
|
801
|
+
query: str,
|
|
802
|
+
top_k: int = 10,
|
|
803
|
+
min_confidence: Optional[float] = None,
|
|
804
|
+
) -> List[Dict[str, Any]]:
|
|
805
|
+
"""Retrieve confidence-gated KG facts from graph first, then BM25 fallback."""
|
|
806
|
+
threshold = getattr(self, 'min_kg_confidence', 2.0) if min_confidence is None else float(min_confidence)
|
|
807
|
+
graph_facts = self._retrieve_graph_kg_facts(
|
|
808
|
+
query,
|
|
809
|
+
top_k,
|
|
810
|
+
min_confidence=threshold,
|
|
811
|
+
)
|
|
812
|
+
if graph_facts:
|
|
813
|
+
return graph_facts
|
|
814
|
+
return self._retrieve_bm25_kg_facts(query, top_k)
|
|
815
|
+
|
|
816
|
+
def run(self):
|
|
817
|
+
"""运行检索测试"""
|
|
818
|
+
logging.info("Evidence Retriever initialized")
|
|
819
|
+
logging.info(f"FAISS vectors: {len(self.faiss_metadata) if self.faiss_metadata else 0}")
|
|
820
|
+
logging.info(f"BM25 documents: {len(self.bm25_metadata) if self.bm25_metadata else 0}")
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def main():
|
|
824
|
+
"""主函数"""
|
|
825
|
+
import argparse
|
|
826
|
+
|
|
827
|
+
parser = argparse.ArgumentParser(description='Retrieve evidence for COF-ROS queries')
|
|
828
|
+
parser.add_argument('--config', type=str, default='config/config.yaml',
|
|
829
|
+
help='Path to config file')
|
|
830
|
+
parser.add_argument('--query', type=str, default=None,
|
|
831
|
+
help='Query to retrieve (if not provided, run in test mode)')
|
|
832
|
+
parser.add_argument('--top-k', type=int, default=5,
|
|
833
|
+
help='Number of results to return')
|
|
834
|
+
args = parser.parse_args()
|
|
835
|
+
|
|
836
|
+
# 加载配置
|
|
837
|
+
config_path = Path(args.config)
|
|
838
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
839
|
+
config = yaml.safe_load(f)
|
|
840
|
+
|
|
841
|
+
# 初始化检索器
|
|
842
|
+
retriever = EvidenceRetriever(config)
|
|
843
|
+
|
|
844
|
+
if args.query:
|
|
845
|
+
# 检索查询
|
|
846
|
+
results = retriever.retrieve_with_sources(args.query, args.top_k)
|
|
847
|
+
|
|
848
|
+
for i, result in enumerate(results, 1):
|
|
849
|
+
print(f"\n--- Result {i} (score: {result['score']:.3f}) ---")
|
|
850
|
+
print(f"Source: {result['source_type']}")
|
|
851
|
+
print(f"Material: {result['material_context'].get('material_type', 'N/A')}")
|
|
852
|
+
print(f"Dominant ROS: {[item['species'] for item in result['dominant_ros_or_product']]}")
|
|
853
|
+
print(f"Text preview: {result['text'][:200]}...")
|
|
854
|
+
else:
|
|
855
|
+
retriever.run()
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
if __name__ == '__main__':
|
|
859
|
+
main()
|