@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,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']
|