diff --git a/.gitignore b/.gitignore index 0a6775b2a..2df520c41 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ paper/ # macOS Finder metadata .DS_Store +docs/ +.gitnexus +.serena/ +CLAUDE.md +AGENTS.md diff --git a/graphify/__main__.py b/graphify/__main__.py index 708c45819..6b5d4969a 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -442,6 +442,23 @@ def _version_tuple(version: str) -> tuple[int, ...]: + + +def enrich_skill_install() -> None: + """Copy skill-enrich.md to ~/.claude/skills/graphify-enrich/SKILL.md.""" + skill_src = Path(__file__).parent / "skill-enrich.md" + if not skill_src.exists(): + print("error: skill-enrich.md not found in package - reinstall graphify", file=sys.stderr) + sys.exit(1) + skill_dst = Path.home() / ".claude" / "skills" / "graphify-enrich" / "SKILL.md" + skill_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(skill_src, skill_dst) + print(f" skill installed → {skill_dst}") + print() + print("Done. In Claude Code type:") + print() + print(" /graphify-enrich --index-dir ") + print() def main() -> None: @@ -649,6 +666,10 @@ def main() -> None: print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/") print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)") print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/") + print( + " enrich [--index-dir ] [--watch] [--dry-run] [--master-only] enrich INDEX.md files from graph.json" + ) + print(" enrich-skill install install /graphify-enrich skill for Claude Code") print() return diff --git a/graphify/cli.py b/graphify/cli.py index 25bf5499b..afbbebdd2 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1842,6 +1842,36 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": result = run_benchmark(graph_path, corpus_words=corpus_words) print_benchmark(result) + elif cmd == "enrich-skill": + from graphify.__main__ import enrich_skill_install + + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + enrich_skill_install() + else: + print("Usage: graphify enrich-skill install", file=sys.stderr) + sys.exit(1) + + elif cmd == "enrich": + from graphify.enrich import enrich as _enrich + + if len(sys.argv) < 3: + print("Usage: graphify enrich [--index-dir ] [--watch] [--dry-run] [--master-only]", file=sys.stderr) + sys.exit(1) + + corpus_path = Path(sys.argv[2]) + args = sys.argv[3:] + watch = "--watch" in args + dry_run = "--dry-run" in args + master_only = "--master-only" in args + index_dir = None + if "--index-dir" in args: + idx = args.index("--index-dir") + if idx + 1 < len(args): + index_dir = Path(args[idx + 1]) + + _enrich(corpus_path, index_dir=index_dir, watch=watch, dry_run=dry_run, master_only=master_only) + elif cmd == "global": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" from graphify.global_graph import ( diff --git a/graphify/enrich.py b/graphify/enrich.py new file mode 100644 index 000000000..b28255749 --- /dev/null +++ b/graphify/enrich.py @@ -0,0 +1,437 @@ +# Enrich corpus subfolders with semantic INDEX.md files derived from the knowledge graph +from __future__ import annotations +from pathlib import Path +import datetime +import networkx as nx + + +def _group_nodes_by_folder(G: nx.Graph, corpus_path: Path) -> dict[Path, list[str]]: + """Group node IDs by their immediate parent folder, excluding graphify-out/. + + Folder keys are always relative to corpus_path. Absolute source_file paths + that fall under corpus_path are made relative; others are used as-is. + """ + corpus_path = Path(corpus_path).resolve() + groups: dict[Path, list[str]] = {} + for node_id, data in G.nodes(data=True): + src = data.get("source_file", "") + if not src: + continue + p = Path(src) + if not p.parts: + continue + # Normalise absolute paths to corpus-relative + if p.is_absolute(): + try: + p = p.relative_to(corpus_path) + except ValueError: + pass # outside corpus — use as-is + folder = p.parent + if not folder.parts: + continue + if "graphify-out" in folder.parts: + continue + groups.setdefault(folder, []).append(node_id) + return groups + + +def _cross_folder_edges( + folder: Path, + node_ids: list[str], + G: nx.Graph, +) -> list[dict]: + """Return edges from nodes in `folder` that cross into other folders. + + Each unique (source, target) pair is emitted at most once. + """ + node_set = set(node_ids) + # frozenset dedup is correct for nx.Graph (undirected); would need revision for DiGraph + seen: set[frozenset] = set() + results = [] + for nid in node_ids: + for neighbor in G.neighbors(nid): + if neighbor in node_set: + continue + pair = frozenset((nid, neighbor)) + if pair in seen: + continue + seen.add(pair) + n_src = G.nodes[neighbor].get("source_file", "") + if not n_src: + continue + target_folder = Path(n_src).parent + if not target_folder.parts: + continue + edata = G.edges[nid, neighbor] + results.append({ + "source_node": nid, + "target_node": neighbor, + "target_folder": target_folder, + "relation": edata.get("relation", ""), + "confidence": edata.get("confidence", "EXTRACTED"), + }) + return results + + +def _write_subfolder_index( + folder: Path, + data: dict, + dry_run: bool = False, +) -> str | None: + """Write enriched INDEX.md for a subfolder. + + Returns the content string when dry_run=True (no file written). + Returns None after writing the file when dry_run=False. + """ + folder_path = Path(data["folder"]) + now = datetime.date.today().isoformat() + nodes = data["nodes"] + cross_edges = data.get("cross_edges", []) + summary = data.get("summary", "") + + docs = sorted({Path(n["source_file"]).name for n in nodes if n.get("source_file")}) + entities = [n["label"] for n in nodes if n.get("label")] + # Quote each entity so the YAML list is valid when parsed programmatically + entity_list = ", ".join(f'"{e}"' for e in entities[:10]) + + connected: dict[str, str] = {} + for e in cross_edges: + tf = str(e["target_folder"]) + if tf not in connected: + connected[tf] = e["relation"] + + lines = [ + "---", + f'folder: "{folder_path}"', + f'entities: [{entity_list}]', + f'last_enriched: "{now}"', + "---", + "", + f"# {folder_path.name.replace('-', ' ').replace('_', ' ').title()}", + "", + ] + + if summary: + lines += ["## What's here", "", summary, ""] + + lines += ["## Documents", ""] + for doc in docs: + lines.append(f"- {doc}") + lines.append("") + + if entities: + lines += ["## Key entities", ""] + for entity in entities[:20]: + lines.append(f"- {entity}") + lines.append("") + + if connected: + lines += ["## Connected folders", ""] + for tf, relation in connected.items(): + lines.append(f"- [[{tf}]] — `{relation}`") + lines.append("") + + content = "\n".join(lines) + + if dry_run: + return content + + index_path = folder / "INDEX.md" + index_path.write_text(content, encoding="utf-8") + return None + + +def _write_master_index( + corpus_path: Path, + folder_summaries: dict[Path, dict], + dry_run: bool = False, +) -> str | None: + """Write master INDEX.md at corpus root. + + Returns the content string when dry_run=True (no file written). + Returns None after writing the file when dry_run=False. + """ + corpus_path = Path(corpus_path) + now = datetime.date.today().isoformat() + + lines = [ + "---", + f'last_enriched: "{now}"', + f"total_folders: {len(folder_summaries)}", + "---", + "", + "# Master Index", + "", + "## Folder map", + "", + "| Folder | What's there | Key entities |", + "| --- | --- | --- |", + ] + + for folder, data in sorted(folder_summaries.items()): + summary = data.get("summary", "").replace("|", r"\|") + entities = ", ".join(e.replace("|", r"\|") for e in data.get("entities", [])[:5]) + lines.append(f"| {folder} | {summary} | {entities} |") + + lines += ["", "## Entity → Folder map", ""] + + entity_map: dict[str, list[str]] = {} + for folder, data in folder_summaries.items(): + for entity in data.get("entities", []): + entity_map.setdefault(entity, []).append(str(folder)) + + lines += ["| Entity | Folder(s) |", "| --- | --- |"] + for entity, folders in sorted(entity_map.items()): + safe_entity = entity.replace("|", r"\|") + lines.append(f"| {safe_entity} | {', '.join(folders)} |") + + lines.append("") + content = "\n".join(lines) + + if dry_run: + return content + + index_path = Path(corpus_path) / "INDEX.md" + index_path.write_text(content, encoding="utf-8") + return None + + +def _generate_summary( + folder: Path, + entities: list[str], + _mock: bool = False, +) -> str: + """Generate a 2-3 sentence summary for a folder using Claude. + + Falls back to a plain entity list if no API key is available. + Pass _mock=True in tests to skip API calls. + """ + if _mock: + name = Path(folder).name.replace("-", " ").replace("_", " ") + return f"{name.title()} folder containing: {', '.join(entities[:5])}." + + try: + import anthropic + client = anthropic.Anthropic() + entity_str = ", ".join(entities[:20]) + prompt = ( + f"Folder: {folder}\n" + f"Entities found: {entity_str}\n\n" + f"Write a 2-3 sentence plain-English summary of what this folder contains " + f"and what it is for. Be specific and factual. No bullet points." + ) + message = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=150, + messages=[{"role": "user", "content": prompt}], + ) + return message.content[0].text.strip() + except Exception: + # No API key or network error — fall back to entity list + name = Path(folder).name.replace("-", " ").replace("_", " ") + return f"{name.title()} folder containing: {', '.join(entities[:5])}." + + +def _patch_index( + existing: str, + summary: str, + entities: list[str], + cross_refs: list[dict], +) -> str: + """Patch semantic sections in an existing INDEX.md stub. + + Replaces: Summary body, Key Entities section (creates if missing), Cross-References body. + Preserves: Type, Owner, Status, Key Files, Subfolders, Open Items. + Adds: last_enriched line after Last Updated. + """ + now = datetime.date.today().isoformat() + lines = existing.splitlines() + out = [] + i = 0 + last_enriched_added = False + key_entities_written = False + + while i < len(lines): + line = lines[i] + + # Inject last_enriched after Last Updated + if line.startswith("Last Updated:") and not last_enriched_added: + out.append(line) + # Remove existing last_enriched if already there + if i + 1 < len(lines) and lines[i + 1].startswith("last_enriched:"): + i += 1 + out.append(f"last_enriched: {now}") + last_enriched_added = True + i += 1 + continue + + # Replace Summary section body + if line.strip() == "## Summary": + out.append(line) + out.append("") + out.append(summary) + out.append("") + i += 1 + # Skip old summary lines until next ## + while i < len(lines) and not lines[i].startswith("## "): + i += 1 + continue + + # Replace Cross-References section body + if line.strip() == "## Cross-References": + out.append(line) + i += 1 + # Skip old cross-ref lines until next ## + while i < len(lines) and not lines[i].startswith("## "): + i += 1 + if cross_refs: + for ref in cross_refs: + conf = ref.get("confidence", "INFERRED") + score = ref.get("confidence_score", "") + score_str = f" {score:.2f}" if isinstance(score, float) else "" + rel = ref.get("relation", "") + out.append(f"- [[{ref['target']}]] — `{rel}` [{conf}{score_str}]") + else: + out.append("- ") + out.append("") + continue + + # Drop existing Key Entities section (will re-inject at the right place) + if line.strip() == "## Key Entities": + i += 1 + while i < len(lines) and not lines[i].startswith("## "): + i += 1 + continue + + # Inject Key Entities before Subfolders, Cross-References, or Open Items + if ( + entities + and not key_entities_written + and line.startswith("## ") + and line.strip() in ("## Subfolders", "## Cross-References", "## Open Items") + ): + out.append("## Key Entities") + out.append("") + for entity in entities[:20]: + out.append(f"- {entity}") + out.append("") + key_entities_written = True + + out.append(line) + i += 1 + + # Append Key Entities at end if no suitable injection point was found + if entities and not key_entities_written: + out.append("") + out.append("## Key Entities") + out.append("") + for entity in entities[:20]: + out.append(f"- {entity}") + + return "\n".join(out) + + +def enrich( + corpus_path: Path, + graph_json_path: Path | None = None, + index_dir: Path | None = None, + watch: bool = False, + dry_run: bool = False, + master_only: bool = False, +) -> None: + """Read graph.json and write enriched INDEX.md files. + + If index_dir is provided, indexes are written there instead of into the corpus. + When index_dir contains an existing INDEX.md stub, it is patched rather than overwritten. + """ + import json + from graphify.build import build_from_json + + corpus_path = Path(corpus_path) + index_root = Path(index_dir) if index_dir else corpus_path + + if graph_json_path is None: + graph_json_path = corpus_path / "graphify-out" / "graph.json" + + if not Path(graph_json_path).exists(): + raise FileNotFoundError(f"graph.json not found at {graph_json_path}. Run graphify first.") + + data = json.loads(Path(graph_json_path).read_text()) + G = build_from_json(data) + + groups = _group_nodes_by_folder(G, corpus_path) + + folder_summaries: dict[Path, dict] = {} + + for folder, node_ids in groups.items(): + nodes = [dict(id=nid, **G.nodes[nid]) for nid in node_ids] + cross_edges = _cross_folder_edges(folder, node_ids, G) + entities = [n.get("label", "") for n in nodes if n.get("label")] + summary = _generate_summary(folder, entities) + + folder_data = { + "folder": folder, + "node_ids": node_ids, + "nodes": nodes, + "cross_edges": cross_edges, + "summary": summary, + } + folder_summaries[folder] = {"summary": summary, "entities": entities} + + if not master_only: + abs_folder = index_root / folder + if not dry_run: + abs_folder.mkdir(parents=True, exist_ok=True) + existing_index = abs_folder / "INDEX.md" + if existing_index.exists() and not dry_run: + existing = existing_index.read_text(encoding="utf-8") + content = _patch_index(existing, summary=summary, entities=entities, cross_refs=cross_edges) + existing_index.write_text(content, encoding="utf-8") + else: + _write_subfolder_index(abs_folder, folder_data, dry_run=dry_run) + + _write_master_index(index_root, folder_summaries, dry_run=dry_run) + + if watch: + _watch_and_enrich(corpus_path, Path(graph_json_path), master_only=master_only, index_dir=index_root) + + +def _watch_and_enrich( + corpus_path: Path, + graph_json_path: Path, + master_only: bool = False, + index_dir: Path | None = None, + _enrich_fn=None, + _stop_event=None, + _poll_interval: float = 5.0, +) -> None: + """Poll graph.json mtime and re-run enrichment on change. + + Runs until KeyboardInterrupt or _stop_event is set (for testing). + _enrich_fn and _stop_event are injection points for tests. + """ + import time + import threading + + if _enrich_fn is None: + _enrich_fn = lambda cp, gp, mo: enrich(cp, gp, index_dir=index_dir, watch=False, master_only=mo) + + last_mtime = Path(graph_json_path).stat().st_mtime + print(f"[graphify enrich] watching {graph_json_path} (poll every {_poll_interval}s) ...") + + try: + while True: + if _stop_event is not None and _stop_event.is_set(): + break + time.sleep(_poll_interval) + try: + mtime = Path(graph_json_path).stat().st_mtime + except OSError: + continue + if mtime != last_mtime: + last_mtime = mtime + print("[graphify enrich] graph.json updated — re-enriching ...") + _enrich_fn(corpus_path, graph_json_path, master_only) + print("[graphify enrich] done.") + except KeyboardInterrupt: + print("[graphify enrich] stopped.") diff --git a/graphify/skill-enrich.md b/graphify/skill-enrich.md new file mode 100644 index 000000000..486700b35 --- /dev/null +++ b/graphify/skill-enrich.md @@ -0,0 +1,120 @@ +--- +name: graphify-enrich +description: Enrich corpus INDEX.md files from a graphify knowledge graph — Claude Code generates folder summaries using its own model (no ANTHROPIC_API_KEY needed) +trigger: /graphify-enrich +--- + +# graphify-enrich skill + +Enrich INDEX.md files for a corpus using the graphify knowledge graph. +Claude Code generates folder summaries; graphify CLI handles all structural work. + +## Usage + +``` +/graphify-enrich [--index-dir ] [--master-only] [--dry-run] +``` + +**Arguments:** +- `corpus_path` — path to the corpus root (must contain `graphify-out/graph.json`) +- `--index-dir ` — write INDEX.md files here instead of into the corpus (recommended for keeping corpus clean) +- `--master-only` — write root INDEX.md only, skip per-folder files +- `--dry-run` — preview without writing files + +## Prerequisites + +`graphify-out/graph.json` must exist. If it does not: +```bash +graphify +``` +Run the full graphify pipeline first, then come back to this skill. +If `graph.json` exists but contains no nodes (`"nodes": []`), abort and tell the user to run `graphify ` first. + +## Workflow + +### Step 1 — Structural pass (graphify CLI) + +Run: +```bash +graphify enrich [--index-dir ] [--master-only] [--dry-run] +``` + +This writes INDEX.md files containing: +- YAML frontmatter (folder, entities, last_enriched) +- Documents list +- Key entities list +- Connected folders (cross-references) +- Summary section with plain entity-list fallback + +### Step 2 — Read the knowledge graph + +Read `/graphify-out/graph.json`. Structure: +```json +{ + "nodes": [{"id": "...", "label": "...", "source_file": "...", "file_type": "..."}], + "links": [{"source": "...", "target": "...", "relation": "...", "confidence": "..."}] +} +``` + +Group nodes by `parent folder` of their `source_file` path, relative to `corpus_path`. +Exclude any node whose `source_file` contains `graphify-out`. +You will use these folder groups in Step 3 to generate summaries, which Step 4 then patches into the INDEX.md files. + +### Step 3 — Generate folder summaries + +For each folder group, write a 2-3 sentence plain-English summary: +- What kinds of files/entities live here +- What purpose this folder serves in the corpus +- Any notable cross-folder relationships visible from the edges + +Rules: +- Be specific and factual +- No bullet points +- No hedging phrases ("appears to contain", "seems to be") +- Use the entity labels and relation types from the graph as evidence + +### Step 4 — Patch Summary sections in INDEX.md files + +For each INDEX.md written in Step 1, patch the summary: + +**Case A — file has `## What's here` section:** +Replace the body of that section with the generated summary. +Preserve the heading line. Replace everything until the next `##` heading. + +**Case B — file has `## Summary` section:** +Same as Case A — replace body, preserve heading. + +**Case C — neither section exists:** +Insert after the closing `---` of the YAML frontmatter: +``` +## What's here + + + +``` + +Do NOT touch any other section: Key Entities, Documents, Connected Folders, +Key Files, Subfolders, Cross-References, Open Items, Type, Owner, Status. + +### Step 5 — Update master INDEX.md + +Read `/INDEX.md` (or `/INDEX.md` if no --index-dir). +If `--index-dir` was not passed, `index-dir` defaults to `corpus_path`. +In the `## Folder map` table, the `What's there` column may be empty or contain +a plain entity list. Replace each cell with the one-sentence version of the +folder summary (first sentence only, truncated at the last word boundary before 100 characters if needed). + +### Step 6 — Commit + +Skip this step if `--dry-run` was passed. + +```bash +git add +git commit -m "enrich: update INDEX.md summaries" +``` + +## Notes + +- If a folder has no INDEX.md (e.g. --master-only was used), skip it silently +- If graph.json has no nodes for a folder that has an INDEX.md, leave the Summary as-is +- The graphify CLI step (Step 1) is always required even on re-runs — it updates entities and cross-refs diff --git a/pyproject.toml b/pyproject.toml index 50a7b63d8..6e8cfcec6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ include-package-data = false # under graphify/skills//references/, and the always-on injection blocks # under graphify/always_on/. There is no graphify/skills//SKILL.md in the # repo, so no SKILL.md glob is needed here. -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] +graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skill-enrich.md", "skills/*/references/*.md", "always_on/*.md"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_enrich.py b/tests/test_enrich.py new file mode 100644 index 000000000..5976702d7 --- /dev/null +++ b/tests/test_enrich.py @@ -0,0 +1,412 @@ +import networkx as nx +from pathlib import Path +from graphify.enrich import _group_nodes_by_folder + + +def _make_graph(): + G = nx.Graph() + G.add_node("a", label="DocA", source_file="clients/bridgestone/brief.md", file_type="document") + G.add_node("b", label="DocB", source_file="clients/bridgestone/contract.md", file_type="document") + G.add_node("c", label="DocC", source_file="finance/invoice.md", file_type="document") + G.add_node("d", label="DocD", source_file="graphify-out/graph.json", file_type="code") + return G + + +def test_group_nodes_by_folder_basic(): + G = _make_graph() + groups = _group_nodes_by_folder(G, Path(".")) + assert Path("clients/bridgestone") in groups + assert Path("finance") in groups + assert len(groups[Path("clients/bridgestone")]) == 2 + assert len(groups[Path("finance")]) == 1 + + +def test_group_nodes_excludes_graphify_out(): + G = _make_graph() + groups = _group_nodes_by_folder(G, Path(".")) + for folder in groups: + assert "graphify-out" not in str(folder) + + +def test_group_nodes_excludes_nodes_without_source_file(): + G = nx.Graph() + G.add_node("x", label="X", source_file="", file_type="document") + G.add_node("y", label="Y", file_type="document") + groups = _group_nodes_by_folder(G, Path(".")) + assert len(groups) == 0 + + +# --------------------------------------------------------------------------- +# Task 2: _cross_folder_edges +# --------------------------------------------------------------------------- +from graphify.enrich import _cross_folder_edges + + +def test_cross_folder_edges_finds_edges(): + G = nx.Graph() + G.add_node("a", label="A", source_file="clients/bridgestone/brief.md", file_type="document") + G.add_node("b", label="B", source_file="finance/invoice.md", file_type="document") + G.add_edge("a", "b", relation="references", confidence="EXTRACTED", _src="a", _tgt="b") + edges = _cross_folder_edges(Path("clients/bridgestone"), ["a"], G) + assert len(edges) == 1 + assert edges[0]["target_folder"] == Path("finance") + assert edges[0]["relation"] == "references" + + +def test_cross_folder_edges_ignores_same_folder(): + G = nx.Graph() + G.add_node("a", label="A", source_file="clients/bridgestone/brief.md", file_type="document") + G.add_node("b", label="B", source_file="clients/bridgestone/contract.md", file_type="document") + G.add_edge("a", "b", relation="references", confidence="EXTRACTED", _src="a", _tgt="b") + edges = _cross_folder_edges(Path("clients/bridgestone"), ["a", "b"], G) + assert len(edges) == 0 + + +# --------------------------------------------------------------------------- +# Task 3: _write_subfolder_index +# --------------------------------------------------------------------------- +from graphify.enrich import _write_subfolder_index + + +def _make_folder_data(): + return { + "folder": Path("clients/bridgestone"), + "node_ids": ["a", "b"], + "nodes": [ + {"id": "a", "label": "Contract Renewal", "source_file": "clients/bridgestone/contract.md", "file_type": "document"}, + {"id": "b", "label": "Q2 Review", "source_file": "clients/bridgestone/q2.md", "file_type": "document"}, + ], + "cross_edges": [ + {"target_folder": Path("finance"), "relation": "references", "confidence": "EXTRACTED"}, + ], + "summary": "Bridgestone engagement covering contract renewal and Q2 review.", + } + + +def test_write_subfolder_index_dry_run(tmp_path): + data = _make_folder_data() + written = _write_subfolder_index(tmp_path / "clients/bridgestone", data, dry_run=True) + assert isinstance(written, str) + assert "Contract Renewal" in written + assert not (tmp_path / "clients/bridgestone/INDEX.md").exists() + + +def test_write_subfolder_index_creates_file(tmp_path): + folder = tmp_path / "clients/bridgestone" + folder.mkdir(parents=True) + data = _make_folder_data() + _write_subfolder_index(folder, data, dry_run=False) + content = (folder / "INDEX.md").read_text() + assert "Contract Renewal" in content + assert "Q2 Review" in content + assert "finance" in content + assert "last_enriched" in content + + +def test_write_subfolder_index_lists_documents(tmp_path): + folder = tmp_path / "clients/bridgestone" + folder.mkdir(parents=True) + data = _make_folder_data() + _write_subfolder_index(folder, data, dry_run=False) + content = (folder / "INDEX.md").read_text() + assert "contract.md" in content + assert "q2.md" in content + + +# --------------------------------------------------------------------------- +# Task 4: _write_master_index +# --------------------------------------------------------------------------- +from graphify.enrich import _write_master_index + + +def test_write_master_index_creates_file(tmp_path): + folder_summaries = { + Path("clients/bridgestone"): { + "summary": "Bridgestone engagement.", + "entities": ["Contract Renewal", "Tanaka-san"], + }, + Path("finance"): { + "summary": "Finance and invoices.", + "entities": ["payment terms", "Q2 budget"], + }, + } + _write_master_index(tmp_path, folder_summaries, dry_run=False) + content = (tmp_path / "INDEX.md").read_text() + assert "clients/bridgestone" in content + assert "Contract Renewal" in content + assert "finance" in content + assert "last_enriched" in content + + +def test_write_master_index_entity_folder_map(tmp_path): + folder_summaries = { + Path("clients/bridgestone"): { + "summary": "Bridgestone.", + "entities": ["contract renewal"], + }, + Path("finance"): { + "summary": "Finance.", + "entities": ["contract renewal", "invoice"], + }, + } + _write_master_index(tmp_path, folder_summaries, dry_run=False) + content = (tmp_path / "INDEX.md").read_text() + # contract renewal appears in two folders + assert content.count("contract renewal") >= 2 + + +def test_write_master_index_dry_run(tmp_path): + result = _write_master_index(tmp_path, {}, dry_run=True) + assert not (tmp_path / "INDEX.md").exists() + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# Task 5: enrich() orchestrator +# --------------------------------------------------------------------------- +import json +from graphify.enrich import enrich +from graphify.export import to_json +from graphify.build import build_from_json +from graphify.cluster import cluster + + +def _make_graph_json(tmp_path): + extraction = { + "nodes": [ + {"id": "a", "label": "Contract", "source_file": str(tmp_path / "clients/brief.md"), "file_type": "document"}, + {"id": "b", "label": "Invoice", "source_file": str(tmp_path / "finance/inv.md"), "file_type": "document"}, + ], + "edges": [ + {"source": "a", "target": "b", "relation": "references", "confidence": "EXTRACTED", + "source_file": str(tmp_path / "clients/brief.md"), "weight": 1.0}, + ], + } + G = build_from_json(extraction) + communities = cluster(G) + out = tmp_path / "graphify-out" + out.mkdir() + to_json(G, communities, str(out / "graph.json")) + return out / "graph.json" + + +def test_enrich_creates_subfolder_indexes(tmp_path): + (tmp_path / "clients").mkdir() + (tmp_path / "finance").mkdir() + graph_json = _make_graph_json(tmp_path) + enrich(tmp_path, graph_json_path=graph_json, watch=False, dry_run=False) + assert (tmp_path / "clients" / "INDEX.md").exists() + assert (tmp_path / "finance" / "INDEX.md").exists() + + +def test_enrich_creates_master_index(tmp_path): + (tmp_path / "clients").mkdir() + graph_json = _make_graph_json(tmp_path) + enrich(tmp_path, graph_json_path=graph_json, watch=False, dry_run=False) + assert (tmp_path / "INDEX.md").exists() + + +def test_enrich_dry_run_no_writes(tmp_path): + (tmp_path / "clients").mkdir() + graph_json = _make_graph_json(tmp_path) + enrich(tmp_path, graph_json_path=graph_json, watch=False, dry_run=True) + assert not (tmp_path / "clients" / "INDEX.md").exists() + assert not (tmp_path / "INDEX.md").exists() + + +def test_enrich_master_only(tmp_path): + (tmp_path / "clients").mkdir() + graph_json = _make_graph_json(tmp_path) + enrich(tmp_path, graph_json_path=graph_json, watch=False, dry_run=False, master_only=True) + assert (tmp_path / "INDEX.md").exists() + assert not (tmp_path / "clients" / "INDEX.md").exists() + + +# --------------------------------------------------------------------------- +# Task 6: _watch_and_enrich +# --------------------------------------------------------------------------- +import time +import threading +from graphify.enrich import _watch_and_enrich + + +def test_watch_and_enrich_triggers_on_change(tmp_path): + (tmp_path / "clients").mkdir() + graph_json = _make_graph_json(tmp_path) + + triggered = [] + + def fake_enrich(corpus_path, graph_json_path, master_only): + triggered.append(1) + + stop = threading.Event() + + def run(): + _watch_and_enrich( + tmp_path, + graph_json, + master_only=False, + _enrich_fn=fake_enrich, + _stop_event=stop, + _poll_interval=0.1, + ) + + t = threading.Thread(target=run, daemon=True) + t.start() + time.sleep(0.2) + graph_json.touch() # simulate graph.json update + time.sleep(0.5) + stop.set() + t.join(timeout=2) + + assert len(triggered) >= 1 + + +# --------------------------------------------------------------------------- +# Task 7: CLI wiring +# --------------------------------------------------------------------------- +import subprocess +import sys + + +def test_cli_enrich_help(): + result = subprocess.run( + [sys.executable, "-m", "graphify", "--help"], + capture_output=True, text=True + ) + assert "enrich" in result.stdout + + +# --------------------------------------------------------------------------- +# Task 8: _generate_summary +# --------------------------------------------------------------------------- +from graphify.enrich import _generate_summary + + +def test_generate_summary_returns_string(): + entities = ["Contract Renewal", "Tanaka-san", "Q2 Review"] + folder = Path("clients/bridgestone") + summary = _generate_summary(folder, entities, _mock=True) + assert isinstance(summary, str) + assert len(summary) > 0 + + +def test_generate_summary_mock_contains_folder_name(): + entities = ["DocA"] + folder = Path("clients/bridgestone") + summary = _generate_summary(folder, entities, _mock=True) + assert "bridgestone" in summary.lower() + + +# --------------------------------------------------------------------------- +# Task 9: _patch_index +# --------------------------------------------------------------------------- +from graphify.enrich import _patch_index + + +STUB = """# BrewNexus Index +Type: #project #active +Owner: #ballu +Status: #active +Last Updated: 2025-09-02 + +## Summary +Delivery engagement — briefs, deliverables, and correspondence. Contains 5 files. + +## Key Files +| File | Description | Date | +|------|-------------|------| +| [[BrewNexus/doc|doc]] | PDF | 2025-08-22 | + +## Subfolders +- `sub1/` + +## Cross-References +- + +## Open Items +- [ ] Add cross-references to related folders +""" + + +def test_patch_index_preserves_owner_and_type(): + result = _patch_index(STUB, summary="New summary.", entities=[], cross_refs=[]) + assert "#ballu" in result + assert "#project #active" in result + + +def test_patch_index_replaces_summary(): + result = _patch_index(STUB, summary="Updated summary.", entities=[], cross_refs=[]) + assert "Updated summary." in result + assert "Delivery engagement" not in result + + +def test_patch_index_adds_key_entities_section(): + result = _patch_index(STUB, summary="S.", entities=["ML Analytics", "Brewcrafts"], cross_refs=[]) + assert "## Key Entities" in result + assert "ML Analytics" in result + + +def test_patch_index_replaces_cross_references(): + refs = [{"target": "DataChamps/INDEX", "relation": "shared_client", "confidence": "EXTRACTED"}] + result = _patch_index(STUB, summary="S.", entities=[], cross_refs=refs) + assert "DataChamps/INDEX" in result + assert "