chore: track claude skills, tools, templates, reference code and research-wiki

- Add all claude skills (brainstorming, commit, debugging, TDD, etc.)
- Add claude hooks (pre-commit-guard, post-edit-quality)
- Add research templates (experiment plan, research brief, etc.)
- Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa)
- Add TRM4 reference implementation as algorithm fidelity baseline
- Add research-wiki content (plans, index, graph, query_pack)
- Update .gitignore to exclude .graphify_version runtime state
This commit is contained in:
2026-07-06 20:59:03 -04:00
parent 0616d16956
commit 6bdb802f01
98 changed files with 19321 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
"""arXiv 搜索与 PDF 下载的独立命令行工具。"""
from __future__ import annotations
import argparse
import json
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from urllib.parse import quote
from urllib.request import Request, urlopen
ATOM_NS = "http://www.w3.org/2005/Atom"
ARXIV_API_URL = (
"http://export.arxiv.org/api/query?search_query=all:{query}&max_results={max}"
)
ARXIV_PDF_URL = "https://arxiv.org/pdf/{arxiv_id}.pdf"
USER_AGENT = "arxiv_fetch.py/1.0"
MIN_PDF_SIZE = 10 * 1024
DOWNLOAD_DELAY_SECONDS = 1.0
def _clean_text(value: str) -> str:
"""压缩空白并清理文本内容。"""
return " ".join(value.split())
def _normalize_arxiv_id(raw_id: str) -> str:
"""将 arXiv 标识归一化为不带版本号的论文 ID。"""
value = raw_id.strip()
if value.startswith("http://") or value.startswith("https://"):
value = value.rsplit("/", 1)[-1]
if value.startswith("abs/") or value.startswith("pdf/"):
value = value.split("/", 1)[1]
if value.endswith(".pdf"):
value = value[:-4]
if value.startswith("arXiv:") or value.startswith("arxiv:"):
value = value.split(":", 1)[1]
if "v" in value:
head, tail = value.rsplit("v", 1)
if tail.isdigit():
value = head
return value
def _request_text(url: str) -> bytes:
"""发起 HTTP 请求并返回原始字节内容。"""
request = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(request) as response:
return response.read()
def _parse_arxiv_feed(xml_bytes: bytes) -> list[dict[str, Any]]:
"""解析 arXiv Atom Feed 并提取论文条目。"""
root = ET.fromstring(xml_bytes)
namespace = {"atom": ATOM_NS}
results: list[dict[str, Any]] = []
for entry in root.findall("atom:entry", namespace):
title = _clean_text(
entry.findtext("atom:title", default="", namespaces=namespace)
)
abstract = _clean_text(
entry.findtext("atom:summary", default="", namespaces=namespace)
)
published = _clean_text(
entry.findtext("atom:published", default="", namespaces=namespace)
)
if "T" in published:
published = published.split("T", 1)[0]
authors: list[str] = []
for author in entry.findall("atom:author", namespace):
name = _clean_text(
author.findtext("atom:name", default="", namespaces=namespace)
)
if name:
authors.append(name)
categories: list[str] = []
for category in entry.findall("atom:category", namespace):
term = category.get("term", "").strip()
if term and term not in categories:
categories.append(term)
raw_id = entry.findtext("atom:id", default="", namespaces=namespace)
arxiv_id = _normalize_arxiv_id(raw_id.rsplit("/", 1)[-1] if raw_id else "")
results.append(
{
"title": title,
"authors": authors,
"arxiv_id": arxiv_id,
"abstract": abstract,
"categories": categories,
"published": published,
}
)
return results
def search_arxiv(query: str, max_results: int) -> list[dict[str, Any]]:
"""搜索 arXiv 并返回结构化论文结果。"""
encoded_query = quote(query, safe="")
url = ARXIV_API_URL.format(query=encoded_query, max=max_results)
feed_bytes = _request_text(url)
return _parse_arxiv_feed(feed_bytes)
def download_arxiv_pdf(arxiv_id: str, target_dir: Path) -> Path:
"""下载指定 arXiv 论文的 PDF 并校验文件大小。"""
normalized_id = _normalize_arxiv_id(arxiv_id)
target_dir.mkdir(parents=True, exist_ok=True)
output_path = target_dir / f"{normalized_id}.pdf"
pdf_url = ARXIV_PDF_URL.format(arxiv_id=normalized_id)
request = Request(pdf_url, headers={"User-Agent": USER_AGENT})
time.sleep(DOWNLOAD_DELAY_SECONDS)
try:
with urlopen(request) as response, output_path.open("wb") as handle:
while True:
chunk = response.read(8192)
if not chunk:
break
handle.write(chunk)
except Exception:
if output_path.exists():
output_path.unlink()
raise
if output_path.stat().st_size <= MIN_PDF_SIZE:
output_path.unlink(missing_ok=True)
raise ValueError(f"下载文件过小,疑似失败: {output_path}")
return output_path
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="arXiv 搜索与 PDF 下载工具")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="搜索 arXiv 论文")
search_parser.add_argument("query", help="搜索关键词")
search_parser.add_argument("--max", type=int, default=10, help="最大返回数量")
download_parser = subparsers.add_parser("download", help="下载 arXiv PDF")
download_parser.add_argument("arxiv_id", help="arXiv 论文 ID")
download_parser.add_argument(
"--dir", dest="target_dir", default=".", help="保存目录"
)
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "search":
results = search_arxiv(args.query, args.max)
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
if args.command == "download":
output_path = download_arxiv_pdf(args.arxiv_id, Path(args.target_dir))
sys.stdout.write(f"{output_path}\n")
return 0
except Exception as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
parser.error(f"未知命令: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())
+52
View File
@@ -0,0 +1,52 @@
"""DeepXiv CLI 的轻量适配器。"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from typing import cast
if shutil.which("deepxiv") is None:
print("警告: 未找到 deepxiv CLI,已跳过 DeepXiv 适配器。", file=sys.stderr)
sys.exit(0)
def _run_deepxiv(arguments: list[str]) -> int:
"""调用底层 deepxiv 命令并透传输出。"""
completed = subprocess.run(["deepxiv", *arguments], check=False)
return completed.returncode
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="DeepXiv 命令适配器")
subparsers = parser.add_subparsers(dest="command", required=True)
for command_name in ("search", "paper-brief", "paper-head", "paper-section"):
subparser = subparsers.add_parser(
command_name, help=f"转发 deepxiv {command_name} 命令"
)
subparser.add_argument(
"args", nargs=argparse.REMAINDER, help="透传给 deepxiv 的参数"
)
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args(argv)
try:
command_args = [args.command, *cast(list[str], args.args)]
return _run_deepxiv(command_args)
except OSError as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+144
View File
@@ -0,0 +1,144 @@
"""Exa 搜索命令行工具。"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import asdict, is_dataclass
from typing import Any
try:
import exa
except ImportError:
print("警告: 未安装 exa-py SDK,已跳过 Exa CLI。", file=sys.stderr)
sys.exit(0)
def _to_jsonable(value: Any) -> Any:
"""将任意 SDK 返回值递归转换为可序列化对象。"""
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
if isinstance(value, dict):
return {str(key): _to_jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_jsonable(item) for item in value]
if is_dataclass(value):
return _to_jsonable(asdict(value))
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
return _to_jsonable(model_dump())
if hasattr(value, "__dict__"):
data = {
key: _to_jsonable(item)
for key, item in vars(value).items()
if not key.startswith("_")
}
if data:
return data
return str(value)
def _build_contents_option(content_mode: str) -> dict[str, Any]:
"""根据命令行参数构造 Exa contents 配置。"""
if content_mode == "text":
return {"text": True}
return {"highlights": True}
def _extract_results(response: Any) -> list[dict[str, Any]]:
"""从 Exa 响应中提取结果列表并标准化为 JSON 对象。"""
raw_results: Any
if isinstance(response, dict):
raw_results = response.get("results", [])
else:
raw_results = getattr(response, "results", [])
if not isinstance(raw_results, list):
return []
normalized_results: list[dict[str, Any]] = []
for item in raw_results:
normalized_item = _to_jsonable(item)
if isinstance(normalized_item, dict):
normalized_results.append(normalized_item)
else:
normalized_results.append({"value": normalized_item})
return normalized_results
def search_exa(
query: str,
max_results: int,
category: str | None,
content_mode: str,
) -> list[dict[str, Any]]:
"""执行 Exa 搜索并返回结果列表。"""
api_key = os.environ.get("EXA_API_KEY", "").strip()
if not api_key:
print("警告: 缺少 EXA_API_KEY 环境变量,已跳过 Exa CLI。", file=sys.stderr)
sys.exit(0)
client = exa.Exa(api_key=api_key)
search_kwargs: dict[str, Any] = {
"num_results": max_results,
"contents": _build_contents_option(content_mode),
}
if category:
search_kwargs["category"] = category
response = client.search(query, **search_kwargs)
return _extract_results(response)
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="Exa 搜索工具")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="搜索 Exa 索引")
search_parser.add_argument("query", help="搜索关键词")
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
search_parser.add_argument(
"--category",
default=None,
help="数据类别,例如 research paper、news、pdf 等",
)
search_parser.add_argument(
"--content",
choices=("highlights", "text"),
default="highlights",
help="返回的内容类型",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "search":
results = search_exa(
query=args.query,
max_results=args.max_results,
category=args.category,
content_mode=args.content,
)
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
except Exception as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
parser.error(f"未知命令: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())
+190
View File
@@ -0,0 +1,190 @@
"""OpenAlex 搜索命令行工具。"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any
try:
import requests
except ImportError:
print("警告: 未安装 requests,已跳过 OpenAlex CLI。", file=sys.stderr)
sys.exit(0)
API_URL = "https://api.openalex.org/works"
REQUEST_TIMEOUT_SECONDS = 30.0
USER_AGENT = "openalex_fetch.py/1.0"
def _clean_text(value: Any) -> str:
"""压缩空白并清理文本值。"""
return " ".join(str(value).split())
def _normalize_year_filter(raw_year: str) -> str:
"""将命令行年份参数转换为 OpenAlex filter 语法。"""
value = raw_year.strip()
if not value:
raise ValueError("年份参数不能为空")
if value.isdigit() and len(value) == 4:
return f"publication_year:{value}"
if value.endswith("-") and value[:-1].strip().isdigit():
start_year = value[:-1].strip()
return f"from_publication_date:{start_year}-01-01"
if value.startswith("-") and value[1:].strip().isdigit():
end_year = value[1:].strip()
return f"to_publication_date:{end_year}-12-31"
if "-" in value:
start_year, end_year = [part.strip() for part in value.split("-", 1)]
if start_year.isdigit() and end_year.isdigit():
return f"publication_year:{start_year}-{end_year}"
raise ValueError(f"无法识别的年份参数: {raw_year}")
def _normalize_sort_value(sort_value: str) -> str:
"""将用户输入的排序参数转换为 OpenAlex sort 语法。"""
value = sort_value.strip()
if not value:
raise ValueError("排序参数不能为空")
if value in {"relevance", "relevance_score"}:
return "relevance_score:desc"
return value
def _normalize_work(work: dict[str, Any]) -> dict[str, Any]:
"""将 OpenAlex work 记录压缩成稳定输出结构。"""
authorships = work.get("authorships")
authors: list[str] = []
institutions: list[str] = []
if isinstance(authorships, list):
for authorship in authorships:
if not isinstance(authorship, dict):
continue
author = authorship.get("author")
if isinstance(author, dict):
display_name = author.get("display_name")
if display_name:
cleaned_name = _clean_text(display_name)
if cleaned_name not in authors:
authors.append(cleaned_name)
author_institutions = authorship.get("institutions")
if isinstance(author_institutions, list):
for institution in author_institutions:
if not isinstance(institution, dict):
continue
display_name = institution.get("display_name")
if display_name:
cleaned_name = _clean_text(display_name)
if cleaned_name not in institutions:
institutions.append(cleaned_name)
return {
"title": _clean_text(work.get("display_name", "")),
"authors": authors,
"year": work.get("publication_year"),
"cited_by_count": work.get("cited_by_count"),
"institutions": institutions,
"doi": work.get("doi"),
}
def search_openalex(
query: str,
max_results: int,
year: str | None,
work_type: str | None,
sort: str,
) -> list[dict[str, Any]]:
"""搜索 OpenAlex 并返回标准化论文结果。"""
params: dict[str, Any] = {
"search": query,
"per-page": max_results,
"sort": _normalize_sort_value(sort),
}
filters: list[str] = []
if year:
filters.append(_normalize_year_filter(year))
if work_type:
filters.append(f"type:{work_type.strip()}")
if filters:
params["filter"] = ",".join(filters)
response = requests.get(
API_URL,
params=params,
headers={"User-Agent": USER_AGENT},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("OpenAlex 返回了非对象类型的 JSON")
works = payload.get("results", [])
if not isinstance(works, list):
raise ValueError("OpenAlex 响应缺少 results 列表")
normalized_results: list[dict[str, Any]] = []
for work in works:
if isinstance(work, dict):
normalized_results.append(_normalize_work(work))
return normalized_results
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="OpenAlex 搜索工具")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="搜索 OpenAlex works")
search_parser.add_argument("query", help="搜索关键词")
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
search_parser.add_argument("--year", default=None, help="年份或年份范围")
search_parser.add_argument(
"--type", dest="work_type", default=None, help="作品类型"
)
search_parser.add_argument(
"--sort",
default="relevance",
help="排序字段,relevance 会映射为 relevance_score:desc",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "search":
results = search_openalex(
query=args.query,
max_results=args.max_results,
year=args.year,
work_type=args.work_type,
sort=args.sort,
)
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
except (requests.RequestException, ValueError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
parser.error(f"未知命令: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())
+681
View File
@@ -0,0 +1,681 @@
"""研究 Wiki 的初始化与日志工具。"""
from __future__ import annotations
import argparse
import json
import re
import unicodedata
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from urllib.request import urlopen
import xml.etree.ElementTree as ET
ENTITY_TYPES = {
"paper": "papers",
"idea": "ideas",
"experiment": "experiments",
"claim": "claims",
"gap": "gaps",
"design": "designs",
"finding": "findings",
"adr": "adrs",
"plan": "plans",
"review": "reviews",
"schema": "schemas",
"metric": "metrics",
}
EDGE_TYPES = [
"extends",
"contradicts",
"supersedes",
"addresses_gap",
"inspired_by",
"tested_by",
"supports",
"invalidates",
"implements",
"reveals",
"informs",
"refines",
"depends_on",
"measures",
"evaluates",
]
QUERY_PACK_BUDGET = 8000
def slugify(text: str) -> str:
"""将文本转换为 URL 安全的 slug。"""
normalized = unicodedata.normalize("NFKD", text)
normalized = re.sub(r"[^\w\s-]", "", normalized.lower())
return re.sub(r"[-\s]+", "_", normalized).strip("_")[:60]
def _write_if_missing(path: Path, content: str) -> None:
"""仅在文件不存在时写入内容。
参数:
path: 目标文件路径。
content: 需要写入的文本内容。
"""
if path.exists():
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def append_log(wiki_dir: str, message: str) -> None:
"""向 Wiki 变更日志追加一条记录。
参数:
wiki_dir: Wiki 根目录。
message: 需要记录的消息。
"""
log_path = Path(wiki_dir) / "log.md"
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
with log_path.open("a", encoding="utf-8") as handle:
handle.write(f"- [{timestamp}] {message}\n")
def _render_frontmatter(data: dict[str, Any]) -> str:
"""将字典渲染为 YAML frontmatter。"""
lines = ["---"]
for key, value in data.items():
if isinstance(value, list):
rendered_value = json.dumps(value, ensure_ascii=False)
elif isinstance(value, str) and " " in value:
rendered_value = f'"{value}"'
else:
rendered_value = str(value)
lines.append(f"{key}: {rendered_value}")
lines.append("---")
return "\n".join(lines) + "\n"
def _read_frontmatter(filepath: Path) -> dict[str, Any]:
"""从 markdown 文件读取简易 frontmatter。"""
text = filepath.read_text(encoding="utf-8")
if not text.startswith("---\n"):
return {}
lines = text.splitlines()
result: dict[str, Any] = {}
for line in lines[1:]:
if line == "---":
break
if ":" not in line:
continue
key, _, raw_value = line.partition(":")
value = raw_value.strip()
if value.startswith("[") and value.endswith("]"):
try:
result[key.strip()] = json.loads(value)
continue
except json.JSONDecodeError:
pass
if len(value) >= 2 and (
(value.startswith('"') and value.endswith('"'))
or (value.startswith("'") and value.endswith("'"))
):
value = value[1:-1]
result[key.strip()] = value
return result
def _add_node_to_graph(
wiki_dir: str, node_id: str, label: str, entity_type: str
) -> None:
"""向关系图中添加节点,若已存在则跳过。"""
graph_path = Path(wiki_dir) / "graph" / "edges.json"
graph = json.loads(graph_path.read_text(encoding="utf-8"))
nodes = graph.setdefault("nodes", [])
if not any(node.get("id") == node_id for node in nodes):
nodes.append({"id": node_id, "label": label, "type": entity_type})
graph_path.write_text(
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8"
)
def add_edge(
wiki_dir: str,
from_id: str,
to_id: str,
edge_type: str,
evidence: str = "",
) -> None:
"""向关系图中添加一条边。"""
if edge_type not in EDGE_TYPES:
raise ValueError(f"未知边类型: {edge_type},合法值: {EDGE_TYPES}")
graph_path = Path(wiki_dir) / "graph" / "edges.json"
graph = json.loads(graph_path.read_text(encoding="utf-8"))
links = graph.setdefault("links", [])
for link in links:
if (
link.get("source") == from_id
and link.get("target") == to_id
and link.get("relation") == edge_type
):
return
links.append(
{
"source": from_id,
"target": to_id,
"relation": edge_type,
"evidence": evidence,
"added": datetime.now(timezone.utc).isoformat(),
}
)
graph_path.write_text(
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8"
)
append_log(wiki_dir, f"新增边: {from_id} --{edge_type}--> {to_id}")
def add_entity(
wiki_dir: str,
entity_type: str,
entity_id: str,
title: str,
extra_frontmatter: dict[str, Any] | None = None,
) -> Path:
"""创建一个实体页面并同步到关系图。"""
if entity_type not in ENTITY_TYPES:
raise ValueError(f"未知实体类型: {entity_type},合法值: {list(ENTITY_TYPES)}")
root = Path(wiki_dir)
entity_subdir = root / ENTITY_TYPES[entity_type]
node_id = f"{entity_type}:{entity_id}"
for existing_path in entity_subdir.glob("*.md"):
frontmatter = _read_frontmatter(existing_path)
if frontmatter.get("node_id") == node_id:
return existing_path
filepath = entity_subdir / f"{entity_id}.md"
frontmatter: dict[str, Any] = {
"type": entity_type,
"node_id": node_id,
"title": title,
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
}
if extra_frontmatter:
frontmatter.update(extra_frontmatter)
content = _render_frontmatter(frontmatter) + f"\n# {title}\n\n"
filepath.write_text(content, encoding="utf-8")
_add_node_to_graph(wiki_dir, node_id, title, entity_type)
append_log(wiki_dir, f"新增 {entity_type}: {title} ({node_id})")
return filepath
def _fetch_arxiv_metadata(arxiv_id: str) -> dict[str, Any] | None:
"""通过 arXiv Atom API 拉取论文元数据。"""
clean_id = (
arxiv_id.removeprefix("arxiv:")
.removeprefix("arXiv:")
.removeprefix("ARXIV:")
.strip()
)
url = f"http://export.arxiv.org/api/query?id_list={clean_id}"
try:
with urlopen(url) as response:
root = ET.fromstring(response.read())
except Exception:
return None
namespace = {"atom": "http://www.w3.org/2005/Atom"}
entry = root.find("atom:entry", namespace)
if entry is None:
return None
def _text(path: str) -> str:
value = entry.findtext(path, default="", namespaces=namespace)
return " ".join(value.split())
authors = [
" ".join(author.findtext("atom:name", default="", namespaces=namespace).split())
for author in entry.findall("atom:author", namespace)
]
authors = [author for author in authors if author]
published = _text("atom:published")
year = published[:4] if len(published) >= 4 else ""
return {
"title": _text("atom:title"),
"authors": authors,
"year": year,
"abstract": _text("atom:summary"),
"arxiv_id": clean_id,
}
def _last_name(full_name: str) -> str:
"""提取人名最后一个词作为姓氏。"""
parts = full_name.strip().split()
return parts[-1].lower() if parts else "unknown"
def _markdown_body(text: str) -> str:
"""提取 markdown 中 frontmatter 之后的正文。"""
if not text.startswith("---\n"):
return text
lines = text.splitlines()
for index, line in enumerate(lines[1:], start=1):
if line == "---":
return "\n".join(lines[index + 1 :])
return ""
def _iter_entity_pages(wiki_dir: str) -> list[tuple[str, Path, dict[str, Any], str]]:
"""收集 Wiki 中所有实体页面。"""
root = Path(wiki_dir)
pages: list[tuple[str, Path, dict[str, Any], str]] = []
for entity_type, subdir_name in ENTITY_TYPES.items():
subdir = root / subdir_name
for path in sorted(subdir.glob("*.md")):
frontmatter = _read_frontmatter(path)
body = _markdown_body(path.read_text(encoding="utf-8"))
resolved_type = (
str(frontmatter.get("type", entity_type)).strip() or entity_type
)
pages.append((resolved_type, path, frontmatter, body))
return pages
def rebuild_index(wiki_dir: str) -> None:
"""按实体类型重建索引页。"""
root = Path(wiki_dir)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
pages = _iter_entity_pages(wiki_dir)
grouped: dict[str, list[tuple[Path, dict[str, Any]]]] = {}
for entity_type, path, frontmatter, _ in pages:
grouped.setdefault(entity_type, []).append((path, frontmatter))
lines = [
"# Research Wiki 索引",
"",
f"> 自动生成,更新时间:{timestamp}",
"",
]
for entity_type in ENTITY_TYPES:
entries = grouped.get(entity_type, [])
if not entries:
continue
entries = sorted(
entries,
key=lambda item: (
str(item[1].get("title", item[0].stem)).casefold(),
item[0].name,
),
)
lines.append(f"## {entity_type} ({len(entries)})")
for path, frontmatter in entries:
title = str(frontmatter.get("title", path.stem)).strip() or path.stem
node_id = str(
frontmatter.get("node_id", f"{entity_type}:{path.stem}")
).strip()
relative_path = path.relative_to(root).as_posix()
lines.append(f"- [{title}]({relative_path}) `{node_id}`")
lines.append("")
content = "\n".join(lines).rstrip() + "\n"
(root / "index.md").write_text(content, encoding="utf-8")
append_log(wiki_dir, f"重建索引: {len(pages)} 篇页面")
def rebuild_query_pack(wiki_dir: str) -> None:
"""基于 frontmatter 重新生成 Query Pack。"""
pages = _iter_entity_pages(wiki_dir)
failed_ideas: list[tuple[str, str]] = []
open_gaps: list[str] = []
core_papers: list[tuple[str, str, str]] = []
for entity_type, path, frontmatter, _ in pages:
title = str(frontmatter.get("title", path.stem)).strip() or path.stem
if entity_type == "idea":
outcome = str(frontmatter.get("outcome", "")).strip().lower()
if outcome in {"negative", "failed"}:
failure_reason = str(frontmatter.get("failure_reason", "")).strip()
failed_ideas.append((title, failure_reason or "未填写"))
if entity_type == "gap":
status = str(frontmatter.get("status", "open")).strip().lower()
if not status or status == "open":
open_gaps.append(title)
if entity_type == "paper":
year = str(frontmatter.get("year", "")).strip()
venue = str(frontmatter.get("venue", "")).strip()
core_papers.append(
(
title,
year or "未知年份",
venue or "未知 venue",
)
)
failed_ideas.sort(key=lambda item: item[0].casefold())
open_gaps.sort(key=str.casefold)
core_papers.sort(key=lambda item: (item[1], item[0].casefold(), item[2].casefold()))
sections: list[tuple[str, list[str]]] = []
if failed_ideas:
sections.append(
(
"## 失败 Idea(禁区)",
[f"- ❌ {title}: {reason}" for title, reason in failed_ideas[:10]],
)
)
if open_gaps:
sections.append(
(
"## 未解决空白",
[f"- {title}" for title in open_gaps[:5]],
)
)
if core_papers:
sections.append(
(
"## 核心论文",
[
f"- [{title}] ({year}) {venue}"
for title, year, venue in core_papers[:12]
],
)
)
content_parts = [
"# Query Pack",
"",
"> 自动生成,请勿手动编辑。",
"",
"",
]
current_content = "\n".join(content_parts)
for section_title, section_lines in sections:
section_text = "\n".join([section_title, *section_lines, "", ""])
candidate = current_content + section_text
if len(candidate) > QUERY_PACK_BUDGET:
break
current_content = candidate
content = current_content.rstrip() + "\n"
(Path(wiki_dir) / "query_pack.md").write_text(content, encoding="utf-8")
append_log(wiki_dir, f"重建 Query Pack: {len(content)} 字符")
def get_stats(wiki_dir: str) -> str:
"""统计 Wiki 规模与图谱信息。"""
root = Path(wiki_dir)
lines: list[str] = []
for entity_type, subdir_name in ENTITY_TYPES.items():
count = len(list((root / subdir_name).glob("*.md")))
lines.append(f"{entity_type}: {count}")
graph_path = root / "graph" / "edges.json"
if graph_path.exists():
graph = json.loads(graph_path.read_text(encoding="utf-8"))
else:
graph = {"nodes": [], "links": []}
lines.append(f"nodes: {len(graph.get('nodes', []))}")
lines.append(f"links: {len(graph.get('links', []))}")
return "\n".join(lines)
def lint_wiki(wiki_dir: str) -> list[str]:
"""检查 Wiki 中的孤立节点与稀疏页面。"""
root = Path(wiki_dir)
issues: list[str] = []
graph_path = root / "graph" / "edges.json"
if graph_path.exists():
graph = json.loads(graph_path.read_text(encoding="utf-8"))
else:
graph = {"nodes": [], "links": []}
linked_node_ids = {
str(link.get("source", "")).strip()
for link in graph.get("links", [])
if str(link.get("source", "")).strip()
}
linked_node_ids.update(
str(link.get("target", "")).strip()
for link in graph.get("links", [])
if str(link.get("target", "")).strip()
)
for node in graph.get("nodes", []):
node_id = str(node.get("id", "")).strip()
if node_id and node_id not in linked_node_ids:
label = str(node.get("label", "")).strip()
issues.append(f"孤立节点: {node_id}" + (f" ({label})" if label else ""))
for _, path, _, body in _iter_entity_pages(wiki_dir):
if len(body.strip()) < 50:
relative_path = path.relative_to(root).as_posix()
issues.append(f"稀疏页面: {relative_path} (正文 {len(body.strip())} 字符)")
return issues
def ingest_paper(
wiki_dir: str,
arxiv_id: str | None = None,
title: str | None = None,
authors: str | None = None,
year: str | None = None,
venue: str = "arXiv",
thesis: str = "",
) -> Path | None:
"""创建 paper 实体并补充标准化正文模板。"""
abstract = ""
authors_list: list[str]
paper_title = title
paper_year = year
paper_arxiv_id = None
if arxiv_id:
metadata = _fetch_arxiv_metadata(arxiv_id)
if metadata is None:
return None
paper_title = metadata["title"]
authors_list = metadata["authors"]
paper_year = metadata["year"]
abstract = metadata["abstract"]
paper_arxiv_id = metadata["arxiv_id"]
else:
if not (title and authors and year):
raise ValueError("非 arXiv 论文需提供 --title、--authors、--year")
authors_list = [
author.strip() for author in authors.split(",") if author.strip()
]
if not authors_list:
raise ValueError("authors 不能为空")
if not paper_title:
raise ValueError("论文标题不能为空")
if not paper_year:
raise ValueError("论文年份不能为空")
if not authors_list:
raise ValueError("论文作者不能为空")
slug = f"{_last_name(authors_list[0])}{paper_year}_{slugify(paper_title)[:30]}"
extra_frontmatter: dict[str, Any] = {
"authors": authors_list,
"year": paper_year,
"venue": venue,
}
if paper_arxiv_id is not None:
extra_frontmatter["arxiv_id"] = paper_arxiv_id
path = add_entity(wiki_dir, "paper", slug, paper_title, extra_frontmatter)
content = path.read_text(encoding="utf-8")
if "## 一句话论点" not in content:
body = f"\n## 一句话论点\n\n{thesis or '待填写'}\n\n"
if abstract:
body += f"## 摘要\n\n> {' '.join(abstract.split())}\n\n"
body += (
"## 问题/空白\n\n"
"## 方法\n\n"
"## 关键结果\n\n"
"## 局限性\n\n"
"## 与本项目的相关性\n\n"
)
path.write_text(content + body, encoding="utf-8")
return path
def init_wiki(wiki_dir: str) -> None:
"""初始化研究 Wiki 的目录结构与基础文件。
参数:
wiki_dir: Wiki 根目录。
"""
root = Path(wiki_dir)
root.mkdir(parents=True, exist_ok=True)
for entity_dir in ENTITY_TYPES.values():
(root / entity_dir).mkdir(parents=True, exist_ok=True)
graph_dir = root / "graph"
graph_dir.mkdir(parents=True, exist_ok=True)
_write_if_missing(
root / "index.md", "# Research Wiki 索引\n\n> 自动生成,请勿手动编辑。\n"
)
_write_if_missing(root / "log.md", "# Wiki 变更日志\n\n")
_write_if_missing(root / "gap_map.md", "# 领域空白汇总\n\n")
_write_if_missing(
root / "query_pack.md",
"# Query Pack\n\n> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。\n",
)
_write_if_missing(
graph_dir / "edges.json",
json.dumps({"directed": True, "nodes": [], "links": []}, ensure_ascii=False),
)
append_log(wiki_dir, "Wiki 初始化完成")
def main() -> None:
"""命令行入口。"""
parser = argparse.ArgumentParser(description="研究 Wiki 工具")
subparsers = parser.add_subparsers(dest="command", required=True)
init_parser = subparsers.add_parser("init", help="初始化 Wiki")
init_parser.add_argument("wiki_dir", help="Wiki 根目录")
log_parser = subparsers.add_parser("log", help="追加日志")
log_parser.add_argument("wiki_dir", help="Wiki 根目录")
log_parser.add_argument("message", nargs="+", help="日志内容")
entity_parser = subparsers.add_parser("add_entity", help="创建实体")
entity_parser.add_argument("wiki_dir", help="Wiki 根目录")
entity_parser.add_argument(
"--type", required=True, choices=list(ENTITY_TYPES), help="实体类型"
)
entity_parser.add_argument("--id", required=True, help="实体 ID")
entity_parser.add_argument("--title", required=True, help="实体标题")
edge_parser = subparsers.add_parser("add_edge", help="创建关系边")
edge_parser.add_argument("wiki_dir", help="Wiki 根目录")
edge_parser.add_argument(
"--from", dest="from_id", required=True, help="起点节点 ID"
)
edge_parser.add_argument("--to", dest="to_id", required=True, help="终点节点 ID")
edge_parser.add_argument(
"--type", required=True, choices=EDGE_TYPES, dest="edge_type", help="边类型"
)
edge_parser.add_argument("--evidence", default="", help="证据说明")
ingest_parser = subparsers.add_parser("ingest_paper", help="导入论文")
ingest_parser.add_argument("wiki_dir", help="Wiki 根目录")
ingest_parser.add_argument("--arxiv-id", dest="arxiv_id", help="arXiv ID")
ingest_parser.add_argument("--title", help="论文标题")
ingest_parser.add_argument("--authors", help="作者,逗号分隔")
ingest_parser.add_argument("--year", help="年份")
ingest_parser.add_argument("--venue", default="arXiv", help="发表 venue")
ingest_parser.add_argument("--thesis", default="", help="一句话论点")
rebuild_query_pack_parser = subparsers.add_parser(
"rebuild_query_pack", help="重建 Query Pack"
)
rebuild_query_pack_parser.add_argument("wiki_dir", help="Wiki 根目录")
rebuild_index_parser = subparsers.add_parser("rebuild_index", help="重建索引")
rebuild_index_parser.add_argument("wiki_dir", help="Wiki 根目录")
stats_parser = subparsers.add_parser("stats", help="输出统计信息")
stats_parser.add_argument("wiki_dir", help="Wiki 根目录")
lint_parser = subparsers.add_parser("lint", help="检查 Wiki 问题")
lint_parser.add_argument("wiki_dir", help="Wiki 根目录")
args = parser.parse_args()
if args.command == "init":
init_wiki(args.wiki_dir)
return
if args.command == "log":
append_log(args.wiki_dir, " ".join(args.message))
return
if args.command == "add_entity":
add_entity(args.wiki_dir, args.type, args.id, args.title)
return
if args.command == "add_edge":
add_edge(args.wiki_dir, args.from_id, args.to_id, args.edge_type, args.evidence)
return
if args.command == "ingest_paper":
ingest_paper(
args.wiki_dir,
arxiv_id=args.arxiv_id,
title=args.title,
authors=args.authors,
year=args.year,
venue=args.venue,
thesis=args.thesis,
)
return
if args.command == "rebuild_query_pack":
rebuild_query_pack(args.wiki_dir)
return
if args.command == "rebuild_index":
rebuild_index(args.wiki_dir)
return
if args.command == "stats":
print(get_stats(args.wiki_dir))
return
if args.command == "lint":
issues = lint_wiki(args.wiki_dir)
print("\n".join(issues) if issues else "未发现问题")
return
if __name__ == "__main__":
main()
+224
View File
@@ -0,0 +1,224 @@
"""Semantic Scholar 搜索命令行工具。"""
from __future__ import annotations
import argparse
import json
import sys
import time
from typing import Any
try:
import requests
except ImportError:
print("警告: 未安装 requests,已跳过 Semantic Scholar CLI。", file=sys.stderr)
sys.exit(0)
API_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
REQUEST_FIELDS = "title,authors,year,venue,citationCount,externalIds,tldr"
REQUEST_INTERVAL_SECONDS = 1.0
REQUEST_TIMEOUT_SECONDS = 30.0
USER_AGENT = "semantic_scholar_fetch.py/1.0"
_LAST_REQUEST_AT: float | None = None
def _normalize_filter_values(values: list[str] | None) -> str | None:
"""将过滤参数整理为 API 需要的逗号分隔字符串。"""
if not values:
return None
items: list[str] = []
for value in values:
for item in value.split(","):
cleaned = item.strip()
if cleaned:
items.append(cleaned)
if not items:
return None
return ",".join(items)
def _respect_rate_limit() -> None:
"""确保连续请求之间至少间隔一秒。"""
global _LAST_REQUEST_AT
now = time.monotonic()
if _LAST_REQUEST_AT is not None:
elapsed = now - _LAST_REQUEST_AT
if elapsed < REQUEST_INTERVAL_SECONDS:
time.sleep(REQUEST_INTERVAL_SECONDS - elapsed)
_LAST_REQUEST_AT = time.monotonic()
def _clean_text(value: Any) -> str:
"""压缩并清理任意文本值。"""
return " ".join(str(value).split())
def _normalize_authors(authors: Any) -> list[str]:
"""将作者字段统一为作者姓名列表。"""
normalized_authors: list[str] = []
if not isinstance(authors, list):
return normalized_authors
for author in authors:
if isinstance(author, dict):
name = author.get("name")
if name:
normalized_authors.append(_clean_text(name))
elif author:
normalized_authors.append(_clean_text(author))
return normalized_authors
def _normalize_external_ids(external_ids: Any) -> dict[str, str]:
"""将 externalIds 统一整理为字符串字典,并保留 arXiv 标识。"""
normalized: dict[str, str] = {}
if isinstance(external_ids, dict):
for key, value in external_ids.items():
if value is None:
continue
cleaned_value = _clean_text(value)
if cleaned_value:
normalized[str(key)] = cleaned_value
arxiv_id = (
normalized.get("ArXiv") or normalized.get("arXiv") or normalized.get("arxiv")
)
if arxiv_id and "ArXiv" not in normalized:
normalized["ArXiv"] = arxiv_id
return normalized
def _normalize_tldr(tldr: Any) -> Any:
"""保留 TLDR 字段原始结构,但去除明显的空字符串。"""
if isinstance(tldr, dict):
normalized_tldr: dict[str, Any] = {}
for key, value in tldr.items():
if isinstance(value, str):
cleaned = _clean_text(value)
if cleaned:
normalized_tldr[key] = cleaned
elif value is not None:
normalized_tldr[key] = value
return normalized_tldr or None
return tldr
def _normalize_paper(paper: dict[str, Any]) -> dict[str, Any]:
"""将 API 返回的论文记录压缩成稳定的输出结构。"""
return {
"title": _clean_text(paper.get("title", "")),
"authors": _normalize_authors(paper.get("authors")),
"year": paper.get("year"),
"venue": _clean_text(paper.get("venue", "")),
"citationCount": paper.get("citationCount"),
"externalIds": _normalize_external_ids(paper.get("externalIds")),
"tldr": _normalize_tldr(paper.get("tldr")),
}
def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]:
"""发起受限频率控制的 GET 请求并返回 JSON 对象。"""
_respect_rate_limit()
response = requests.get(
url,
params=params,
headers={"User-Agent": USER_AGENT},
timeout=REQUEST_TIMEOUT_SECONDS,
)
response.raise_for_status()
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Semantic Scholar 返回了非对象类型的 JSON")
return payload
def search_papers(
query: str,
max_results: int,
fields_of_study: list[str] | None = None,
publication_types: list[str] | None = None,
) -> list[dict[str, Any]]:
"""搜索 Semantic Scholar 论文并返回标准化结果。"""
params: dict[str, Any] = {
"query": query,
"limit": max_results,
"fields": REQUEST_FIELDS,
}
normalized_fields_of_study = _normalize_filter_values(fields_of_study)
if normalized_fields_of_study is not None:
params["fieldsOfStudy"] = normalized_fields_of_study
normalized_publication_types = _normalize_filter_values(publication_types)
if normalized_publication_types is not None:
params["publicationTypes"] = normalized_publication_types
payload = _request_json(API_URL, params)
papers = payload.get("data", [])
if not isinstance(papers, list):
raise ValueError("Semantic Scholar 响应缺少 data 列表")
normalized_papers: list[dict[str, Any]] = []
for paper in papers:
if isinstance(paper, dict):
normalized_papers.append(_normalize_paper(paper))
return normalized_papers
def build_parser() -> argparse.ArgumentParser:
"""构建命令行参数解析器。"""
parser = argparse.ArgumentParser(description="Semantic Scholar 搜索工具")
subparsers = parser.add_subparsers(dest="command", required=True)
search_parser = subparsers.add_parser("search", help="搜索论文")
search_parser.add_argument("query", help="搜索关键词")
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
search_parser.add_argument(
"--fields-of-study",
nargs="+",
default=None,
help="按学科领域过滤",
)
search_parser.add_argument(
"--publication-types",
nargs="+",
default=None,
help="按出版类型过滤",
)
return parser
def main(argv: list[str] | None = None) -> int:
"""CLI 入口。"""
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "search":
results = search_papers(
query=args.query,
max_results=args.max_results,
fields_of_study=args.fields_of_study,
publication_types=args.publication_types,
)
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
except (requests.RequestException, ValueError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
parser.error(f"未知命令: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())