feat(skills): add litmap academic research skill

This commit is contained in:
Ct201314 2026-06-12 20:03:35 +08:00
parent b45241dcda
commit 059050ad61
5 changed files with 515 additions and 0 deletions

62
skills/litmap/SKILL.md Normal file
View File

@ -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) — 三类缺口的判定逻辑

View File

@ -0,0 +1,39 @@
# 缺口启发判定逻辑
litmap 从主题规模与时间分布出发,给出三类缺口线索。它们是「值得你去核实的方向」,
不是定论。
## 冷门主题(少有人碰)
判定:某主题文献数 ≤ 2 篇,且 ≤ 总量的 10%。
含义:这个子方向在你的文献集里几乎没人做。可能是真空白(机会),也可能是死胡同
(已被证明不可行)——需要你判断是哪种。
## 停滞主题(近年无新作)
判定:某主题最新一篇文献距全集最新年份 ≥ 3 年。
含义:这个方向曾有人做,但近几年没人更新了。可能因为问题已解决、热度转移,也
可能因为遇到瓶颈被搁置。若有新数据/新工具,停滞主题往往是「重启」的好机会。
## 新兴主题(新近出现量小)
判定:某主题起始年份 ≥ 全集最新年份 2且文献数 ≤ 3 篇。
含义:这个方向刚冒头、量还小,可能是正在升温的前沿。早进入有先发优势,但也要
警惕是否只是短暂热点。
## 怎么用这些线索
- 冷门 + 你判断有价值 → 可能是被忽视的机会。
- 停滞 + 有新方法可用 → 经典问题重做。
- 新兴 + 与你方向相关 → 尽早布局。
三类缺口都需要结合你对领域的理解二次确认,工具只负责把「数字上的异常」标出来。
## 局限
- 判定依赖年份信息,题录缺年份时缺口分析会失真。
- 文献集规模小时(< 10 统计意义有限结论仅供参考
- 主题归类基于关键词,归类粒度受主题桶设计影响。

View File

@ -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 位年份 |

View File

@ -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())

View File

@ -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"]))