#!/usr/bin/env python3 """ Continuity Recall — Active Recall Layer Phase 1 查詢來源:MEMORY.md + memory/*.md + wiki/topics/*.md 用於 hard trigger(進度詢問、定義回溯、決策查詢) Usage: python3 continuity_recall.py "XKB 下一步是什麼" python3 continuity_recall.py "active recall 的定義" --json python3 continuity_recall.py "query" --source memory # 只查 memory python3 continuity_recall.py "query" --source wiki # 只查 wiki """ from __future__ import annotations import argparse import json import math import os import re import sys import threading from pathlib import Path from typing import NamedTuple sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths import xkb_provenance import xkb_relevance import xkb_text WORKSPACE = xkb_paths.WORKSPACE _SKILL_DIR = xkb_paths.SKILL_DIR MEMORY_DIR = xkb_paths.DATA_DIR WIKI_TOPICS_DIR = xkb_paths.WIKI_TOPICS_DIR MEMORY_MD = xkb_paths.MEMORY_MD STOPWORDS = { "的", "了", "是", "我", "你", "他", "她", "它", "在", "有", "和", "與", "就", "也", "都", "很", "想", "要", "用", "讓", "把", "跟", "對", "中", "上", "下", "嗎", "呢", "啊", "吧", "這", "那", "this", "that", "with", "from", "have", "will", "about", "your", "they", "what", "when", "where", "which", "how", "why", "for", "and", "the", "are", "was", } class RecallResult(NamedTuple): source_type: str # memory | wiki source_file: str # relative path section: str # section title or "" excerpt: str # text snippet (150 chars) score: float url: str = "" # wiki topic URL or "" def _result(*, source_type, source_file, section, excerpt, score, url="") -> RecallResult: """Build a result with the bookkeeping taken out. Governance stamps every promoted line with the candidate fingerprint that makes its batch reversible. A recall for 碳盤查 was answering with 64 characters of hex in the middle of the sentence. There are three places that build a result and more than one that renders it, so the removal belongs here, once, where the next one added inherits it. """ return RecallResult( source_type=source_type, source_file=source_file, section=xkb_provenance.strip_markers(section), excerpt=xkb_provenance.strip_markers(excerpt), score=score, url=url, ) def tokenize(text: str) -> list[str]: return xkb_text.tokenize(text, STOPWORDS) def _score_text(tokens: list[str], text: str) -> float: """Simple token overlap score.""" if not tokens or not text: return 0.0 text_lower = text.lower() # Phrase bonus: check if two consecutive tokens appear adjacent phrase_bonus = 0.0 for i in range(len(tokens) - 1): phrase = tokens[i] + tokens[i + 1] if phrase in text_lower or f"{tokens[i]} {tokens[i+1]}" in text_lower: phrase_bonus += 0.5 return xkb_text.overlap_score(tokens, text) + phrase_bonus def _split_into_sections(content: str) -> list[tuple[str, str]]: """Split markdown into (section_title, body) pairs.""" sections: list[tuple[str, str]] = [] current_title = "" current_lines: list[str] = [] for line in content.splitlines(): if re.match(r"^#{1,3} .+", line): if current_lines: sections.append((current_title, "\n".join(current_lines))) current_title = re.sub(r"^#{1,3} ", "", line).strip() current_lines = [] else: current_lines.append(line) if current_lines: sections.append((current_title, "\n".join(current_lines))) return sections def _excerpt(text: str, tokens: list[str], max_len: int = 200) -> str: """Extract the most relevant snippet from text.""" text_clean = re.sub(r"\n+", " ", text).strip() if len(text_clean) <= max_len: return text_clean # Find best window containing most tokens words = text_clean.split() best_start = 0 best_score = 0.0 window = 30 # words for i in range(0, max(1, len(words) - window)): chunk = " ".join(words[i:i + window]) s = _score_text(tokens, chunk) if s > best_score: best_score = s best_start = i snippet = " ".join(words[best_start:best_start + window]) if len(snippet) > max_len: snippet = snippet[:max_len - 1] + "…" return snippet # ── Memory recall ───────────────────────────────────────────────────────────── def recall_from_memory(query: str, top_k: int = 3) -> list[RecallResult]: """Search MEMORY.md + memory/*.md for relevant sections.""" tokens = tokenize(query) if not tokens: return [] candidates: list[RecallResult] = [] # Collect all memory files memory_files: list[Path] = [] if MEMORY_MD.exists(): memory_files.append(MEMORY_MD) if MEMORY_DIR.exists(): memory_files.extend(MEMORY_DIR.glob("*.md")) for path in memory_files: try: content = path.read_text(encoding="utf-8") except Exception: continue rel_path = str(path.relative_to(WORKSPACE)) sections = _split_into_sections(content) for section_title, body in sections: if not body.strip(): continue # Score: title match weighted higher title_score = _score_text(tokens, section_title) * 2.0 body_score = _score_text(tokens, body) total = title_score + body_score * 0.5 if total < 0.3: continue excerpt = _excerpt(body, tokens) candidates.append(_result( source_type="memory", source_file=rel_path, section=section_title, excerpt=excerpt, score=round(total, 3), )) candidates.sort(key=lambda r: r.score, reverse=True) return candidates[:top_k] # ── Wiki recall ─────────────────────────────────────────────────────────────── def _parse_wiki_frontmatter(content: str) -> dict[str, str]: m = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL) if not m: return {} data: dict[str, str] = {} for line in m.group(1).splitlines(): if ":" in line: k, v = line.split(":", 1) data[k.strip()] = v.strip() return data # ── 語意召回 ────────────────────────────────────────────────────────────────── # # XKB 的核心不是資料庫,是根據語意主動召回。但 wiki 這一層原本只有字串比對: # 中文沒有空格,切 n-gram 之後「之前」「怎麼」「處理」會命中任何一份文件, # 於是「我們之前怎麼處理碳盤查的」撈回一堆無關結果,還給出 0.42 的分數。 # # 語意分數說得出「沒有夠像的」——實測那句在 wiki 裡最高只有 0.574, # 因為知識庫裡本來就沒有碳盤查的內容。字串比對永遠說不出這句話。 WIKI_MIN_SIMILARITY = xkb_relevance.threshold("wiki_recall") _VECTORS: dict[str, list[float]] | None = None _PROVIDER = None _INDEX_LOCK = threading.RLock() SEMANTIC_PREFIXES = {"wiki/topics/": "wiki_semantic", "memory/": "memory_semantic"} def _load_semantic_vectors() -> dict[str, list[float]]: """載入 wiki 與記憶檔的段落向量(已單位化)。 優先讀二進位小檔:整份 vector_index.json 有 8,000 多個卡片向量, 存成 JSON 光載入就要 5 秒,而召回是每句話都要跑的。 """ with _INDEX_LOCK: global _VECTORS if _VECTORS is not None: return _VECTORS _VECTORS = {} meta_path = Path(os.getenv("XKB_SEMANTIC_INDEX", str(xkb_paths.BOOKMARKS_DIR / "semantic_index.json"))) bin_path = meta_path.with_suffix(".bin") if meta_path.exists() and bin_path.exists(): try: import array with meta_path.open(encoding="utf-8") as fh: meta = json.load(fh) dims, keys = int(meta["dims"]), list(meta["keys"]) packed = array.array("f") payload = bin_path.read_bytes() # 二進位格式沒有自我描述能力,對不上時不會拋錯,只會安靜地 # 切出空的或錯位的向量——分數變成 0,看起來就像「知識庫沒東西」。 # 所以寧可大聲退回 JSON,也不要拿可能錯位的資料去算相似度。 problems = [] if meta.get("byteorder") and meta["byteorder"] != sys.byteorder: problems.append(f"byteorder {meta['byteorder']} != {sys.byteorder}") if meta.get("itemsize") and int(meta["itemsize"]) != packed.itemsize: problems.append(f"itemsize {meta['itemsize']} != {packed.itemsize}") expected = len(keys) * dims * packed.itemsize if len(payload) != expected: problems.append(f"size {len(payload)} != expected {expected}" f"(keys 與 .bin 不同步,可能只重建了其中一個)") if problems: raise ValueError("; ".join(problems)) packed.frombytes(payload) _VECTORS = { key: packed[i * dims:(i + 1) * dims].tolist() for i, key in enumerate(keys) } return _VECTORS except (OSError, ValueError, KeyError) as exc: print(f"(semantic index unusable, falling back to JSON: {exc})", file=sys.stderr) _VECTORS = {} # 退路:直接讀完整索引(慢,但至少能動) try: with xkb_paths.VECTOR_FILE.open(encoding="utf-8") as fh: data = json.load(fh) _VECTORS = { k: v for k, v in (data.get("vectors") or {}).items() if any(k.startswith(prefix) for prefix in SEMANTIC_PREFIXES) and not k.startswith("memory/cards/") } except (OSError, ValueError): pass return _VECTORS _CARD_KEYS: dict[str, int] | None = None _CARD_DIMS = 0 # 卡片相關度下限。gbrain 回的是 RRF 排名分數,不是相關度—— # 名次第一永遠約 0.88,即使整個知識庫裡沒有一張卡跟問題有關。 # 實測「量子力學的波函數塌縮」(知識庫裡完全沒有的主題)照樣拿到 0.855, # 而且第一名是一段 Bilibili 導覽選單的爬蟲殘渣。 # # 換成真實餘弦相似度後兩者分得很開(2026-07-29 實測,各取前三名): # seedance 工作流程 0.60 0.64 0.67 # gpt image 2 人像 prompt 0.66 0.74 0.65 # 碳盤查 0.49 0.48 0.51 # 量子力學的波函數塌縮 0.49 0.50 0.57 # 幫我訂明天的餐廳 0.47 0.48 0.53 # 相關的落在 0.60~0.74,不相關的落在 0.47~0.57,門檻取中間。 CARD_MIN_SIMILARITY = xkb_relevance.threshold("card_recall") def _card_index_paths() -> tuple[Path, Path]: meta = Path(os.getenv("XKB_CARDS_INDEX", str(xkb_paths.BOOKMARKS_DIR / "cards_index.json"))) return meta, meta.with_suffix(".bin") def _find_card_rows(key: str, *, card_level_only: bool = False, exact: bool = False) -> list[int]: """同一張卡在不同來源有不同前綴,比對時要把它們對起來。 索引鍵是 `01-openclaw-workflows/X.md` 或 `memory/cards/X.md`, gbrain 回的是 `cards/01-openclaw-workflows/X.md`。 直接字串比對永遠對不上——而對不上的後果是靜默的: 算不出相似度就不過濾,等於這個功能沒生效卻沒人知道。 """ assert _CARD_KEYS is not None if key in _CARD_KEYS: return [_CARD_KEYS[key]] stem = key for prefix in ("cards/", "memory/cards/", "memory/"): if stem.startswith(prefix): stem = stem[len(prefix):] break for candidate in (stem, f"cards/{stem}", f"memory/cards/{stem}"): if candidate in _CARD_KEYS: return [_CARD_KEYS[candidate]] # 論點級向量的鍵是 relpath#kpN。卡片級找不到時要看這張卡的**所有**論點, # 不是字典順序的第一個——分數要面對 0.55 這道硬門檻,所以「第三個論點 # 正好命中」的卡片,會因為第一個論點離題而被丟掉。semantic_recall 本來 # 就是取最大值,這條路徑漏了。 # # 2026-09-01 實測:目前 1,628 張有論點向量的卡片,全部都有卡片級鍵, # 所以這條路徑現在走不到,實際影響是零。留著修好的版本是因為 # 「卡片級鍵不存在」是完全可能出現的狀態(索引重建、卡片改名), # 而那時候取第一個論點會是靜默的錯誤。 if not card_level_only: # 呼叫端明講只要卡片級時跳過這一段。退回論點級會偷偷換掉粒度, # 而有些門檻(吸收閘門的 MAX_REDUNDANCY)是照卡片級校準的。 # 只擋這一段:下面用檔名比對的那一層只可能命中卡片級鍵 # (論點鍵長 X.md#kp1,檔名永遠不等於 X.md),把它一起擋掉的話, # 目錄改名之後重複度檢查會整個靜靜關掉——而那正是那層存在的理由。 for candidate in (key, stem): rows = [i for k, i in _CARD_KEYS.items() if k.startswith(f"{candidate}#")] if rows: return rows # 最後退路:只比對檔名。分類目錄改過名時仍能對上。 if exact: # 最後這層是拿檔名猜的:目錄不同、檔名相同就會命中**別張卡**。 # 對召回來說那是可接受的近似,對「這張卡自己的向量」不是。 # # 更正一個我寫錯的理由:我原本說「判不出重複最多讓一張重複的卡片 # 過去,下一輪還抓得到」。不會——收錄之後它的網址就進了 # existing_by_topic,永遠不再是候選。兩種失敗都是永久的,所以那個 # 論證根本不適用。真正的理由是:拿別張卡的向量去判,錯的是「判斷 # 本身無效」;查不到而不判,錯的是「沒有判斷」——後者至少不會把一個 # 無效的判斷寫成永久決策。而查不到這件事本身要出聲,見呼叫端。 return [] name = stem.rsplit("/", 1)[-1] return [i for k, i in _CARD_KEYS.items() if k.rsplit("/", 1)[-1] == name][:1] # 一張卡最多讀幾條論點向量。定長記錄的 seek 很便宜,但沒有上限的話, # 一個命名異常的鍵可能讓一次召回讀進上百筆。 MAX_CARD_ARGUMENT_ROWS = 8 def lookup_card_vectors(keys: list[str], *, card_level_only: bool = False, exact: bool = False) -> dict[str, list[list[float]]]: """只取指定卡片的向量,用 seek 讀單筆。 卡片有 6,000 多個向量、100MB,全部載入要好幾秒——但我們只需要驗證 gbrain 挑出來的那三五張。定長記錄的好處就是可以直接算出位移, 讀 12KB 而不是 100MB。 """ with _INDEX_LOCK: global _CARD_KEYS, _CARD_DIMS meta_path, bin_path = _card_index_paths() if _CARD_KEYS is None: _CARD_KEYS = {} try: with meta_path.open(encoding="utf-8") as fh: meta = json.load(fh) _CARD_DIMS = int(meta["dims"]) _CARD_KEYS = {k: i for i, k in enumerate(meta["keys"])} except (OSError, ValueError, KeyError): _CARD_KEYS = {} if not _CARD_KEYS or not bin_path.exists(): return {} import array row_bytes = _CARD_DIMS * 4 out: dict[str, list[list[float]]] = {} try: with bin_path.open("rb") as fh: for key in keys: # 一張卡可能有好幾條論點向量。全部讀回來,讓上面決定用哪一個。 rows = _find_card_rows(key, card_level_only=card_level_only, exact=exact) for row in rows[:MAX_CARD_ARGUMENT_ROWS]: fh.seek(row * row_bytes) chunk = fh.read(row_bytes) if len(chunk) != row_bytes: continue vec = array.array("f") vec.frombytes(chunk) out.setdefault(key, []).append(vec.tolist()) except OSError: return {} return out def card_similarities(query: str, keys: list[str]) -> dict[str, float] | None: """回傳每張卡片與問題的真實相似度。None 代表無法判斷(不該據此過濾)。""" if not keys: return {} vectors = lookup_card_vectors(keys) if not vectors: return None query_vector = _embed_query(query) if query_vector is None: return None # 一張卡取它最像的那條論點。用第一條的話,第三點正好命中的卡片會被 # 第一點的分數判死。 return {k: max(_cosine(query_vector, v) for v in rows) for k, rows in vectors.items() if rows} # 同一句話在一次召回裡會被 embed 兩次:混合檢索先打一次,相關度過濾再打一次。 # 查詢字串一模一樣,向量當然也一樣,第二次純粹是多付一趟網路來回。 # 小快取就夠——一次召回內命中,而且行程結束就丟掉,不會拿到過期的向量。 _QUERY_VECTORS: dict[str, list[float] | None] = {} _QUERY_VECTOR_LIMIT = 64 def _embed_query(query: str) -> list[float] | None: """拿不到 embedding 就回 None,呼叫端會退回字串比對。""" with _INDEX_LOCK: if query in _QUERY_VECTORS: return _QUERY_VECTORS[query] vector = _embed_query_uncached(query) with _INDEX_LOCK: if len(_QUERY_VECTORS) >= _QUERY_VECTOR_LIMIT: _QUERY_VECTORS.clear() _QUERY_VECTORS[query] = vector return vector def _embed_query_uncached(query: str) -> list[float] | None: global _PROVIDER if _PROVIDER is None: try: sys.path.insert(0, str(xkb_paths.SKILL_DIR)) from tools.embedding_providers import get_provider _PROVIDER = get_provider() except Exception as exc: print(f"(semantic recall unavailable: {exc})", file=sys.stderr) _PROVIDER = False if not _PROVIDER: return None try: return _PROVIDER.embed(query) except Exception as exc: print(f"(embedding failed: {exc})", file=sys.stderr) return None def _cosine(a: list[float], b: list[float]) -> float: dot = sum(x * y for x, y in zip(a, b)) na = math.sqrt(sum(x * x for x in a)) nb = math.sqrt(sum(y * y for y in b)) return dot / (na * nb) if na * nb > 1e-9 else 0.0 # 逐段用 Python 迴圈算餘弦,4,357 段 × 3,072 維一次要 1 秒;每輪最多三條查詢, # 又被 GIL 排成一列,2026-10-05 實測 wiki 這條腿中位數 5.7 秒。整批矩陣乘法 # 約 0.01 秒,前 20 名與分數跟逐段算的一致。沒有 numpy、或向量維度不齊 # (混了不同模型的向量)時退回逐段算,結果不變,只是慢。 _MATRIX: tuple | None = None def _score_all(vectors: dict[str, list[float]], query_vector: list[float]) -> list[tuple[float, str]]: """回傳 (餘弦, 鍵) 由高到低;與逐段 _cosine 同一個定義。""" global _MATRIX try: import numpy as np with _INDEX_LOCK: if _MATRIX is None or _MATRIX[0] is not vectors: keys = list(vectors) try: matrix = np.asarray([vectors[k] for k in keys], dtype=np.float32) if matrix.ndim != 2: raise ValueError("ragged vectors") _MATRIX = (vectors, keys, matrix, np.linalg.norm(matrix, axis=1)) except ValueError: # 記住失敗:否則每次查詢都在鎖裡重轉一次整份索引才退回。 _MATRIX = (vectors, None, None, None) _, keys, matrix, norms = _MATRIX if matrix is None: raise ValueError("ragged vectors") query = np.asarray(query_vector, dtype=np.float32) if query.shape != (matrix.shape[1],): raise ValueError("query dimension mismatch") denominator = norms * float(np.linalg.norm(query)) sims = np.where(denominator > 1e-9, (matrix @ query) / np.maximum(denominator, 1e-9), 0.0) return sorted(zip(sims.tolist(), keys), reverse=True) except (ImportError, ValueError): return sorted(((_cosine(query_vector, vec), key) for key, vec in vectors.items()), reverse=True) # 索引用 @N 標示「同一段被切成好幾塊」、~N 標示「同名標題的第幾個」,兩個都 # 加在 # 之後,所以讀取端會拿「做法-Workflow~2」去找標題。實測索引裡有 895 # 個這種鍵,648 個是 wiki 的——每一個都佔掉名額然後顯示空白。 _INDEX_SUFFIX = re.compile(r"[@~]\d+$") def _base_heading(source: str, section: str) -> str: """把索引加上的 @N / ~N 拿掉,還原成真正的標題。 只在「原樣找不到、去掉後綴找得到」時才換:真的以 ~2 結尾的標題照樣能用。 後綴會**疊加**,所以要一層一層剝。寫入端加兩種:`~N` 是同名標題在這一頁 的第幾次出現,`@N` 是那個段落切成的第幾塊,於是會出現 `#結論(消化自累積筆記)~2@7` 這種鍵。原本只剝一層,這類鍵一律讀不回內容。 這件事本來看不出來:另外 506 個已經死掉的鍵靠「退回母標題」碰巧讀得回東西, 把失敗率稀釋到測試的 90% 門檻之下。清掉死鍵沒有弄壞這個測試,是不再幫它遮掩。 """ if not section: return section # 沒有後綴就沒得剝——直接回。這一條要放在最前面:召回的熱路徑上絕大多數 # 的鍵都沒有後綴,而 _section_text 每次都要重讀整份頁面(有的到 680K)。 if not _INDEX_SUFFIX.search(section): return section candidate = section # 最多剝兩層(~N 與 @N),多留一次餘裕以防之後又加了第三種後綴。 for _ in range(3): if _section_text(source, candidate): return candidate if not _INDEX_SUFFIX.search(candidate): return section candidate = _INDEX_SUFFIX.sub("", candidate) return candidate if _section_text(source, candidate) else section def _section_text(topic_file: str, section: str) -> str: """一個 wiki 段落的內文。 索引鍵長這樣:wiki/topics/learning-base.md#做法-Workflow。呼叫端拿掉 wiki/ 之後傳進來的是 topics/learning-base.md,而 WIKI_TOPICS_DIR 本身 就以 topics 結尾——原本直接相接,於是每一次都在找 .../wiki/topics/topics/learning-base.md,讀不到、回空字串。 每一個 wiki 命中都是空的,而 format_chat 在摘要為空時會退回顯示標題, 所以看起來一直像正常運作。 """ name = topic_file[len("topics/"):] if topic_file.startswith("topics/") else topic_file path = WIKI_TOPICS_DIR / name try: content = re.sub(r"^---\n.*?\n---\n", "", path.read_text(encoding="utf-8"), flags=re.DOTALL) except OSError: return "" for title, body in _split_into_sections(content): if title == section: return body.strip() return "" def _memory_section_text(filename: str, section: str) -> str: for base in (MEMORY_DIR, MEMORY_MD.parent): path = base / filename if not path.exists(): continue try: content = path.read_text(encoding="utf-8") except OSError: continue for title, body in _split_into_sections(content): if title == section: return body.strip() return content.strip() return "" def recall_semantic(query: str, top_k: int = 2, *, min_similarity: float | None = None, sections_per_document: int = 1, excerpt_limit: int = 200) -> list[RecallResult] | None: """向量召回。回傳 None 代表「語意能力不可用」,空 list 代表「真的沒有夠相關的」。 這兩者必須分得開:不可用要退回字串比對,沒有夠相關的就該安靜。 """ floor = WIKI_MIN_SIMILARITY if min_similarity is None else min_similarity vectors = _load_semantic_vectors() if not vectors: return None query_vector = _embed_query(query) if query_vector is None: return None scored = _score_all(vectors, query_vector) if sections_per_document > 1: # Dense similarity often prefers an overview or the same broad advice. # Reserve the companion slot for the best explicit-term match within # that same indexed document. Keep its real cosine score and section # identity; the shared judge still decides whether it is applicable. tokens = tokenize(query) companions, first_sections = {}, {} # Only inspect companions of the leading documents. Resolving indexed # suffixes can read a source file, so do not do it for the whole library. for similarity, key in scored: if similarity < floor or not key.startswith('wiki/'): continue rest, _, heading = key[len('wiki/'):].partition('#') if rest not in first_sections: first_sections[rest] = _base_heading(rest, heading) if len(first_sections) >= top_k: break for similarity, key in scored: if similarity < floor or not key.startswith('wiki/'): continue rest, _, heading = key[len('wiki/'):].partition('#') if rest not in first_sections: continue heading = _base_heading(rest, heading) if heading == first_sections[rest]: continue lexical = _score_text(tokens, heading) candidate = (lexical, similarity, key) if lexical > 0 and candidate > companions.get(rest, (0, 0, '')): companions[rest] = candidate expanded, visited = [], set() for similarity, key in scored: expanded.append((similarity, key)) rest = key[len('wiki/'):].partition('#')[0] if key.startswith('wiki/') else '' if rest and rest not in visited: visited.add(rest) if rest in companions: _, companion_score, companion_key = companions[rest] expanded.append((companion_score, companion_key)) scored = expanded results: list[RecallResult] = [] seen_sections: set[tuple[str, str]] = set() topic_counts: dict[str, int] = {} for similarity, key in scored: if similarity < floor or len(results) >= top_k: break prefix = next((p for p in SEMANTIC_PREFIXES if key.startswith(p)), "") source_type = SEMANTIC_PREFIXES.get(prefix, "wiki_semantic") rest, _, section = key[len(prefix):].partition("#") section = _base_heading(rest, section) identity = (f"{prefix}{rest}", section) if identity in seen_sections or topic_counts.get(identity[0], 0) >= sections_per_document: continue seen_sections.add(identity) topic_counts[identity[0]] = topic_counts.get(identity[0], 0) + 1 is_wiki = source_type == "wiki_semantic" excerpt = _section_text(rest, section) if is_wiki else _memory_section_text(rest, section) from xkb_evidence import bounded_excerpt excerpt = xkb_provenance.strip_markers(excerpt) excerpt = bounded_excerpt(excerpt, excerpt_limit) if sections_per_document > 1 else excerpt[:excerpt_limit] results.append(_result( source_type=source_type, source_file=f"{prefix}{rest}", section=section, excerpt=excerpt, score=round(similarity, 3), url=f"wiki/topics/{Path(rest).stem}" if is_wiki else "", )) return results def recall_from_wiki(query: str, top_k: int = 2, semantic: bool = True) -> list[RecallResult]: """Search wiki/topics/*.md for relevant content. semantic=True 時優先用向量。語意可用但沒有夠相關的內容,就回空—— 不再退回字串比對,否則剛擋掉的雜訊會從後門進來。 """ if semantic: semantic_results = recall_semantic(query, top_k) if semantic_results is not None: return semantic_results return _recall_from_wiki_keyword(query, top_k) def _recall_from_wiki_keyword(query: str, top_k: int = 2) -> list[RecallResult]: """字串比對版本。語意不可用時的退路。""" tokens = tokenize(query) if not tokens or not WIKI_TOPICS_DIR.exists(): return [] candidates: list[RecallResult] = [] for path in WIKI_TOPICS_DIR.glob("*.md"): try: content = path.read_text(encoding="utf-8") except Exception: continue fm = _parse_wiki_frontmatter(content) title = fm.get("title", path.stem) tags_str = fm.get("tags", "") # Remove frontmatter for body search body = re.sub(r"^---\n.*?\n---\n", "", content, flags=re.DOTALL).strip() # Score title_score = _score_text(tokens, title) * 3.0 tags_score = _score_text(tokens, tags_str) * 1.5 body_score = _score_text(tokens, body) * 0.4 total = title_score + tags_score + body_score if total < 0.4: continue # Find best excerpt from body sections = _split_into_sections(body) best_excerpt = "" best_section = "" best_sec_score = 0.0 for sec_title, sec_body in sections: s = _score_text(tokens, sec_title) * 2 + _score_text(tokens, sec_body) if s > best_sec_score: best_sec_score = s best_section = sec_title best_excerpt = _excerpt(sec_body, tokens) if not best_excerpt: best_excerpt = _excerpt(body, tokens) candidates.append(_result( source_type="wiki", source_file=f"wiki/topics/{path.name}", section=best_section or title, excerpt=best_excerpt, score=round(total, 3), url=f"wiki/topics/{path.stem}", )) candidates.sort(key=lambda r: r.score, reverse=True) return candidates[:top_k] # ── Main ────────────────────────────────────────────────────────────────────── def recall(query: str, source: str = "both", top_k: int = 5, semantic: bool = True) -> list[RecallResult]: """Unified entry: search memory and/or wiki. 語意可用時,memory 與 wiki 都走同一次向量查詢——兩層的段落都在同一份索引裡, 分開查只會多打一次 embedding API。語意不可用才各自退回字串比對。 """ if semantic and source == "both": semantic_results = recall_semantic(query, top_k=top_k) if semantic_results is not None: return semantic_results results: list[RecallResult] = [] if source in ("memory", "both"): results.extend(recall_from_memory(query, top_k=3)) if source in ("wiki", "both"): results.extend(recall_from_wiki(query, top_k=2, semantic=semantic)) results.sort(key=lambda r: r.score, reverse=True) return results[:top_k] def format_chat(results: list[RecallResult]) -> str: if not results: return "" lines = ["【知識庫補位】"] for r in results: # 語意路徑產生的是 memory_semantic / wiki_semantic,不是 memory / # wiki——原本只比對 "memory",所以每一筆語意召回的記憶檔都被標成 # wiki/memory/xxx.md:說它是 wiki 頁,還給一個不存在的路徑。 if r.source_type.startswith("memory"): source_label = "MEMORY" elif r.url: source_label = f"wiki/{r.url.split('/')[-1]}" else: source_label = f"wiki/{Path(r.source_file).stem}" lines.append(f"• [{source_label}] {r.section}") if r.excerpt: lines.append(f" {r.excerpt[:150]}") lines.append("") return "\n".join(lines).strip() def main() -> int: parser = argparse.ArgumentParser(description="Continuity Recall — searches MEMORY.md + wiki/topics") parser.add_argument("query", nargs="?", help="Search query") parser.add_argument("--source", choices=["memory", "wiki", "both"], default="both") parser.add_argument("--limit", type=int, default=5) parser.add_argument("--json", action="store_true") parser.add_argument("--format", choices=["chat", "full"], default="chat") args = parser.parse_args() query = args.query or sys.stdin.read().strip() if not query: print("Usage: continuity_recall.py ") return 1 results = recall(query, source=args.source, top_k=args.limit) if args.json: print(json.dumps([r._asdict() for r in results], ensure_ascii=False, indent=2)) return 0 if not results: print("(沒找到相關的 continuity 記憶)") return 0 if args.format == "chat": print(format_chat(results)) else: for r in results: print(f"[{r.source_type}] {r.source_file} § {r.section}") print(f" score: {r.score}") print(f" {r.excerpt[:200]}") print() return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())