diff --git a/skills/litmap/SKILL.md b/skills/litmap/SKILL.md new file mode 100644 index 0000000..5c95021 --- /dev/null +++ b/skills/litmap/SKILL.md @@ -0,0 +1,62 @@ +--- +name: litmap +version: 1.0.0 +description: "文献地图生成器:把一批文献题录(.bib/.ris 或标题列表)按主题归类,按年份铺出时间线图景,并启发式指出冷门主题、停滞主题、新兴主题三类研究缺口。当用户提到「文献综述框架」「文献主题归类」「文献地图」「这领域做到哪了」「找研究缺口」「literature review」时触发。" +metadata: + requires: + optional_bins: ["python"] +--- + +# litmap(文献地图生成器) + +文献囤了一文件夹没看?litmap 帮你理成「这领域到哪了」:按主题归类、按年份铺出 +时间线,并指出可能的研究缺口。 + +## 何时使用本技能 + +- 把囤的论文理成一张主题地图 +- 看清某方向做到哪、还剩什么空白 +- 想要综述的骨架与几个能接着做的方向 + +## 与同类工具的区别 + +同类综述工具多依赖在线检索或 LLM 直接写综述。本技能**纯本地、纯标准库**,处理的是 +你已经收集好的题录,聚焦「主题归类 + 时间线 + 缺口启发」三件结构化的事。尤其是 +**停滞主题检测**(某主题最新文献已数年无更新)和**新兴主题检测**,是单纯堆摘要给不出 +的洞察。它给综述脚手架与缺口线索,不替你写综述结论。 + +## 三个核心能力 + +| 能力 | 说明 | +|------|------| +| 主题归类 | 按主题桶关键词把文献分到若干主题组 | +| 时间线图景 | 按年份统计文献量,文本柱状图铺出分布 | +| 缺口启发 | 冷门(文献少)/ 停滞(近年无新作)/ 新兴(新近出现量小)三类 | + +## 工作流 + +```bash +# 从题录文件 +python scripts/litmap.py --inputs refs.bib --output map.md +# 从标题列表(一行一条,可写 "标题 | 年份") +python scripts/litmap.py --titles titles.txt --format json +``` + +| 参数 | 说明 | +|------|------| +| `--inputs` | 一个或多个 .bib/.ris 文件 | +| `--titles` | 一行一条的标题列表(可含 `| 年份`) | +| `--format` | `markdown`(默认)或 `json` | +| `--output` | 输出文件 | + +## 注意事项 + +- 主题桶覆盖常见 AI/医学主题,可在 `litmap.py` 的 `TOPIC_BUCKETS` 中扩充。 +- 一条文献可同时归入多个主题(如「对比学习做分割」同属两类)。 +- 缺口启发是基于规模与时间的线索,需你结合领域判断核实。 +- 全程离线,不调用任何文献库。 + +## References + +- [topics.md](references/topics.md) — 主题桶与归类规则 +- [gap-heuristics.md](references/gap-heuristics.md) — 三类缺口的判定逻辑 diff --git a/skills/litmap/references/gap-heuristics.md b/skills/litmap/references/gap-heuristics.md new file mode 100644 index 0000000..a72da79 --- /dev/null +++ b/skills/litmap/references/gap-heuristics.md @@ -0,0 +1,39 @@ +# 缺口启发判定逻辑 + +litmap 从主题规模与时间分布出发,给出三类缺口线索。它们是「值得你去核实的方向」, +不是定论。 + +## 冷门主题(少有人碰) + +判定:某主题文献数 ≤ 2 篇,且 ≤ 总量的 10%。 + +含义:这个子方向在你的文献集里几乎没人做。可能是真空白(机会),也可能是死胡同 +(已被证明不可行)——需要你判断是哪种。 + +## 停滞主题(近年无新作) + +判定:某主题最新一篇文献距全集最新年份 ≥ 3 年。 + +含义:这个方向曾有人做,但近几年没人更新了。可能因为问题已解决、热度转移,也 +可能因为遇到瓶颈被搁置。若有新数据/新工具,停滞主题往往是「重启」的好机会。 + +## 新兴主题(新近出现量小) + +判定:某主题起始年份 ≥ 全集最新年份 − 2,且文献数 ≤ 3 篇。 + +含义:这个方向刚冒头、量还小,可能是正在升温的前沿。早进入有先发优势,但也要 +警惕是否只是短暂热点。 + +## 怎么用这些线索 + +- 冷门 + 你判断有价值 → 可能是被忽视的机会。 +- 停滞 + 有新方法可用 → 经典问题重做。 +- 新兴 + 与你方向相关 → 尽早布局。 + +三类缺口都需要结合你对领域的理解二次确认,工具只负责把「数字上的异常」标出来。 + +## 局限 + +- 判定依赖年份信息,题录缺年份时缺口分析会失真。 +- 文献集规模小时(< 10 篇)统计意义有限,结论仅供参考。 +- 主题归类基于关键词,归类粒度受主题桶设计影响。 diff --git a/skills/litmap/references/topics.md b/skills/litmap/references/topics.md new file mode 100644 index 0000000..3cb37a1 --- /dev/null +++ b/skills/litmap/references/topics.md @@ -0,0 +1,38 @@ +# 主题桶与归类规则 + +## 归类方式 + +每条文献取「标题 + 关键词」做小写匹配,命中某主题桶的任一关键词即归入该主题。 +一条文献可同时归入多个主题(如「对比学习做分割」同属「对比/自监督学习」与「分割」)。 +未命中任何桶的归入「其它/未分类」。 + +## 预设主题桶 + +| 主题 | 命中关键词(部分) | +|------|------------------| +| 对比/自监督学习 | contrastive, self-supervised, 对比, 自监督 | +| 迁移/域适应 | transfer, domain adaptation, 迁移, 域适应 | +| 分割 | segmentation, 分割 | +| 检测 | detection, 检测 | +| 分类/识别 | classification, recognition, 分类, 识别 | +| 生成模型 | generative, gan, diffusion, vae, 生成, 扩散 | +| 图神经网络 | graph neural, gnn, 图神经 | +| 大模型/预训练 | pretrain, foundation model, llm, 预训练, 大模型 | +| 可解释性 | interpret, explainable, 可解释 | +| 小样本/零样本 | few-shot, zero-shot, 小样本, 零样本 | +| 医学应用 | medical, clinical, 医学, 临床 | +| 鲁棒性/对抗 | robust, adversarial, 鲁棒, 对抗 | + +## 扩充主题桶 + +主题桶定义在 `litmap.py` 的 `TOPIC_BUCKETS` 字典里。换领域时,把上面的桶替换成 +你领域的主题词即可(如材料、金融、遥感各有自己的主题体系)。关键词中英文混排, +覆盖中英文题录。 + +## 输入格式 + +| 格式 | 取用字段 | +|------|---------| +| .bib | title, year, keywords | +| .ris | TI/T1, PY/Y1, KW | +| 标题列表 .txt | 每行「标题」或「标题 \| 年份」;无年份时尝试从标题中提取 4 位年份 | diff --git a/skills/litmap/scripts/litmap.py b/skills/litmap/scripts/litmap.py new file mode 100644 index 0000000..bc4ffe7 --- /dev/null +++ b/skills/litmap/scripts/litmap.py @@ -0,0 +1,286 @@ +"""litmap:文献地图生成器。 + +把你存的一堆文献题录(.bib / .ris,或一行一条的标题列表),理成一张「文献地图」: +按主题归类、按年份铺出时间线图景,并启发式地指出可能的研究缺口(哪些主题少有人 +碰、哪些主题近年没人更新)。 + +三个核心能力: +1. 主题归类——从每条文献的标题/关键词中抽取主题词,按预设主题桶 + 高频词聚类, + 把文献分到若干主题组,看清「这领域分成哪几块、各块有多少工作」。 +2. 时间线图景——按年份统计各主题的文献量,用文本柱状图铺出时间分布,看清 + 「这方向哪年热、近年还活跃吗」。 +3. 缺口启发——基于主题规模与时间分布,提示三类可能缺口:冷门主题(文献少)、 + 停滞主题(近年无新作)、新兴主题(仅近年出现、量还小)。 + +本工具纯本地、纯标准库:不联网、不调用任何文献库。它处理的是你已经收集好的题录, +帮你从「一文件夹论文」理出「这领域到哪了」。它给的是综述的脚手架与缺口线索,不替 +你写综述结论。 + +用法: + python litmap.py --inputs refs.bib --output map.md + python litmap.py --titles titles.txt --format json +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + +# 主题桶:主题名 -> 关键词(命中即归入该主题) +TOPIC_BUCKETS = { + "对比/自监督学习": ["contrastive", "self-supervised", "self supervised", "对比", "自监督"], + "迁移/域适应": ["transfer", "domain adaptation", "domain-adaptation", "迁移", "域适应"], + "分割": ["segmentation", "segment", "分割"], + "检测": ["detection", "detect", "检测"], + "分类/识别": ["classification", "recognition", "classify", "分类", "识别"], + "生成模型": ["generative", "gan", "diffusion", "vae", "生成", "扩散"], + "图神经网络": ["graph neural", "gnn", "graph network", "图神经"], + "大模型/预训练": ["pretrain", "pre-train", "foundation model", "large language", "llm", "预训练", "大模型"], + "可解释性": ["interpret", "explainab", "explainable", "可解释"], + "小样本/零样本": ["few-shot", "zero-shot", "few shot", "小样本", "零样本"], + "医学应用": ["medical", "clinical", "healthcare", "医学", "临床", "医疗"], + "鲁棒性/对抗": ["robust", "adversarial", "鲁棒", "对抗"], +} + +STOP = set("the a an and or of to in on for with by from as is are this that we our study using based novel approach method methods toward via".split()) + + +def parse_bibtex(text: str) -> list[dict[str, Any]]: + records = [] + for m in re.finditer(r"@(\w+)\s*\{([^,]*),(.*?)\n\}", text, re.DOTALL): + body = m.group(3) + rec: dict[str, Any] = {} + for fm in re.finditer(r"(\w+)\s*=\s*[{\"](.+?)[}\"]\s*,?\s*\n", body + "\n", re.DOTALL): + rec[fm.group(1).lower()] = re.sub(r"\s+", " ", fm.group(2)).strip() + records.append({ + "title": rec.get("title", ""), + "year": str(rec.get("year", "")).strip()[:4], + "keywords": rec.get("keywords", ""), + }) + return records + + +def parse_ris(text: str) -> list[dict[str, Any]]: + records, cur, kw = [], {}, [] + for line in text.splitlines(): + m = re.match(r"^([A-Z][A-Z0-9])\s+-\s*(.*)$", line) + if not m: + continue + tag, val = m.group(1), m.group(2).strip() + if tag == "TY": + cur, kw = {}, [] + elif tag in ("TI", "T1"): + cur["title"] = val + elif tag in ("PY", "Y1"): + cur["year"] = val[:4] + elif tag == "KW": + kw.append(val) + elif tag == "ER": + if cur.get("title"): + cur["keywords"] = "; ".join(kw) + records.append({"title": cur.get("title", ""), "year": cur.get("year", ""), + "keywords": cur.get("keywords", "")}) + return records + + +def parse_titles(text: str) -> list[dict[str, Any]]: + """一行一条:可选 '标题 | 年份' 或纯标题。""" + records = [] + for line in text.splitlines(): + line = line.strip().lstrip("\ufeff") + if not line: + continue + year = "" + if "|" in line: + title, _, y = line.rpartition("|") + title, year = title.strip(), y.strip()[:4] + else: + title = line + ym = re.search(r"\b(19|20)\d{2}\b", line) + year = ym.group(0) if ym else "" + records.append({"title": title, "year": year, "keywords": ""}) + return records + + +def parse_file(path: Path) -> list[dict[str, Any]]: + text = path.read_text(encoding="utf-8", errors="replace") + suf = path.suffix.lower() + if suf == ".ris": + return parse_ris(text) + if suf == ".bib": + return parse_bibtex(text) + return parse_titles(text) + + +def classify_topic(rec: dict[str, Any]) -> list[str]: + """把一条文献归入命中的主题桶(可多归)。""" + text = f"{rec.get('title', '')} {rec.get('keywords', '')}".lower() + hits = [topic for topic, kws in TOPIC_BUCKETS.items() if any(k in text for k in kws)] + return hits or ["其它/未分类"] + + +def cluster(records: list[dict[str, Any]]) -> dict[str, Any]: + """主题归类 + 时间线 + 缺口启发。""" + topic_recs: dict[str, list[dict[str, Any]]] = defaultdict(list) + topic_years: dict[str, list[int]] = defaultdict(list) + all_years: list[int] = [] + + for rec in records: + for topic in classify_topic(rec): + topic_recs[topic].append(rec) + y = _safe_year(rec.get("year")) + if y: + topic_years[topic].append(y) + all_years.append(y) + + max_year = max(all_years) if all_years else 0 + topics = [] + for topic, recs in sorted(topic_recs.items(), key=lambda kv: len(kv[1]), reverse=True): + years = sorted(topic_years[topic]) + topics.append({ + "topic": topic, + "count": len(recs), + "year_min": years[0] if years else None, + "year_max": years[-1] if years else None, + "titles": [r["title"] for r in recs][:8], + }) + + gaps = _find_gaps(topics, max_year, total=len(records)) + timeline = _build_timeline(records) + return {"total": len(records), "topics": topics, "timeline": timeline, "gaps": gaps} + + +def _safe_year(v: Any) -> int | None: + try: + y = int(str(v)[:4]) + return y if 1900 <= y <= 2100 else None + except (TypeError, ValueError): + return None + + +def _build_timeline(records: list[dict[str, Any]]) -> dict[str, int]: + c: Counter[str] = Counter() + for r in records: + y = _safe_year(r.get("year")) + if y: + c[str(y)] += 1 + return dict(sorted(c.items())) + + +def _find_gaps(topics: list[dict[str, Any]], max_year: int, total: int) -> dict[str, list[str]]: + cold, stale, emerging = [], [], [] + for t in topics: + if t["topic"] == "其它/未分类": + continue + # 冷门:文献数 ≤ 总量 10% 且 ≤ 2 篇 + if t["count"] <= 2 and t["count"] <= max(1, total * 0.1): + cold.append(f"{t['topic']}(仅 {t['count']} 篇)") + # 停滞:最近一篇距今 ≥ 3 年 + if max_year and t["year_max"] and (max_year - t["year_max"]) >= 3: + stale.append(f"{t['topic']}(最新到 {t['year_max']},已 {max_year - t['year_max']} 年无新作)") + # 新兴:起始年都很近(≥ max_year-2)且量不大 + if max_year and t["year_min"] and t["year_min"] >= max_year - 2 and t["count"] <= 3: + emerging.append(f"{t['topic']}({t['year_min']} 才出现,仅 {t['count']} 篇)") + return {"冷门主题(少有人碰)": cold, "停滞主题(近年无新作)": stale, + "新兴主题(新近出现量小)": emerging} + + +def _bar(n: int, peak: int, width: int = 16) -> str: + filled = int(round(n / peak * width)) if peak else 0 + return "#" * filled + "." * (width - filled) + + +def render_markdown(data: dict[str, Any]) -> str: + lines = [ + "# 文献地图", + "", + f"共 {data['total']} 条文献,归入 {len(data['topics'])} 个主题。", + "", + "## 一、主题图景", + "", + "| 主题 | 文献数 | 年份跨度 |", + "|------|:----:|:----:|", + ] + for t in data["topics"]: + span = (f"{t['year_min']}–{t['year_max']}" if t["year_min"] else "—") + lines.append(f"| {t['topic']} | {t['count']} | {span} |") + lines += ["", "## 二、年份时间线", ""] + tl = data["timeline"] + if tl: + peak = max(tl.values()) + for y, n in tl.items(): + lines.append(f"- `{y}` {_bar(n, peak)} {n}") + else: + lines.append("- (题录缺少年份信息)") + + lines += ["", "## 三、研究缺口启发", ""] + for cat, items in data["gaps"].items(): + lines.append(f"### {cat}") + if items: + for it in items: + lines.append(f"- {it}") + else: + lines.append("- (未发现明显此类缺口)") + lines.append("") + + lines += ["## 四、各主题代表文献", ""] + for t in data["topics"]: + if t["topic"] == "其它/未分类": + continue + lines.append(f"### {t['topic']}({t['count']} 篇)") + for title in t["titles"]: + lines.append(f"- {title}") + lines.append("") + lines += ["---", "", "由 litmap 生成。基于你提供的题录做主题归类与缺口启发,给的是综述脚手架,不替你写结论。"] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="litmap", description="文献地图生成器") + p.add_argument("--inputs", nargs="+", help="一个或多个 .bib/.ris 文件") + p.add_argument("--titles", type=Path, help="一行一条的标题列表(可含 | 年份)") + p.add_argument("--format", choices=["markdown", "json"], default="markdown") + p.add_argument("--output", type=Path) + args = p.parse_args(argv) + + records: list[dict[str, Any]] = [] + if args.inputs: + for f in args.inputs: + fp = Path(f) + if fp.exists(): + records.extend(parse_file(fp)) + else: + print(f"警告:找不到 {f}", file=sys.stderr) + if args.titles and args.titles.exists(): + records.extend(parse_titles(args.titles.read_text(encoding="utf-8", errors="replace"))) + + if not records: + print("错误:请用 --inputs 或 --titles 提供文献题录。", file=sys.stderr) + return 2 + + data = cluster(records) + out = (json.dumps(data, ensure_ascii=False, indent=2) if args.format == "json" + else render_markdown(data)) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(out, encoding="utf-8") + print(f"已写入 {args.output}") + else: + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/litmap/tests/test_litmap.py b/skills/litmap/tests/test_litmap.py new file mode 100644 index 0000000..a10bb97 --- /dev/null +++ b/skills/litmap/tests/test_litmap.py @@ -0,0 +1,90 @@ +"""litmap 单元测试。""" +from __future__ import annotations +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import pytest +from litmap import (parse_titles, parse_bibtex, classify_topic, cluster, + _find_gaps, render_markdown) + + +class TestParseTitles: + def test_with_year_pipe(self): + recs = parse_titles("Some title | 2024\nAnother | 2023") + assert len(recs) == 2 + assert recs[0]["year"] == "2024" + + def test_year_inline(self): + recs = parse_titles("A paper from 2021 about X") + assert recs[0]["year"] == "2021" + + def test_no_year(self): + recs = parse_titles("A title without year") + assert recs[0]["year"] == "" + + +class TestClassify: + def test_segmentation(self): + assert "分割" in classify_topic({"title": "image segmentation method", "keywords": ""}) + + def test_multi_topic(self): + hits = classify_topic({"title": "contrastive learning for segmentation", "keywords": ""}) + assert "对比/自监督学习" in hits and "分割" in hits + + def test_unclassified(self): + assert classify_topic({"title": "something totally unrelated xyz", "keywords": ""}) == ["其它/未分类"] + + +class TestCluster: + def _recs(self): + return [ + {"title": "contrastive learning seg", "year": "2024", "keywords": ""}, + {"title": "self-supervised pretrain", "year": "2023", "keywords": ""}, + {"title": "graph neural network", "year": "2018", "keywords": ""}, + ] + + def test_total(self): + d = cluster(self._recs()) + assert d["total"] == 3 + + def test_timeline(self): + d = cluster(self._recs()) + assert d["timeline"]["2024"] == 1 + assert d["timeline"]["2018"] == 1 + + def test_topics_sorted_desc(self): + d = cluster(self._recs()) + counts = [t["count"] for t in d["topics"]] + assert counts == sorted(counts, reverse=True) + + +class TestGaps: + def test_stale_detected(self): + topics = [{"topic": "图神经网络", "count": 1, "year_min": 2018, "year_max": 2018}] + gaps = _find_gaps(topics, max_year=2025, total=10) + assert any("图神经网络" in s for s in gaps["停滞主题(近年无新作)"]) + + def test_cold_detected(self): + topics = [{"topic": "可解释性", "count": 1, "year_min": 2023, "year_max": 2023}] + gaps = _find_gaps(topics, max_year=2024, total=20) + assert any("可解释性" in s for s in gaps["冷门主题(少有人碰)"]) + + +class TestBibtex: + def test_parse(self): + recs = parse_bibtex('@article{k,\n title = {Deep segmentation},\n year = {2022}\n}') + assert recs[0]["title"] == "Deep segmentation" + assert recs[0]["year"] == "2022" + + +class TestRender: + def test_render(self): + d = cluster([{"title": "contrastive seg", "year": "2024", "keywords": ""}]) + md = render_markdown(d) + assert "文献地图" in md + assert "主题图景" in md + assert "研究缺口启发" in md + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))