@lzhzzzzwill/cofos 1.0.1 → 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,520 @@
1
+ #!/usr/bin/env python3
2
+ """Local student-model inference with optional confidence-gated KG/BM25 RAG."""
3
+
4
+ import logging
5
+ import re
6
+ from pathlib import Path
7
+ from threading import Thread
8
+ from typing import Any, Dict, Generator, List, Optional
9
+
10
+ import torch
11
+ import yaml
12
+
13
+ try:
14
+ from peft import PeftModel
15
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
16
+ except ImportError as exc:
17
+ raise ImportError(
18
+ "Student inference requires transformers, peft, and torch. "
19
+ "Install the training/inference dependencies first."
20
+ ) from exc
21
+
22
+ from inference.query_router import QueryRouter
23
+ from inference.run_kg_grounded_inference import KGRAGInference
24
+
25
+
26
+ class StudentAdapterInference:
27
+ """Run a fine-tuned student model with optional grounded retrieval."""
28
+
29
+ REQUIRED_PROMPTS = (
30
+ "student_qa_system_prompt",
31
+ "student_qa_user_prompt",
32
+ "student_rag_user_prompt",
33
+ )
34
+
35
+ @staticmethod
36
+ def _is_hf_repo(path: str) -> bool:
37
+ """Return True if ``path`` looks like a Hugging Face repo id (org/repo)."""
38
+ return bool(re.match(r"^[A-Za-z0-9_\-\.]+/[A-Za-z0-9_\-\.]+$", path))
39
+
40
+ def __init__(
41
+ self,
42
+ config: Dict[str, Any],
43
+ model_path: str,
44
+ adapter_path: Optional[str],
45
+ use_rag: bool = True,
46
+ max_new_tokens: Optional[int] = None,
47
+ ):
48
+ self.config = config
49
+ self.model_path_str = model_path
50
+ self.model_path = Path(model_path)
51
+ self.adapter_path = Path(adapter_path) if adapter_path else None
52
+ self.model_is_hf = self._is_hf_repo(model_path)
53
+ self.use_rag = use_rag
54
+ generation_config = config.get("generation", {})
55
+ self.max_new_tokens = int(
56
+ max_new_tokens or generation_config.get("max_new_tokens", 1024)
57
+ )
58
+ self.do_sample = bool(generation_config.get("do_sample", False))
59
+ self.temperature = float(generation_config.get("temperature", 0.2))
60
+ self.top_p = float(generation_config.get("top_p", 0.9))
61
+ self.repetition_penalty = float(generation_config.get("repetition_penalty", 1.08))
62
+ self.num_beams = int(generation_config.get("num_beams", 1))
63
+ self.no_repeat_ngram_size = int(generation_config.get("no_repeat_ngram_size", 0))
64
+ self.min_new_tokens = int(generation_config.get("min_new_tokens", 0))
65
+ self.history_max_turns = int(generation_config.get("history_max_turns", 4))
66
+ self.refine_answers = bool(generation_config.get("refine_answers", True))
67
+ self.refinement_max_new_tokens = int(
68
+ generation_config.get(
69
+ "refinement_max_new_tokens",
70
+ min(max(self.max_new_tokens, 128), 512),
71
+ )
72
+ )
73
+ self.prompt_templates = self._load_prompt_templates()
74
+ self.rag = KGRAGInference(config) if use_rag else None
75
+ self.tokenizer = None
76
+ self.model = None
77
+
78
+ def _load_prompt_templates(self) -> Dict[str, str]:
79
+ prompt_path = Path(
80
+ self.config.get("prompts", {}).get(
81
+ "path", "config/prompt_templates.yaml"
82
+ )
83
+ )
84
+ if not prompt_path.exists():
85
+ raise FileNotFoundError(f"Prompt template file not found: {prompt_path}")
86
+
87
+ with open(prompt_path, "r", encoding="utf-8") as handle:
88
+ prompts = yaml.safe_load(handle) or {}
89
+
90
+ missing = [key for key in self.REQUIRED_PROMPTS if not prompts.get(key)]
91
+ if missing:
92
+ raise ValueError(
93
+ f"Missing required prompt templates in {prompt_path}: {', '.join(missing)}"
94
+ )
95
+ return prompts
96
+
97
+ def validate_paths(self) -> None:
98
+ if self.model_is_hf:
99
+ # Hugging Face repo id — skip local path checks; transformers will
100
+ # download (or use the local HF cache) automatically.
101
+ return
102
+ if not self.model_path.exists():
103
+ raise FileNotFoundError(f"Base model path not found: {self.model_path}")
104
+ if self.adapter_path is None:
105
+ return
106
+ if not self.adapter_path.exists():
107
+ raise FileNotFoundError(f"Adapter path not found: {self.adapter_path}")
108
+ if not (self.adapter_path / "adapter_config.json").exists():
109
+ raise FileNotFoundError(
110
+ f"LoRA adapter_config.json not found in: {self.adapter_path}"
111
+ )
112
+ if not (self.adapter_path / "adapter_model.safetensors").exists():
113
+ raise FileNotFoundError(
114
+ f"LoRA adapter_model.safetensors not found in: {self.adapter_path}"
115
+ )
116
+
117
+ def load_model(self) -> None:
118
+ self.validate_paths()
119
+ logging.info("Loading base model from %s", self.model_path_str)
120
+ try:
121
+ self.tokenizer = AutoTokenizer.from_pretrained(
122
+ self.model_path_str, trust_remote_code=True
123
+ )
124
+ except (AttributeError, ValueError) as exc:
125
+ logging.warning(
126
+ "Default tokenizer loading failed (%s); retrying with Qwen2 fast tokenizer compatibility",
127
+ exc,
128
+ )
129
+ self.tokenizer = AutoTokenizer.from_pretrained(
130
+ self.model_path_str,
131
+ trust_remote_code=True,
132
+ use_fast=True,
133
+ tokenizer_type="qwen2",
134
+ extra_special_tokens={},
135
+ )
136
+ if self.tokenizer.pad_token is None:
137
+ self.tokenizer.pad_token = self.tokenizer.eos_token
138
+
139
+ model = AutoModelForCausalLM.from_pretrained(
140
+ self.model_path_str,
141
+ device_map="auto",
142
+ torch_dtype=torch.bfloat16,
143
+ trust_remote_code=True,
144
+ )
145
+ if self.adapter_path is not None:
146
+ logging.info("Loading LoRA adapter from %s", self.adapter_path)
147
+ model = PeftModel.from_pretrained(model, str(self.adapter_path))
148
+ else:
149
+ logging.info("Using merged model without a separate LoRA adapter")
150
+
151
+ self.model = model
152
+ self.model.eval()
153
+
154
+ def _generation_kwargs(self, max_new_tokens: Optional[int] = None) -> Dict[str, Any]:
155
+ kwargs = {
156
+ "max_new_tokens": int(max_new_tokens or self.max_new_tokens),
157
+ "do_sample": self.do_sample,
158
+ "repetition_penalty": self.repetition_penalty,
159
+ "eos_token_id": self.tokenizer.eos_token_id,
160
+ "pad_token_id": self.tokenizer.pad_token_id,
161
+ }
162
+ if self.do_sample:
163
+ kwargs["temperature"] = self.temperature
164
+ kwargs["top_p"] = self.top_p
165
+ if self.num_beams > 1:
166
+ kwargs["num_beams"] = self.num_beams
167
+ kwargs["early_stopping"] = True
168
+ if self.no_repeat_ngram_size > 0:
169
+ kwargs["no_repeat_ngram_size"] = self.no_repeat_ngram_size
170
+ if self.min_new_tokens > 0:
171
+ kwargs["min_new_tokens"] = self.min_new_tokens
172
+ return kwargs
173
+
174
+ def _qa_messages(
175
+ self,
176
+ question: str,
177
+ no_high_confidence_evidence: bool = False,
178
+ ) -> List[Dict[str, str]]:
179
+ template_key = (
180
+ "student_qa_no_evidence_user_prompt"
181
+ if no_high_confidence_evidence
182
+ and self.prompt_templates.get("student_qa_no_evidence_user_prompt")
183
+ else "student_qa_user_prompt"
184
+ )
185
+ user_prompt = self.prompt_templates[template_key].format(question=question)
186
+ return [
187
+ {
188
+ "role": "system",
189
+ "content": self.prompt_templates["student_qa_system_prompt"].strip(),
190
+ },
191
+ {"role": "user", "content": user_prompt.strip()},
192
+ ]
193
+
194
+ def _rag_context(self, question: str, top_k: int = 5) -> Dict[str, Any]:
195
+ if self.rag is None:
196
+ return {
197
+ "messages": self._qa_messages(question),
198
+ "mode": "qa",
199
+ "kg_facts": [],
200
+ "evidence": [],
201
+ }
202
+
203
+ retriever = self.rag.retriever
204
+ if hasattr(retriever, "is_domain_query") and not retriever.is_domain_query(question):
205
+ return {
206
+ "messages": self._qa_messages(question),
207
+ "mode": "qa",
208
+ "kg_facts": [],
209
+ "evidence": [],
210
+ }
211
+
212
+ try:
213
+ kg_facts = retriever.retrieve_kg_facts(
214
+ question,
215
+ top_k,
216
+ min_confidence=getattr(retriever, "min_kg_confidence", None),
217
+ )
218
+ except TypeError:
219
+ kg_facts = retriever.retrieve_kg_facts(question, top_k)
220
+ evidence = [
221
+ item for item in retriever.retrieve_with_sources(question, top_k)
222
+ if not hasattr(retriever, "is_confident_result")
223
+ or retriever.is_confident_result(item)
224
+ ]
225
+
226
+ if not kg_facts and not evidence:
227
+ logging.info("No high-confidence RAG context; answering in QA mode")
228
+ return {
229
+ "messages": self._qa_messages(
230
+ question,
231
+ no_high_confidence_evidence=True,
232
+ ),
233
+ "mode": "qa",
234
+ "kg_facts": [],
235
+ "evidence": [],
236
+ }
237
+
238
+ kg_facts_text = self.rag.format_kg_facts(kg_facts, max_items=top_k)
239
+ evidence_text = self.rag.format_evidence(evidence, max_items=top_k)
240
+ user_prompt = self.prompt_templates["student_rag_user_prompt"].format(
241
+ question=question,
242
+ kg_facts=kg_facts_text,
243
+ evidence=evidence_text,
244
+ )
245
+ return {
246
+ "messages": [
247
+ {
248
+ "role": "system",
249
+ "content": self.prompt_templates["student_qa_system_prompt"].strip(),
250
+ },
251
+ {"role": "user", "content": user_prompt.strip()},
252
+ ],
253
+ "mode": "rag",
254
+ "kg_facts": kg_facts,
255
+ "evidence": evidence,
256
+ }
257
+
258
+ def build_messages(self, question: str, top_k: int = 5) -> List[Dict[str, str]]:
259
+ return self._rag_context(question, top_k=top_k)["messages"]
260
+
261
+ def _with_history(
262
+ self,
263
+ messages: List[Dict[str, str]],
264
+ history: Optional[List[Dict[str, str]]] = None,
265
+ ) -> List[Dict[str, str]]:
266
+ if not history:
267
+ return messages
268
+ max_items = max(0, self.history_max_turns) * 2
269
+ recent_history = history[-max_items:] if max_items else []
270
+ if not recent_history:
271
+ return messages
272
+ return [messages[0], *recent_history, *messages[1:]]
273
+
274
+ def _render_prompt(self, messages: List[Dict[str, str]]) -> str:
275
+ if self.tokenizer is None:
276
+ raise RuntimeError("Tokenizer must be loaded before rendering a chat prompt")
277
+ return self.tokenizer.apply_chat_template(
278
+ messages,
279
+ tokenize=False,
280
+ add_generation_prompt=True,
281
+ )
282
+
283
+ def build_prompt(self, question: str, top_k: int = 5) -> str:
284
+ return self._render_prompt(self.build_messages(question, top_k=top_k))
285
+
286
+ def _generate_from_messages(
287
+ self,
288
+ messages: List[Dict[str, str]],
289
+ max_new_tokens: Optional[int] = None,
290
+ ) -> str:
291
+ prompt = self._render_prompt(messages)
292
+ inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
293
+ with torch.no_grad():
294
+ output = self.model.generate(
295
+ **inputs,
296
+ **self._generation_kwargs(max_new_tokens=max_new_tokens),
297
+ )
298
+ generated = output[0][inputs["input_ids"].shape[-1] :]
299
+ return self.tokenizer.decode(generated, skip_special_tokens=True)
300
+
301
+ @staticmethod
302
+ def _has_report_style(answer: str) -> bool:
303
+ report_patterns = (
304
+ r"(?i)\bbased on (?:the )?(?:provided|given|available|supplied)",
305
+ r"(?i)\bprovided (?:information|text|context|excerpt|data)",
306
+ r"(?i)\bgiven (?:the )?(?:information|text|context|excerpt|data)",
307
+ r"(?i)\b(?:supported|confirmed|backed) by (?:high )?(?:efficiency|performance|observations?|data|evidence|metrics)",
308
+ r"(?i)\bexplicit (?:mechanistic pathways?|detection evidence|data) (?:are|is) not",
309
+ r"(?i)\bprovided excerpt\b",
310
+ )
311
+ return any(re.search(pattern, answer) for pattern in report_patterns)
312
+
313
+ def _render_final_answer(
314
+ self,
315
+ question: str,
316
+ draft_answer: str,
317
+ extra_instruction: str = "",
318
+ ) -> str:
319
+ system_prompt = self.prompt_templates.get(
320
+ "student_answer_refinement_system_prompt"
321
+ )
322
+ user_prompt_template = self.prompt_templates.get(
323
+ "student_answer_refinement_user_prompt"
324
+ )
325
+ if not system_prompt or not user_prompt_template:
326
+ return draft_answer
327
+
328
+ user_prompt = user_prompt_template.format(
329
+ question=question,
330
+ draft_answer=draft_answer,
331
+ ).strip()
332
+ if extra_instruction:
333
+ user_prompt = f"{user_prompt}\n\n{extra_instruction.strip()}"
334
+
335
+ messages = [
336
+ {"role": "system", "content": system_prompt.strip()},
337
+ {"role": "user", "content": user_prompt},
338
+ ]
339
+ return self.clean_answer(
340
+ self._generate_from_messages(
341
+ messages,
342
+ max_new_tokens=self.refinement_max_new_tokens,
343
+ )
344
+ )
345
+
346
+ def _refine_answer(self, question: str, draft_answer: str) -> str:
347
+ if not self.refine_answers:
348
+ return draft_answer
349
+
350
+ refined = self._render_final_answer(question, draft_answer)
351
+ if not refined or refined == "No answer was generated.":
352
+ return draft_answer
353
+
354
+ if self._has_report_style(refined):
355
+ retry = self._render_final_answer(
356
+ question,
357
+ refined,
358
+ extra_instruction=(
359
+ "The previous answer still sounded like a report about source material. "
360
+ "Rewrite it once more as a direct answer to the user. Keep the same facts, "
361
+ "but remove source-attribution language, evidence-status language, and comments "
362
+ "about what is or is not present in excerpts/data unless the user asked for evidence."
363
+ ),
364
+ )
365
+ if retry and retry != "No answer was generated.":
366
+ return retry
367
+
368
+ return refined
369
+
370
+ def answer_with_metadata(
371
+ self,
372
+ question: str,
373
+ top_k: int = 5,
374
+ history: Optional[List[Dict[str, str]]] = None,
375
+ ) -> Dict[str, Any]:
376
+ quick_response = QueryRouter.quick_response(question)
377
+ if quick_response is not None:
378
+ return {
379
+ "answer": quick_response,
380
+ "mode": "qa",
381
+ "kg_facts_used": 0,
382
+ "evidence_used": 0,
383
+ }
384
+
385
+ if self.model is None or self.tokenizer is None:
386
+ self.load_model()
387
+
388
+ context = self._rag_context(question, top_k=top_k)
389
+ messages = self._with_history(context["messages"], history)
390
+ answer = self.clean_answer(self._generate_from_messages(messages))
391
+ answer = self._refine_answer(question, answer)
392
+ return {
393
+ "answer": answer,
394
+ "mode": context["mode"],
395
+ "kg_facts_used": len(context["kg_facts"]),
396
+ "evidence_used": len(context["evidence"]),
397
+ }
398
+
399
+ def answer(self, question: str, top_k: int = 5) -> str:
400
+ return self.answer_with_metadata(question, top_k=top_k)["answer"]
401
+
402
+ def answer_stream(
403
+ self,
404
+ question: str,
405
+ top_k: int = 5,
406
+ history: Optional[List[Dict[str, str]]] = None,
407
+ ) -> Generator[str, None, None]:
408
+ """Stream tokens for *question* via :class:`TextIteratorStreamer`."""
409
+ quick_response = QueryRouter.quick_response(question)
410
+ if quick_response is not None:
411
+ yield quick_response
412
+ return
413
+
414
+ if self.model is None or self.tokenizer is None:
415
+ self.load_model()
416
+
417
+ if self.refine_answers:
418
+ yield self.answer_with_metadata(
419
+ question,
420
+ top_k=top_k,
421
+ history=history,
422
+ )["answer"]
423
+ return
424
+
425
+ context = self._rag_context(question, top_k=top_k)
426
+ prompt = self._render_prompt(self._with_history(context["messages"], history))
427
+ inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
428
+
429
+ streamer = TextIteratorStreamer(
430
+ self.tokenizer,
431
+ skip_prompt=True,
432
+ skip_special_tokens=True,
433
+ )
434
+ gen_kwargs = {
435
+ **inputs,
436
+ "streamer": streamer,
437
+ **self._generation_kwargs(),
438
+ }
439
+ thread = Thread(target=self.model.generate, kwargs=gen_kwargs)
440
+ thread.start()
441
+
442
+ for text in streamer:
443
+ yield text
444
+
445
+ @staticmethod
446
+ def _strip_reasoning_scaffold(answer: str) -> str:
447
+ final_match = re.search(
448
+ r"(?ims)^\s*(?:final\s+(?:answer|response)|answer)\s*[::]\s*(.+)$",
449
+ answer,
450
+ )
451
+ if final_match:
452
+ return final_match.group(1).strip()
453
+
454
+ scaffold_heading = re.compile(
455
+ r"(?im)^\s*(?:thinking\s+process|analysis|plan|draft\s+the\s+final\s+answer|"
456
+ r"refine\s+for\s+natural\s+flow|final\s+polish|check\s+against\s+constraints)\s*[::]?\s*$"
457
+ )
458
+ if not scaffold_heading.search(answer):
459
+ return answer
460
+
461
+ paragraphs = [part.strip() for part in re.split(r"\n\s*\n", answer) if part.strip()]
462
+ content_paragraphs = []
463
+ meta_line = re.compile(
464
+ r"(?i)^\s*(?:\d+[.)]\s*)?(?:analy[sz]e|evaluate|draft|refine|final\s+polish|"
465
+ r"check|constraints?|task|input|output|preserve|return\s+only|start\s+directly)\b"
466
+ )
467
+ bullet_meta = re.compile(r"^\s*[*-]\s*(?:Input|Task|Constraints?|Material|Condition|Primary|Nature|Evidence|Mechanism)\s*:", re.I)
468
+ for paragraph in paragraphs:
469
+ lines = [line.strip() for line in paragraph.splitlines() if line.strip()]
470
+ if not lines:
471
+ continue
472
+ if scaffold_heading.match(lines[0]):
473
+ if len(lines) > 1 and re.match(r"(?i)^\s*final\s+polish\s*[::]?\s*$", lines[0]):
474
+ content_paragraphs.append("\n".join(lines[1:]).strip())
475
+ continue
476
+ if meta_line.match(lines[0]) or bullet_meta.match(lines[0]):
477
+ continue
478
+ content_paragraphs.append(paragraph)
479
+
480
+ return content_paragraphs[-1] if content_paragraphs else answer
481
+
482
+ @staticmethod
483
+ def clean_answer(answer: str) -> str:
484
+ answer = answer.strip()
485
+ if not answer:
486
+ return "No answer was generated."
487
+ answer = re.sub(r"(?is)<think>.*?</think>", "", answer).strip()
488
+ answer = re.sub(r"(?is)^.*?</think>", "", answer).strip()
489
+ answer = StudentAdapterInference._strip_reasoning_scaffold(answer)
490
+ if not answer:
491
+ return "No answer was generated."
492
+ answer = re.sub(r"(?im)^\s*(assistant|model|模型)\s*[::]\s*", "", answer)
493
+ answer = re.sub(
494
+ r"(?is)^\s*sure[,,]?\s*(here(?:'s| is)\s+)?(?:the\s+)?answer\s*[::]?\s*",
495
+ "",
496
+ answer,
497
+ )
498
+ return re.sub(r"\n{3,}", "\n\n", answer).strip()
499
+
500
+ def chat(self, top_k: int = 5) -> None:
501
+ print("进入 COF-ROS 学生模型问答模式,输入 exit 退出。")
502
+ history: List[Dict[str, str]] = []
503
+ while True:
504
+ question = input("\n你: ").strip()
505
+ if question.lower() in {"exit", "quit", "q"}:
506
+ print("退出")
507
+ break
508
+ if question.lower() in {"clear", "/clear"}:
509
+ history.clear()
510
+ print("已清空对话历史")
511
+ continue
512
+ if not question:
513
+ continue
514
+ print("\n模型:")
515
+ result = self.answer_with_metadata(question, top_k=top_k, history=history)
516
+ print(result["answer"])
517
+ history.extend([
518
+ {"role": "user", "content": question},
519
+ {"role": "assistant", "content": result["answer"]},
520
+ ])
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ COF-ROS-Reasoner: 检索模块初始化
4
+ """
5
+
6
+ from .build_faiss_index import FAISSIndexBuilder
7
+ from .build_bm25_index import BM25IndexBuilder
8
+ from .retrieve_evidence import EvidenceRetriever
9
+
10
+ __all__ = ['FAISSIndexBuilder', 'BM25IndexBuilder', 'EvidenceRetriever']