@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.
@@ -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 chat: loads an LLM once, reads prompts from stdin, streams to stdout."""
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
- from threading import Thread
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import re
5
20
 
6
- from transformers import (
7
- AutoModelForCausalLM,
8
- AutoTokenizer,
9
- TextIteratorStreamer,
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
- MODEL_ID = "Willlzh/COFOS"
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
- model_id = sys.argv[1] if len(sys.argv) > 1 else MODEL_ID
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] Loading {model_id}", file=sys.stderr, flush=True)
19
- print(f"[chat] First run may take a while while the COFOS model is downloaded or read from cache.", file=sys.stderr, flush=True)
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
- tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
22
- if tokenizer.pad_token_id is None:
23
- tokenizer.pad_token_id = tokenizer.eos_token_id
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
- model = AutoModelForCausalLM.from_pretrained(
26
- model_id, dtype="auto", device_map="auto", trust_remote_code=True, low_cpu_mem_usage=True,
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
- print("[chat] Ready.", file=sys.stderr, flush=True)
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
- messages: list[dict] = []
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
- line = raw.strip("\r\n")
37
- if not line or line == "/exit":
448
+ question = raw.strip("\r\n")
449
+ if not question or question == "/exit":
38
450
  break
39
- if line.startswith("/model "):
40
- mid = line[len("/model "):].strip()
41
- tokenizer = AutoTokenizer.from_pretrained(mid, trust_remote_code=True)
42
- if tokenizer.pad_token_id is None:
43
- tokenizer.pad_token_id = tokenizer.eos_token_id
44
- model = AutoModelForCausalLM.from_pretrained(
45
- mid, dtype="auto", device_map="auto", trust_remote_code=True, low_cpu_mem_usage=True,
46
- )
47
- model.eval()
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
- messages.append({"role": "user", "content": line})
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
- reply = ""
76
- for tok in streamer:
77
- sys.stdout.write(tok)
78
- sys.stdout.flush()
79
- reply += tok
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
- messages.append({"role": "assistant", "content": reply})
82
- print("\n===DONE===", flush=True)
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 e:
91
- print(f"[chat] Error: {e}", file=sys.stderr)
508
+ except Exception as exc:
509
+ print(f"[chat] Error: {exc}", file=sys.stderr, flush=True)
92
510
  sys.exit(1)