From 7cb3db90a9d041eda113337b02dc4b18fbe3dfa6 Mon Sep 17 00:00:00 2001 From: Mandar Gite Date: Thu, 9 Apr 2026 18:02:49 +0530 Subject: [PATCH 1/7] feat: add graphify enrich command Adds graphify enrich command with 10 tasks implemented: - _group_nodes_by_folder, _cross_folder_edges, _write_subfolder_index, _write_master_index - enrich() orchestrator, _watch_and_enrich, CLI wiring - Claude-generated summaries with graceful fallback - _patch_index for patching existing stubs - --index-dir flag for writing to a separate target directory 374 tests pass. Two code review passes completed. --- .gitignore | 5 + graphify/__main__.py | 3 + graphify/cli.py | 20 ++ graphify/enrich.py | 437 +++++++++++++++++++++++++++++++++++++++++++ tests/test_enrich.py | 412 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 877 insertions(+) create mode 100644 graphify/enrich.py create mode 100644 tests/test_enrich.py 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..455d89480 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -649,6 +649,9 @@ 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() return diff --git a/graphify/cli.py b/graphify/cli.py index 25bf5499b..0c12ab6f7 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1842,6 +1842,26 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": result = run_benchmark(graph_path, corpus_words=corpus_words) print_benchmark(result) + 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/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 "