diff --git a/codenib/wiki/__init__.py b/codenib/wiki/__init__.py index e3e0e40c2..cc5df750f 100644 --- a/codenib/wiki/__init__.py +++ b/codenib/wiki/__init__.py @@ -24,6 +24,7 @@ get_visual_evidence, search_visual_context, ) +from .media_pipeline import build_multimodal_repository_knowledge from .media_vlm import OpenAICompatibleVisualFactExtractor __all__ = [ @@ -31,6 +32,7 @@ "OpenAICompatibleVisualFactExtractor", "build_media_evidence_pack", "build_multimodal_knowledge_view", + "build_multimodal_repository_knowledge", "build_visual_facts_manifest", "deterministic_visual_facts", "discover_media_manifest", diff --git a/codenib/wiki/media_pipeline.py b/codenib/wiki/media_pipeline.py new file mode 100644 index 000000000..e9087d431 --- /dev/null +++ b/codenib/wiki/media_pipeline.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end construction for multimodal repository knowledge.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from ..repository_source_selection import ( + DEFAULT_REPOSITORY_SOURCE_SELECTION, + RepositorySourceSelection, +) +from .media_artifacts import discover_media_manifest +from .media_facts import VisualFactExtractor, build_visual_facts_manifest +from .media_grounding import ( + discover_source_symbol_candidates, + ground_visual_facts_to_sources, +) +from .media_knowledge import build_multimodal_knowledge_view + + +def build_multimodal_repository_knowledge( + repo_path: str | Path, + *, + commit: str | None = None, + exclude_roots: tuple[str | Path, ...] = (), + selection: RepositorySourceSelection = DEFAULT_REPOSITORY_SOURCE_SELECTION, + extractor: VisualFactExtractor | None = None, + max_artifacts: int = 4096, + max_source_candidates: int = 8192, +) -> dict[str, Any]: + """Build the deterministic multimodal repository knowledge bundle.""" + + media_manifest = discover_media_manifest( + repo_path, + commit=commit, + exclude_roots=exclude_roots, + selection=selection, + max_artifacts=max_artifacts, + ) + facts_kwargs: dict[str, Any] = {} + if extractor is not None: + facts_kwargs["extractor"] = extractor + visual_facts_manifest = build_visual_facts_manifest(media_manifest, **facts_kwargs) + source_candidates = discover_source_symbol_candidates( + repo_path, + exclude_roots=exclude_roots, + selection=selection, + max_candidates=max_source_candidates, + ) + grounding_manifest = ground_visual_facts_to_sources( + visual_facts_manifest, + source_candidates, + ) + knowledge_view = build_multimodal_knowledge_view( + media_manifest, + visual_facts_manifest, + grounding_manifest, + ) + return { + "media_manifest": media_manifest, + "visual_facts_manifest": visual_facts_manifest, + "source_candidate_count": len(source_candidates), + "grounding_manifest": grounding_manifest, + "knowledge_view": knowledge_view, + } + + +__all__ = ["build_multimodal_repository_knowledge"] diff --git a/docs/experiments/multimodal_repository_knowledge.md b/docs/experiments/multimodal_repository_knowledge.md index e3224a025..04723e3e8 100644 --- a/docs/experiments/multimodal_repository_knowledge.md +++ b/docs/experiments/multimodal_repository_knowledge.md @@ -105,6 +105,22 @@ print(view["entry_count"]) This creates a deterministic local view. A VLM extractor can be added later by passing a custom extractor into `build_visual_facts_manifest()`. +For callers that want the full deterministic pipeline in one step: + +```python +from codenib.wiki import build_multimodal_repository_knowledge + +bundle = build_multimodal_repository_knowledge(repo) +view = bundle["knowledge_view"] +``` + +The same deterministic bundle can be written from the command line: + +```text +python scripts/build_multimodal_knowledge.py /path/to/repository \ + --output /tmp/multimodal-knowledge.json +``` + ```python from codenib.wiki import OpenAICompatibleVisualFactExtractor diff --git a/scripts/build_multimodal_knowledge.py b/scripts/build_multimodal_knowledge.py new file mode 100644 index 000000000..34bf55e32 --- /dev/null +++ b/scripts/build_multimodal_knowledge.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +"""Build a deterministic multimodal repository knowledge bundle.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from codenib.wiki import build_multimodal_repository_knowledge + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo", help="Repository root to scan") + parser.add_argument( + "--output", + "-o", + required=True, + help="Path to write the multimodal knowledge bundle JSON", + ) + parser.add_argument( + "--commit", + default=None, + help="Optional commit identity to record in the media manifest", + ) + parser.add_argument( + "--max-artifacts", + type=int, + default=4096, + help="Maximum media artifacts to include", + ) + parser.add_argument( + "--max-source-candidates", + type=int, + default=8192, + help="Maximum source-symbol candidates to consider for grounding", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + bundle = build_multimodal_repository_knowledge( + args.repo, + commit=args.commit, + max_artifacts=args.max_artifacts, + max_source_candidates=args.max_source_candidates, + ) + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + json.dumps(bundle, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + counts = { + "media_artifacts": bundle["media_manifest"]["artifact_count"], + "visual_fact_packs": bundle["visual_facts_manifest"]["fact_count"], + "source_candidates": bundle["source_candidate_count"], + "visual_code_bindings": bundle["grounding_manifest"]["binding_count"], + "knowledge_entries": bundle["knowledge_view"]["entry_count"], + } + print(json.dumps(counts, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/scripts/test_build_multimodal_knowledge.py b/test/scripts/test_build_multimodal_knowledge.py new file mode 100644 index 000000000..e7e6f4faa --- /dev/null +++ b/test/scripts/test_build_multimodal_knowledge.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2025-2026 CodeNib Contributors +# +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import json +import subprocess +import sys + + +def test_build_multimodal_knowledge_script_writes_bundle(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "docs").mkdir() + (repo / "src").mkdir() + (repo / "docs" / "architecture.svg").write_text( + "WikiService", + encoding="utf-8", + ) + (repo / "README.md").write_text( + "![WikiService architecture](docs/architecture.svg)", + encoding="utf-8", + ) + (repo / "src" / "wiki.py").write_text( + "class WikiService: pass", + encoding="utf-8", + ) + output = tmp_path / "bundle.json" + + completed = subprocess.run( + [ + sys.executable, + "scripts/build_multimodal_knowledge.py", + str(repo), + "--output", + str(output), + "--commit", + "abc123", + ], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + + counts = json.loads(completed.stdout) + bundle = json.loads(output.read_text(encoding="utf-8")) + assert counts["media_artifacts"] == 1 + assert counts["knowledge_entries"] == 1 + assert bundle["media_manifest"]["commit"] == "abc123" + assert bundle["knowledge_view"]["entry_count"] == 1 diff --git a/test/wiki/test_media_pipeline.py b/test/wiki/test_media_pipeline.py index cfc9b59cb..b8aef1bdf 100644 --- a/test/wiki/test_media_pipeline.py +++ b/test/wiki/test_media_pipeline.py @@ -6,6 +6,7 @@ from codenib.wiki import ( build_multimodal_knowledge_view, + build_multimodal_repository_knowledge, build_visual_facts_manifest, discover_media_manifest, discover_source_symbol_candidates, @@ -57,3 +58,24 @@ def test_multimodal_repository_knowledge_pipeline(tmp_path): assert view["entry_count"] == 1 assert search_visual_context(view, "IndexCompiler architecture") assert find_visual_code_links(view, "src/compiler.py", symbol="IndexCompiler") + + +def test_build_multimodal_repository_knowledge_wraps_pipeline(tmp_path): + (tmp_path / "docs").mkdir() + (tmp_path / "src").mkdir() + (tmp_path / "docs" / "diagram.png").write_bytes(b"png") + (tmp_path / "README.md").write_text( + "![WikiService diagram](docs/diagram.png)", + encoding="utf-8", + ) + (tmp_path / "src" / "wiki.py").write_text( + "class WikiService: pass", + encoding="utf-8", + ) + + bundle = build_multimodal_repository_knowledge(tmp_path, commit="abc123") + + assert bundle["media_manifest"]["artifact_count"] == 1 + assert bundle["visual_facts_manifest"]["fact_count"] == 1 + assert bundle["source_candidate_count"] >= 1 + assert bundle["knowledge_view"]["entry_count"] == 1