@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.
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ COF-ROS-Reasoner: 数据处理工具函数
4
+ """
5
+
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import Dict, Any
9
+
10
+
11
+ def setup_logging(config: Dict[str, Any]) -> None:
12
+ """设置日志配置"""
13
+ logs_dir = Path(config['paths']['outputs_dir']) / 'logs'
14
+ logs_dir.mkdir(parents=True, exist_ok=True)
15
+
16
+ log_file = logs_dir / f"process_{__name__.split('.')[-1]}.log"
17
+
18
+ logging.basicConfig(
19
+ level=logging.INFO,
20
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
21
+ handlers=[
22
+ logging.FileHandler(log_file, encoding='utf-8'),
23
+ logging.StreamHandler()
24
+ ]
25
+ )
26
+
27
+
28
+ def load_config(config_path: str) -> Dict[str, Any]:
29
+ """加载 YAML 配置文件"""
30
+ import yaml
31
+ with open(config_path, 'r', encoding='utf-8') as f:
32
+ return yaml.safe_load(f)
33
+
34
+
35
+ def ensure_directory(path: Path) -> Path:
36
+ """确保目录存在"""
37
+ path.mkdir(parents=True, exist_ok=True)
38
+ return path
39
+
40
+
41
+ def save_jsonl(records: list, file_path: Path) -> None:
42
+ """保存记录为 JSONL 格式"""
43
+ ensure_directory(file_path.parent)
44
+ with open(file_path, 'w', encoding='utf-8') as f:
45
+ for record in records:
46
+ f.write(json.dumps(record, ensure_ascii=False) + '\n')
47
+
48
+
49
+ def load_jsonl(file_path: Path) -> list:
50
+ """加载 JSONL 文件"""
51
+ records = []
52
+ if file_path.exists():
53
+ with open(file_path, 'r', encoding='utf-8') as f:
54
+ for line in f:
55
+ if line.strip():
56
+ records.append(json.loads(line))
57
+ return records
58
+
59
+
60
+ def load_json(file_path: Path) -> Any:
61
+ """加载 JSON 文件"""
62
+ with open(file_path, 'r', encoding='utf-8') as f:
63
+ return json.load(f)
64
+
65
+
66
+ def save_json(data: Any, file_path: Path) -> None:
67
+ """保存数据为 JSON 文件"""
68
+ ensure_directory(file_path.parent)
69
+ with open(file_path, 'w', encoding='utf-8') as f:
70
+ json.dump(data, f, ensure_ascii=False, indent=2)
71
+
72
+
73
+ # 全局导入
74
+ import json
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ COF-ROS-Reasoner: 推理模块初始化
4
+ """
5
+
6
+ from .run_kg_grounded_inference import KGRAGInference
7
+
8
+ __all__ = ['KGRAGInference']
@@ -0,0 +1,33 @@
1
+ """Minimal conversational routing before domain retrieval."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import yaml
8
+
9
+
10
+ class QueryRouter:
11
+ """Handle only turns that should not invoke either retrieval or generation."""
12
+
13
+ _RESPONSES = None
14
+
15
+ @classmethod
16
+ def _load_responses(cls) -> dict:
17
+ if cls._RESPONSES is not None:
18
+ return cls._RESPONSES
19
+ prompt_path = Path("config/prompt_templates.yaml")
20
+ if prompt_path.exists():
21
+ with open(prompt_path, "r", encoding="utf-8") as f:
22
+ prompt_config = yaml.safe_load(f)
23
+ cls._RESPONSES = prompt_config.get("interaction", {}).get("greetings", {})
24
+ else:
25
+ cls._RESPONSES = {}
26
+ return cls._RESPONSES
27
+
28
+ @classmethod
29
+ def quick_response(cls, question: str) -> Optional[str]:
30
+ normalized = re.sub(
31
+ r"[^a-z0一-鿿]+", " ", question.lower()
32
+ ).strip()
33
+ return cls._load_responses().get(normalized)
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ COF-ROS-Reasoner: KG-RAG 推理 demo
4
+
5
+ 目标:
6
+ - 实现基于知识图谱和检索的问答推理
7
+ - 输出必须包含:主导物种、辅助物种、反应条件、证据依据、机理解释、不确定性
8
+ """
9
+
10
+ import os
11
+ import json
12
+ import yaml
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import Dict, List, Any, Optional
16
+ from datetime import datetime
17
+
18
+ # 添加 src 路径
19
+
20
+ try:
21
+ from openai import OpenAI
22
+ except ImportError:
23
+ OpenAI = None
24
+
25
+ try:
26
+ import requests
27
+ except ImportError:
28
+ requests = None
29
+
30
+ try:
31
+ from sentence_transformers import SentenceTransformer
32
+ except ImportError:
33
+ SentenceTransformer = None
34
+
35
+ try:
36
+ import faiss
37
+ except ImportError:
38
+ faiss = None
39
+
40
+ try:
41
+ from rank_bm25 import BM25Okapi
42
+ except ImportError:
43
+ BM25Okapi = None
44
+
45
+ from retrieval.retrieve_evidence import EvidenceRetriever
46
+ from data_processing.utils import setup_logging, ensure_directory
47
+
48
+
49
+ class KGRAGInference:
50
+ """KG-RAG 推理"""
51
+
52
+ def __init__(self, config: Dict[str, Any]):
53
+ self.config = config
54
+ self.retriever = None
55
+
56
+ # 设置日志
57
+ setup_logging(config)
58
+
59
+ # 推理配置
60
+ self.temperature = config.get('generation', {}).get('temperature', 0.2)
61
+ self.max_new_tokens = config.get('generation', {}).get('max_new_tokens', 1024)
62
+ llm_config = config.get('llm', {})
63
+ self.llm_provider = llm_config.get('provider', 'ollama')
64
+ self.generate_url = os.environ.get(
65
+ 'COFROS_LLM_URL',
66
+ llm_config.get('generate_url', 'http://localhost:11434/api/generate')
67
+ )
68
+ self.model_name = (
69
+ os.environ.get('COFROS_LLM_MODEL')
70
+ or os.environ.get('COFROS_INFERENCE_MODEL')
71
+ or llm_config.get('inference_model')
72
+ or llm_config.get('teacher_model')
73
+ or llm_config.get('model')
74
+ or config.get('models', {}).get('teacher_model', 'qwen3.6:35b')
75
+ )
76
+ self.timeout = llm_config.get('timeout', 120)
77
+
78
+ # 加载检索器
79
+ self._load_retriever()
80
+
81
+ # 加载 KG facts
82
+ self.kg_facts = self._load_kg_facts()
83
+
84
+ # 加载 prompt 模板
85
+ self.system_prompt = self._load_system_prompt()
86
+ self.user_prompt_template = self._load_user_prompt_template()
87
+
88
+ def _load_retriever(self):
89
+ """加载检索器"""
90
+ try:
91
+ self.retriever = EvidenceRetriever(self.config)
92
+ logging.info("Retriever loaded successfully")
93
+ except Exception as e:
94
+ logging.error(f"Failed to load retriever: {e}")
95
+
96
+ def _load_kg_facts(self) -> List[Dict[str, Any]]:
97
+ """加载 KG facts"""
98
+ kg_path = Path(self.config['paths']['kg_dir']) / 'cofros_kg_triples.jsonl'
99
+ facts = []
100
+
101
+ if kg_path.exists():
102
+ try:
103
+ with open(kg_path, 'r', encoding='utf-8') as f:
104
+ for line in f:
105
+ if line.strip():
106
+ facts.append(json.loads(line))
107
+ logging.info(f"Loaded {len(facts)} KG facts")
108
+ except Exception as e:
109
+ logging.error(f"Failed to load KG facts: {e}")
110
+
111
+ return facts
112
+
113
+ def _load_system_prompt(self) -> str:
114
+ """加载系统提示"""
115
+ prompt_path = Path('config/prompt_templates.yaml')
116
+ if not prompt_path.exists():
117
+ raise FileNotFoundError(f"Prompt template file not found: {prompt_path}")
118
+ with open(prompt_path, 'r', encoding='utf-8') as f:
119
+ prompt_config = yaml.safe_load(f) or {}
120
+ prompt = prompt_config.get('kg_rag_system_prompt')
121
+ if not prompt:
122
+ raise ValueError(f"kg_rag_system_prompt is missing from {prompt_path}")
123
+ return prompt
124
+
125
+ def _load_user_prompt_template(self) -> str:
126
+ """加载用户提示模板"""
127
+ prompt_path = Path('config/prompt_templates.yaml')
128
+ if not prompt_path.exists():
129
+ raise FileNotFoundError(f"Prompt template file not found: {prompt_path}")
130
+ with open(prompt_path, 'r', encoding='utf-8') as f:
131
+ prompt_config = yaml.safe_load(f) or {}
132
+ prompt = prompt_config.get('kg_rag_user_prompt')
133
+ if not prompt:
134
+ raise ValueError(f"kg_rag_user_prompt is missing from {prompt_path}")
135
+ return prompt
136
+
137
+ def format_kg_facts(self, kg_facts: List[Dict[str, Any]], max_items: int = 10) -> str:
138
+ """格式化 KG facts,标注可信度。"""
139
+ if not kg_facts:
140
+ return "No relevant knowledge graph facts found."
141
+
142
+ support_icon = {
143
+ 'supported': '✓',
144
+ 'partially_supported': '~',
145
+ 'unsupported': '?',
146
+ }
147
+
148
+ formatted = []
149
+ for fact in kg_facts[:max_items]:
150
+ head = fact.get('head', '')
151
+ relation = fact.get('relation', '')
152
+ tail = fact.get('tail', '')
153
+ tail_category = fact.get('tail_category', '')
154
+
155
+ line = f"- {head} {relation} {tail}"
156
+ if tail_category:
157
+ line += f" ({tail_category})"
158
+
159
+ condition = fact.get('condition') or {}
160
+ if isinstance(condition, dict):
161
+ condition_text = '; '.join(
162
+ str(value) for value in [
163
+ condition.get('phase'),
164
+ condition.get('oxidant_source'),
165
+ condition.get('driving_force'),
166
+ ]
167
+ if value
168
+ )
169
+ if condition_text:
170
+ line += f" | condition: {condition_text}"
171
+
172
+ evidence = fact.get('evidence') or {}
173
+ if isinstance(evidence, dict):
174
+ status = evidence.get('support_status') or ''
175
+ level = evidence.get('evidence_level') or ''
176
+ evidence_text = ' '.join(filter(None, [status, level]))
177
+ icon = support_icon.get(status, '')
178
+ if icon:
179
+ evidence_text = f"{icon} {evidence_text}"
180
+ else:
181
+ evidence_text = str(evidence)
182
+ if evidence_text:
183
+ line += f" | {evidence_text}"
184
+
185
+ formatted.append(line)
186
+
187
+ return '\n'.join(formatted)
188
+
189
+ @staticmethod
190
+ def _truncate_at_sentence(text: str, max_chars: int = 500) -> str:
191
+ """Truncate *text* at the last sentence boundary before *max_chars*."""
192
+ if len(text) <= max_chars:
193
+ return text
194
+ snippet = text[:max_chars]
195
+ for sep in ('. ', '.\n', '? ', '?\n', '! ', '!\n', '。', ';'):
196
+ idx = snippet.rfind(sep)
197
+ if idx > max_chars * 0.5:
198
+ return snippet[:idx + len(sep.rstrip())]
199
+ last_space = snippet.rfind(' ')
200
+ if last_space > max_chars * 0.5:
201
+ return snippet[:last_space]
202
+ return snippet
203
+
204
+ def format_evidence(self, evidence_list: List[Dict[str, Any]], max_items: int = 10) -> str:
205
+ """格式化证据,标注可信度等级和验证状态。"""
206
+ if not evidence_list:
207
+ return "No relevant evidence found."
208
+
209
+ level_badge = {
210
+ 'high': '[HIGH confidence]',
211
+ 'medium': '[MEDIUM confidence]',
212
+ 'low': '[LOW confidence]',
213
+ }
214
+ support_badge = {
215
+ 'supported': 'verified',
216
+ 'partially_supported': 'partial',
217
+ 'unsupported': 'unverified',
218
+ }
219
+
220
+ formatted = []
221
+ for i, evidence in enumerate(evidence_list[:max_items], 1):
222
+ source_type = evidence.get('source_type', 'unknown')
223
+ material = evidence.get('material_context', {}).get('material_type', 'N/A')
224
+ dominant = [item.get('species', 'N/A') for item in evidence.get('dominant_ros_or_product', [])]
225
+ evidence_level = evidence.get('evidence_level', 'low')
226
+ support_status = evidence.get('support_status', 'unsupported')
227
+
228
+ level_str = level_badge.get(evidence_level, evidence_level)
229
+ support_str = support_badge.get(support_status, support_status)
230
+ text = self._truncate_at_sentence(evidence.get('text', ''), max_chars=500)
231
+
232
+ formatted.append(
233
+ f"[{i}] {source_type} {level_str} ({support_str})\n"
234
+ f" Material: {material}\n"
235
+ f" Dominant ROS: {', '.join(dominant)}\n"
236
+ f" {text}"
237
+ )
238
+ return '\n\n'.join(formatted)
239
+
240
+ def call_llm(self, system_prompt: str, user_prompt: str) -> str:
241
+ """调用本地/内网 LLM。"""
242
+ if self.llm_provider != 'ollama':
243
+ raise ValueError(f"Unsupported LLM provider: {self.llm_provider}")
244
+
245
+ if requests is None:
246
+ raise ImportError("requests required. Run: pip install requests")
247
+
248
+ prompt = f"{system_prompt.strip()}\n\n{user_prompt.strip()}"
249
+ payload = {
250
+ "model": self.model_name,
251
+ "prompt": prompt,
252
+ "stream": False,
253
+ "options": {
254
+ "temperature": self.temperature,
255
+ "top_p": self.config.get('generation', {}).get('top_p', 0.9),
256
+ "num_predict": self.max_new_tokens,
257
+ },
258
+ }
259
+
260
+ response = requests.post(self.generate_url, json=payload, timeout=self.timeout)
261
+ response.raise_for_status()
262
+ data = response.json()
263
+ return data.get("response", "").strip()
264
+
265
+ def answer_question(self, question: str, top_k: int = 5) -> Dict[str, Any]:
266
+ """回答问题"""
267
+ result = {
268
+ 'question': question,
269
+ 'timestamp': datetime.now().isoformat()
270
+ }
271
+
272
+ if self.retriever is None:
273
+ result['kg_facts'] = []
274
+ result['evidence'] = []
275
+ result['answer'] = "Evidence retrieval is unavailable. Build retrieval indexes before running KG-RAG inference."
276
+ return result
277
+
278
+ # 1. 检索 KG facts
279
+ kg_facts = self.retriever.retrieve_kg_facts(question, top_k)
280
+ result['kg_facts'] = kg_facts
281
+
282
+ # 2. 检索文献证据
283
+ evidence = self.retriever.retrieve_with_sources(question, top_k)
284
+ result['evidence'] = evidence
285
+
286
+ # 3. 构造 prompt
287
+ kg_facts_str = self.format_kg_facts(kg_facts)
288
+ evidence_str = self.format_evidence(evidence)
289
+
290
+ user_prompt = self.user_prompt_template.format(
291
+ question=question,
292
+ kg_facts=kg_facts_str,
293
+ evidence=evidence_str
294
+ )
295
+
296
+ # 4. 调用 LLM
297
+ answer = self.call_llm(self.system_prompt, user_prompt)
298
+ result['answer'] = answer
299
+
300
+ return result
301
+
302
+ def run_demo(self):
303
+ """运行 demo"""
304
+ logging.info("KG-RAG Inference Demo")
305
+ logging.info("=" * 50)
306
+
307
+ # 从 prompt_templates.yaml 加载 demo 问题
308
+ import yaml
309
+ prompt_path = Path("config/prompt_templates.yaml")
310
+ test_questions = [
311
+ "What ROS does TT-T-COF generate under visible light photocatalysis?",
312
+ "Which COF materials are reported to generate H2O2 as the main product?",
313
+ "What is the mechanism of ROS generation in donor-acceptor COFs?",
314
+ ]
315
+ if prompt_path.exists():
316
+ with open(prompt_path, "r", encoding="utf-8") as f:
317
+ prompt_config = yaml.safe_load(f)
318
+ test_questions = prompt_config.get("demo", {}).get("questions", test_questions)
319
+
320
+ for question in test_questions:
321
+ print(f"\n{'=' * 50}")
322
+ print(f"Question: {question}")
323
+ print("=" * 50)
324
+
325
+ result = self.answer_question(question)
326
+
327
+ print(f"\nAnswer:")
328
+ print(result['answer'])
329
+
330
+ def run(self):
331
+ """运行推理服务"""
332
+ logging.info("KG-RAG Inference initialized")
333
+ logging.info(f"KG facts loaded: {len(self.kg_facts)}")
334
+
335
+
336
+ def main():
337
+ """主函数"""
338
+ import argparse
339
+
340
+ parser = argparse.ArgumentParser(description='Run KG-RAG inference for COF-ROS questions')
341
+ parser.add_argument('--config', type=str, default='config/config.yaml',
342
+ help='Path to config file')
343
+ parser.add_argument('--question', type=str, default=None,
344
+ help='Single question to answer')
345
+ parser.add_argument('--demo', action='store_true',
346
+ help='Run demo mode')
347
+ args = parser.parse_args()
348
+
349
+ # 加载配置
350
+ config_path = Path(args.config)
351
+ with open(config_path, 'r', encoding='utf-8') as f:
352
+ config = yaml.safe_load(f)
353
+
354
+ # 初始化推理器
355
+ inference = KGRAGInference(config)
356
+
357
+ if args.question:
358
+ # 单个问题
359
+ result = inference.answer_question(args.question)
360
+ print(f"\nQuestion: {args.question}")
361
+ print(f"\nAnswer:\n{result['answer']}")
362
+ elif args.demo:
363
+ # Demo 模式
364
+ inference.run_demo()
365
+ else:
366
+ inference.run()
367
+
368
+
369
+ if __name__ == '__main__':
370
+ main()