scripts/contrarian_recall.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
Contrarian Recall — Active Recall Layer Phase 1.3
查詢來源:wiki/topics/*.md + memory/*.md
專找反例、衝突資訊、限制、失敗案例。
用於在「快速收斂方案」時主動提醒已知的反方觀點。
Usage:
python3 contrarian_recall.py "active recall 自動觸發"
python3 contrarian_recall.py "query" --json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import NamedTuple
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
import xkb_text
WORKSPACE = xkb_paths.WORKSPACE
_SKILL_DIR = xkb_paths.SKILL_DIR
WIKI_TOPICS_DIR = xkb_paths.WIKI_TOPICS_DIR
MEMORY_DIR = xkb_paths.DATA_DIR
MEMORY_MD = xkb_paths.MEMORY_MD
STOPWORDS = {
"的", "了", "是", "我", "你", "在", "有", "和", "也", "都", "很", "要", "用",
"this", "that", "with", "from", "have", "will", "about", "your", "the", "are",
}
# ── 反例 / 衝突信號詞 ──────────────────────────────────────────────────────────
CONTRARIAN_SIGNALS = [
# 中文否定 / 警示
r"不[要建議適合]", r"避免", r"問題[是在]", r"失敗", r"踩坑", r"缺陷", r"限制",
r"注意[::]?", r"⚠️", r"警告", r"但[是]?.*不", r"然而", r"反例", r"反而",
r"缺口", r"弱點", r"做不到", r"沒有解決", r"無法", r"不夠",
# 英文
r"avoid", r"warning", r"caveat", r"limitation", r"failed", r"problem",
r"however", r"but\s+not", r"don.?t", r"issue", r"drawback", r"risk",
]
CONTRARIAN_RE = re.compile("|".join(CONTRARIAN_SIGNALS), re.IGNORECASE)
class ContrarianResult(NamedTuple):
source_type: str # wiki | memory
source_file: str
section: str
excerpt: str
score: float
signal_words: list[str]
def tokenize(text: str) -> list[str]:
return xkb_text.tokenize(text, STOPWORDS)
def _relevance_score(tokens: list[str], text: str) -> float:
if not tokens or not text:
return 0.0
text_lower = text.lower()
hits = sum(1 for t in tokens if t in text_lower)
return hits / max(len(tokens), 1)
def _signal_count(text: str) -> tuple[int, list[str]]:
"""Count contrarian signal words and return (count, found_signals)."""
found = re.findall(CONTRARIAN_RE, text)
return len(found), found[:5]
def _excerpt_around_signal(text: str, tokens: list[str], max_len: int = 200) -> str:
"""Find the sentence with the most signals + relevance."""
sentences = re.split(r"[。!?\n]", text)
best = ""
best_score = 0.0
for s in sentences:
s = s.strip()
if len(s) < 10:
continue
sig_count, _ = _signal_count(s)
rel = _relevance_score(tokens, s)
score = sig_count * 0.4 + rel * 0.6
if score > best_score:
best_score = score
best = s
result = best or text[:max_len]
return result[:max_len] + ("…" if len(result) > max_len else "")
def _split_sections(content: str) -> list[tuple[str, str]]:
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 _search_file(
path: Path,
tokens: list[str],
source_type: str,
rel_base: Path,
) -> list[ContrarianResult]:
try:
content = path.read_text(encoding="utf-8")
except Exception:
return []
# Remove frontmatter
content = re.sub(r"^---\n.*?\n---\n", "", content, flags=re.DOTALL).strip()
try:
rel_path = str(path.relative_to(rel_base))
except ValueError:
rel_path = str(path)
results = []
for title, body in _split_sections(content):
if len(body.strip()) < 20:
continue
sig_count, signals = _signal_count(body)
if sig_count == 0:
continue
rel = _relevance_score(tokens, f"{title} {body}")
if rel < 0.15:
continue
score = round(rel * 0.6 + min(sig_count / 5, 1.0) * 0.4, 3)
excerpt = _excerpt_around_signal(body, tokens)
results.append(ContrarianResult(
source_type=source_type,
source_file=rel_path,
section=title,
excerpt=excerpt,
score=score,
signal_words=signals,
))
return results
def recall(query: str, top_k: int = 2) -> list[ContrarianResult]:
tokens = tokenize(query)
if not tokens:
return []
candidates: list[ContrarianResult] = []
# Search wiki topics
if WIKI_TOPICS_DIR.exists():
for path in WIKI_TOPICS_DIR.glob("*.md"):
candidates.extend(_search_file(path, tokens, "wiki", WORKSPACE))
# Search memory
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:
candidates.extend(_search_file(path, tokens, "memory", WORKSPACE))
candidates.sort(key=lambda r: r.score, reverse=True)
return candidates[:top_k]
def format_hint(results: list[ContrarianResult]) -> str:
if not results:
return ""
lines = ["【⚠️ 反例提醒】"]
for r in results:
label = f"wiki/{r.source_file.split('/')[-1]}" if r.source_type == "wiki" else "MEMORY"
lines.append(f"• [{label}] {r.section}")
lines.append(f" {r.excerpt}")
lines.append("")
return "\n".join(lines).strip()
def main() -> int:
parser = argparse.ArgumentParser(description="Contrarian Recall — finds counter-examples and warnings")
parser.add_argument("query", nargs="?")
parser.add_argument("--limit", type=int, default=2)
parser.add_argument("--json", dest="as_json", action="store_true")
args = parser.parse_args()
query = args.query or sys.stdin.read().strip()
if not query:
print("Usage: contrarian_recall.py <query>")
return 1
results = recall(query, top_k=args.limit)
if args.as_json:
print(json.dumps([r._asdict() for r in results], ensure_ascii=False, indent=2))
return 0
if not results:
print("(沒找到反例或警示)")
return 0
print(format_hint(results))
return 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())