@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.
@@ -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,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.home() / ".cache" / "cofos")).expanduser()
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
- continue
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] Downloaded {remote_path} -> {local_path}", file=sys.stderr, flush=True)
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
- turns.append(json.loads(line))
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
- # Keep only the last N turns
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 turns[-max_items:] if len(turns) > max_items else turns
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(question.split(maxsplit=1)[1]))
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
- from inference.run_kg_grounded_inference import KGRAGInference
467
- runner.rag = KGRAGInference(config)
468
- print("RAG enabled for this session.")
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
- print(f"History saved to {HISTORY_FILE} ({len(history) // 2} turns).")
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(f"\n===DONE===", flush=True)
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
- ```