@lzhzzzzwill/cofos 1.0.2 → 1.1.0

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.
@@ -11,16 +11,130 @@ import torch
11
11
  import yaml
12
12
 
13
13
  try:
14
- from peft import PeftModel
15
14
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
16
15
  except ImportError as exc:
17
16
  raise ImportError(
18
- "Student inference requires transformers, peft, and torch. "
19
- "Install the training/inference dependencies first."
17
+ "Student inference requires transformers and torch. "
18
+ "Install the inference dependencies first."
20
19
  ) from exc
21
20
 
21
+ from data_processing.utils import resolve_under_root
22
22
  from inference.query_router import QueryRouter
23
- from inference.run_kg_grounded_inference import KGRAGInference
23
+ from retrieval.retrieve_evidence import EvidenceRetriever
24
+
25
+
26
+ class StudentRAGContext:
27
+ """Lightweight RAG helper for student-model inference.
28
+
29
+ This intentionally avoids the legacy teacher/LLM inference path; it only
30
+ loads retrieval indexes and formats retrieved context for Willlzh/COFOS.
31
+ """
32
+
33
+ def __init__(self, config: Dict[str, Any]):
34
+ self.retriever = EvidenceRetriever(config)
35
+
36
+ def format_kg_facts(self, kg_facts: List[Dict[str, Any]], max_items: int = 10) -> str:
37
+ if not kg_facts:
38
+ return "No relevant knowledge graph facts found."
39
+
40
+ support_icon = {
41
+ "supported": "✓",
42
+ "partially_supported": "~",
43
+ "unsupported": "?",
44
+ }
45
+ formatted = []
46
+ for fact in kg_facts[:max_items]:
47
+ head = fact.get("head", "")
48
+ relation = fact.get("relation", "")
49
+ tail = fact.get("tail", "")
50
+ tail_category = fact.get("tail_category", "")
51
+
52
+ line = f"- {head} {relation} {tail}"
53
+ if tail_category:
54
+ line += f" ({tail_category})"
55
+
56
+ condition = fact.get("condition") or {}
57
+ if isinstance(condition, dict):
58
+ condition_text = "; ".join(
59
+ str(value)
60
+ for value in [
61
+ condition.get("phase"),
62
+ condition.get("oxidant_source"),
63
+ condition.get("driving_force"),
64
+ ]
65
+ if value
66
+ )
67
+ if condition_text:
68
+ line += f" | condition: {condition_text}"
69
+
70
+ evidence = fact.get("evidence") or {}
71
+ if isinstance(evidence, dict):
72
+ status = evidence.get("support_status") or ""
73
+ level = evidence.get("evidence_level") or ""
74
+ evidence_text = " ".join(filter(None, [status, level]))
75
+ icon = support_icon.get(status, "")
76
+ if icon:
77
+ evidence_text = f"{icon} {evidence_text}"
78
+ else:
79
+ evidence_text = str(evidence)
80
+ if evidence_text:
81
+ line += f" | {evidence_text}"
82
+
83
+ formatted.append(line)
84
+
85
+ return "\n".join(formatted)
86
+
87
+ @staticmethod
88
+ def _truncate_at_sentence(text: str, max_chars: int = 500) -> str:
89
+ if len(text) <= max_chars:
90
+ return text
91
+ snippet = text[:max_chars]
92
+ for sep in (". ", ".\n", "? ", "?\n", "! ", "!\n", "。", ";"):
93
+ idx = snippet.rfind(sep)
94
+ if idx > max_chars * 0.5:
95
+ return snippet[:idx + len(sep.rstrip())]
96
+ last_space = snippet.rfind(" ")
97
+ if last_space > max_chars * 0.5:
98
+ return snippet[:last_space]
99
+ return snippet
100
+
101
+ def format_evidence(self, evidence_list: List[Dict[str, Any]], max_items: int = 10) -> str:
102
+ if not evidence_list:
103
+ return "No relevant evidence found."
104
+
105
+ level_badge = {
106
+ "high": "[HIGH confidence]",
107
+ "medium": "[MEDIUM confidence]",
108
+ "low": "[LOW confidence]",
109
+ }
110
+ support_badge = {
111
+ "supported": "verified",
112
+ "partially_supported": "partial",
113
+ "unsupported": "unverified",
114
+ }
115
+
116
+ formatted = []
117
+ for i, evidence in enumerate(evidence_list[:max_items], 1):
118
+ source_type = evidence.get("source_type", "unknown")
119
+ material = evidence.get("material_context", {}).get("material_type", "N/A")
120
+ dominant = [
121
+ item.get("species", "N/A")
122
+ for item in evidence.get("dominant_ros_or_product", [])
123
+ ]
124
+ evidence_level = evidence.get("evidence_level", "low")
125
+ support_status = evidence.get("support_status", "unsupported")
126
+
127
+ level_str = level_badge.get(evidence_level, evidence_level)
128
+ support_str = support_badge.get(support_status, support_status)
129
+ text = self._truncate_at_sentence(evidence.get("text", ""), max_chars=500)
130
+
131
+ formatted.append(
132
+ f"[{i}] {source_type} {level_str} ({support_str})\n"
133
+ f" Material: {material}\n"
134
+ f" Dominant ROS: {', '.join(dominant)}\n"
135
+ f" {text}"
136
+ )
137
+ return "\n\n".join(formatted)
24
138
 
25
139
 
26
140
  class StudentAdapterInference:
@@ -48,7 +162,12 @@ class StudentAdapterInference:
48
162
  self.config = config
49
163
  self.model_path_str = model_path
50
164
  self.model_path = Path(model_path)
51
- self.adapter_path = Path(adapter_path) if adapter_path else None
165
+ if adapter_path:
166
+ raise ValueError(
167
+ "The standalone fwdemo runtime only supports merged models; "
168
+ "publish or pass a merged Transformers checkpoint instead of a LoRA adapter."
169
+ )
170
+ self.adapter_path = None
52
171
  self.model_is_hf = self._is_hf_repo(model_path)
53
172
  self.use_rag = use_rag
54
173
  generation_config = config.get("generation", {})
@@ -64,6 +183,7 @@ class StudentAdapterInference:
64
183
  self.min_new_tokens = int(generation_config.get("min_new_tokens", 0))
65
184
  self.history_max_turns = int(generation_config.get("history_max_turns", 4))
66
185
  self.refine_answers = bool(generation_config.get("refine_answers", True))
186
+ self.stream_refinement = bool(generation_config.get("stream_refinement", True))
67
187
  self.refinement_max_new_tokens = int(
68
188
  generation_config.get(
69
189
  "refinement_max_new_tokens",
@@ -71,12 +191,12 @@ class StudentAdapterInference:
71
191
  )
72
192
  )
73
193
  self.prompt_templates = self._load_prompt_templates()
74
- self.rag = KGRAGInference(config) if use_rag else None
194
+ self.rag = StudentRAGContext(config) if use_rag else None
75
195
  self.tokenizer = None
76
196
  self.model = None
77
197
 
78
198
  def _load_prompt_templates(self) -> Dict[str, str]:
79
- prompt_path = Path(
199
+ prompt_path = resolve_under_root(
80
200
  self.config.get("prompts", {}).get(
81
201
  "path", "config/prompt_templates.yaml"
82
202
  )
@@ -101,18 +221,21 @@ class StudentAdapterInference:
101
221
  return
102
222
  if not self.model_path.exists():
103
223
  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
- )
224
+
225
+ @staticmethod
226
+ def _select_dtype() -> torch.dtype:
227
+ """Pick the fastest dtype the current device actually supports.
228
+
229
+ bfloat16 was hardcoded, which silently falls back to a very slow
230
+ emulated path on CPU and on GPUs without bf16 support.
231
+ """
232
+ if torch.cuda.is_available():
233
+ if torch.cuda.is_bf16_supported():
234
+ return torch.bfloat16
235
+ return torch.float16
236
+ if torch.backends.mps.is_available():
237
+ return torch.float16
238
+ return torch.float32
116
239
 
117
240
  def load_model(self) -> None:
118
241
  self.validate_paths()
@@ -135,19 +258,21 @@ class StudentAdapterInference:
135
258
  )
136
259
  if self.tokenizer.pad_token is None:
137
260
  self.tokenizer.pad_token = self.tokenizer.eos_token
261
+ # Decoder-only models must be left-padded for correct generation.
262
+ self.tokenizer.padding_side = "left"
138
263
 
264
+ dtype = self._select_dtype()
265
+ logging.info("Loading model weights with dtype=%s", dtype)
139
266
  model = AutoModelForCausalLM.from_pretrained(
140
267
  self.model_path_str,
141
268
  device_map="auto",
142
- torch_dtype=torch.bfloat16,
269
+ torch_dtype=dtype,
270
+ low_cpu_mem_usage=True,
143
271
  trust_remote_code=True,
144
272
  )
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")
273
+ logging.info("Using merged model without a separate LoRA adapter")
150
274
 
275
+ model.generation_config.use_cache = True
151
276
  self.model = model
152
277
  self.model.eval()
153
278
 
@@ -290,7 +415,7 @@ class StudentAdapterInference:
290
415
  ) -> str:
291
416
  prompt = self._render_prompt(messages)
292
417
  inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
293
- with torch.no_grad():
418
+ with torch.inference_mode():
294
419
  output = self.model.generate(
295
420
  **inputs,
296
421
  **self._generation_kwargs(max_new_tokens=max_new_tokens),
@@ -298,6 +423,104 @@ class StudentAdapterInference:
298
423
  generated = output[0][inputs["input_ids"].shape[-1] :]
299
424
  return self.tokenizer.decode(generated, skip_special_tokens=True)
300
425
 
426
+ def _stream_from_messages(
427
+ self,
428
+ messages: List[Dict[str, str]],
429
+ max_new_tokens: Optional[int] = None,
430
+ ) -> Generator[str, None, None]:
431
+ """Yield generated text incrementally, suppressing reasoning scaffold.
432
+
433
+ Raw token streaming would leak ``<think>`` blocks and leading
434
+ ``Assistant:`` / ``Final Answer:`` labels to the user, because
435
+ :meth:`clean_answer` can only run on a complete string. This wraps the
436
+ stream in a small state machine that withholds text until it is known to
437
+ be part of the real answer.
438
+ """
439
+ prompt = self._render_prompt(messages)
440
+ inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
441
+
442
+ streamer = TextIteratorStreamer(
443
+ self.tokenizer,
444
+ skip_prompt=True,
445
+ skip_special_tokens=True,
446
+ )
447
+ gen_kwargs = {
448
+ **inputs,
449
+ "streamer": streamer,
450
+ **self._generation_kwargs(max_new_tokens=max_new_tokens),
451
+ }
452
+
453
+ errors: List[BaseException] = []
454
+
455
+ def _run() -> None:
456
+ try:
457
+ with torch.inference_mode():
458
+ self.model.generate(**gen_kwargs)
459
+ except BaseException as exc: # surfaced to the caller below
460
+ errors.append(exc)
461
+ streamer.end()
462
+
463
+ thread = Thread(target=_run, daemon=True)
464
+ thread.start()
465
+ try:
466
+ yield from self._sanitize_stream(streamer)
467
+ finally:
468
+ thread.join()
469
+ if errors:
470
+ raise errors[0]
471
+
472
+ @staticmethod
473
+ def _sanitize_stream(chunks: Any) -> Generator[str, None, None]:
474
+ """Drop ``<think>`` blocks and leading labels from a text stream."""
475
+ buffer = ""
476
+ started = False
477
+ in_think = False
478
+
479
+ for chunk in chunks:
480
+ buffer += chunk
481
+
482
+ while True:
483
+ if in_think:
484
+ end = buffer.find("</think>")
485
+ if end == -1:
486
+ # Keep only enough tail to match a split closing tag.
487
+ buffer = buffer[-len("</think>") :]
488
+ break
489
+ buffer = buffer[end + len("</think>") :]
490
+ in_think = False
491
+ continue
492
+
493
+ start = buffer.find("<think>")
494
+ if start != -1:
495
+ buffer = buffer[start + len("<think>") :]
496
+ in_think = True
497
+ continue
498
+ break
499
+
500
+ if in_think:
501
+ continue
502
+
503
+ if not started:
504
+ # Hold back until we are past any leading label, and keep a tail
505
+ # that could still turn out to be the start of a "<think>" tag.
506
+ stripped = re.sub(
507
+ r"(?is)^\s*(?:assistant|model|模型|final\s+answer|answer)\s*[::]\s*",
508
+ "",
509
+ buffer,
510
+ )
511
+ if not stripped.strip():
512
+ continue
513
+ buffer = stripped.lstrip()
514
+ started = True
515
+
516
+ if len(buffer) > len("<think>"):
517
+ emit, buffer = buffer[: -len("<think>")], buffer[-len("<think>") :]
518
+ if emit:
519
+ yield emit
520
+
521
+ if not in_think and buffer.strip():
522
+ yield buffer if started else buffer.lstrip()
523
+
301
524
  @staticmethod
302
525
  def _has_report_style(answer: str) -> bool:
303
526
  report_patterns = (
@@ -316,26 +539,18 @@ class StudentAdapterInference:
316
539
  draft_answer: str,
317
540
  extra_instruction: str = "",
318
541
  ) -> 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:
542
+ messages = self._refinement_messages(question, draft_answer)
543
+ if messages is None:
326
544
  return draft_answer
327
545
 
328
- user_prompt = user_prompt_template.format(
329
- question=question,
330
- draft_answer=draft_answer,
331
- ).strip()
332
546
  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
- ]
547
+ messages = [
548
+ messages[0],
549
+ {
550
+ "role": "user",
551
+ "content": f"{messages[1]['content']}\n\n{extra_instruction.strip()}",
552
+ },
553
+ ]
339
554
  return self.clean_answer(
340
555
  self._generate_from_messages(
341
556
  messages,
@@ -399,13 +614,44 @@ class StudentAdapterInference:
399
614
  def answer(self, question: str, top_k: int = 5) -> str:
400
615
  return self.answer_with_metadata(question, top_k=top_k)["answer"]
401
616
 
617
+ def _refinement_messages(
618
+ self,
619
+ question: str,
620
+ draft_answer: str,
621
+ ) -> Optional[List[Dict[str, str]]]:
622
+ system_prompt = self.prompt_templates.get(
623
+ "student_answer_refinement_system_prompt"
624
+ )
625
+ user_prompt_template = self.prompt_templates.get(
626
+ "student_answer_refinement_user_prompt"
627
+ )
628
+ if not system_prompt or not user_prompt_template:
629
+ return None
630
+ return [
631
+ {"role": "system", "content": system_prompt.strip()},
632
+ {
633
+ "role": "user",
634
+ "content": user_prompt_template.format(
635
+ question=question,
636
+ draft_answer=draft_answer,
637
+ ).strip(),
638
+ },
639
+ ]
640
+
402
641
  def answer_stream(
403
642
  self,
404
643
  question: str,
405
644
  top_k: int = 5,
406
645
  history: Optional[List[Dict[str, str]]] = None,
407
646
  ) -> Generator[str, None, None]:
408
- """Stream tokens for *question* via :class:`TextIteratorStreamer`."""
647
+ """Stream the answer for *question* incrementally.
648
+
649
+ With ``refine_answers`` on, the draft pass is generated silently and the
650
+ *refinement* pass is the one streamed, so the user sees the final text
651
+ appear token by token rather than waiting for two full generations. Set
652
+ ``generation.stream_refinement: false`` to keep the stricter
653
+ non-streaming path, which can additionally retry a report-style answer.
654
+ """
409
655
  quick_response = QueryRouter.quick_response(question)
410
656
  if quick_response is not None:
411
657
  yield quick_response
@@ -414,7 +660,7 @@ class StudentAdapterInference:
414
660
  if self.model is None or self.tokenizer is None:
415
661
  self.load_model()
416
662
 
417
- if self.refine_answers:
663
+ if self.refine_answers and not self.stream_refinement:
418
664
  yield self.answer_with_metadata(
419
665
  question,
420
666
  top_k=top_k,
@@ -423,24 +669,29 @@ class StudentAdapterInference:
423
669
  return
424
670
 
425
671
  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)
672
+ messages = self._with_history(context["messages"], history)
428
673
 
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()
674
+ if not self.refine_answers:
675
+ yield from self._stream_from_messages(messages)
676
+ return
677
+
678
+ draft = self.clean_answer(self._generate_from_messages(messages))
679
+ refinement = self._refinement_messages(question, draft)
680
+ if refinement is None:
681
+ yield draft
682
+ return
441
683
 
442
- for text in streamer:
443
- yield text
684
+ streamed = ""
685
+ for chunk in self._stream_from_messages(
686
+ refinement,
687
+ max_new_tokens=self.refinement_max_new_tokens,
688
+ ):
689
+ streamed += chunk
690
+ yield chunk
691
+
692
+ if not streamed.strip():
693
+ # The refinement pass produced nothing usable; fall back to the draft.
694
+ yield draft
444
695
 
445
696
  @staticmethod
446
697
  def _strip_reasoning_scaffold(answer: str) -> str:
@@ -1,10 +1,17 @@
1
1
  #!/usr/bin/env python3
2
- """
3
- COF-ROS-Reasoner: 检索模块初始化
4
- """
2
+ """COFOS retrieval package."""
5
3
 
6
- from .build_faiss_index import FAISSIndexBuilder
7
- from .build_bm25_index import BM25IndexBuilder
8
- from .retrieve_evidence import EvidenceRetriever
9
4
 
10
- __all__ = ['FAISSIndexBuilder', 'BM25IndexBuilder', 'EvidenceRetriever']
5
+ def __getattr__(name):
6
+ if name == "BM25IndexBuilder":
7
+ from .build_bm25_index import BM25IndexBuilder
8
+
9
+ return BM25IndexBuilder
10
+ if name == "EvidenceRetriever":
11
+ from .retrieve_evidence import EvidenceRetriever
12
+
13
+ return EvidenceRetriever
14
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
15
+
16
+
17
+ __all__ = ["BM25IndexBuilder", "EvidenceRetriever"]