@lzhzzzzwill/cofos 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, ensure_directory
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 _load_kg_triples(self) -> None:
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.kg_triples = []
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
- if not self.is_domain_query(query):
556
- return []
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
- results = []
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
- return confident[:top_k]
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
- embedding_model = self.config.get('models', {}).get('embedding_model', '')
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,13 @@ 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
+
21
28
  HISTORY_FILE = os.path.join(os.path.expanduser("~"), ".cofos_history.jsonl")
22
29
  HISTORY_MAX_TURNS = 20
23
30
 
@@ -26,7 +33,7 @@ DEFAULT_MODEL_PATH = os.environ.get(
26
33
  "Willlzh/COFOS",
27
34
  )
28
35
  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()
36
+ CACHE_ROOT = Path(os.environ.get("COFOS_CACHE_DIR", Path.cwd() / ".cofos")).expanduser()
30
37
 
31
38
 
32
39
  def package_root() -> Path:
@@ -37,16 +44,8 @@ def find_runtime_root() -> Path:
37
44
  bundled = package_root() / "runtime"
38
45
  if (bundled / "src" / "inference" / "student_adapter_inference.py").exists():
39
46
  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
47
  raise FileNotFoundError(
49
- "Could not find COFOS runtime files. The npm package should include "
48
+ "Could not find bundled COFOS runtime files. The npm package should include "
50
49
  "runtime/src and runtime/config."
51
50
  )
52
51
 
@@ -357,7 +356,12 @@ def ensure_runtime_data(project_root: Path, config: dict) -> None:
357
356
 
358
357
 
359
358
  def _load_history() -> list:
360
- """Load recent conversation turns from the persistent history file."""
359
+ """Load recent conversation turns from the persistent history file.
360
+
361
+ The chat template requires strictly alternating user/assistant turns, so a
362
+ truncated or interleaved file must be repaired here rather than blowing up
363
+ inside ``apply_chat_template``.
364
+ """
361
365
  if not os.path.exists(HISTORY_FILE):
362
366
  return []
363
367
  turns = []
@@ -368,14 +372,33 @@ def _load_history() -> list:
368
372
  if not line:
369
373
  continue
370
374
  try:
371
- turns.append(json.loads(line))
375
+ turn = json.loads(line)
372
376
  except json.JSONDecodeError:
373
377
  continue
378
+ if (
379
+ isinstance(turn, dict)
380
+ and turn.get("role") in {"user", "assistant"}
381
+ and isinstance(turn.get("content"), str)
382
+ ):
383
+ turns.append(turn)
374
384
  except OSError:
375
385
  return []
376
- # Keep only the last N turns
386
+
387
+ # Drop anything that breaks user→assistant alternation (e.g. a user turn
388
+ # whose answer never completed because generation was interrupted).
389
+ paired = []
390
+ for turn in turns:
391
+ expected = "user" if len(paired) % 2 == 0 else "assistant"
392
+ if turn["role"] == expected:
393
+ paired.append(turn)
394
+ elif turn["role"] == "user" and expected == "assistant":
395
+ # Previous user turn never got an answer; replace it.
396
+ paired[-1] = turn
397
+ if len(paired) % 2:
398
+ paired.pop()
399
+
377
400
  max_items = HISTORY_MAX_TURNS * 2
378
- return turns[-max_items:] if len(turns) > max_items else turns
401
+ return paired[-max_items:] if len(paired) > max_items else paired
379
402
 
380
403
 
381
404
  def _save_turn(role: str, content: str) -> None:
@@ -388,8 +411,29 @@ def _save_turn(role: str, content: str) -> None:
388
411
  pass # best-effort
389
412
 
390
413
 
414
+ def _rewrite_history(turns: list) -> bool:
415
+ """Replace the on-disk history with *turns*. Returns True on success."""
416
+ try:
417
+ with open(HISTORY_FILE, "w", encoding="utf-8") as fh:
418
+ for turn in turns:
419
+ json.dump(turn, fh, ensure_ascii=False)
420
+ fh.write("\n")
421
+ return True
422
+ except OSError:
423
+ return False
424
+
425
+
391
426
  def main() -> None:
392
427
  args = parse_args()
428
+
429
+ # Resolve user-supplied paths against the shell's cwd *before* chdir below,
430
+ # otherwise a relative --pdf-dir would be looked up inside the installed
431
+ # npm package directory instead of where the user actually keeps the PDFs.
432
+ if args.pdf_dir:
433
+ args.pdf_dir = str(Path(args.pdf_dir).expanduser().resolve())
434
+ if args.config and args.config != DEFAULT_CONFIG and not is_hf_repo_id(args.config):
435
+ args.config = str(Path(args.config).expanduser().resolve())
436
+
393
437
  runtime_root = find_runtime_root()
394
438
  os.chdir(runtime_root)
395
439
  src_path = runtime_root / "src"
@@ -399,7 +443,7 @@ def main() -> None:
399
443
  sys.path.insert(0, str(scripts_path))
400
444
 
401
445
  import yaml
402
- from inference.student_adapter_inference import StudentAdapterInference
446
+ from inference.student_adapter_inference import StudentAdapterInference, StudentRAGContext
403
447
 
404
448
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
405
449
 
@@ -448,38 +492,53 @@ def main() -> None:
448
492
  question = raw.strip("\r\n")
449
493
  if not question or question == "/exit":
450
494
  break
451
- if question.startswith("/topk "):
495
+ if question == "/topk" or question.startswith("/topk "):
496
+ parts = question.split(maxsplit=1)
452
497
  try:
453
- top_k = max(1, int(question.split(maxsplit=1)[1]))
498
+ top_k = max(1, int(parts[1]))
454
499
  print(f"top_k set to {top_k}")
455
- except ValueError:
500
+ except (IndexError, ValueError):
456
501
  print("Usage: /topk 5")
457
502
  print("===DONE===", flush=True)
458
503
  continue
459
504
  if question == "/rag off":
460
505
  runner.rag = None
506
+ runner.use_rag = False
461
507
  print("RAG disabled for this session.")
462
508
  print("===DONE===", flush=True)
463
509
  continue
464
510
  if question == "/rag on":
465
511
  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.")
512
+ # Started with --no-rag, so the indexes may never have been
513
+ # fetched. Do it now, and keep a failure from killing the
514
+ # backend the way an uncaught exception here used to.
515
+ try:
516
+ ensure_runtime_data(runtime_root, config)
517
+ runner.rag = StudentRAGContext(config)
518
+ runner.use_rag = True
519
+ print("RAG enabled for this session.")
520
+ except Exception as exc:
521
+ print(f"Could not enable RAG: {exc}")
522
+ else:
523
+ print("RAG is already enabled.")
469
524
  print("===DONE===", flush=True)
470
525
  continue
471
526
  if question == "/clear":
472
527
  history.clear()
528
+ # Also clear the file; otherwise the next launch silently restores
529
+ # the turns the user just asked to forget.
530
+ _rewrite_history([])
473
531
  print("Conversation history cleared.")
474
532
  print("===DONE===", flush=True)
475
533
  continue
476
534
  if question == "/save":
477
- print(f"History saved to {HISTORY_FILE} ({len(history) // 2} turns).")
535
+ if _rewrite_history(history):
536
+ print(f"History saved to {HISTORY_FILE} ({len(history) // 2} turns).")
537
+ else:
538
+ print(f"Could not write history to {HISTORY_FILE}.")
478
539
  print("===DONE===", flush=True)
479
540
  continue
480
541
 
481
- _save_turn("user", question)
482
-
483
542
  try:
484
543
  t_gen = time.time()
485
544
  print("===RESPONSE===", flush=True)
@@ -489,13 +548,17 @@ def main() -> None:
489
548
  answer_parts.append(token)
490
549
  answer = "".join(answer_parts)
491
550
  gen_elapsed = time.time() - t_gen
492
- print(f"\n===DONE===", flush=True)
551
+ print("\n===DONE===", flush=True)
493
552
  print(f"[chat] Generated in {gen_elapsed:.1f}s", file=sys.stderr, flush=True)
494
553
 
554
+ # Persist both turns only once the answer completed, so an
555
+ # interrupted generation cannot leave a dangling user turn behind.
495
556
  history.append({"role": "user", "content": question})
496
557
  history.append({"role": "assistant", "content": answer})
558
+ _save_turn("user", question)
497
559
  _save_turn("assistant", answer)
498
560
  except Exception as exc:
561
+ logging.exception("Generation failed")
499
562
  print(f"\n[COFOS error] {exc}")
500
563
  print("===DONE===", flush=True)
501
564
 
@@ -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
- ```