
Obsidian Literature Workflow
- 257 installs
- 5k repo stars
- Updated July 17, 2026
- galaxy-dawn/claude-scholar
Manage academic literature in Obsidian with Claude Scholar: reading queues, structured notes, bidirectional links, synthesis maps, and literature-review outlines for papers or theses.
About
obsidian-literature-workflow in claude-scholar sets up Obsidian for scholarly reading: templated notes, linked concepts, reading pipelines, and synthesis artifacts that support literature reviews and long-form research writing.
- Reading queues
- Structured annotations
- Bidirectional links
- Synthesis maps
- Review outlines
Obsidian Literature Workflow by the numbers
- 257 all-time installs (skills.sh)
- Ranked #947 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/galaxy-dawn/claude-scholar --skill obsidian-literature-workflowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 257 |
|---|---|
| repo stars | ★ 5k |
| Last updated | July 17, 2026 |
| Repository | galaxy-dawn/claude-scholar ↗ |
What it does
Manage academic literature in Obsidian with Claude Scholar: reading queues, structured notes, bidirectional links, synthesis maps, and literature-review outlines for papers or theses.
Files
Obsidian Literature Workflow
This skill owns the project literature workflow.
Main flow
Sources/Papers/ -> Knowledge/ -> Writing/ -> Maps/literature.canvasDefault outputs
Sources/Papers/{paper-slug}.mdKnowledge/Literature Overview.mdKnowledge/Method Taxonomy.mdKnowledge/Research Gaps.mdKnowledge/Claim Map.mdWriting/related-work-draft.mdonly after promoted claims pass the evidence gateWriting/comparison-matrix.mdonly after promoted claims pass the evidence gateMaps/literature.canvas
Rules
- paper notes stay under
Sources/Papers/ - literature synthesis does not live inside source notes
- every synthesis note must link its supporting source notes
- every research gap must carry evidence
- every promoted claim must carry an Evidence Record ID, source type, claim strength, allowed wording, and forbidden stronger wording when it may flow into writing or rebuttal
- weak paper notes, abstract-only pages, and webpage placeholders may update coverage or
To-Read, but they cannot supportKnowledgeorWritingconclusions - default graph output is
Maps/literature.canvas - do not generate extra canvases unless explicitly requested
Read next
references/PAPER-NOTE-SCHEMA.mdreferences/LITERATURE-OVERVIEW.mdreferences/CLAIM-EXTRACTION.mdreferences/METHOD-TAXONOMY.mdreferences/RESEARCH-GAPS.mdreferences/LITERATURE-CANVAS.md
Literature Canvas Workflow
Default output
Maps/literature.canvas
Purpose
Provide a default Obsidian literature graph that:
- keeps
Sources/Papers/as the canonical paper-note surface, - keeps
Knowledge/as the canonical synthesis surface, - visualizes paper-to-paper and paper-to-knowledge relationships,
- stays lightweight enough to refresh after each major Zotero ingestion.
Default behavior
- Use paper-note frontmatter and wikilinks as the primary graph source.
- Use
Sources/Papers/*.mdand relevantKnowledge/*.mdas file nodes. - Create
.canvasby default for literature ingestion and review workflows. - Treat Mermaid or markdown graph notes as optional legacy companions, not the default graph artifact.
- Prefer argument-map structure with
paper,claim,method, andgapnodes over raw all-to-all paper linking. - Thin edges aggressively; keep only the main reasoning chain and a small number of explicit semantic paper-to-paper relations.
- Hide or down-rank side branches when they clutter the display graph.
Refresh triggers
Refresh the literature canvas when:
- new Zotero-sourced paper notes are added,
- paper notes gain new
linked_knowledgeedges or meaningful wikilinks, - knowledge synthesis notes are updated after a literature pass,
- a batch Zotero review or note-ingestion pass finishes,
- a full-collection normalization pass changes many paper-note relationships.
Recommended command
python3 "${CLAUDE_PLUGIN_ROOT}/skills/obsidian-literature-workflow/scripts/build_literature_canvas.py" --cwd "$PWD"Display rule
- If a second lightweight showcase graph is useful, maintain
Maps/literature-main.canvasas a filtered presentation copy rather than bloating the default working canvas.
CLAIM EXTRACTION
Claim extraction should produce reusable literature notes, not vague summaries.
Extract at least these fields
For each paper or source note, identify:
- Claim — what the paper says it achieves or establishes
- Claim type — author claim, community consensus, or project interpretation
- Claim strength — speculative, observed, supported, or strong
- Evidence — the concrete support for that claim (dataset, metric, experiment, analysis)
- Method — the approach or mechanism behind the claim
- Limitation — where the claim may not hold
- Contradicts / weakens — evidence or conditions that reduce confidence in the claim
- Project relevance — why this matters for the current project
Writing rules
- Write claims in plain, reusable language.
- Distinguish the author claim from your project interpretation.
- Do not copy entire abstract sentences when a shorter paraphrase is clearer.
- Pair every durable claim with at least one evidence anchor.
- Do not promote a claim strength without naming the evidence that justifies the stronger level.
Minimal output shape
This Key Claims block is a paper-note projection of the shared Evidence Record contract. Preserve the same evidence anchor, limitation, contradiction, project relevance, and claim strength so downstream synthesis can map it back to the canonical evidence record.
## Key Claims
- Claim: ...
- Claim type: author claim | community consensus | project interpretation
- Claim strength: speculative | observed | supported | strong
- Evidence: ...
- Method: ...
- Limitation: ...
- Contradicts / weakens: ...
- Project relevance: ...Promotion rule
If a claim is reusable across multiple papers or sources, promote it from a paper note into Knowledge/ instead of leaving it stranded in Sources/Papers/.
Only promote claims that include both an evidence anchor and a claim strength. If the evidence is weak or indirect, keep the claim as speculative or observed and preserve the uncertainty.
Legacy Literature Map Workflow
This reference is kept for backwards compatibility only.
Legacy output
Knowledge/Literature-Map.md
Current default
- Use
CANVAS-WORKFLOW.mdfor the current default literature graph artifact. - The default graph is now
Maps/literature.canvas, not a Mermaid note. - Keep this markdown workflow only when a note-based map is specifically requested or needed for compatibility.
Refresh triggers
Refresh the markdown map only when:
- new Zotero-sourced paper notes are added,
- paper-note links or metadata materially change,
- a batch literature review finishes.
Recommended command
python3 "${CLAUDE_PLUGIN_ROOT}/skills/obsidian-literature-workflow/scripts/build_literature_graph.py" --cwd "$PWD"LITERATURE CANVAS
The default literature canvas lives at Maps/literature.canvas and should stay sparse, argument-oriented, and source-backed.
LITERATURE OVERVIEW
Literature overview notes summarize the current field view for the project and must link source paper notes.
METHOD TAXONOMY
Method taxonomy notes group methods across papers and should link each taxonomy entry back to supporting paper notes.
PAPER NOTE SCHEMA
Use a strong paper schema with metadata, project relevance, summary, claims, methods, evidence, limitations, connections, and open questions.
RESEARCH GAPS
Research gap notes must state the gap, why it matters, and which paper notes support it.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
LIST_KEYS = {
"authors",
"keywords",
"concepts",
"methods",
"related_papers",
"linked_knowledge",
"paper_relationships",
"linked_papers",
"argument_claims",
"argument_methods",
"argument_gaps",
"linked_claims",
"linked_methods",
"linked_gaps",
"claim_method_links",
"method_gap_links",
}
PAPER_LIMIT = 48
KNOWLEDGE_LIMIT = 18
KNOWLEDGE_COLUMNS = 3
NODE_WIDTH = 300
NODE_HEIGHT = 180
ARG_NODE_WIDTH = 280
ARG_NODE_HEIGHT = 120
X_GAP = 80
Y_GAP = 70
GROUP_X_GAP = 180
GROUP_Y_GAP = 180
ARG_GROUP_X_GAP = 200
PAPER_RELATION_LIMIT = 2
PAPER_CLAIM_LIMIT = 1
PAPER_METHOD_LIMIT = 1
PAPER_RELATION_LIMIT = 1
SUBFIELD_ORDER = (
"speech-transfer-constraints",
"alignment-and-domain-adaptation",
"geometry-and-representation",
"subject-aware-adaptation",
"other",
)
SUBFIELD_LABELS = {
"speech-transfer-constraints": "Speech-specific transfer constraints",
"alignment-and-domain-adaptation": "Alignment and domain adaptation",
"geometry-and-representation": "Geometry and representation",
"subject-aware-adaptation": "Subject-aware adaptation",
"other": "Other papers",
}
SUBFIELD_COLORS = {
"speech-transfer-constraints": "6",
"alignment-and-domain-adaptation": "2",
"geometry-and-representation": "5",
"subject-aware-adaptation": "3",
"other": "1",
}
SUBFIELD_COLUMNS = 2
@dataclass(frozen=True)
class NoteRecord:
title: str
note_relpath: str
vault_relpath: str
file_name: str
related_papers: tuple[str, ...]
linked_knowledge: tuple[str, ...]
paper_relationships: tuple[tuple[str, str], ...]
linked_papers: tuple[str, ...]
claim_refs: tuple[str, ...]
method_refs: tuple[str, ...]
gap_refs: tuple[str, ...]
claim_method_links: tuple[tuple[str, str, str], ...]
method_gap_links: tuple[tuple[str, str, str], ...]
wikilinks: tuple[str, ...]
subfield: str
canvas_visibility: str
def load_project_kb_module() -> Any:
script_dir = Path(__file__).resolve().parents[2] / "obsidian-project-kb-core" / "scripts"
sys.path.insert(0, str(script_dir))
import kb_common # type: ignore
return kb_common
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Build a default literature canvas for the bound Obsidian project."
)
parser.add_argument("--cwd", default=".")
parser.add_argument("--project-id", default="")
parser.add_argument("--output", default="Maps/literature.canvas")
return parser.parse_args()
def parse_frontmatter(text: str) -> dict[str, Any]:
if not text.startswith("---\n"):
return {}
end = text.find("\n---\n", 4)
if end == -1:
return {}
lines = text[4:end].splitlines()
data: dict[str, Any] = {}
current_key: str | None = None
for raw_line in lines:
if not raw_line.strip():
continue
if raw_line.startswith(" - ") or raw_line.startswith("- "):
if current_key and current_key in LIST_KEYS:
data.setdefault(current_key, []).append(
raw_line.split("- ", 1)[1].strip().strip('"')
)
continue
if ":" not in raw_line:
current_key = None
continue
key, value = raw_line.split(":", 1)
key = key.strip()
value = value.strip()
current_key = key
if not value:
if key in LIST_KEYS:
data[key] = []
else:
data[key] = ""
continue
data[key] = value.strip('"')
return data
def body_without_frontmatter(text: str) -> str:
if not text.startswith("---\n"):
return text
end = text.find("\n---\n", 4)
if end == -1:
return text
return text[end + 5 :]
def normalize_note_target(value: str) -> str:
cleaned = value.strip()
if cleaned.endswith(".md"):
cleaned = cleaned[:-3]
if cleaned.startswith("./"):
cleaned = cleaned[2:]
return cleaned
def parse_relationship_entries(values: list[str]) -> tuple[tuple[str, str], ...]:
parsed: list[tuple[str, str]] = []
for raw in values:
cleaned = raw.strip()
if not cleaned:
continue
if "::" in cleaned:
target, label = cleaned.split("::", 1)
else:
target, label = cleaned, "related"
parsed.append((normalize_note_target(target), label.strip() or "related"))
return tuple(parsed)
def parse_triple_entries(values: list[str]) -> tuple[tuple[str, str, str], ...]:
parsed: list[tuple[str, str, str]] = []
for raw in values:
cleaned = raw.strip()
if not cleaned:
continue
parts = [part.strip() for part in cleaned.split("::")]
if len(parts) == 2:
parsed.append((parts[0], parts[1], "relates"))
elif len(parts) >= 3:
parsed.append((parts[0], parts[1], parts[2] or "relates"))
return tuple(parsed)
def parse_plain_labels(values: list[str]) -> tuple[str, ...]:
labels = []
for raw in values:
cleaned = raw.strip()
if cleaned and cleaned not in labels:
labels.append(cleaned)
return tuple(labels)
def extract_wikilinks(text: str) -> tuple[str, ...]:
seen: list[str] = []
for match in WIKILINK_RE.findall(text):
target = normalize_note_target(match)
if target not in seen:
seen.append(target)
return tuple(seen)
def note_id(prefix: str, value: str) -> str:
digest = hashlib.sha1(f"{prefix}:{value}".encode("utf-8")).hexdigest()
return digest[:16]
def file_node(note: NoteRecord, x: int, y: int, color: str) -> dict[str, Any]:
return {
"id": note_id("node", note.note_relpath),
"type": "file",
"x": x,
"y": y,
"width": NODE_WIDTH,
"height": NODE_HEIGHT,
"file": note.vault_relpath,
"color": color,
}
def text_node(node_key: str, text: str, x: int, y: int, width: int, height: int) -> dict[str, Any]:
return {
"id": note_id("text", node_key),
"type": "text",
"x": x,
"y": y,
"width": width,
"height": height,
"text": text,
"color": "5",
}
def group_node(node_key: str, label: str, x: int, y: int, width: int, height: int, color: str) -> dict[str, Any]:
return {
"id": note_id("group", node_key),
"type": "group",
"x": x,
"y": y,
"width": width,
"height": height,
"label": label,
"color": color,
}
def edge_id(source: str, target: str, label: str) -> str:
digest = hashlib.sha1(f"{source}:{target}:{label}".encode("utf-8")).hexdigest()
return digest[:16]
def make_edge(from_node: str, to_node: str, label: str) -> dict[str, Any]:
return {
"id": edge_id(from_node, to_node, label),
"fromNode": from_node,
"fromSide": "bottom",
"toNode": to_node,
"toSide": "top",
"toEnd": "arrow",
"label": label,
}
def collect_notes(folder: Path, folder_name: str, vault_root: Path, limit: int) -> list[NoteRecord]:
notes: list[NoteRecord] = []
if not folder.exists():
return notes
for path in sorted(folder.glob("*.md"))[:limit]:
text = path.read_text(encoding="utf-8")
frontmatter = parse_frontmatter(text)
body = body_without_frontmatter(text)
title = str(frontmatter.get("title") or path.stem.replace("-", " "))
notes.append(
NoteRecord(
title=title,
note_relpath=f"{folder_name}/{path.name}",
vault_relpath=str(path.relative_to(vault_root)).replace("\\", "/"),
file_name=path.name,
related_papers=tuple(
normalize_note_target(v) for v in frontmatter.get("related_papers", [])
),
linked_knowledge=tuple(
normalize_note_target(v) for v in frontmatter.get("linked_knowledge", [])
),
paper_relationships=parse_relationship_entries(
list(frontmatter.get("paper_relationships", []))
),
linked_papers=tuple(
normalize_note_target(v) for v in frontmatter.get("linked_papers", [])
),
claim_refs=parse_plain_labels(
list(frontmatter.get("argument_claims", []))
+ list(frontmatter.get("linked_claims", []))
),
method_refs=parse_plain_labels(
list(frontmatter.get("argument_methods", []))
+ list(frontmatter.get("linked_methods", []))
),
gap_refs=parse_plain_labels(
list(frontmatter.get("argument_gaps", []))
+ list(frontmatter.get("linked_gaps", []))
),
claim_method_links=parse_triple_entries(
list(frontmatter.get("claim_method_links", []))
),
method_gap_links=parse_triple_entries(
list(frontmatter.get("method_gap_links", []))
),
wikilinks=extract_wikilinks(body),
subfield=str(frontmatter.get("subfield") or "other"),
canvas_visibility=str(frontmatter.get("canvas_visibility") or "show"),
)
)
return notes
def looks_like_literature_knowledge(note: NoteRecord) -> bool:
key = note.note_relpath.lower()
if any(token in key for token in ("literature", "paper", "method", "survey", "gap", "related")):
return True
return any(target.startswith("Sources/Papers/") for target in note.wikilinks)
def select_knowledge_notes(
all_knowledge: list[NoteRecord], papers: list[NoteRecord]
) -> list[NoteRecord]:
requested = set()
for note in papers:
requested.update(note.linked_knowledge)
requested.update(target for target in note.wikilinks if target.startswith("Knowledge/"))
selected: list[NoteRecord] = []
seen: set[str] = set()
for note in all_knowledge:
note_key = note.note_relpath[:-3]
if note_key in requested:
selected.append(note)
seen.add(note_key)
if selected:
return selected[:KNOWLEDGE_LIMIT]
for note in all_knowledge:
note_key = note.note_relpath[:-3]
if note_key in seen:
continue
if looks_like_literature_knowledge(note):
selected.append(note)
seen.add(note_key)
if len(selected) >= KNOWLEDGE_LIMIT:
break
return selected[:KNOWLEDGE_LIMIT]
def layout_grid(index: int, columns: int, x0: int, y0: int) -> tuple[int, int]:
col = index % columns
row = index // columns
x = x0 + col * (NODE_WIDTH + X_GAP)
y = y0 + row * (NODE_HEIGHT + Y_GAP)
return x, y
def compute_group_dimensions(item_count: int, columns: int) -> tuple[int, int]:
rows = max(1, (max(item_count, 1) - 1) // columns + 1)
width = columns * NODE_WIDTH + (columns - 1) * X_GAP + 120
height = rows * NODE_HEIGHT + (rows - 1) * Y_GAP + 140
return width, height
def compute_argument_group_dimensions(item_count: int, columns: int) -> tuple[int, int]:
rows = max(1, (max(item_count, 1) - 1) // columns + 1)
width = columns * ARG_NODE_WIDTH + (columns - 1) * X_GAP + 120
height = rows * ARG_NODE_HEIGHT + (rows - 1) * Y_GAP + 140
return width, height
def argument_node(kind: str, label: str, x: int, y: int) -> dict[str, Any]:
return {
"id": note_id(kind, label),
"type": "text",
"x": x,
"y": y,
"width": ARG_NODE_WIDTH,
"height": ARG_NODE_HEIGHT,
"text": label,
"color": {"claim": "4", "method": "2", "gap": "6"}.get(kind, "5"),
}
def render_canvas(project_title: str, papers: list[NoteRecord], knowledge: list[NoteRecord]) -> dict[str, Any]:
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
node_lookup: dict[str, str] = {}
claim_lookup: dict[str, str] = {}
method_lookup: dict[str, str] = {}
gap_lookup: dict[str, str] = {}
knowledge_rows = max(1, (max(len(knowledge), 1) - 1) // KNOWLEDGE_COLUMNS + 1)
knowledge_group_width = KNOWLEDGE_COLUMNS * NODE_WIDTH + (KNOWLEDGE_COLUMNS - 1) * X_GAP + 120
knowledge_group_height = knowledge_rows * NODE_HEIGHT + (knowledge_rows - 1) * Y_GAP + 140
knowledge_y = 220
argument_y = knowledge_y + knowledge_group_height + 180
nodes.append(
text_node(
"literature-summary",
(
f"# Literature Canvas\\n\\n"
f"Project: {project_title}\\n\\n"
f"- This is an argument map with `paper + claim + method + gap`.\\n"
f"- The graph is intentionally thinned: each paper only keeps its most important claim / method links.\\n"
f"- Papers are grouped by subfield and spaced out to reduce crossing lines."
),
0,
0,
1080,
150,
)
)
nodes.append(group_node("knowledge-group", "Knowledge", -40, knowledge_y - 40, knowledge_group_width, knowledge_group_height, "4"))
for idx, note in enumerate(knowledge):
x, y = layout_grid(idx, KNOWLEDGE_COLUMNS, 20, knowledge_y)
node = file_node(note, x, y, "4")
nodes.append(node)
node_lookup[note.note_relpath[:-3]] = node["id"]
claim_labels: list[str] = []
method_labels: list[str] = []
gap_labels: list[str] = []
for note in (*papers, *knowledge):
for label in note.claim_refs:
if label not in claim_labels:
claim_labels.append(label)
for label in note.method_refs:
if label not in method_labels:
method_labels.append(label)
for label in note.gap_refs:
if label not in gap_labels:
gap_labels.append(label)
claim_group_width, claim_group_height = compute_argument_group_dimensions(len(claim_labels), 2)
method_group_width, method_group_height = compute_argument_group_dimensions(len(method_labels), 2)
gap_group_width, gap_group_height = compute_argument_group_dimensions(len(gap_labels), 2)
argument_row_height = max(claim_group_height, method_group_height, gap_group_height)
argument_groups = [
("claim", "Claims", claim_labels, -40, argument_y, claim_group_width, claim_group_height, "4"),
("method", "Methods", method_labels, -40 + claim_group_width + ARG_GROUP_X_GAP, argument_y, method_group_width, method_group_height, "2"),
("gap", "Gaps", gap_labels, -40 + claim_group_width + ARG_GROUP_X_GAP + method_group_width + ARG_GROUP_X_GAP, argument_y, gap_group_width, gap_group_height, "6"),
]
for kind, label, labels, group_x, group_y, group_width, group_height, color in argument_groups:
nodes.append(group_node(f"{kind}-group", label, group_x, group_y, group_width, group_height, color))
for idx, arg_label in enumerate(labels):
x, y = layout_grid(idx, 2, group_x + 60, group_y + 40)
node = argument_node(kind, arg_label, x, y)
nodes.append(node)
if kind == "claim":
claim_lookup[arg_label] = node["id"]
elif kind == "method":
method_lookup[arg_label] = node["id"]
else:
gap_lookup[arg_label] = node["id"]
paper_y = argument_y + argument_row_height + 180
grouped_papers: dict[str, list[NoteRecord]] = {}
for note in papers:
grouped_papers.setdefault(note.subfield or "other", []).append(note)
active_groups = [key for key in SUBFIELD_ORDER if grouped_papers.get(key)]
if not active_groups:
active_groups = ["other"]
grouped_papers["other"] = list(papers)
group_layout_meta: list[tuple[str, int, int, int, int]] = []
for idx, subfield in enumerate(active_groups):
notes_in_group = grouped_papers[subfield]
group_width, group_height = compute_group_dimensions(len(notes_in_group), 2)
col = idx % SUBFIELD_COLUMNS
row = idx // SUBFIELD_COLUMNS
group_x = -40 + col * (group_width + GROUP_X_GAP)
group_y = paper_y + row * (group_height + GROUP_Y_GAP)
group_layout_meta.append((subfield, group_x, group_y, group_width, group_height))
nodes.append(
group_node(
f"paper-group-{subfield}",
SUBFIELD_LABELS.get(subfield, subfield.replace("-", " ").title()),
group_x,
group_y,
group_width,
group_height,
SUBFIELD_COLORS.get(subfield, "2"),
)
)
for note_idx, note in enumerate(notes_in_group):
x, y = layout_grid(note_idx, 2, group_x + 60, group_y + 40)
node = file_node(note, x, y, SUBFIELD_COLORS.get(subfield, "2"))
nodes.append(node)
node_lookup[note.note_relpath[:-3]] = node["id"]
seen_edges: set[tuple[str, str, str]] = set()
for note in papers:
source = node_lookup.get(note.note_relpath[:-3])
if not source:
continue
explicit_relationships = list(note.paper_relationships)
if not explicit_relationships:
explicit_relationships = [(target, "related") for target in note.related_papers]
for target, label in explicit_relationships[:PAPER_RELATION_LIMIT]:
target_node = node_lookup.get(target)
if not target_node:
continue
marker = (source, target_node, label)
if marker not in seen_edges:
edges.append(make_edge(source, target_node, label))
seen_edges.add(marker)
for claim in note.claim_refs[:PAPER_CLAIM_LIMIT]:
target_node = claim_lookup.get(claim)
if not target_node:
continue
marker = (source, target_node, "supports")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "supports"))
seen_edges.add(marker)
for method in note.method_refs[:PAPER_METHOD_LIMIT]:
target_node = method_lookup.get(method)
if not target_node:
continue
marker = (source, target_node, "uses")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "uses"))
seen_edges.add(marker)
for note in knowledge:
source = node_lookup.get(note.note_relpath[:-3])
if not source:
continue
for target in note.linked_papers:
target_node = node_lookup.get(target)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for claim in note.claim_refs:
target_node = claim_lookup.get(claim)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for method in note.method_refs:
target_node = method_lookup.get(method)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for gap in note.gap_refs:
target_node = gap_lookup.get(gap)
if not target_node:
continue
marker = (source, target_node, "summarizes")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "summarizes"))
seen_edges.add(marker)
for claim, method, label in note.claim_method_links:
source_node = claim_lookup.get(claim)
target_node = method_lookup.get(method)
if not source_node or not target_node:
continue
marker = (source_node, target_node, label)
if marker not in seen_edges:
edges.append(make_edge(source_node, target_node, label))
seen_edges.add(marker)
for method, gap, label in note.method_gap_links:
source_node = method_lookup.get(method)
target_node = gap_lookup.get(gap)
if not source_node or not target_node:
continue
marker = (source_node, target_node, label)
if marker not in seen_edges:
edges.append(make_edge(source_node, target_node, label))
seen_edges.add(marker)
for target in note.wikilinks:
if not target.startswith("Knowledge/"):
continue
target_node = node_lookup.get(target)
if not target_node:
continue
marker = (source, target_node, "relates")
if marker not in seen_edges:
edges.append(make_edge(source, target_node, "relates"))
seen_edges.add(marker)
return {"nodes": nodes, "edges": edges}
def main() -> None:
args = parse_args()
project_kb = load_project_kb_module()
repo_root = project_kb.find_repo_root(Path(args.cwd).resolve())
binding = project_kb.resolve_binding(repo_root, args.project_id or None)
papers = [
note
for note in collect_notes(binding.project_root / "Sources" / "Papers", "Papers", binding.vault_path, PAPER_LIMIT)
if note.canvas_visibility != "hidden"
]
all_knowledge = collect_notes(
binding.project_root / "Knowledge", "Knowledge", binding.vault_path, KNOWLEDGE_LIMIT * 3
)
knowledge = select_knowledge_notes(all_knowledge, papers)
canvas = render_canvas(project_kb.titleize_slug(binding.project_id), papers, knowledge)
output_path = binding.project_root / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(canvas, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
json.dumps(
{
"project_id": binding.project_id,
"output": str(output_path),
"paper_count": len(papers),
"knowledge_count": len(knowledge),
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PAPER_LIMIT = 48
EDGE_LIMIT = 160
WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]")
LIST_KEYS = {
'authors', 'keywords', 'concepts', 'methods',
'related_papers', 'linked_experiments', 'linked_results'
}
@dataclass(frozen=True)
class PaperNote:
title: str
note_relpath: str
file_name: str
concepts: tuple[str, ...]
methods: tuple[str, ...]
related_papers: tuple[str, ...]
linked_experiments: tuple[str, ...]
linked_results: tuple[str, ...]
wikilinks: tuple[str, ...]
def load_project_kb_module() -> Any:
script_dir = Path(__file__).resolve().parents[2] / 'obsidian-project-kb-core' / 'scripts'
sys.path.insert(0, str(script_dir))
import kb_common # type: ignore
return kb_common
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Build a literature graph note for the bound Obsidian project.')
parser.add_argument('--cwd', default='.')
parser.add_argument('--project-id', default='')
parser.add_argument('--output', default='Knowledge/Literature-Map.md')
return parser.parse_args()
def parse_frontmatter(text: str) -> dict[str, Any]:
if not text.startswith('---\n'):
return {}
end = text.find('\n---\n', 4)
if end == -1:
return {}
lines = text[4:end].splitlines()
data: dict[str, Any] = {}
current_key: str | None = None
for raw_line in lines:
if not raw_line.strip():
continue
if raw_line.startswith(' - ') or raw_line.startswith('- '):
if current_key and current_key in LIST_KEYS:
data.setdefault(current_key, []).append(raw_line.split('- ', 1)[1].strip().strip('"'))
continue
if ':' not in raw_line:
current_key = None
continue
key, value = raw_line.split(':', 1)
key = key.strip()
value = value.strip()
current_key = key
if not value:
if key in LIST_KEYS:
data[key] = []
else:
data[key] = ''
continue
data[key] = value.strip('"')
return data
def body_without_frontmatter(text: str) -> str:
if not text.startswith('---\n'):
return text
end = text.find('\n---\n', 4)
if end == -1:
return text
return text[end + 5:]
def normalize_note_target(value: str) -> str:
cleaned = value.strip()
if cleaned.endswith('.md'):
cleaned = cleaned[:-3]
if cleaned.startswith('./'):
cleaned = cleaned[2:]
return cleaned
def mermaid_id(prefix: str, value: str) -> str:
slug = re.sub(r'[^a-zA-Z0-9]+', '_', value).strip('_').lower()
if not slug:
slug = 'node'
return f'{prefix}_{slug[:48]}'
def extract_wikilinks(text: str) -> tuple[str, ...]:
seen: list[str] = []
for match in WIKILINK_RE.findall(text):
target = normalize_note_target(match)
if target not in seen:
seen.append(target)
return tuple(seen)
def collect_paper_notes(project_root: Path) -> list[PaperNote]:
papers_dir = project_root / 'Sources' / 'Papers'
notes: list[PaperNote] = []
if not papers_dir.exists():
return notes
for path in sorted(papers_dir.glob('*.md'))[:PAPER_LIMIT]:
text = path.read_text(encoding='utf-8')
frontmatter = parse_frontmatter(text)
body = body_without_frontmatter(text)
title = str(frontmatter.get('title') or path.stem.replace('-', ' '))
notes.append(
PaperNote(
title=title,
note_relpath=f'Sources/Papers/{path.name}',
file_name=path.name,
concepts=tuple(frontmatter.get('concepts', [])),
methods=tuple(frontmatter.get('methods', [])),
related_papers=tuple(normalize_note_target(v) for v in frontmatter.get('related_papers', [])),
linked_experiments=tuple(normalize_note_target(v) for v in frontmatter.get('linked_experiments', [])),
linked_results=tuple(normalize_note_target(v) for v in frontmatter.get('linked_results', [])),
wikilinks=extract_wikilinks(body),
)
)
return notes
def render_mermaid(notes: list[PaperNote]) -> str:
lines: list[str] = ['graph LR']
paper_index = {note.note_relpath[:-3]: note for note in notes}
seen_edges: set[tuple[str, str, str]] = set()
for note in notes:
paper_node = mermaid_id('paper', note.file_name)
lines.append(f' {paper_node}["{note.title}"]')
for concept in note.concepts:
concept_node = mermaid_id('concept', concept)
lines.append(f' {concept_node}(("{concept}"))')
edge = (paper_node, concept_node, 'concept')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|concept| {concept_node}')
seen_edges.add(edge)
for method in note.methods:
method_node = mermaid_id('method', method)
lines.append(f' {method_node}{{"{method}"}}')
edge = (paper_node, method_node, 'method')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|method| {method_node}')
seen_edges.add(edge)
related_targets = set(note.related_papers)
related_targets.update(target for target in note.wikilinks if target.startswith('Sources/Papers/'))
for target in sorted(related_targets):
target_note = paper_index.get(target)
if not target_note:
continue
target_node = mermaid_id('paper', target_note.file_name)
edge = (paper_node, target_node, 'related')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|related| {target_node}')
seen_edges.add(edge)
for experiment in note.linked_experiments:
exp_node = mermaid_id('experiment', experiment)
label = experiment.split('/')[-1]
lines.append(f' {exp_node}["{label}"]')
edge = (paper_node, exp_node, 'experiment')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|experiment| {exp_node}')
seen_edges.add(edge)
for result in note.linked_results:
result_node = mermaid_id('result', result)
label = result.split('/')[-1]
lines.append(f' {result_node}["{label}"]')
edge = (paper_node, result_node, 'result')
if edge not in seen_edges and len(seen_edges) < EDGE_LIMIT:
lines.append(f' {paper_node} -->|result| {result_node}')
seen_edges.add(edge)
deduped: list[str] = []
seen_lines: set[str] = set()
for line in lines:
if line not in seen_lines:
deduped.append(line)
seen_lines.add(line)
return '\n'.join(deduped)
def render_map_note(project_id: str, notes: list[PaperNote], mermaid_graph: str, updated: str) -> str:
paper_bullets = '\n'.join(f'- [[{note.note_relpath[:-3]}]]' for note in notes) or '- No paper notes yet.'
concept_counts: dict[str, int] = {}
method_counts: dict[str, int] = {}
for note in notes:
for concept in note.concepts:
concept_counts[concept] = concept_counts.get(concept, 0) + 1
for method in note.methods:
method_counts[method] = method_counts.get(method, 0) + 1
concept_lines = '\n'.join(
f'- {name} ({count})' for name, count in sorted(concept_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:12]
) or '- No concept clusters recorded yet.'
method_lines = '\n'.join(
f'- {name} ({count})' for name, count in sorted(method_counts.items(), key=lambda kv: (-kv[1], kv[0]))[:12]
) or '- No method clusters recorded yet.'
return f'''---
type: knowledge
title: Literature Map
project: {project_id}
updated: {updated}
---
# Literature Map
## Purpose
- Provide a lightweight literature knowledge map for the current project.
- Help navigate paper notes, concept clusters, and downstream experiment/result hooks.
## Paper notes included
{paper_bullets}
## Concept clusters
{concept_lines}
## Method clusters
{method_lines}
## Graph
```mermaid
{mermaid_graph}
```
## How to use
- Open the linked paper notes above for detailed reading notes.
- Use Obsidian backlinks/local graph on top of these wikilinks.
- Refresh this note after a batch Zotero ingestion or major paper-note update.
'''
def main() -> None:
args = parse_args()
project_kb = load_project_kb_module()
repo_root = project_kb.find_repo_root(Path(args.cwd).resolve())
binding = project_kb.resolve_binding(repo_root, args.project_id or None)
notes = collect_paper_notes(binding.project_root)
output_rel = args.output if args.output.endswith('.md') else f'{args.output}.md'
output_path = binding.project_root / output_rel
mermaid_graph = render_mermaid(notes)
content = render_map_note(binding.project_id, notes, mermaid_graph, project_kb.now_iso())
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content.rstrip() + '\n', encoding='utf-8')
print(output_path)
if __name__ == '__main__':
main()