@lzhzzzzwill/cofos 1.0.1 → 1.0.4
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 +97 -93
- package/bin/cofos.js +119 -41
- package/package.json +8 -3
- 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,60 @@
|
|
|
1
|
+
"""Shared lexical tokenization for BM25 indexing and querying.
|
|
2
|
+
|
|
3
|
+
The tokenizer preserves chemical notation (·OH, O₂·⁻, SO₄·⁻, etc.) so that
|
|
4
|
+
chemically distinct species stay distinct in the BM25 index. The previous
|
|
5
|
+
\\b\\w+\\b regex stripped radical dots, charge superscripts, and Unicode
|
|
6
|
+
subscripts, collapsing ·OH → oh (same as OH⁻) and O₂·⁻ → o2 (same as O₂).
|
|
7
|
+
|
|
8
|
+
IMPORTANT: after changing this tokenizer you MUST rebuild the BM25 index:
|
|
9
|
+
python scripts/build_retrieval.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import unicodedata
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
STOPWORDS = {
|
|
18
|
+
"a", "an", "and", "are", "as", "at", "be", "by", "does", "for",
|
|
19
|
+
"from", "how", "in", "is", "it", "of", "on", "or", "the", "to",
|
|
20
|
+
"under", "what", "which", "with",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Punctuation characters that are stripped from token *boundaries* only.
|
|
24
|
+
# Internal occurrences (e.g. hyphens in COF names, dots in radicals) are kept.
|
|
25
|
+
_BOUNDARY_STRIP = ''.join(
|
|
26
|
+
set('.,;:!?()[]{}"\'“”‘’«»、。,;:!?)】…›‹«»„‚')
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def tokenize_search_text(text: str) -> List[str]:
|
|
31
|
+
"""Tokenize text preserving chemical notation (·OH, O₂·⁻, SO₄·⁻, etc.).
|
|
32
|
+
|
|
33
|
+
Splits on whitespace and strips boundary punctuation only. Internal
|
|
34
|
+
special characters (radical dots, charge marks, hyphens, subscripts) are
|
|
35
|
+
kept so that chemically distinct species produce distinct tokens.
|
|
36
|
+
"""
|
|
37
|
+
if not text:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
# NFKC normalisation keeps sub/superscripts readable while canonicalising
|
|
41
|
+
# fullwidth punctuation and ligatures.
|
|
42
|
+
text = unicodedata.normalize('NFKC', text.lower())
|
|
43
|
+
# Normalise common chemical-notation variants to a single form.
|
|
44
|
+
text = text.replace('−', '-') # minus sign
|
|
45
|
+
text = text.replace('⋅', '·') # dot operator → middle dot
|
|
46
|
+
text = text.replace('•', '·') # bullet → middle dot
|
|
47
|
+
|
|
48
|
+
tokens: List[str] = []
|
|
49
|
+
for raw_token in text.split():
|
|
50
|
+
token = raw_token.strip(_BOUNDARY_STRIP)
|
|
51
|
+
if not token:
|
|
52
|
+
continue
|
|
53
|
+
if token in STOPWORDS:
|
|
54
|
+
continue
|
|
55
|
+
# Drop single-char punctuation-only tokens.
|
|
56
|
+
if len(token) == 1 and not token.isalnum():
|
|
57
|
+
continue
|
|
58
|
+
tokens.append(token)
|
|
59
|
+
|
|
60
|
+
return tokens
|
package/scripts/chat.py
CHANGED
|
@@ -1,85 +1,503 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Persistent
|
|
2
|
+
"""Persistent COFOS RAG chat backend.
|
|
3
|
+
|
|
4
|
+
Loads the merged model from Hugging Face (Willlzh/COFOS) and auto-downloads the
|
|
5
|
+
KG + BM25 retrieval indexes from Willlzh/COFOS_data/runtime when they are not
|
|
6
|
+
present locally. Serves the Node CLI through stdin/stdout protocol markers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
3
15
|
import sys
|
|
4
|
-
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import re
|
|
5
20
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
21
|
+
HISTORY_FILE = os.path.join(os.path.expanduser("~"), ".cofos_history.jsonl")
|
|
22
|
+
HISTORY_MAX_TURNS = 20
|
|
23
|
+
|
|
24
|
+
DEFAULT_MODEL_PATH = os.environ.get(
|
|
25
|
+
"COFOS_MODEL",
|
|
26
|
+
"Willlzh/COFOS",
|
|
10
27
|
)
|
|
28
|
+
DEFAULT_CONFIG = os.environ.get("COFOS_CONFIG", "config/config.yaml")
|
|
29
|
+
CACHE_ROOT = Path(os.environ.get("COFOS_CACHE_DIR", Path.home() / ".cache" / "cofos")).expanduser()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def package_root() -> Path:
|
|
33
|
+
return Path(__file__).resolve().parents[1]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_runtime_root() -> Path:
|
|
37
|
+
bundled = package_root() / "runtime"
|
|
38
|
+
if (bundled / "src" / "inference" / "student_adapter_inference.py").exists():
|
|
39
|
+
return bundled
|
|
40
|
+
|
|
41
|
+
current = Path(__file__).resolve()
|
|
42
|
+
for parent in [current.parent, *current.parents]:
|
|
43
|
+
if (parent / "src" / "inference" / "student_adapter_inference.py").exists():
|
|
44
|
+
return parent
|
|
45
|
+
cwd = Path.cwd().resolve()
|
|
46
|
+
if (cwd / "src" / "inference" / "student_adapter_inference.py").exists():
|
|
47
|
+
return cwd
|
|
48
|
+
raise FileNotFoundError(
|
|
49
|
+
"Could not find COFOS runtime files. The npm package should include "
|
|
50
|
+
"runtime/src and runtime/config."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_config_path(runtime_root: Path) -> Path:
|
|
55
|
+
configured = Path(DEFAULT_CONFIG).expanduser()
|
|
56
|
+
if configured.is_absolute():
|
|
57
|
+
return configured
|
|
58
|
+
candidate = runtime_root / configured
|
|
59
|
+
if candidate.exists():
|
|
60
|
+
return candidate
|
|
61
|
+
return (runtime_root / "config" / "config.yaml").resolve()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def runtime_cache_root() -> Path:
|
|
65
|
+
CACHE_ROOT.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
return CACHE_ROOT.resolve()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def configure_cache_paths(config: dict, pdf_dir: str | None = None) -> dict:
|
|
70
|
+
cache = runtime_cache_root()
|
|
71
|
+
updated = dict(config)
|
|
72
|
+
paths = dict(updated.get("paths", {}))
|
|
73
|
+
raw_pdf_dir = Path(pdf_dir).expanduser().resolve() if pdf_dir else cache / "raw_pdfs"
|
|
74
|
+
paths.update({
|
|
75
|
+
"raw_pdfs": str(raw_pdf_dir),
|
|
76
|
+
"raw_json": str(cache / "raw_json"),
|
|
77
|
+
"raw_wos": str(cache / "raw_wos" / "wos_abstracts.xlsx"),
|
|
78
|
+
"processed_dir": str(cache / "data" / "processed"),
|
|
79
|
+
"sft_dir": str(cache / "data" / "sft"),
|
|
80
|
+
"kg_dir": str(cache / "data" / "kg"),
|
|
81
|
+
"eval_dir": str(cache / "data" / "eval"),
|
|
82
|
+
"retrieval_store": str(cache / "retrieval_store" / "embedding"),
|
|
83
|
+
"bm25_store": str(cache / "retrieval_store" / "bm25"),
|
|
84
|
+
"checkpoints_dir": str(cache / "checkpoints"),
|
|
85
|
+
"outputs_dir": str(cache / "outputs"),
|
|
86
|
+
"logs_dir": str(cache / "outputs" / "logs"),
|
|
87
|
+
})
|
|
88
|
+
updated["paths"] = paths
|
|
89
|
+
return updated
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_args() -> argparse.Namespace:
|
|
93
|
+
parser = argparse.ArgumentParser(description="Run persistent COFOS 9B + RAG chat backend.")
|
|
94
|
+
parser.add_argument("--config", default=DEFAULT_CONFIG)
|
|
95
|
+
parser.add_argument("--merged-model-path", "--model", dest="model_path", default=DEFAULT_MODEL_PATH)
|
|
96
|
+
parser.add_argument("--top-k", type=int, default=int(os.environ.get("COFOS_TOP_K", "5")))
|
|
97
|
+
parser.add_argument("--max-new-tokens", type=int, default=None)
|
|
98
|
+
parser.add_argument("--pdf-dir", default=os.environ.get("COFOS_PDF_DIR"))
|
|
99
|
+
parser.add_argument("--rebuild-pdf-index", action="store_true")
|
|
100
|
+
parser.add_argument("--no-rag", action="store_true")
|
|
101
|
+
return parser.parse_args()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_HF_REPO_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-\.]*/[A-Za-z0-9][A-Za-z0-9_\-\.]*$")
|
|
105
|
+
_LOCAL_PREFIXES = {
|
|
106
|
+
"config",
|
|
107
|
+
"data",
|
|
108
|
+
"retrieval_store",
|
|
109
|
+
"checkpoints",
|
|
110
|
+
"scripts",
|
|
111
|
+
"src",
|
|
112
|
+
"fwdemo",
|
|
113
|
+
".",
|
|
114
|
+
"..",
|
|
115
|
+
"~",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_hf_repo_id(value: str) -> bool:
|
|
120
|
+
if not _HF_REPO_RE.match(value):
|
|
121
|
+
return False
|
|
122
|
+
first = value.split("/", 1)[0]
|
|
123
|
+
if first in _LOCAL_PREFIXES:
|
|
124
|
+
return False
|
|
125
|
+
if Path(value).suffix:
|
|
126
|
+
return False
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_project_path(project_root: Path, value: str) -> Path | str:
|
|
131
|
+
if is_hf_repo_id(value):
|
|
132
|
+
return value
|
|
133
|
+
path = Path(value).expanduser()
|
|
134
|
+
if path.is_absolute():
|
|
135
|
+
return path
|
|
136
|
+
return (project_root / path).resolve()
|
|
11
137
|
|
|
12
|
-
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _project_path(project_root: Path, value: str) -> Path:
|
|
141
|
+
path = Path(value).expanduser()
|
|
142
|
+
if path.is_absolute():
|
|
143
|
+
return path
|
|
144
|
+
return (project_root / path).resolve()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _with_pdf_dir(config: dict, pdf_dir: str | None) -> dict:
|
|
148
|
+
if not pdf_dir:
|
|
149
|
+
return config
|
|
150
|
+
updated = dict(config)
|
|
151
|
+
paths = dict(updated.get("paths", {}))
|
|
152
|
+
paths["raw_pdfs"] = str(Path(pdf_dir).expanduser().resolve())
|
|
153
|
+
updated["paths"] = paths
|
|
154
|
+
return updated
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _iter_jsonl(path: Path):
|
|
158
|
+
if not path.exists():
|
|
159
|
+
return
|
|
160
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
161
|
+
for line in handle:
|
|
162
|
+
line = line.strip()
|
|
163
|
+
if line:
|
|
164
|
+
yield json.loads(line)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _doc_key(meta: dict, text: str = "") -> tuple[str, str, str]:
|
|
168
|
+
source_type = str(meta.get("source_type") or meta.get("source") or "")
|
|
169
|
+
doc_id = str(meta.get("doc_id") or meta.get("chunk_id") or "")
|
|
170
|
+
if doc_id:
|
|
171
|
+
return source_type, doc_id, ""
|
|
172
|
+
return source_type, "", text[:256]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _pdf_manifest_path(project_root: Path, config: dict) -> Path:
|
|
176
|
+
return _project_path(project_root, config["paths"]["processed_dir"]) / "pdf_ingest_manifest.json"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _current_pdf_manifest(project_root: Path, config: dict) -> dict:
|
|
180
|
+
pdf_dir = _project_path(project_root, config["paths"]["raw_pdfs"])
|
|
181
|
+
pdf_files = sorted(pdf_dir.glob("*.pdf")) if pdf_dir.exists() else []
|
|
182
|
+
files = []
|
|
183
|
+
for pdf_path in pdf_files:
|
|
184
|
+
stat = pdf_path.stat()
|
|
185
|
+
files.append({
|
|
186
|
+
"name": pdf_path.name,
|
|
187
|
+
"path": str(pdf_path.resolve()),
|
|
188
|
+
"size": stat.st_size,
|
|
189
|
+
"mtime_ns": stat.st_mtime_ns,
|
|
190
|
+
})
|
|
191
|
+
return {"pdf_dir": str(pdf_dir), "files": files}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _load_pdf_manifest(project_root: Path, config: dict) -> dict | None:
|
|
195
|
+
path = _pdf_manifest_path(project_root, config)
|
|
196
|
+
if not path.exists():
|
|
197
|
+
return None
|
|
198
|
+
try:
|
|
199
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
200
|
+
except (OSError, json.JSONDecodeError):
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _save_pdf_manifest(project_root: Path, config: dict, manifest: dict) -> None:
|
|
205
|
+
path = _pdf_manifest_path(project_root, config)
|
|
206
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _pdf_inputs_changed(project_root: Path, config: dict) -> bool:
|
|
211
|
+
chunks_path = _project_path(project_root, config["paths"]["processed_dir"]) / "pdf_chunks.jsonl"
|
|
212
|
+
return (not chunks_path.exists()) or _load_pdf_manifest(project_root, config) != _current_pdf_manifest(project_root, config)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _load_existing_bm25_rows(project_root: Path, config: dict) -> list[tuple[str, dict]]:
|
|
216
|
+
bm25_dir = _project_path(project_root, config["paths"]["bm25_store"])
|
|
217
|
+
metadata = list(_iter_jsonl(bm25_dir / "bm25_metadata.jsonl") or [])
|
|
218
|
+
documents = list(_iter_jsonl(bm25_dir / "bm25_documents.jsonl") or [])
|
|
219
|
+
rows = []
|
|
220
|
+
for index, meta in enumerate(metadata):
|
|
221
|
+
doc_text = str(documents[index].get("text", "")) if index < len(documents) else ""
|
|
222
|
+
text = str(meta.get("text") or doc_text)
|
|
223
|
+
if text.strip():
|
|
224
|
+
merged_meta = dict(meta)
|
|
225
|
+
merged_meta["text"] = text
|
|
226
|
+
rows.append((text, merged_meta))
|
|
227
|
+
return rows
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _load_pdf_rows(project_root: Path, config: dict) -> list[tuple[str, dict]]:
|
|
231
|
+
chunks_path = _project_path(project_root, config["paths"]["processed_dir"]) / "pdf_chunks.jsonl"
|
|
232
|
+
rows = []
|
|
233
|
+
for record in _iter_jsonl(chunks_path) or []:
|
|
234
|
+
text = str(record.get("text", ""))
|
|
235
|
+
if not text.strip() or not record.get("is_ros_related", False):
|
|
236
|
+
continue
|
|
237
|
+
rows.append((text, {
|
|
238
|
+
"doc_id": record.get("chunk_id", ""),
|
|
239
|
+
"source_type": "pdf",
|
|
240
|
+
"source": record.get("source_type", "pdf_fulltext"),
|
|
241
|
+
"title": record.get("title", ""),
|
|
242
|
+
"doi": record.get("doi", ""),
|
|
243
|
+
"page": record.get("page", 0),
|
|
244
|
+
"is_ros_related": record.get("is_ros_related", False),
|
|
245
|
+
"text": text,
|
|
246
|
+
}))
|
|
247
|
+
return rows
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_pdfs_if_needed(project_root: Path, config: dict, force: bool = False) -> bool:
|
|
251
|
+
from data_processing.parse_pdf_text import PDFParser
|
|
252
|
+
|
|
253
|
+
pdf_dir = _project_path(project_root, config["paths"]["raw_pdfs"])
|
|
254
|
+
pdf_files = sorted(pdf_dir.glob("*.pdf")) if pdf_dir.exists() else []
|
|
255
|
+
if not pdf_files:
|
|
256
|
+
changed = _pdf_inputs_changed(project_root, config)
|
|
257
|
+
if changed:
|
|
258
|
+
chunks_path = _project_path(project_root, config["paths"]["processed_dir"]) / "pdf_chunks.jsonl"
|
|
259
|
+
chunks_path.parent.mkdir(parents=True, exist_ok=True)
|
|
260
|
+
chunks_path.write_text("", encoding="utf-8")
|
|
261
|
+
_save_pdf_manifest(project_root, config, _current_pdf_manifest(project_root, config))
|
|
262
|
+
print(f"[chat] No PDFs found in {pdf_dir}; cleared PDF chunks.", file=sys.stderr, flush=True)
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
if not force and not _pdf_inputs_changed(project_root, config):
|
|
266
|
+
print("[chat] PDF inputs unchanged; skipping PDF parsing.", file=sys.stderr, flush=True)
|
|
267
|
+
return False
|
|
268
|
+
print(f"[chat] Parsing {len(pdf_files)} PDFs from {pdf_dir} ...", file=sys.stderr, flush=True)
|
|
269
|
+
PDFParser(config).run()
|
|
270
|
+
_save_pdf_manifest(project_root, config, _current_pdf_manifest(project_root, config))
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def build_augmented_bm25_if_needed(project_root: Path, config: dict, force: bool = False) -> None:
|
|
275
|
+
from retrieval.build_bm25_index import BM25IndexBuilder
|
|
276
|
+
|
|
277
|
+
pdf_rows = _load_pdf_rows(project_root, config)
|
|
278
|
+
existing_rows = _load_existing_bm25_rows(project_root, config)
|
|
279
|
+
pdf_keys = {_doc_key(meta, text) for text, meta in pdf_rows}
|
|
280
|
+
existing_keys = {_doc_key(meta, text) for text, meta in existing_rows}
|
|
281
|
+
if pdf_rows and pdf_keys.issubset(existing_keys) and not force:
|
|
282
|
+
print("[chat] PDF chunks already present in BM25; skipping BM25 rebuild.", file=sys.stderr, flush=True)
|
|
283
|
+
return
|
|
284
|
+
if not pdf_rows and not any(str(meta.get("source_type") or meta.get("source") or "") == "pdf" for _, meta in existing_rows):
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
merged = {}
|
|
288
|
+
for text, meta in existing_rows:
|
|
289
|
+
if str(meta.get("source_type") or meta.get("source") or "") == "pdf":
|
|
290
|
+
continue
|
|
291
|
+
merged[_doc_key(meta, text)] = (text, meta)
|
|
292
|
+
for text, meta in pdf_rows:
|
|
293
|
+
merged[_doc_key(meta, text)] = (text, meta)
|
|
294
|
+
documents = [row[0] for row in merged.values()]
|
|
295
|
+
metadata = [row[1] for row in merged.values()]
|
|
296
|
+
print(f"[chat] Rebuilding BM25 with {len(documents)} docs ({len(pdf_rows)} PDF chunks).", file=sys.stderr, flush=True)
|
|
297
|
+
builder = BM25IndexBuilder(config)
|
|
298
|
+
builder.build_index(documents, metadata)
|
|
299
|
+
builder.save_index()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def ensure_runtime_data(project_root: Path, config: dict) -> None:
|
|
303
|
+
"""Download runtime KG + BM25 data from Hugging Face if not present locally.
|
|
304
|
+
|
|
305
|
+
``Willlzh/COFOS_data`` hosts ``runtime/kg/`` and ``runtime/bm25/`` with the
|
|
306
|
+
pre-built retrieval indexes. This function mirrors them into the local
|
|
307
|
+
``data/kg`` and ``retrieval_store/bm25`` directories, skipping files that
|
|
308
|
+
already exist.
|
|
309
|
+
"""
|
|
310
|
+
import shutil
|
|
311
|
+
from pathlib import Path as _Path
|
|
312
|
+
|
|
313
|
+
kg_dir = _Path(config["paths"]["kg_dir"])
|
|
314
|
+
bm25_dir = _Path(config["paths"]["bm25_store"])
|
|
315
|
+
|
|
316
|
+
kg_done = (kg_dir / "cofros_kg_triples.jsonl").exists()
|
|
317
|
+
bm25_done = (bm25_dir / "bm25_index.pkl").exists()
|
|
318
|
+
if kg_done and bm25_done:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
print("[chat] Runtime data not found locally; downloading from Willlzh/COFOS_data ...",
|
|
322
|
+
file=sys.stderr, flush=True)
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
from huggingface_hub import hf_hub_download
|
|
326
|
+
except ImportError:
|
|
327
|
+
print("[chat] huggingface_hub not installed. Run: pip install huggingface_hub",
|
|
328
|
+
file=sys.stderr, flush=True)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
repo = "Willlzh/COFOS_data"
|
|
332
|
+
file_map = {
|
|
333
|
+
"runtime/kg/cofros_kg_triples.jsonl": kg_dir,
|
|
334
|
+
"runtime/kg/cofros_kg_nodes.csv": kg_dir,
|
|
335
|
+
"runtime/kg/cofros_kg_edges.csv": kg_dir,
|
|
336
|
+
"runtime/bm25/bm25_index.pkl": bm25_dir,
|
|
337
|
+
"runtime/bm25/bm25_metadata.jsonl": bm25_dir,
|
|
338
|
+
"runtime/bm25/bm25_documents.jsonl": bm25_dir,
|
|
339
|
+
"runtime/bm25/bm25_info.json": bm25_dir,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for remote_path, target_dir in file_map.items():
|
|
343
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
local_name = remote_path.rsplit("/", 1)[-1]
|
|
345
|
+
local_path = target_dir / local_name
|
|
346
|
+
if local_path.exists():
|
|
347
|
+
continue
|
|
348
|
+
cached = hf_hub_download(
|
|
349
|
+
repo_id=repo,
|
|
350
|
+
repo_type="dataset",
|
|
351
|
+
filename=remote_path,
|
|
352
|
+
)
|
|
353
|
+
shutil.copy2(cached, local_path)
|
|
354
|
+
print(f"[chat] Downloaded {remote_path} -> {local_path}", file=sys.stderr, flush=True)
|
|
355
|
+
|
|
356
|
+
print("[chat] Runtime data ready.", file=sys.stderr, flush=True)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _load_history() -> list:
|
|
360
|
+
"""Load recent conversation turns from the persistent history file."""
|
|
361
|
+
if not os.path.exists(HISTORY_FILE):
|
|
362
|
+
return []
|
|
363
|
+
turns = []
|
|
364
|
+
try:
|
|
365
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as fh:
|
|
366
|
+
for line in fh:
|
|
367
|
+
line = line.strip()
|
|
368
|
+
if not line:
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
turns.append(json.loads(line))
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
continue
|
|
374
|
+
except OSError:
|
|
375
|
+
return []
|
|
376
|
+
# Keep only the last N turns
|
|
377
|
+
max_items = HISTORY_MAX_TURNS * 2
|
|
378
|
+
return turns[-max_items:] if len(turns) > max_items else turns
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _save_turn(role: str, content: str) -> None:
|
|
382
|
+
"""Append a single turn to the persistent history file."""
|
|
383
|
+
try:
|
|
384
|
+
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
|
|
385
|
+
json.dump({"role": role, "content": content}, fh, ensure_ascii=False)
|
|
386
|
+
fh.write("\n")
|
|
387
|
+
except OSError:
|
|
388
|
+
pass # best-effort
|
|
13
389
|
|
|
14
390
|
|
|
15
391
|
def main() -> None:
|
|
16
|
-
|
|
392
|
+
args = parse_args()
|
|
393
|
+
runtime_root = find_runtime_root()
|
|
394
|
+
os.chdir(runtime_root)
|
|
395
|
+
src_path = runtime_root / "src"
|
|
396
|
+
scripts_path = runtime_root / "scripts"
|
|
397
|
+
sys.path.insert(0, str(src_path))
|
|
398
|
+
if scripts_path.exists():
|
|
399
|
+
sys.path.insert(0, str(scripts_path))
|
|
400
|
+
|
|
401
|
+
import yaml
|
|
402
|
+
from inference.student_adapter_inference import StudentAdapterInference
|
|
403
|
+
|
|
404
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
|
405
|
+
|
|
406
|
+
config_path = default_config_path(runtime_root) if args.config == DEFAULT_CONFIG else resolve_project_path(runtime_root, args.config)
|
|
407
|
+
model_path = resolve_project_path(runtime_root, args.model_path)
|
|
17
408
|
|
|
18
|
-
print(f"[chat]
|
|
19
|
-
print(f"[chat]
|
|
409
|
+
print(f"[chat] Runtime root: {runtime_root}", file=sys.stderr, flush=True)
|
|
410
|
+
print(f"[chat] Cache root: {runtime_cache_root()}", file=sys.stderr, flush=True)
|
|
411
|
+
print(f"[chat] Config: {config_path}", file=sys.stderr, flush=True)
|
|
412
|
+
print(f"[chat] Model: {model_path}", file=sys.stderr, flush=True)
|
|
413
|
+
print(f"[chat] RAG: {'disabled' if args.no_rag else 'enabled'}", file=sys.stderr, flush=True)
|
|
20
414
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
415
|
+
t0 = time.time()
|
|
416
|
+
with open(config_path, "r", encoding="utf-8") as handle:
|
|
417
|
+
config = yaml.safe_load(handle) or {}
|
|
418
|
+
config = configure_cache_paths(config, args.pdf_dir)
|
|
24
419
|
|
|
25
|
-
|
|
26
|
-
|
|
420
|
+
if not args.no_rag:
|
|
421
|
+
ensure_runtime_data(runtime_root, config)
|
|
422
|
+
pdf_changed = parse_pdfs_if_needed(runtime_root, config, force=args.rebuild_pdf_index)
|
|
423
|
+
build_augmented_bm25_if_needed(runtime_root, config, force=args.rebuild_pdf_index or pdf_changed)
|
|
424
|
+
|
|
425
|
+
print("[chat] Loading model (this may take 1-2 min on first run) ...", file=sys.stderr, flush=True)
|
|
426
|
+
|
|
427
|
+
runner = StudentAdapterInference(
|
|
428
|
+
config=config,
|
|
429
|
+
model_path=str(model_path),
|
|
430
|
+
adapter_path=None,
|
|
431
|
+
use_rag=not args.no_rag,
|
|
432
|
+
max_new_tokens=args.max_new_tokens,
|
|
27
433
|
)
|
|
28
|
-
model.eval()
|
|
29
434
|
|
|
30
|
-
|
|
435
|
+
runner.load_model()
|
|
436
|
+
|
|
437
|
+
elapsed = time.time() - t0
|
|
438
|
+
print(f"[chat] Ready. (loaded in {elapsed:.1f}s)", file=sys.stderr, flush=True)
|
|
31
439
|
print("===READY===", flush=True)
|
|
32
440
|
|
|
33
|
-
|
|
441
|
+
top_k = args.top_k
|
|
442
|
+
history = _load_history()
|
|
443
|
+
if history:
|
|
444
|
+
print(f"[chat] Restored {len(history) // 2} previous turns from history.",
|
|
445
|
+
file=sys.stderr, flush=True)
|
|
34
446
|
|
|
35
447
|
for raw in sys.stdin:
|
|
36
|
-
|
|
37
|
-
if not
|
|
448
|
+
question = raw.strip("\r\n")
|
|
449
|
+
if not question or question == "/exit":
|
|
38
450
|
break
|
|
39
|
-
if
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
451
|
+
if question.startswith("/topk "):
|
|
452
|
+
try:
|
|
453
|
+
top_k = max(1, int(question.split(maxsplit=1)[1]))
|
|
454
|
+
print(f"top_k set to {top_k}")
|
|
455
|
+
except ValueError:
|
|
456
|
+
print("Usage: /topk 5")
|
|
457
|
+
print("===DONE===", flush=True)
|
|
458
|
+
continue
|
|
459
|
+
if question == "/rag off":
|
|
460
|
+
runner.rag = None
|
|
461
|
+
print("RAG disabled for this session.")
|
|
462
|
+
print("===DONE===", flush=True)
|
|
463
|
+
continue
|
|
464
|
+
if question == "/rag on":
|
|
465
|
+
if runner.rag is None:
|
|
466
|
+
from inference.run_kg_grounded_inference import KGRAGInference
|
|
467
|
+
runner.rag = KGRAGInference(config)
|
|
468
|
+
print("RAG enabled for this session.")
|
|
469
|
+
print("===DONE===", flush=True)
|
|
470
|
+
continue
|
|
471
|
+
if question == "/clear":
|
|
472
|
+
history.clear()
|
|
473
|
+
print("Conversation history cleared.")
|
|
474
|
+
print("===DONE===", flush=True)
|
|
475
|
+
continue
|
|
476
|
+
if question == "/save":
|
|
477
|
+
print(f"History saved to {HISTORY_FILE} ({len(history) // 2} turns).")
|
|
48
478
|
print("===DONE===", flush=True)
|
|
49
479
|
continue
|
|
50
480
|
|
|
51
|
-
|
|
52
|
-
prompt = tokenizer.apply_chat_template(
|
|
53
|
-
messages, tokenize=False, add_generation_prompt=True,
|
|
54
|
-
)
|
|
55
|
-
inputs = tokenizer(prompt, return_tensors="pt")
|
|
56
|
-
|
|
57
|
-
streamer = TextIteratorStreamer(
|
|
58
|
-
tokenizer, skip_prompt=True, skip_special_tokens=True,
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
gen_kw = dict(
|
|
62
|
-
input_ids=inputs.input_ids,
|
|
63
|
-
attention_mask=inputs.attention_mask,
|
|
64
|
-
streamer=streamer,
|
|
65
|
-
max_new_tokens=512,
|
|
66
|
-
do_sample=True,
|
|
67
|
-
temperature=0.7,
|
|
68
|
-
top_p=0.9,
|
|
69
|
-
repetition_penalty=1.05,
|
|
70
|
-
pad_token_id=tokenizer.eos_token_id,
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
Thread(target=model.generate, kwargs=gen_kw).start()
|
|
481
|
+
_save_turn("user", question)
|
|
74
482
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
483
|
+
try:
|
|
484
|
+
t_gen = time.time()
|
|
485
|
+
print("===RESPONSE===", flush=True)
|
|
486
|
+
answer_parts = []
|
|
487
|
+
for token in runner.answer_stream(question, top_k=top_k, history=history):
|
|
488
|
+
print(token, end="", flush=True)
|
|
489
|
+
answer_parts.append(token)
|
|
490
|
+
answer = "".join(answer_parts)
|
|
491
|
+
gen_elapsed = time.time() - t_gen
|
|
492
|
+
print(f"\n===DONE===", flush=True)
|
|
493
|
+
print(f"[chat] Generated in {gen_elapsed:.1f}s", file=sys.stderr, flush=True)
|
|
80
494
|
|
|
81
|
-
|
|
82
|
-
|
|
495
|
+
history.append({"role": "user", "content": question})
|
|
496
|
+
history.append({"role": "assistant", "content": answer})
|
|
497
|
+
_save_turn("assistant", answer)
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
print(f"\n[COFOS error] {exc}")
|
|
500
|
+
print("===DONE===", flush=True)
|
|
83
501
|
|
|
84
502
|
|
|
85
503
|
if __name__ == "__main__":
|
|
@@ -87,6 +505,6 @@ if __name__ == "__main__":
|
|
|
87
505
|
main()
|
|
88
506
|
except KeyboardInterrupt:
|
|
89
507
|
pass
|
|
90
|
-
except Exception as
|
|
91
|
-
print(f"[chat] Error: {
|
|
508
|
+
except Exception as exc:
|
|
509
|
+
print(f"[chat] Error: {exc}", file=sys.stderr, flush=True)
|
|
92
510
|
sys.exit(1)
|