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) --- 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 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/src/spawn/templates/rag/__init__.py b/src/spawn/templates/rag/__init__.py new file mode 100644 index 0000000..57a28b5 --- /dev/null +++ b/src/spawn/templates/rag/__init__.py @@ -0,0 +1,120 @@ +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") + + 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/src/spawn/templates/rag/content.py b/src/spawn/templates/rag/content.py new file mode 100644 index 0000000..859ac9b --- /dev/null +++ b/src/spawn/templates/rag/content.py @@ -0,0 +1,449 @@ +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 = """\ +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 = _sanitize_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 + sqlite_file = CHROMA_PATH / "chroma.sqlite3" + return sqlite_file.exists() and sqlite_file.stat().st_size > 0 +""" + +# ─── Ingestion ──────────────────────────────────────────────────────────── + +INGESTION_CONTENT = """\ +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 + +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() + 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 +""" + +# ─── Retrieval ──────────────────────────────────────────────────────────── + +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 + + +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, + ) + 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) +""" + +# ─── 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") + try: + 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") + while True: + question = input("You: ").strip() + if not question: + continue + if question.lower() in ("quit", "exit"): + break + try: + answer = answer_question(question) + print(f"\\nAnswer: {{answer}}\\n") + except RuntimeError as e: + print(f"\\n[Error] {{e}}\\n") + continue + + +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_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" + + +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 + + +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 ────────────────────────────────────────────────────────────────── + +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 +""" 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 """ 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_rag_generator.py b/tests/test_rag_generator.py new file mode 100644 index 0000000..85e555a --- /dev/null +++ b/tests/test_rag_generator.py @@ -0,0 +1,211 @@ +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 + + +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 diff --git a/tests/test_rag_template.py b/tests/test_rag_template.py new file mode 100644 index 0000000..0b24cfe --- /dev/null +++ b/tests/test_rag_template.py @@ -0,0 +1,324 @@ +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 + # 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(): + 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"(?", "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" + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index 86c9734..f873a32 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(): @@ -166,3 +166,21 @@ def test_agent_metadata(): assert meta.display_name == "AI Agent" assert "pydantic-ai" in meta.available_frameworks assert "openai-agents" in meta.available_frameworks + + +def test_rag_is_registered(): + from spawn.templates.rag import RAGTemplate + t = get_template("rag") + assert t is not None + assert isinstance(t, RAGTemplate) + + +def test_rag_metadata(): + meta = get_metadata("rag") + assert meta is not None + assert meta.slug == "rag" + assert meta.display_name == "RAG System" + assert not meta.available_frameworks + assert not meta.available_providers + assert "ruff" in meta.available_extras + assert "pytest" in meta.available_extras 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" },