From 5d1f4c9c2fa0f8e9d867c0c9d01c4b15a1c30d09 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Wed, 8 Jul 2026 13:57:15 +0530 Subject: [PATCH 01/10] Rag content --- src/spawn/templates/rag/__init__.py | 1 + src/spawn/templates/rag/content.py | 340 ++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 src/spawn/templates/rag/__init__.py create mode 100644 src/spawn/templates/rag/content.py diff --git a/src/spawn/templates/rag/__init__.py b/src/spawn/templates/rag/__init__.py new file mode 100644 index 0000000..49a0292 --- /dev/null +++ b/src/spawn/templates/rag/__init__.py @@ -0,0 +1 @@ +# RAG template diff --git a/src/spawn/templates/rag/content.py b/src/spawn/templates/rag/content.py new file mode 100644 index 0000000..5b36bed --- /dev/null +++ b/src/spawn/templates/rag/content.py @@ -0,0 +1,340 @@ +INIT_CONTENT = "" + +# ─── Knowledge base ─────────────────────────────────────────────────────── + +SAMPLE_KNOWLEDGE_CONTENT = """\ +# Spawn Knowledge Base + +## What is Spawn? + +Spawn is an intent-based project generator that helps developers create +production-friendly project foundations in seconds. Instead of manually +creating folders, installing dependencies, and configuring tools, developers +run one command and get a complete, immediately runnable project. + +## Spawn Intents + +Spawn supports the following project intents: + +- Backend API: FastAPI, Flask, or Django with production structure +- CLI Application: Typer, Click, or Argparse command-line tools +- Automation Tool: Workflow-based automation with logging +- AI Chatbot: Conversational AI with PydanticAI, OpenAI SDK, or LiteLLM +- AI Agent: Tool-calling agents with PydanticAI or OpenAI Agents SDK +- RAG System: Retrieval-Augmented Generation with LlamaIndex and ChromaDB + +## How Spawn Works + +Run `spawn create`, select your intent, choose your framework and provider, +pick any extras (ruff, pytest, github-actions), and Spawn generates the full +project structure, installs dependencies via uv, and shows you the exact +commands to run next. The entire setup takes under 30 seconds. + +## RAG System Intent + +The RAG System intent (added in v0.8.0) generates a complete +Retrieval-Augmented Generation application using LlamaIndex and ChromaDB. +It ingests documents from the data/ directory, creates embeddings using +OpenAI's text-embedding-3-small model, stores them in a local ChromaDB +vector database, and answers questions using retrieved context. On first +run the ingestion step is triggered automatically before the Q&A loop starts. + +## Version History + +- v0.6.0: Initial release with Backend API, CLI Application, Automation Tool, + and AI Chatbot intents. +- v0.7.0: Added AI Agent intent with PydanticAI and OpenAI Agents SDK support, + plus Groq provider across all AI intents. +- v0.8.0: Added RAG System intent with LlamaIndex, ChromaDB, and OpenAI. +""" + +# ─── Config ─────────────────────────────────────────────────────────────── + +SETTINGS_CONTENT = """\ +import os +from pathlib import Path + +from dotenv import load_dotenv +from llama_index.core import Settings +from llama_index.embeddings.openai import OpenAIEmbedding +from llama_index.llms.openai import OpenAI + + +def load_env() -> None: + load_dotenv() + + +def configure_llm() -> None: + api_key = os.getenv("OPENAI_API_KEY", "") + model = os.getenv("OPENAI_MODEL", "gpt-4o-mini") + Settings.llm = OpenAI(model=model, api_key=api_key) + Settings.embed_model = OpenAIEmbedding( + model="text-embedding-3-small", + api_key=api_key, + ) +""" + +# ─── Knowledge index ────────────────────────────────────────────────────── + +KNOWLEDGE_INDEX_CONTENT = """\ +from pathlib import Path + +import chromadb +from llama_index.core import StorageContext +from llama_index.vector_stores.chroma import ChromaVectorStore + +CHROMA_PATH = Path("chroma_db") +COLLECTION_NAME = "{project_name}" + + +def get_vector_store() -> ChromaVectorStore: + client = chromadb.PersistentClient(path=str(CHROMA_PATH)) + collection = client.get_or_create_collection(COLLECTION_NAME) + return ChromaVectorStore(chroma_collection=collection) + + +def get_storage_context() -> StorageContext: + return StorageContext.from_defaults(vector_store=get_vector_store()) + + +def index_exists() -> bool: + if not CHROMA_PATH.exists(): + return False + return any(CHROMA_PATH.iterdir()) +""" + +# ─── Ingestion ──────────────────────────────────────────────────────────── + +INGESTION_CONTENT = """\ +from pathlib import Path + +from llama_index.core import SimpleDirectoryReader, VectorStoreIndex + +from src.knowledge.index import get_storage_context + +DATA_PATH = Path("data") + + +def ingest_documents() -> VectorStoreIndex: + print("Ingesting documents from data/...") + documents = SimpleDirectoryReader( + input_dir=str(DATA_PATH), + required_exts=[".txt", ".md"], + recursive=True, + ).load_data() + print(f"Loaded {{len(documents)}} document(s).") + storage_context = get_storage_context() + index = VectorStoreIndex.from_documents( + documents, + storage_context=storage_context, + show_progress=True, + ) + print("Ingestion complete. Index stored in chroma_db/") + return index +""" + +# ─── Retrieval ──────────────────────────────────────────────────────────── + +RETRIEVAL_CONTENT = """\ +from llama_index.core import VectorStoreIndex + +from src.knowledge.index import get_storage_context, get_vector_store + + +def load_index() -> VectorStoreIndex: + vector_store = get_vector_store() + storage_context = get_storage_context() + return VectorStoreIndex.from_vector_store( + vector_store, + storage_context=storage_context, + ) + + +def answer_question(question: str) -> str: + index = load_index() + query_engine = index.as_query_engine( + similarity_top_k=3, + ) + response = query_engine.query(question) + return str(response) +""" + +# ─── Main ───────────────────────────────────────────────────────────────── + +MAIN_CONTENT = """\ +from src.config.settings import configure_llm, load_env +from src.ingestion.ingest import ingest_documents +from src.knowledge.index import index_exists +from src.retrieval.retrieve import answer_question + + +def main() -> None: + load_env() + configure_llm() + print("{project_name} RAG System") + print("-" * 40) + if not index_exists(): + print("No index found. Running ingestion...\\n") + ingest_documents() + print() + print("Ask a question (or type 'quit' to exit):\\n") + while True: + question = input("You: ").strip() + if not question: + continue + if question.lower() in ("quit", "exit"): + break + answer = answer_question(question) + print(f"\\nAnswer: {{answer}}\\n") + + +if __name__ == "__main__": + main() +""" + +# ─── Tests ──────────────────────────────────────────────────────────────── + +CONFTEST_CONTENT = """\ +# conftest.py — shared test fixtures for RAG tests +""" + +TEST_CONTENT = """\ +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def test_index_exists_false_when_no_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + from src.knowledge.index import index_exists + assert not index_exists() + + +def test_index_exists_false_when_empty_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "chroma_db").mkdir() + from src.knowledge.index import index_exists + assert not index_exists() + + +def test_index_exists_true_when_has_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + chroma = tmp_path / "chroma_db" + chroma.mkdir() + (chroma / "chroma.sqlite3").write_text("db") + from src.knowledge.index import index_exists + assert index_exists() + + +def test_sample_knowledge_file_exists(): + data_path = Path("data") / "sample_knowledge.md" + assert data_path.exists(), "data/sample_knowledge.md must exist" + + +def test_answer_question_mock(): + mock_engine = MagicMock() + mock_engine.query.return_value = "Spawn is a project generator." + mock_index = MagicMock() + mock_index.as_query_engine.return_value = mock_engine + + with patch("src.retrieval.retrieve.load_index", return_value=mock_index): + from src.retrieval.retrieve import answer_question + result = answer_question("What is Spawn?") + + assert isinstance(result, str) + assert len(result) > 0 +""" + +# ─── Env ────────────────────────────────────────────────────────────────── + +ENV_CONTENT = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini +""" + +# ─── README ─────────────────────────────────────────────────────────────── + + +def make_readme() -> str: + return ( + "# {project_name}\n\n" + "A RAG system generated with Spawn using LlamaIndex and ChromaDB.\n\n" + "## Getting Started\n\n" + "1. Rename `.env.example` to `.env`\n\n" + "2. Add your `OPENAI_API_KEY` to `.env`:\n\n" + "```env\n" + "OPENAI_API_KEY=your-key\n" + "```\n\n" + "3. (Optional) Add your own `.txt` or `.md` files to `data/`\n\n" + "4. Run the RAG system:\n\n" + "```bash\n" + "uv run python -m src.main\n" + "```\n\n" + "On first run, documents in `data/` are automatically ingested.\n" + "Then ask questions about your knowledge base.\n\n" + "## Example\n\n" + "```\n" + "You: What is Spawn?\n" + "Answer: Spawn is an intent-based project generator...\n" + "```\n\n" + "## Adding Knowledge\n\n" + "Drop `.txt` or `.md` files into `data/` and delete `chroma_db/` to re-index:\n\n" + "```bash\n" + "rm -rf chroma_db/\n" + "uv run python -m src.main\n" + "```\n\n" + "## Project Structure\n\n" + "```\n" + "{project_name}/\n" + "├── data/ # Knowledge documents\n" + "│ └── sample_knowledge.md\n" + "├── src/\n" + "│ ├── config/ # Settings and LLM configuration\n" + "│ ├── knowledge/ # ChromaDB index management\n" + "│ ├── ingestion/ # Document ingestion pipeline\n" + "│ ├── retrieval/ # Query engine and retrieval\n" + "│ └── main.py\n" + "├── tests/\n" + "├── chroma_db/ # Created on first run\n" + "├── .env.example\n" + "└── README.md\n" + "```\n\n" + "## Running Tests\n\n" + "```bash\n" + "uv run pytest\n" + "```\n" + ) + +# ─── GitHub Actions ─────────────────────────────────────────────────────── + +GITHUB_ACTIONS_CI_BASE = """\ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync +""" + +GITHUB_ACTIONS_CI_RUFF_STEP = """\ + - name: Lint + run: uv run ruff check . +""" + +GITHUB_ACTIONS_CI_PYTEST_STEP = """\ + - name: Test + run: uv run pytest +""" From fd63e81c6eb86001cc9226b3a2fc3315b8ad82fa Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Wed, 8 Jul 2026 14:00:51 +0530 Subject: [PATCH 02/10] RAGTemplate class --- src/spawn/templates/rag/__init__.py | 113 +++++++++++++++++++++++++- src/spawn/templates/shared_content.py | 4 + 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/spawn/templates/rag/__init__.py b/src/spawn/templates/rag/__init__.py index 49a0292..78f1a9e 100644 --- a/src/spawn/templates/rag/__init__.py +++ b/src/spawn/templates/rag/__init__.py @@ -1 +1,112 @@ -# RAG template +from pathlib import Path + +from spawn.templates.base import BaseTemplate +from spawn.templates.rag.content import ( + INIT_CONTENT, + SAMPLE_KNOWLEDGE_CONTENT, + SETTINGS_CONTENT, + KNOWLEDGE_INDEX_CONTENT, + INGESTION_CONTENT, + RETRIEVAL_CONTENT, + MAIN_CONTENT, + TEST_CONTENT, + CONFTEST_CONTENT, + ENV_CONTENT, + GITHUB_ACTIONS_CI_BASE, + GITHUB_ACTIONS_CI_RUFF_STEP, + GITHUB_ACTIONS_CI_PYTEST_STEP, + make_readme, +) + +RAG_FOLDERS = [ + "data", + "chroma_db", + "src/ingestion", + "src/retrieval", + "src/knowledge", + "src/config", + "tests", +] + + +def _build_files() -> list: + return [ + ("data/sample_knowledge.md", SAMPLE_KNOWLEDGE_CONTENT), + ("src/__init__.py", INIT_CONTENT), + ("src/config/__init__.py", INIT_CONTENT), + ("src/config/settings.py", SETTINGS_CONTENT), + ("src/knowledge/__init__.py", INIT_CONTENT), + ("src/knowledge/index.py", KNOWLEDGE_INDEX_CONTENT), + ("src/ingestion/__init__.py", INIT_CONTENT), + ("src/ingestion/ingest.py", INGESTION_CONTENT), + ("src/retrieval/__init__.py", INIT_CONTENT), + ("src/retrieval/retrieve.py", RETRIEVAL_CONTENT), + ("src/main.py", MAIN_CONTENT), + ("tests/__init__.py", INIT_CONTENT), + ("tests/conftest.py", CONFTEST_CONTENT), + ("tests/test_rag.py", TEST_CONTENT), + (".env.example", ENV_CONTENT), + ] + + +class RAGTemplate(BaseTemplate): + def __init__(self, extras: list[str] | None = None) -> None: + self.extras = extras or [] + + super().__init__( + name="RAG System", + folders=list(RAG_FOLDERS), + starter_files=_build_files(), + next_steps=[ + "cd {project_name}", + "Rename .env.example to .env and add your OPENAI_API_KEY", + "uv run python -m src.main", + ], + ) + + def get_readme_content(self, context: dict) -> str | None: + return make_readme().format_map(context) + + def get_dependencies(self) -> list[str]: + base = [ + "llama-index-core", + "llama-index-vector-stores-chroma", + "llama-index-embeddings-openai", + "llama-index-llms-openai", + "chromadb", + "python-dotenv", + ] + if "pytest" in self.extras: + base.append("pytest") + if "ruff" in self.extras: + base.append("ruff") + return base + + def post_install(self, project_path: Path) -> None: + pyproject = project_path / "pyproject.toml" + current = pyproject.read_text(encoding="utf-8") + additions = "" + + if "pytest" in self.extras: + additions += "\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n" + + if "ruff" in self.extras: + additions += "\n[tool.ruff]\nline-length = 88\n" + + if additions: + pyproject.write_text(current + additions, encoding="utf-8") + + if "github-actions" in self.extras: + workflows_path = project_path / ".github" / "workflows" + workflows_path.mkdir(parents=True, exist_ok=True) + ci = GITHUB_ACTIONS_CI_BASE + if "ruff" in self.extras: + ci += GITHUB_ACTIONS_CI_RUFF_STEP + if "pytest" in self.extras: + ci += GITHUB_ACTIONS_CI_PYTEST_STEP + (workflows_path / "ci.yml").write_text(ci, encoding="utf-8") + + # Write .gitkeep into chroma_db/ so git tracks the directory + # but the actual DB files are gitignored + gitkeep = project_path / "chroma_db" / ".gitkeep" + gitkeep.write_text("", encoding="utf-8") diff --git a/src/spawn/templates/shared_content.py b/src/spawn/templates/shared_content.py index 89ab95c..e843d50 100644 --- a/src/spawn/templates/shared_content.py +++ b/src/spawn/templates/shared_content.py @@ -50,4 +50,8 @@ # Logs logs/*.log + +# ChromaDB vector store (regenerate with: delete chroma_db/ and re-run) +chroma_db/ +!chroma_db/.gitkeep """ From 7cb5d4e7a4782d66b62577f8ef026a0e334fe80e Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Wed, 8 Jul 2026 14:04:56 +0530 Subject: [PATCH 03/10] Registry + Version Bump --- pyproject.toml | 2 +- src/spawn/__init__.py | 2 +- src/spawn/core/registry.py | 8 ++++++++ tests/test_agent_generator.py | 2 +- tests/test_registry.py | 2 +- uv.lock | 2 +- 6 files changed, 13 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e79f9ea..1ca9457 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spawn" -version = "0.7.0" +version = "0.8.0" description = "Spawn a ready-to-use Python project in seconds" readme = "README.md" requires-python = ">=3.12" diff --git a/src/spawn/__init__.py b/src/spawn/__init__.py index d20f58e..d7e5a6c 100644 --- a/src/spawn/__init__.py +++ b/src/spawn/__init__.py @@ -3,4 +3,4 @@ try: __version__ = version("spawn") except PackageNotFoundError: - __version__ = "0.7.0" + __version__ = "0.8.0" diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index 1e775c8..fc32cdd 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -11,6 +11,7 @@ from spawn.templates.automation import AutomationTemplate from spawn.templates.chatbot import ChatbotTemplate from spawn.templates.agent import AgentTemplate +from spawn.templates.rag import RAGTemplate from spawn.templates.base import BaseTemplate @@ -74,6 +75,13 @@ class TemplateMetadata: available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], available_extras=["ruff", "pytest", "github-actions"], ), + "rag": TemplateMetadata( + slug="rag", + display_name="RAG System", + description="Retrieval-Augmented Generation with LlamaIndex and ChromaDB", + template_class=RAGTemplate, + available_extras=["ruff", "pytest", "github-actions"], + ), } diff --git a/tests/test_agent_generator.py b/tests/test_agent_generator.py index 785eba6..55b93ad 100644 --- a/tests/test_agent_generator.py +++ b/tests/test_agent_generator.py @@ -173,7 +173,7 @@ def test_agent_meta_json_has_correct_intent(tmp_path, monkeypatch): assert meta["intent"] == "agent" assert meta["framework"] == "pydantic-ai" assert meta["provider"] == "openai" - assert meta["spawn_version"] == "0.7.0" + assert meta["spawn_version"] == "0.8.0" def test_agent_meta_json_openai_agents(tmp_path, monkeypatch): diff --git a/tests/test_registry.py b/tests/test_registry.py index 86c9734..53dde72 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -25,7 +25,7 @@ def test_list_templates_returns_all(): assert "cli" in slugs assert "automation" in slugs assert "chatbot" in slugs - assert len(slugs) == 5 + assert len(slugs) == 6 def test_get_metadata_returns_none_for_unknown(): diff --git a/uv.lock b/uv.lock index c1bfbaa..7ad066d 100644 --- a/uv.lock +++ b/uv.lock @@ -142,7 +142,7 @@ wheels = [ [[package]] name = "spawn" -version = "0.7.0" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "rich" }, From d8bf36354256b997a9df77d1c7d246a0a37bb9ea Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Wed, 8 Jul 2026 14:15:11 +0530 Subject: [PATCH 04/10] New tests for Rag --- tests/test_rag_generator.py | 193 ++++++++++++++++++++++++++ tests/test_rag_template.py | 268 ++++++++++++++++++++++++++++++++++++ tests/test_registry.py | 18 +++ 3 files changed, 479 insertions(+) create mode 100644 tests/test_rag_generator.py create mode 100644 tests/test_rag_template.py diff --git a/tests/test_rag_generator.py b/tests/test_rag_generator.py new file mode 100644 index 0000000..7875dff --- /dev/null +++ b/tests/test_rag_generator.py @@ -0,0 +1,193 @@ +import json +from contextlib import contextmanager +from unittest.mock import patch + +from spawn.core.models import ProjectConfig +from spawn.generators.project_generator import ProjectGenerator +from spawn.templates.rag import RAGTemplate + + +def _cfg( + name: str = "my-rag", + extras: list[str] | None = None, +) -> ProjectConfig: + return ProjectConfig( + name=name, + template="rag", + use_git=False, + extras=extras or [], + ) + + +@contextmanager +def _mock_uv_and_install(): + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(RAGTemplate, "post_install"): + yield + + +# ─── Structure ─────────────────────────────────────────────────────────── + + +def test_rag_creates_root(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag").is_dir() + + +def test_rag_creates_data_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "data").is_dir() + + +def test_rag_creates_chroma_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "chroma_db").is_dir() + + +def test_rag_creates_knowledge_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "knowledge").is_dir() + + +def test_rag_creates_ingestion_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "ingestion").is_dir() + + +def test_rag_creates_retrieval_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "retrieval").is_dir() + + +# ─── Files ─────────────────────────────────────────────────────────────── + + +def test_rag_creates_sample_knowledge(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "data" / "sample_knowledge.md").exists() + + +def test_rag_creates_index_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "knowledge" / "index.py").exists() + + +def test_rag_creates_ingest_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "ingestion" / "ingest.py").exists() + + +def test_rag_creates_retrieve_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "retrieval" / "retrieve.py").exists() + + +def test_rag_creates_main(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "src" / "main.py").exists() + + +def test_rag_creates_test_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / "tests" / "test_rag.py").exists() + + +def test_rag_creates_env_example(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-rag" / ".env.example").exists() + + +# ─── Content ───────────────────────────────────────────────────────────── + + +def test_rag_readme_has_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="my-knowledge-base")) + readme = (tmp_path / "my-knowledge-base" / "README.md").read_text(encoding="utf-8") + assert "my-knowledge-base" in readme + + +def test_rag_env_has_openai_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + env = (tmp_path / "my-rag" / ".env.example").read_text(encoding="utf-8") + assert "OPENAI_API_KEY" in env + + +def test_rag_gitignore_has_chroma(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + gitignore = (tmp_path / "my-rag" / ".gitignore").read_text(encoding="utf-8") + assert "chroma_db/" in gitignore + + +def test_rag_sample_knowledge_contains_spawn(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + knowledge = (tmp_path / "my-rag" / "data" / "sample_knowledge.md").read_text( + encoding="utf-8" + ) + assert "Spawn" in knowledge + + +# ─── meta.json ─────────────────────────────────────────────────────────── + + +def test_rag_meta_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + meta = json.loads( + (tmp_path / "my-rag" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "rag" + assert meta["framework"] is None + assert meta["provider"] is None + assert meta["spawn_version"] == "0.8.0" + + +# ─── Dependencies ──────────────────────────────────────────────────────── + + +def test_rag_install_packages_called_with_correct_deps(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(RAGTemplate, "post_install"): + ProjectGenerator().generate(_cfg()) + args = mock_install.call_args[0][1] + assert "llama-index-core" in args + assert "chromadb" in args + assert "llama-index-embeddings-openai" in args + assert "python-dotenv" in args diff --git a/tests/test_rag_template.py b/tests/test_rag_template.py new file mode 100644 index 0000000..1e155a5 --- /dev/null +++ b/tests/test_rag_template.py @@ -0,0 +1,268 @@ +import os +import py_compile +import re +import tempfile + +import pytest + +from spawn.templates.rag import RAGTemplate + +# ─── Basic instantiation ────────────────────────────────────────────────── + + +def test_rag_template_name(): + t = RAGTemplate() + assert t.name == "RAG System" + + +def test_rag_template_no_framework(): + t = RAGTemplate() + assert not hasattr(t, "framework") or getattr(t, "framework", None) is None + + +def test_rag_template_default_extras(): + t = RAGTemplate() + assert t.extras == [] + + +# ─── Folders ───────────────────────────────────────────────────────────── + + +def test_rag_folders_complete(): + t = RAGTemplate() + for required in [ + "data", "chroma_db", "src/ingestion", + "src/retrieval", "src/knowledge", "src/config", "tests", + ]: + assert required in t.folders, f"Missing folder: {required}" + + +# ─── Files ─────────────────────────────────────────────────────────────── + +REQUIRED_FILES = [ + "data/sample_knowledge.md", + "src/__init__.py", + "src/config/settings.py", + "src/knowledge/index.py", + "src/ingestion/ingest.py", + "src/retrieval/retrieve.py", + "src/main.py", + "tests/conftest.py", + "tests/test_rag.py", + ".env.example", +] + + +def test_rag_required_files(): + t = RAGTemplate() + paths = [p for p, _ in t.starter_files] + for f in REQUIRED_FILES: + assert f in paths, f"Missing file: {f}" + + +def test_rag_has_no_utils_dir(): + t = RAGTemplate() + assert "src/utils" not in t.folders + + +# ─── Content checks ────────────────────────────────────────────────────── + + +def test_settings_configures_openai_embedding(): + t = RAGTemplate() + files = dict(t.starter_files) + settings = files["src/config/settings.py"] + assert "OpenAIEmbedding" in settings + assert "text-embedding-3-small" in settings + + +def test_settings_configures_llm(): + t = RAGTemplate() + files = dict(t.starter_files) + settings = files["src/config/settings.py"] + assert "Settings.llm" in settings + assert "Settings.embed_model" in settings + + +def test_knowledge_index_uses_persistent_client(): + t = RAGTemplate() + files = dict(t.starter_files) + index = files["src/knowledge/index.py"] + assert "PersistentClient" in index + assert "chroma_db" in index + + +def test_knowledge_index_has_exists_check(): + t = RAGTemplate() + files = dict(t.starter_files) + index = files["src/knowledge/index.py"] + assert "index_exists" in index + + +def test_ingestion_loads_txt_and_md(): + t = RAGTemplate() + files = dict(t.starter_files) + ingest = files["src/ingestion/ingest.py"] + assert ".txt" in ingest + assert ".md" in ingest + + +def test_ingestion_uses_vector_store_index(): + t = RAGTemplate() + files = dict(t.starter_files) + ingest = files["src/ingestion/ingest.py"] + assert "VectorStoreIndex" in ingest + + +def test_retrieval_uses_query_engine(): + t = RAGTemplate() + files = dict(t.starter_files) + retrieve = files["src/retrieval/retrieve.py"] + assert "as_query_engine" in retrieve + assert "similarity_top_k" in retrieve + + +def test_main_auto_ingests_on_first_run(): + t = RAGTemplate() + files = dict(t.starter_files) + main = files["src/main.py"] + assert "index_exists" in main + assert "ingest_documents" in main + + +def test_env_has_openai_key(): + t = RAGTemplate() + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "OPENAI_API_KEY" in env + assert "OPENAI_MODEL" in env + + +def test_sample_knowledge_mentions_spawn(): + t = RAGTemplate() + files = dict(t.starter_files) + knowledge = files["data/sample_knowledge.md"] + assert "Spawn" in knowledge + assert len(knowledge) > 100 + + +# ─── Compile checks ────────────────────────────────────────────────────── + +PYTHON_FILES = [ + "src/config/settings.py", + "src/knowledge/index.py", + "src/ingestion/ingest.py", + "src/retrieval/retrieve.py", + "src/main.py", + "tests/test_rag.py", +] + + +@pytest.mark.parametrize("filepath", PYTHON_FILES) +def test_file_is_valid_python(filepath): + t = RAGTemplate() + files = dict(t.starter_files) + content = files[filepath].format_map({"project_name": "test-rag"}) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as f: + f.write(content) + fname = f.name + try: + py_compile.compile(fname, doraise=True) + except py_compile.PyCompileError as e: + raise AssertionError(f"{filepath} is not valid Python: {e}") from e + finally: + os.unlink(fname) + + +@pytest.mark.parametrize("filepath", PYTHON_FILES) +def test_no_unescaped_braces(filepath): + t = RAGTemplate() + files = dict(t.starter_files) + content = files[filepath] + singles = re.findall(r"(? Date: Wed, 8 Jul 2026 20:25:54 +0530 Subject: [PATCH 05/10] Polish: Readme --- README.md | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2e93773..8e3ea2a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ It's repetitive. It's inconsistent. And you haven't written a single line of *re | Feature | What it does | |---|---| -| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool, AI Chatbot | +| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool, AI Chatbot, AI Agent, RAG System | | **Extras system** | Opt-in ruff, pytest, Docker, GitHub Actions — installed and wired automatically | | **Dependency installation** | `uv add` runs automatically with the right packages for your choices | | **Git + uv** | Optionally runs `git init`, `uv init`, and `uv venv` | @@ -92,8 +92,10 @@ Spawn rejects names with spaces or special characters, and tells you immediately 2 CLI Application 3 Automation Tool 4 AI Chatbot + 5 AI Agent + 6 RAG System -Choose Template [1-4]: 1 +Choose Template [1-6]: 1 ``` **Step 3 — Additional prompts** *(template-dependent)* @@ -284,7 +286,8 @@ my-chatbot/ │ ├── chatbot/ │ ├── providers/ │ ├── prompts/ -│ ├── utils/ +│ ├── memory/ +│ ├── config/ │ └── main.py ├── tests/ ├── .env.example @@ -293,10 +296,120 @@ my-chatbot/ ```bash cd my-chatbot -# Add API_KEY to .env +# Add API key to .env +uv run python -m src.main +``` + +**AI Chatbot — Framework and provider** + +``` + 1 pydantic-ai + 2 openai-sdk + 3 litellm + +Choose Framework [1-3]: 1 + + 1 openai + 2 anthropic + 3 gemini + 4 openrouter + 5 ollama + 6 groq + +Choose Provider [1-6]: 1 +``` + +--- + +### `[5]` AI Agent + +Best for: task automation, research assistants, tool-calling workflows. + +``` +my-agent/ +├── src/ +│ ├── agent/ +│ ├── tools/ +│ ├── prompts/ +│ ├── config/ +│ └── main.py +├── tests/ +├── .env.example +└── README.md +``` + +```bash +cd my-agent +# Add API key to .env uv run python -m src.main ``` +**AI Agent — Framework and provider** + +``` + 1 pydantic-ai + 2 openai-agents + +Choose Framework [1-2]: 1 + + 1 openai + 2 anthropic + 3 gemini + 4 openrouter + 5 ollama + 6 groq + +Choose Provider [1-6]: 1 +``` + +Note: if you pick `openai-agents`, only `openai` and `openrouter` appear as provider choices — the list is filtered per framework. + +Every generated AI Agent project ships with one working tool (a calculator) so you can see tool-calling work immediately, with no external services or extra API keys required beyond your chosen provider. + +--- + +### `[6]` RAG System + +Best for: documentation Q&A, knowledge base search, document retrieval. + +``` +my-rag/ +├── data/ +│ └── sample_knowledge.md +├── src/ +│ ├── knowledge/ +│ ├── ingestion/ +│ ├── retrieval/ +│ ├── config/ +│ └── main.py +├── tests/ +├── chroma_db/ # created on first run +├── .env.example +└── README.md +``` + +```bash +cd my-rag +# Add OPENAI_API_KEY to .env +uv run python -m src.main +# Documents are ingested automatically on first run +``` + +**RAG System — Extras only, no framework/provider prompt** + +``` + 1 ruff + 2 pytest + 3 github-actions + + Enter numbers separated by commas, or press Enter to skip +Extras []: 1,2 +``` + +RAG System uses a fixed stack (LlamaIndex + ChromaDB + OpenAI) — there's no framework or provider choice. On first run, it automatically ingests documents from `data/` into a local ChromaDB index, then lets you ask questions against them. + +Requires an `OPENAI_API_KEY` (used for both the LLM and embeddings). + --- ## Other Commands @@ -341,7 +454,7 @@ spawn doctor ./path/to/project ```bash spawn version -# → Spawn v0.6.0 +# → Spawn v0.8.0 ``` ### Publish to GitHub @@ -378,8 +491,8 @@ All tests should pass. If they don't, please [open an issue](https://github.com/ - [x] **CLI Application intent** — Typer, Click, Argparse with Utility/Interactive sub-types (v0.4.0) - [x] **Automation Tool intent** — workflow-based automation with logging, tasks, and integrations (v0.5.0) - [x] **AI Chatbot intent** — PydanticAI and OpenAI SDK with provider abstraction (v0.6.0) -- [ ] **AI Agent intent** — tool-calling agent scaffold (v0.7.0) -- [ ] **RAG System intent** — retrieval-augmented generation scaffold (v0.8.0) +- [x] **AI Agent intent** — tool-calling agent scaffold with PydanticAI and OpenAI Agents SDK (v0.7.0) +- [x] **RAG System intent** — retrieval-augmented generation with LlamaIndex and ChromaDB (v0.8.0) - [ ] **Data Project intent** — analysis, dashboard, ETL, ML sub-options (v0.9.0) --- From ff081b003bbcdfceab78bff06017194a1f2ad25a Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Wed, 8 Jul 2026 20:35:23 +0530 Subject: [PATCH 06/10] Update: Changelog --- docs/changelog.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 062824a..b1b0428 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,41 @@ All notable changes to Spawn are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## v0.8.0 — 2026 + +### New Features + +- **RAG System intent** — generates a fully runnable Retrieval-Augmented Generation + project using a fixed stack: LlamaIndex + ChromaDB + OpenAI +- **Fixed stack, no framework/provider prompt** — RAG skips the framework/provider + selection entirely; `spawn create` goes straight from intent to extras +- **Auto-ingestion on first run** — if `chroma_db/` is empty, `src/main.py` ingests + everything in `data/` automatically before accepting questions; no separate ingest + command required +- **Sample knowledge base** — every generated project ships with + `data/sample_knowledge.md` so users get a working Q&A demo immediately, with zero + setup beyond an API key +- **Local vector persistence** — `src/knowledge/index.py` owns a + `chromadb.PersistentClient` writing to `chroma_db/`, shared by both ingestion and + retrieval +- **Correct modern LlamaIndex packaging** — installs `llama-index-core`, + `llama-index-vector-stores-chroma`, `llama-index-embeddings-openai`, + `llama-index-llms-openai`, and `chromadb` explicitly, instead of the heavy + `llama-index` meta-package +- **`chroma_db/` gitignored correctly** — binary ChromaDB files are excluded via + `.gitignore`, while a `.gitkeep` keeps the directory tracked in git + +### Internal + +- Registry: `rag` slug added with `available_extras` only — no `available_frameworks` + or `available_providers` +- `RAGTemplate` follows the same `BaseTemplate` contract as other intents + (`get_dependencies()`, `get_readme_content()`, `post_install()`, `next_steps`) +- `prompts.py` required no changes — empty `available_frameworks` and + `available_providers` already skip those prompts automatically +- `shared_content.py`'s `GITIGNORE_CONTENT` extended with a `chroma_db/` rule +- Version bumped to `0.8.0` + ## v0.7.0 — 2026 ### New Features From cce4e25827bc7d8febe4f5731ca0d8042f019879 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Thu, 9 Jul 2026 20:23:56 +0530 Subject: [PATCH 07/10] Python traceback ERROR --- src/spawn/templates/rag/content.py | 75 ++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/src/spawn/templates/rag/content.py b/src/spawn/templates/rag/content.py index 5b36bed..21c5582 100644 --- a/src/spawn/templates/rag/content.py +++ b/src/spawn/templates/rag/content.py @@ -137,6 +137,7 @@ def ingest_documents() -> VectorStoreIndex: RETRIEVAL_CONTENT = """\ from llama_index.core import VectorStoreIndex +from openai import APIError, AuthenticationError, RateLimitError from src.knowledge.index import get_storage_context, get_vector_store @@ -155,7 +156,23 @@ def answer_question(question: str) -> str: query_engine = index.as_query_engine( similarity_top_k=3, ) - response = query_engine.query(question) + try: + response = query_engine.query(question) + except AuthenticationError: + raise RuntimeError( + "OpenAI rejected your API key. Check OPENAI_API_KEY in .env." + ) from None + except RateLimitError as e: + if "insufficient_quota" in str(e): + raise RuntimeError( + "Your OpenAI account has no available quota/credits. " + "Check billing at https://platform.openai.com/settings/organization/billing" + ) from None + raise RuntimeError( + "OpenAI rate limit hit. Wait a moment and try again." + ) from None + except APIError as e: + raise RuntimeError(f"OpenAI API error: {{e}}") from None return str(response) """ @@ -175,7 +192,11 @@ def main() -> None: print("-" * 40) if not index_exists(): print("No index found. Running ingestion...\\n") - ingest_documents() + try: + ingest_documents() + except RuntimeError as e: + print(f"\\n[Error] {{e}}\\n") + return print() print("Ask a question (or type 'quit' to exit):\\n") while True: @@ -184,8 +205,12 @@ def main() -> None: continue if question.lower() in ("quit", "exit"): break - answer = answer_question(question) - print(f"\\nAnswer: {{answer}}\\n") + try: + answer = answer_question(question) + print(f"\\nAnswer: {{answer}}\\n") + except RuntimeError as e: + print(f"\\n[Error] {{e}}\\n") + continue if __name__ == "__main__": @@ -242,6 +267,48 @@ def test_answer_question_mock(): assert isinstance(result, str) assert len(result) > 0 + + +def test_answer_question_rate_limit_raises_runtime_error(): + from openai import RateLimitError + + mock_engine = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 429 + mock_engine.query.side_effect = RateLimitError( + "insufficient_quota", response=mock_response, body=None + ) + mock_index = MagicMock() + mock_index.as_query_engine.return_value = mock_engine + + with patch("src.retrieval.retrieve.load_index", return_value=mock_index): + from src.retrieval.retrieve import answer_question + try: + answer_question("What is Spawn?") + assert False, "Expected RuntimeError" + except RuntimeError as e: + assert "quota" in str(e).lower() or "rate limit" in str(e).lower() + + +def test_answer_question_api_error_raises_runtime_error(): + from openai import APIError + + mock_engine = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 500 + mock_engine.query.side_effect = APIError( + "server error", response=mock_response, body=None + ) + mock_index = MagicMock() + mock_index.as_query_engine.return_value = mock_engine + + with patch("src.retrieval.retrieve.load_index", return_value=mock_index): + from src.retrieval.retrieve import answer_question + try: + answer_question("What is Spawn?") + assert False, "Expected RuntimeError" + except RuntimeError as e: + assert "OpenAI API error" in str(e) """ # ─── Env ────────────────────────────────────────────────────────────────── From 16c24e53c0a94508dcf89dae8fefd921cf543cac Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Thu, 9 Jul 2026 22:06:39 +0530 Subject: [PATCH 08/10] Sanitize ChromaDB collection name --- src/spawn/templates/rag/content.py | 14 +++++++- tests/test_rag_template.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/spawn/templates/rag/content.py b/src/spawn/templates/rag/content.py index 21c5582..3767b61 100644 --- a/src/spawn/templates/rag/content.py +++ b/src/spawn/templates/rag/content.py @@ -77,14 +77,26 @@ def configure_llm() -> None: # ─── Knowledge index ────────────────────────────────────────────────────── KNOWLEDGE_INDEX_CONTENT = """\ +import re from pathlib import Path import chromadb from llama_index.core import StorageContext from llama_index.vector_stores.chroma import ChromaVectorStore + +def _sanitize_collection_name(name: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9._-]", "-", name) + cleaned = cleaned.strip("-._") + if len(cleaned) < 3: + cleaned = f"rag-{{cleaned}}".strip("-._") + if len(cleaned) < 3: + cleaned = "rag-collection" + return cleaned[:512] + + CHROMA_PATH = Path("chroma_db") -COLLECTION_NAME = "{project_name}" +COLLECTION_NAME = _sanitize_collection_name("{project_name}") def get_vector_store() -> ChromaVectorStore: diff --git a/tests/test_rag_template.py b/tests/test_rag_template.py index 1e155a5..a1c519d 100644 --- a/tests/test_rag_template.py +++ b/tests/test_rag_template.py @@ -266,3 +266,56 @@ def test_gitignore_excludes_chroma_db(): def test_gitignore_keeps_gitkeep(): from spawn.templates.shared_content import GITIGNORE_CONTENT assert "!chroma_db/.gitkeep" in GITIGNORE_CONTENT + + +# ─── Collection name sanitizer ─────────────────────────────────────────── + + +def test_collection_name_is_valid_for_various_project_names(): + import re + import types + + t = RAGTemplate() + files = dict(t.starter_files) + raw_index = files["src/knowledge/index.py"] + + pattern = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{1,510}[a-zA-Z0-9]$") + + for project_name in ["my-rag", "ab", "x", "my rag project"]: + rendered = raw_index.format_map({"project_name": project_name}) + ns: dict = {} + # Stub out heavy imports so exec works without llama-index installed + for mod in [ + "chromadb", "llama_index", "llama_index.core", + "llama_index.vector_stores", "llama_index.vector_stores.chroma", + ]: + ns[mod] = types.ModuleType(mod) + import sys + stubs = {} + for mod in [ + "chromadb", "llama_index", "llama_index.core", + "llama_index.vector_stores", "llama_index.vector_stores.chroma", + ]: + stubs[mod] = sys.modules.get(mod) + sys.modules[mod] = types.ModuleType(mod) + # Provide minimal fakes so the module-level code executes + import types as _types + llama_core = _types.ModuleType("llama_index.core") + llama_core.StorageContext = object # type: ignore[attr-defined] + sys.modules["llama_index.core"] = llama_core + vs_fake = _types.ModuleType("llama_index.vector_stores.chroma") + vs_fake.ChromaVectorStore = object # type: ignore[attr-defined] + sys.modules["llama_index.vector_stores.chroma"] = vs_fake + try: + exec(compile(rendered, "", "exec"), ns) # noqa: S102 + finally: + for mod, orig in stubs.items(): + if orig is None: + sys.modules.pop(mod, None) + else: + sys.modules[mod] = orig + collection_name = ns.get("COLLECTION_NAME", "") + assert pattern.match(collection_name), ( + f"project_name={project_name!r} → COLLECTION_NAME={collection_name!r} " + "does not match ChromaDB constraints" + ) From 9d348b8e164762b56c73981bf81a8c65afb33209 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Thu, 9 Jul 2026 22:08:51 +0530 Subject: [PATCH 09/10] Give ingestion the same error handling as retrieval, fix the phantom-index trap --- src/spawn/templates/rag/content.py | 42 +++++++++++++++++++++++++----- tests/test_rag_template.py | 3 +++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/spawn/templates/rag/content.py b/src/spawn/templates/rag/content.py index 3767b61..859ac9b 100644 --- a/src/spawn/templates/rag/content.py +++ b/src/spawn/templates/rag/content.py @@ -112,7 +112,8 @@ def get_storage_context() -> StorageContext: def index_exists() -> bool: if not CHROMA_PATH.exists(): return False - return any(CHROMA_PATH.iterdir()) + sqlite_file = CHROMA_PATH / "chroma.sqlite3" + return sqlite_file.exists() and sqlite_file.stat().st_size > 0 """ # ─── Ingestion ──────────────────────────────────────────────────────────── @@ -121,6 +122,7 @@ def index_exists() -> bool: from pathlib import Path from llama_index.core import SimpleDirectoryReader, VectorStoreIndex +from openai import APIError, AuthenticationError, RateLimitError from src.knowledge.index import get_storage_context @@ -136,11 +138,27 @@ def ingest_documents() -> VectorStoreIndex: ).load_data() print(f"Loaded {{len(documents)}} document(s).") storage_context = get_storage_context() - index = VectorStoreIndex.from_documents( - documents, - storage_context=storage_context, - show_progress=True, - ) + try: + index = VectorStoreIndex.from_documents( + documents, + storage_context=storage_context, + show_progress=True, + ) + except AuthenticationError: + raise RuntimeError( + "OpenAI rejected your API key. Check OPENAI_API_KEY in .env." + ) from None + except RateLimitError as e: + if "insufficient_quota" in str(e): + raise RuntimeError( + "Your OpenAI account has no available quota/credits. " + "Check billing at https://platform.openai.com/settings/organization/billing" + ) from None + raise RuntimeError( + "OpenAI rate limit hit. Wait a moment and try again." + ) from None + except APIError as e: + raise RuntimeError(f"OpenAI API error: {{e}}") from None print("Ingestion complete. Index stored in chroma_db/") return index """ @@ -208,6 +226,9 @@ def main() -> None: ingest_documents() except RuntimeError as e: print(f"\\n[Error] {{e}}\\n") + import shutil + from pathlib import Path + shutil.rmtree(Path("chroma_db"), ignore_errors=True) return print() print("Ask a question (or type 'quit' to exit):\\n") @@ -262,6 +283,15 @@ def test_index_exists_true_when_has_file(tmp_path, monkeypatch): assert index_exists() +def test_index_exists_false_when_sqlite_file_empty(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + chroma = tmp_path / "chroma_db" + chroma.mkdir() + (chroma / "chroma.sqlite3").write_bytes(b"") + from src.knowledge.index import index_exists + assert not index_exists() + + def test_sample_knowledge_file_exists(): data_path = Path("data") / "sample_knowledge.md" assert data_path.exists(), "data/sample_knowledge.md must exist" diff --git a/tests/test_rag_template.py b/tests/test_rag_template.py index a1c519d..0b24cfe 100644 --- a/tests/test_rag_template.py +++ b/tests/test_rag_template.py @@ -97,6 +97,9 @@ def test_knowledge_index_has_exists_check(): files = dict(t.starter_files) index = files["src/knowledge/index.py"] assert "index_exists" in index + # Verify it checks for the specific sqlite marker, not "any file" + assert "chroma.sqlite3" in index + assert "st_size" in index def test_ingestion_loads_txt_and_md(): From 85ccc43d399ab39d6e76435fba3c8e7e0193075d Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Thu, 9 Jul 2026 22:10:59 +0530 Subject: [PATCH 10/10] Fix .gitignore so chroma_db/ contents are actually excluded --- src/spawn/templates/rag/__init__.py | 8 ++++++++ tests/test_rag_generator.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/spawn/templates/rag/__init__.py b/src/spawn/templates/rag/__init__.py index 78f1a9e..57a28b5 100644 --- a/src/spawn/templates/rag/__init__.py +++ b/src/spawn/templates/rag/__init__.py @@ -110,3 +110,11 @@ def post_install(self, project_path: Path) -> None: # but the actual DB files are gitignored gitkeep = project_path / "chroma_db" / ".gitkeep" gitkeep.write_text("", encoding="utf-8") + + gitignore_path = project_path / ".gitignore" + current_gitignore = gitignore_path.read_text(encoding="utf-8") + rag_ignore_rules = "\n# RAG vector database\nchroma_db/*\n!chroma_db/.gitkeep\n" + if "chroma_db/*" not in current_gitignore: + gitignore_path.write_text( + current_gitignore + rag_ignore_rules, encoding="utf-8" + ) diff --git a/tests/test_rag_generator.py b/tests/test_rag_generator.py index 7875dff..85e555a 100644 --- a/tests/test_rag_generator.py +++ b/tests/test_rag_generator.py @@ -191,3 +191,21 @@ def test_rag_install_packages_called_with_correct_deps(tmp_path, monkeypatch): assert "chromadb" in args assert "llama-index-embeddings-openai" in args assert "python-dotenv" in args + + +def test_rag_gitignore_has_chroma_wildcard_rule(tmp_path, monkeypatch): + """post_install must append chroma_db/* and !chroma_db/.gitkeep to .gitignore.""" + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv") as mock_uv: + # initialize_uv needs to create a minimal pyproject.toml so post_install + # can read it without error + def fake_uv(p): + (p / "pyproject.toml").write_text( + '[project]\nname="x"\nversion="0.1.0"\n', encoding="utf-8" + ) + mock_uv.side_effect = fake_uv + ProjectGenerator().generate(_cfg()) + gitignore = (tmp_path / "my-rag" / ".gitignore").read_text(encoding="utf-8") + assert "chroma_db/*" in gitignore + assert "!chroma_db/.gitkeep" in gitignore