@lzhzzzzwill/cofos 1.0.4 → 1.1.1
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 +36 -10
- package/RUNTIME.md +38 -0
- package/bin/cofos.js +82 -20
- package/package.json +13 -5
- package/requirements.txt +0 -3
- package/runtime/config/config.yaml +73 -260
- package/runtime/config/prompt_templates.yaml +73 -369
- package/runtime/src/data_processing/__init__.py +2 -27
- package/runtime/src/data_processing/utils.py +22 -1
- package/runtime/src/inference/__init__.py +2 -6
- package/runtime/src/inference/query_router.py +23 -11
- package/runtime/src/inference/student_adapter_inference.py +312 -61
- package/runtime/src/retrieval/__init__.py +14 -7
- package/runtime/src/retrieval/retrieve_evidence.py +84 -25
- package/scripts/chat.py +112 -29
- package/scripts/sync_runtime.py +45 -0
- package/runtime/config/qwen3_5_config.md +0 -46
- package/runtime/src/data_processing/clean_json_records.py +0 -387
- package/runtime/src/data_processing/parse_wos_xlsx.py +0 -255
- package/runtime/src/data_processing/schema_utils.py +0 -56
- package/runtime/src/inference/run_kg_grounded_inference.py +0 -370
- package/runtime/src/retrieval/build_faiss_index.py +0 -328
|
@@ -8,7 +8,6 @@ COF-ROS-Reasoner: 检索证据脚本
|
|
|
8
8
|
- 返回证据列表
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
import os
|
|
12
11
|
import json
|
|
13
12
|
import yaml
|
|
14
13
|
import logging
|
|
@@ -16,11 +15,8 @@ import pickle
|
|
|
16
15
|
import re
|
|
17
16
|
from pathlib import Path
|
|
18
17
|
from typing import Dict, List, Any, Optional, Tuple
|
|
19
|
-
from datetime import datetime
|
|
20
18
|
from collections import defaultdict
|
|
21
19
|
|
|
22
|
-
import numpy as np
|
|
23
|
-
|
|
24
20
|
# 添加 src 路径
|
|
25
21
|
|
|
26
22
|
try:
|
|
@@ -38,13 +34,16 @@ try:
|
|
|
38
34
|
except ImportError:
|
|
39
35
|
BM25Okapi = None
|
|
40
36
|
|
|
41
|
-
from data_processing.utils import setup_logging
|
|
37
|
+
from data_processing.utils import setup_logging
|
|
42
38
|
from retrieval.text_search import tokenize_search_text
|
|
43
39
|
|
|
44
40
|
|
|
45
41
|
class EvidenceRetriever:
|
|
46
42
|
"""检索证据"""
|
|
47
43
|
|
|
44
|
+
# Upper bound on the per-query ranked-result cache (see ``_ranked_results``).
|
|
45
|
+
_RANKED_CACHE_MAX = 64
|
|
46
|
+
|
|
48
47
|
def __init__(self, config: Dict[str, Any]):
|
|
49
48
|
self.config = config
|
|
50
49
|
self.faiss_dir = Path(config['paths']['retrieval_store'])
|
|
@@ -128,11 +127,39 @@ class EvidenceRetriever:
|
|
|
128
127
|
logging.info(f"Loaded persisted BM25 index from {index_path}")
|
|
129
128
|
|
|
130
129
|
|
|
131
|
-
def
|
|
132
|
-
"""Load real KG triples and build lightweight in-memory indexes."""
|
|
130
|
+
def _reset_kg_indexes(self) -> None:
|
|
133
131
|
self.kg_triples = []
|
|
134
132
|
self.kg_by_head = defaultdict(list)
|
|
135
133
|
self.kg_by_tail = defaultdict(list)
|
|
134
|
+
self._kg_nodes_by_type = None
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def kg_nodes_by_type(self) -> Dict[str, set]:
|
|
138
|
+
"""Map node id prefix ("Material:", "ROS_or_Product:") -> node ids.
|
|
139
|
+
|
|
140
|
+
Built lazily from ``kg_triples`` and cached, so node lookup no longer
|
|
141
|
+
rescans every triple once per prefix on every query. Derived rather
|
|
142
|
+
than populated during load so that callers which assemble a retriever
|
|
143
|
+
by hand (tests) get a consistent index too.
|
|
144
|
+
"""
|
|
145
|
+
cached = getattr(self, '_kg_nodes_by_type', None)
|
|
146
|
+
if cached is not None:
|
|
147
|
+
return cached
|
|
148
|
+
|
|
149
|
+
nodes_by_type: Dict[str, set] = defaultdict(set)
|
|
150
|
+
for triple in getattr(self, 'kg_triples', []):
|
|
151
|
+
for field in ('head', 'tail'):
|
|
152
|
+
node = triple.get(field, '')
|
|
153
|
+
node_type, sep, _ = node.partition(':')
|
|
154
|
+
if sep and node_type:
|
|
155
|
+
nodes_by_type[f"{node_type}:"].add(node)
|
|
156
|
+
|
|
157
|
+
self._kg_nodes_by_type = nodes_by_type
|
|
158
|
+
return nodes_by_type
|
|
159
|
+
|
|
160
|
+
def _load_kg_triples(self) -> None:
|
|
161
|
+
"""Load real KG triples and build lightweight in-memory indexes."""
|
|
162
|
+
self._reset_kg_indexes()
|
|
136
163
|
|
|
137
164
|
kg_path = Path(self.config['paths']['kg_dir']) / 'cofros_kg_triples.jsonl'
|
|
138
165
|
if not kg_path.exists():
|
|
@@ -152,12 +179,11 @@ class EvidenceRetriever:
|
|
|
152
179
|
self.kg_triples.append(triple)
|
|
153
180
|
self.kg_by_head[head].append(triple)
|
|
154
181
|
self.kg_by_tail[tail].append(triple)
|
|
182
|
+
self._kg_nodes_by_type = None # rebuilt on first lookup
|
|
155
183
|
logging.info("Loaded %s KG triples for graph retrieval", len(self.kg_triples))
|
|
156
184
|
except Exception as exc:
|
|
157
185
|
logging.error("Failed to load KG triples from %s: %s", kg_path, exc)
|
|
158
|
-
self.
|
|
159
|
-
self.kg_by_head = defaultdict(list)
|
|
160
|
-
self.kg_by_tail = defaultdict(list)
|
|
186
|
+
self._reset_kg_indexes()
|
|
161
187
|
|
|
162
188
|
@staticmethod
|
|
163
189
|
def _kg_label(node_id: str) -> str:
|
|
@@ -229,12 +255,7 @@ class EvidenceRetriever:
|
|
|
229
255
|
return [node for _, node in self._find_scored_kg_nodes(query, node_prefix)]
|
|
230
256
|
|
|
231
257
|
def _find_scored_kg_nodes(self, query: str, node_prefix: str) -> List[Tuple[float, str]]:
|
|
232
|
-
nodes = set()
|
|
233
|
-
for triple in self.kg_triples:
|
|
234
|
-
for field in ('head', 'tail'):
|
|
235
|
-
node = triple.get(field, '')
|
|
236
|
-
if node.startswith(node_prefix):
|
|
237
|
-
nodes.add(node)
|
|
258
|
+
nodes = self.kg_nodes_by_type.get(node_prefix, set())
|
|
238
259
|
|
|
239
260
|
scored = []
|
|
240
261
|
for node in nodes:
|
|
@@ -551,11 +572,27 @@ class EvidenceRetriever:
|
|
|
551
572
|
return self.is_confident_result(result)
|
|
552
573
|
|
|
553
574
|
def hybrid_retrieve(self, query: str, top_k: int = 10) -> List[Dict[str, Any]]:
|
|
554
|
-
"""
|
|
555
|
-
|
|
556
|
-
|
|
575
|
+
"""混合检索(按 query 缓存,避免同一问题重复打分)。
|
|
576
|
+
|
|
577
|
+
A single question triggers ``retrieve_kg_facts`` *and*
|
|
578
|
+
``retrieve_with_sources``; both funnel into this method, so without the
|
|
579
|
+
cache every question scored the whole BM25 corpus twice. Scoring and
|
|
580
|
+
reranking depend only on the query, so we cache the ranked list and just
|
|
581
|
+
slice it per ``top_k``.
|
|
582
|
+
"""
|
|
583
|
+
ranked = self._ranked_results(query)
|
|
584
|
+
return ranked[:top_k]
|
|
557
585
|
|
|
558
|
-
|
|
586
|
+
def _ranked_results(self, query: str) -> List[Dict[str, Any]]:
|
|
587
|
+
cache = getattr(self, '_ranked_cache', None)
|
|
588
|
+
if cache is None:
|
|
589
|
+
cache = self._ranked_cache = {}
|
|
590
|
+
if query in cache:
|
|
591
|
+
return cache[query]
|
|
592
|
+
|
|
593
|
+
if not self.is_domain_query(query):
|
|
594
|
+
cache[query] = []
|
|
595
|
+
return cache[query]
|
|
559
596
|
|
|
560
597
|
# 1. 向量检索
|
|
561
598
|
vector_results = self._vector_search(query, self.vector_top_k)
|
|
@@ -571,7 +608,31 @@ class EvidenceRetriever:
|
|
|
571
608
|
|
|
572
609
|
# 5. 丢弃低置信度结果,避免无关问题也被强制注入 RAG 上下文
|
|
573
610
|
confident = [result for result in reranked if self.is_confident_result(result)]
|
|
574
|
-
|
|
611
|
+
|
|
612
|
+
# Bound the cache so a long chat session cannot grow it without limit.
|
|
613
|
+
if len(cache) >= self._RANKED_CACHE_MAX:
|
|
614
|
+
cache.pop(next(iter(cache)))
|
|
615
|
+
cache[query] = confident
|
|
616
|
+
return confident
|
|
617
|
+
|
|
618
|
+
def _get_embedding_model(self):
|
|
619
|
+
"""Load the sentence encoder once and reuse it across queries.
|
|
620
|
+
|
|
621
|
+
This used to be constructed inside ``_vector_search``, so every single
|
|
622
|
+
question paid a full SentenceTransformer load.
|
|
623
|
+
"""
|
|
624
|
+
if hasattr(self, '_embedding_model_cache'):
|
|
625
|
+
return self._embedding_model_cache
|
|
626
|
+
|
|
627
|
+
self._embedding_model_cache = None
|
|
628
|
+
embedding_model = self.config.get('models', {}).get('embedding_model', '')
|
|
629
|
+
if embedding_model and SentenceTransformer is not None:
|
|
630
|
+
try:
|
|
631
|
+
self._embedding_model_cache = SentenceTransformer(embedding_model)
|
|
632
|
+
logging.info("Loaded embedding model %s", embedding_model)
|
|
633
|
+
except Exception as exc:
|
|
634
|
+
logging.error("Failed to load embedding model %s: %s", embedding_model, exc)
|
|
635
|
+
return self._embedding_model_cache
|
|
575
636
|
|
|
576
637
|
def _vector_search(self, query: str, top_k: int) -> List[Tuple[int, float]]:
|
|
577
638
|
"""向量搜索"""
|
|
@@ -579,12 +640,10 @@ class EvidenceRetriever:
|
|
|
579
640
|
return []
|
|
580
641
|
|
|
581
642
|
try:
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if not embedding_model:
|
|
643
|
+
model = self._get_embedding_model()
|
|
644
|
+
if model is None:
|
|
585
645
|
return []
|
|
586
646
|
|
|
587
|
-
model = SentenceTransformer(embedding_model)
|
|
588
647
|
query_vec = model.encode([query], normalize_embeddings=True).astype('float32')
|
|
589
648
|
|
|
590
649
|
# 搜索
|
package/scripts/chat.py
CHANGED
|
@@ -18,6 +18,15 @@ from pathlib import Path
|
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
20
|
|
|
21
|
+
# Prefer the regular Hugging Face download path over Xet for better CLI
|
|
22
|
+
# reliability on networks where xet-bridge CDN requests time out.
|
|
23
|
+
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
|
|
24
|
+
os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "0")
|
|
25
|
+
os.environ.setdefault("HF_HUB_DOWNLOAD_TIMEOUT", "120")
|
|
26
|
+
os.environ.setdefault("HF_HUB_ETAG_TIMEOUT", "30")
|
|
27
|
+
os.environ.setdefault("HF_HUB_VERBOSITY", "warning")
|
|
28
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "warning")
|
|
29
|
+
|
|
21
30
|
HISTORY_FILE = os.path.join(os.path.expanduser("~"), ".cofos_history.jsonl")
|
|
22
31
|
HISTORY_MAX_TURNS = 20
|
|
23
32
|
|
|
@@ -26,7 +35,7 @@ DEFAULT_MODEL_PATH = os.environ.get(
|
|
|
26
35
|
"Willlzh/COFOS",
|
|
27
36
|
)
|
|
28
37
|
DEFAULT_CONFIG = os.environ.get("COFOS_CONFIG", "config/config.yaml")
|
|
29
|
-
CACHE_ROOT = Path(os.environ.get("COFOS_CACHE_DIR", Path.
|
|
38
|
+
CACHE_ROOT = Path(os.environ.get("COFOS_CACHE_DIR", Path.cwd() / ".cofos")).expanduser()
|
|
30
39
|
|
|
31
40
|
|
|
32
41
|
def package_root() -> Path:
|
|
@@ -37,16 +46,8 @@ def find_runtime_root() -> Path:
|
|
|
37
46
|
bundled = package_root() / "runtime"
|
|
38
47
|
if (bundled / "src" / "inference" / "student_adapter_inference.py").exists():
|
|
39
48
|
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
49
|
raise FileNotFoundError(
|
|
49
|
-
"Could not find COFOS runtime files. The npm package should include "
|
|
50
|
+
"Could not find bundled COFOS runtime files. The npm package should include "
|
|
50
51
|
"runtime/src and runtime/config."
|
|
51
52
|
)
|
|
52
53
|
|
|
@@ -339,25 +340,46 @@ def ensure_runtime_data(project_root: Path, config: dict) -> None:
|
|
|
339
340
|
"runtime/bm25/bm25_info.json": bm25_dir,
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
missing_items = []
|
|
342
344
|
for remote_path, target_dir in file_map.items():
|
|
343
|
-
target_dir.mkdir(parents=True, exist_ok=True)
|
|
344
345
|
local_name = remote_path.rsplit("/", 1)[-1]
|
|
345
346
|
local_path = target_dir / local_name
|
|
346
|
-
if local_path.exists():
|
|
347
|
-
|
|
347
|
+
if not local_path.exists():
|
|
348
|
+
missing_items.append((remote_path, target_dir, local_path))
|
|
349
|
+
|
|
350
|
+
total = len(missing_items)
|
|
351
|
+
if total == 0:
|
|
352
|
+
print("[chat] Runtime data ready.", file=sys.stderr, flush=True)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
def progress_line(index: int, remote_path: str) -> str:
|
|
356
|
+
width = 18
|
|
357
|
+
filled = int(width * index / total)
|
|
358
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
359
|
+
pct = int(100 * index / total)
|
|
360
|
+
return f"[chat] Runtime data [{index}/{total}] [{bar}] {pct:3d}% {remote_path}"
|
|
361
|
+
|
|
362
|
+
for index, (remote_path, target_dir, local_path) in enumerate(missing_items, 1):
|
|
363
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
print(progress_line(index, remote_path), file=sys.stderr, flush=True)
|
|
348
365
|
cached = hf_hub_download(
|
|
349
366
|
repo_id=repo,
|
|
350
367
|
repo_type="dataset",
|
|
351
368
|
filename=remote_path,
|
|
352
369
|
)
|
|
353
370
|
shutil.copy2(cached, local_path)
|
|
354
|
-
print(f"[chat]
|
|
371
|
+
print(f"[chat] -> {local_path}", file=sys.stderr, flush=True)
|
|
355
372
|
|
|
356
373
|
print("[chat] Runtime data ready.", file=sys.stderr, flush=True)
|
|
357
374
|
|
|
358
375
|
|
|
359
376
|
def _load_history() -> list:
|
|
360
|
-
"""Load recent conversation turns from the persistent history file.
|
|
377
|
+
"""Load recent conversation turns from the persistent history file.
|
|
378
|
+
|
|
379
|
+
The chat template requires strictly alternating user/assistant turns, so a
|
|
380
|
+
truncated or interleaved file must be repaired here rather than blowing up
|
|
381
|
+
inside ``apply_chat_template``.
|
|
382
|
+
"""
|
|
361
383
|
if not os.path.exists(HISTORY_FILE):
|
|
362
384
|
return []
|
|
363
385
|
turns = []
|
|
@@ -368,14 +390,33 @@ def _load_history() -> list:
|
|
|
368
390
|
if not line:
|
|
369
391
|
continue
|
|
370
392
|
try:
|
|
371
|
-
|
|
393
|
+
turn = json.loads(line)
|
|
372
394
|
except json.JSONDecodeError:
|
|
373
395
|
continue
|
|
396
|
+
if (
|
|
397
|
+
isinstance(turn, dict)
|
|
398
|
+
and turn.get("role") in {"user", "assistant"}
|
|
399
|
+
and isinstance(turn.get("content"), str)
|
|
400
|
+
):
|
|
401
|
+
turns.append(turn)
|
|
374
402
|
except OSError:
|
|
375
403
|
return []
|
|
376
|
-
|
|
404
|
+
|
|
405
|
+
# Drop anything that breaks user→assistant alternation (e.g. a user turn
|
|
406
|
+
# whose answer never completed because generation was interrupted).
|
|
407
|
+
paired = []
|
|
408
|
+
for turn in turns:
|
|
409
|
+
expected = "user" if len(paired) % 2 == 0 else "assistant"
|
|
410
|
+
if turn["role"] == expected:
|
|
411
|
+
paired.append(turn)
|
|
412
|
+
elif turn["role"] == "user" and expected == "assistant":
|
|
413
|
+
# Previous user turn never got an answer; replace it.
|
|
414
|
+
paired[-1] = turn
|
|
415
|
+
if len(paired) % 2:
|
|
416
|
+
paired.pop()
|
|
417
|
+
|
|
377
418
|
max_items = HISTORY_MAX_TURNS * 2
|
|
378
|
-
return
|
|
419
|
+
return paired[-max_items:] if len(paired) > max_items else paired
|
|
379
420
|
|
|
380
421
|
|
|
381
422
|
def _save_turn(role: str, content: str) -> None:
|
|
@@ -388,8 +429,29 @@ def _save_turn(role: str, content: str) -> None:
|
|
|
388
429
|
pass # best-effort
|
|
389
430
|
|
|
390
431
|
|
|
432
|
+
def _rewrite_history(turns: list) -> bool:
|
|
433
|
+
"""Replace the on-disk history with *turns*. Returns True on success."""
|
|
434
|
+
try:
|
|
435
|
+
with open(HISTORY_FILE, "w", encoding="utf-8") as fh:
|
|
436
|
+
for turn in turns:
|
|
437
|
+
json.dump(turn, fh, ensure_ascii=False)
|
|
438
|
+
fh.write("\n")
|
|
439
|
+
return True
|
|
440
|
+
except OSError:
|
|
441
|
+
return False
|
|
442
|
+
|
|
443
|
+
|
|
391
444
|
def main() -> None:
|
|
392
445
|
args = parse_args()
|
|
446
|
+
|
|
447
|
+
# Resolve user-supplied paths against the shell's cwd *before* chdir below,
|
|
448
|
+
# otherwise a relative --pdf-dir would be looked up inside the installed
|
|
449
|
+
# npm package directory instead of where the user actually keeps the PDFs.
|
|
450
|
+
if args.pdf_dir:
|
|
451
|
+
args.pdf_dir = str(Path(args.pdf_dir).expanduser().resolve())
|
|
452
|
+
if args.config and args.config != DEFAULT_CONFIG and not is_hf_repo_id(args.config):
|
|
453
|
+
args.config = str(Path(args.config).expanduser().resolve())
|
|
454
|
+
|
|
393
455
|
runtime_root = find_runtime_root()
|
|
394
456
|
os.chdir(runtime_root)
|
|
395
457
|
src_path = runtime_root / "src"
|
|
@@ -399,9 +461,11 @@ def main() -> None:
|
|
|
399
461
|
sys.path.insert(0, str(scripts_path))
|
|
400
462
|
|
|
401
463
|
import yaml
|
|
402
|
-
from inference.student_adapter_inference import StudentAdapterInference
|
|
464
|
+
from inference.student_adapter_inference import StudentAdapterInference, StudentRAGContext
|
|
403
465
|
|
|
404
466
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
|
467
|
+
for noisy_logger in ("httpx", "httpcore", "huggingface_hub"):
|
|
468
|
+
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
|
|
405
469
|
|
|
406
470
|
config_path = default_config_path(runtime_root) if args.config == DEFAULT_CONFIG else resolve_project_path(runtime_root, args.config)
|
|
407
471
|
model_path = resolve_project_path(runtime_root, args.model_path)
|
|
@@ -448,38 +512,53 @@ def main() -> None:
|
|
|
448
512
|
question = raw.strip("\r\n")
|
|
449
513
|
if not question or question == "/exit":
|
|
450
514
|
break
|
|
451
|
-
if question.startswith("/topk "):
|
|
515
|
+
if question == "/topk" or question.startswith("/topk "):
|
|
516
|
+
parts = question.split(maxsplit=1)
|
|
452
517
|
try:
|
|
453
|
-
top_k = max(1, int(
|
|
518
|
+
top_k = max(1, int(parts[1]))
|
|
454
519
|
print(f"top_k set to {top_k}")
|
|
455
|
-
except ValueError:
|
|
520
|
+
except (IndexError, ValueError):
|
|
456
521
|
print("Usage: /topk 5")
|
|
457
522
|
print("===DONE===", flush=True)
|
|
458
523
|
continue
|
|
459
524
|
if question == "/rag off":
|
|
460
525
|
runner.rag = None
|
|
526
|
+
runner.use_rag = False
|
|
461
527
|
print("RAG disabled for this session.")
|
|
462
528
|
print("===DONE===", flush=True)
|
|
463
529
|
continue
|
|
464
530
|
if question == "/rag on":
|
|
465
531
|
if runner.rag is None:
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
532
|
+
# Started with --no-rag, so the indexes may never have been
|
|
533
|
+
# fetched. Do it now, and keep a failure from killing the
|
|
534
|
+
# backend the way an uncaught exception here used to.
|
|
535
|
+
try:
|
|
536
|
+
ensure_runtime_data(runtime_root, config)
|
|
537
|
+
runner.rag = StudentRAGContext(config)
|
|
538
|
+
runner.use_rag = True
|
|
539
|
+
print("RAG enabled for this session.")
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
print(f"Could not enable RAG: {exc}")
|
|
542
|
+
else:
|
|
543
|
+
print("RAG is already enabled.")
|
|
469
544
|
print("===DONE===", flush=True)
|
|
470
545
|
continue
|
|
471
546
|
if question == "/clear":
|
|
472
547
|
history.clear()
|
|
548
|
+
# Also clear the file; otherwise the next launch silently restores
|
|
549
|
+
# the turns the user just asked to forget.
|
|
550
|
+
_rewrite_history([])
|
|
473
551
|
print("Conversation history cleared.")
|
|
474
552
|
print("===DONE===", flush=True)
|
|
475
553
|
continue
|
|
476
554
|
if question == "/save":
|
|
477
|
-
|
|
555
|
+
if _rewrite_history(history):
|
|
556
|
+
print(f"History saved to {HISTORY_FILE} ({len(history) // 2} turns).")
|
|
557
|
+
else:
|
|
558
|
+
print(f"Could not write history to {HISTORY_FILE}.")
|
|
478
559
|
print("===DONE===", flush=True)
|
|
479
560
|
continue
|
|
480
561
|
|
|
481
|
-
_save_turn("user", question)
|
|
482
|
-
|
|
483
562
|
try:
|
|
484
563
|
t_gen = time.time()
|
|
485
564
|
print("===RESPONSE===", flush=True)
|
|
@@ -489,13 +568,17 @@ def main() -> None:
|
|
|
489
568
|
answer_parts.append(token)
|
|
490
569
|
answer = "".join(answer_parts)
|
|
491
570
|
gen_elapsed = time.time() - t_gen
|
|
492
|
-
print(
|
|
571
|
+
print("\n===DONE===", flush=True)
|
|
493
572
|
print(f"[chat] Generated in {gen_elapsed:.1f}s", file=sys.stderr, flush=True)
|
|
494
573
|
|
|
574
|
+
# Persist both turns only once the answer completed, so an
|
|
575
|
+
# interrupted generation cannot leave a dangling user turn behind.
|
|
495
576
|
history.append({"role": "user", "content": question})
|
|
496
577
|
history.append({"role": "assistant", "content": answer})
|
|
578
|
+
_save_turn("user", question)
|
|
497
579
|
_save_turn("assistant", answer)
|
|
498
580
|
except Exception as exc:
|
|
581
|
+
logging.exception("Generation failed")
|
|
499
582
|
print(f"\n[COFOS error] {exc}")
|
|
500
583
|
print("===DONE===", flush=True)
|
|
501
584
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Package-local runtime sync wrapper.
|
|
3
|
+
|
|
4
|
+
When run from the source checkout, delegate to the project-level generator.
|
|
5
|
+
When run from an installed/published package, validate that the generated
|
|
6
|
+
runtime is already bundled and exit without needing repository parent folders.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import runpy
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
package_root = Path(__file__).resolve().parents[1]
|
|
18
|
+
project_sync = package_root.parent / "scripts" / "sync_fwdemo_runtime.py"
|
|
19
|
+
if project_sync.exists():
|
|
20
|
+
sys.argv = [str(project_sync), *sys.argv[1:]]
|
|
21
|
+
runpy.run_path(str(project_sync), run_name="__main__")
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
runtime_root = package_root / "runtime"
|
|
25
|
+
required = [
|
|
26
|
+
runtime_root / "config" / "config.yaml",
|
|
27
|
+
runtime_root / "config" / "prompt_templates.yaml",
|
|
28
|
+
runtime_root / "src" / "inference" / "student_adapter_inference.py",
|
|
29
|
+
runtime_root / "src" / "retrieval" / "retrieve_evidence.py",
|
|
30
|
+
]
|
|
31
|
+
missing = [path.relative_to(package_root) for path in required if not path.exists()]
|
|
32
|
+
if missing:
|
|
33
|
+
print(
|
|
34
|
+
"Packaged COFOS runtime is incomplete; missing: "
|
|
35
|
+
+ ", ".join(str(path) for path in missing),
|
|
36
|
+
file=sys.stderr,
|
|
37
|
+
)
|
|
38
|
+
return 1
|
|
39
|
+
|
|
40
|
+
print("Packaged COFOS runtime is already bundled; no source checkout sync needed.")
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
raise SystemExit(main())
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# COFOS fwdemo runtime model configuration
|
|
2
|
-
|
|
3
|
-
This file describes the bundled runtime used by the standalone `@lzhzzzzwill/cofos` npm package.
|
|
4
|
-
|
|
5
|
-
## Standalone QA path
|
|
6
|
-
|
|
7
|
-
The standalone CLI does use Hugging Face by default:
|
|
8
|
-
|
|
9
|
-
- Student QA model: `Willlzh/COFOS`
|
|
10
|
-
- Runtime KG/BM25 data: `Willlzh/COFOS_data` under `runtime/`
|
|
11
|
-
- Local cache/write location: `~/.cache/cofos/`
|
|
12
|
-
|
|
13
|
-
On startup, fwdemo checks local cache first. It downloads only missing model/data files.
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
cofos
|
|
17
|
-
cofos --pdf-dir ./new_pdfs
|
|
18
|
-
cofos --model /path/to/local/merged_model
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
PDFs passed with `--pdf-dir` are parsed into local chunks and merged into BM25 retrieval data under `~/.cache/cofos/`. They are not uploaded anywhere.
|
|
22
|
-
|
|
23
|
-
## Legacy full-project Ollama settings
|
|
24
|
-
|
|
25
|
-
The `llm:` block in `config.yaml` is kept for full COFOS repository workflows such as Silver JSON extraction, teacher distillation, and teacher KG-RAG experiments. Those workflows can call an internal Ollama-style endpoint, for example:
|
|
26
|
-
|
|
27
|
-
```yaml
|
|
28
|
-
llm:
|
|
29
|
-
provider: ollama
|
|
30
|
-
generate_url: http://10.161.138.150:11434/api/generate
|
|
31
|
-
teacher_model: qwen3.6:35b
|
|
32
|
-
extraction_model: qwen3.6:35b
|
|
33
|
-
inference_model: qwen3.6:35b
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
That legacy block is not the default standalone fwdemo QA model path. The standalone QA model is loaded through Transformers from `Willlzh/COFOS` unless the user passes a local `--model` path.
|
|
37
|
-
|
|
38
|
-
## Retrieval
|
|
39
|
-
|
|
40
|
-
BM25 is enabled by default. FAISS remains disabled unless a local embedding model is configured explicitly.
|
|
41
|
-
|
|
42
|
-
```yaml
|
|
43
|
-
retrieval:
|
|
44
|
-
enable_faiss: false
|
|
45
|
-
enable_bm25: true
|
|
46
|
-
```
|