diff --git a/.agent-plan.md b/.agent-plan.md index f8977c4..3bd62fe 100644 --- a/.agent-plan.md +++ b/.agent-plan.md @@ -6,66 +6,50 @@ ## Current System State -Repository initialized. Codebase empty. Architecture, design, and roadmap documents are locked in `docs/`. Agent-context files (`CLAUDE.md`, `llms.txt`, `.agent-plan.md`) are initialized. Branch/PR workflow rules (including label taxonomy and milestone map) are locked in `CLAUDE.md` and backed by a local `.git/hooks/pre-push` convenience hook (not versioned) and GitHub branch protection. GitHub labels and milestones (v0.1–v1.0) are created. pr-agent-context CI and refresh workflows are live in `.github/workflows/`. +**v0.1.0 shipped.** Installable package, full CLI skeleton (`list-recipes` implemented, others stubbed), core enums/exceptions/models, recipe registry, CI workflow, pre-commit config. All 20 tests pass. --- -## Active Task Breakdown — Phase 1: Milestone 0 (Project Foundation) - -Goal: Create a professional-quality, installable package skeleton with working tooling and CI, so all subsequent work has a stable base. - -- [ ] **1. Repository bootstrap + package skeleton** - - Create `pyproject.toml` (package name `leadforge`, entry point `leadforge = "leadforge.cli.main:app"`) - - Create `leadforge/__init__.py` and `leadforge/version.py` (initial `__version__ = "0.1.0"`) - - Create top-level directories matching canonical layout: `leadforge/{api,cli,core,narrative,schema,structure,mechanisms,simulation,render,exposure,validation,recipes,examples,sample_data}/` - - Add `__init__.py` stubs to all subpackages - - Add `LICENSE` (MIT) - - Add minimal `README.md` with one-liner, install instructions, and quickstart placeholder - - Verify: `pip install -e .` works - -- [ ] **2. Tooling + CI + pre-commit** - - Add `ruff` (lint + format), `mypy` or `pyright`, `pytest` to dev dependencies - - Create `ruff.toml` or inline `[tool.ruff]` config - - Create `pre-commit-config.yaml` with ruff + type-check hooks - - Create `.github/workflows/ci.yml` — runs `pytest`, `ruff check`, type check on push/PR - - Verify: CI passes on empty codebase - -- [ ] **3. CLI entrypoint skeleton** - - Implement `leadforge/cli/main.py` with a minimal app (typer or click) - - Implement stub commands: `list-recipes`, `generate`, `inspect`, `validate` (each prints "not yet implemented") - - Add `leadforge/cli/commands/` directory with one file per command - - Verify: `leadforge --help` lists all four commands - - Verify: `leadforge list-recipes` exits cleanly with a stub message - -- [ ] **4. Core primitives scaffold** - - `leadforge/core/enums.py` — `ExposureMode` enum (`student_public`, `research_instructor`), `DifficultyProfile` enum (`intro`, `intermediate`, `advanced`) - - `leadforge/core/exceptions.py` — `LeadforgeError` base + `InvalidRecipeError`, `InvalidConfigError`, `SimulationError`, `RenderError`, `ValidationError` - - `leadforge/core/models.py` — empty `GenerationConfig`, `WorldSpec`, `WorldBundle` dataclass stubs - - Add pytest smoke tests for enum values and exception hierarchy - -- [ ] **5. Initial recipe directory** - - Create `leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml` with minimal metadata (id, title, primary_task, vertical, supported_modes, supported_difficulty) - - Create `leadforge/recipes/registry.py` with a `list_recipes()` function that reads from the recipes directory - - Wire `list-recipes` CLI command to `list_recipes()` - - Verify: `leadforge list-recipes` outputs at least the `b2b_saas_procurement_v1` entry +## Active Task Breakdown — Milestone 1: Canonical Config, Recipe & Model Objects (v0.2.0) + +Goal: Establish the typed configuration and recipe system that all simulation work will depend on. + +- [ ] **1. Core models + RNG utilities** + - Implement `leadforge/core/rng.py`: seeded RNG root + deterministic named substreams + - Flesh out `GenerationConfig` with full validation (config precedence rules) + - Implement `leadforge/core/hashing.py`: deterministic config hashing for manifest identity + +- [ ] **2. Recipe registry and loading** + - Implement full `Recipe` typed model (dataclass) in `leadforge/api/recipes.py` + - Implement config precedence: CLI flags > override file > recipe defaults > package defaults + - Implement `leadforge/core/serialization.py`: JSON/YAML read-write helpers + +- [ ] **3. Recipe assets + validation tests** + - Add `narrative.yaml`, `difficulty_profiles.yaml` to `b2b_saas_procurement_v1/` + - Implement `Generator.from_recipe(...)` skeleton (no simulation yet) + - Tests: recipe validation, config precedence, RNG determinism, `from_recipe` round-trip --- ## Context Pointers -- Full milestone scope for Phase 1 (Milestone 0): `docs/leadforge_implementation_plan.md` §5 "Milestone 0" -- Next phase (Milestone 1 — config, recipe, model objects): `docs/leadforge_implementation_plan.md` §5 "Milestone 1" -- Dependency graph across all milestones: `docs/leadforge_implementation_plan.md` §6 -- Canonical package layout: `docs/leadforge_architecture_spec.md` §4 +- Milestone 1 scope: `docs/leadforge_implementation_plan.md` §5 "Milestone 1" +- Full milestone dependency graph: `docs/leadforge_implementation_plan.md` §6 - Public API contract: `docs/leadforge_architecture_spec.md` §6 -- CLI command spec: `docs/leadforge_architecture_spec.md` §7 -- Tech stack decisions: `CLAUDE.md` "Tech Stack" section +- Config precedence rules: `docs/leadforge_architecture_spec.md` §24 --- ## Completed Phases -_None yet._ +### Milestone 0 — Project Foundation ✓ (v0.1.0) +- `pyproject.toml`, `README.md`, `LICENSE`, `.pre-commit-config.yaml` +- Full package skeleton with `__init__.py` stubs for all submodules +- `leadforge/core/`: `enums.py`, `exceptions.py`, `models.py`, `rng.py` (stub), `ids.py` (stub) +- `leadforge/cli/`: `main.py` + four commands (`list-recipes` implemented, others stubbed) +- `leadforge/recipes/`: registry + `b2b_saas_procurement_v1/recipe.yaml` +- `.github/workflows/ci.yml`: lint, typecheck, test matrix (3.11 + 3.12) with coverage upload +- 20 tests passing; ruff + mypy clean --- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d1f81e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Lint & format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + + typecheck: + name: Type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e ".[dev]" + - run: mypy leadforge/ + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + env: + COVERAGE_FILE: .coverage.${{ matrix.python-version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" pytest-cov + - run: pytest --cov=leadforge --cov-report=term-missing + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: pr-agent-context-coverage-py${{ matrix.python-version }} + path: .coverage.${{ matrix.python-version }} + include-hidden-files: true + if-no-files-found: ignore diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..37495e0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.5 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0827d08 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# AGENTS.md — leadforge + +Agent-specific conventions layered on top of CLAUDE.md. + +--- + +## PR Review Comment Workflow + +When addressing PR review comments (Copilot, human reviewers, or otherwise): + +1. Triage each comment — recommend one of: resolve as irrelevant, accept and implement, open a separate issue and resolve as out-of-scope, accept a different solution, or resolve as already treated. +2. After the user confirms decisions, implement accepted changes and push **all changes in a single commit** to the PR branch. +3. **After the commit lands, resolve the corresponding GitHub review threads** using the GraphQL API: + +```bash +gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "PRRT_..."}) { thread { isResolved } } }' +``` + +Resolve every addressed thread — whether the action was "implement", "already treated", or "irrelevant/out-of-scope". Unresolved threads indicate open work; resolved threads mean the discussion is closed. + +Do **not** leave threads unresolved after the commit is pushed. + +--- + +## Branch & PR Conventions + +See CLAUDE.md for the full mandatory branch/PR workflow (branch → commit → update `.agent-plan.md` → open PR). diff --git a/README.md b/README.md index 674e4f3..8582db3 100644 --- a/README.md +++ b/README.md @@ -1 +1,74 @@ -# leadforge \ No newline at end of file +# leadforge + +**Opinionated framework for generating synthetic CRM and GTM datasets from simulated commercial worlds.** + +`leadforge` generates narrative-grounded synthetic revenue datasets starting with lead scoring, designed to support teaching, portfolio projects, and research. Rather than sampling rows from a distribution, it simulates a commercial world — a specific company, selling a specific product, to a specific kind of buyer — and renders realistic CRM-style outputs from that world. + +--- + +## Installation + +```bash +pip install leadforge +``` + +For development: + +```bash +git clone https://github.com/leadforge-dev/leadforge.git +cd leadforge +pip install -e ".[dev]" +pre-commit install +``` + +--- + +## Quickstart + +```bash +# List available recipes +leadforge list-recipes + +# Coming in v0.2.0: generate a dataset bundle +# leadforge generate \ +# --recipe b2b_saas_procurement_v1 \ +# --seed 42 \ +# --mode student_public \ +# --difficulty intermediate \ +# --n-leads 5000 \ +# --out ./out/demo_bundle + +# Coming in v0.4.0: inspect a generated bundle +# leadforge inspect ./out/demo_bundle + +# Coming in v0.5.0: validate a generated bundle +# leadforge validate ./out/demo_bundle +``` + +**Python API** (coming in v0.2.0): + +```python +from leadforge.api import Generator + +gen = Generator.from_recipe( + "b2b_saas_procurement_v1", + seed=42, + exposure_mode="student_public", +) +bundle = gen.generate(n_leads=5000, difficulty="intermediate") +bundle.save("./out/demo_bundle") +``` + +--- + +## Documentation + +- [Design document](docs/leadforge_design_doc.md) +- [Architecture spec](docs/leadforge_architecture_spec.md) +- [Implementation plan](docs/leadforge_implementation_plan.md) + +--- + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/leadforge/__init__.py b/leadforge/__init__.py new file mode 100644 index 0000000..4da8d10 --- /dev/null +++ b/leadforge/__init__.py @@ -0,0 +1,5 @@ +"""leadforge — synthetic CRM and GTM dataset generation.""" + +from leadforge.version import __version__ + +__all__ = ["__version__"] diff --git a/leadforge/api/__init__.py b/leadforge/api/__init__.py new file mode 100644 index 0000000..47e54c9 --- /dev/null +++ b/leadforge/api/__init__.py @@ -0,0 +1,5 @@ +"""leadforge public Python API.""" + +from leadforge.api.generator import Generator + +__all__ = ["Generator"] diff --git a/leadforge/api/generator.py b/leadforge/api/generator.py new file mode 100644 index 0000000..77f7af5 --- /dev/null +++ b/leadforge/api/generator.py @@ -0,0 +1,65 @@ +"""Public Generator API — stub for Milestone 1. + +The Generator class is the primary entry point for programmatic dataset +generation. It is fully specified in the architecture doc (§6) and will +be implemented across Milestones 1–9. +""" + +from __future__ import annotations + +from typing import Any + +from leadforge.core.enums import DifficultyProfile, ExposureMode +from leadforge.core.models import GenerationConfig, WorldBundle + + +class Generator: + """High-level entry point for generating a synthetic CRM dataset bundle. + + Usage (once implemented):: + + gen = Generator.from_recipe( + "b2b_saas_procurement_v1", + seed=42, + exposure_mode="student_public", + ) + bundle = gen.generate(n_leads=5000, difficulty="intermediate") + bundle.save("./out/demo_bundle") + + Implemented in Milestone 1 (config/recipe) through Milestone 9 (rendering). + """ + + def __init__(self, config: GenerationConfig) -> None: + self._config = config + + @classmethod + def from_recipe( + cls, + recipe_id: str, + *, + seed: int = 42, + exposure_mode: str | ExposureMode = ExposureMode.student_public, + **kwargs: Any, + ) -> Generator: + """Create a Generator from a recipe ID. + + Not yet implemented — available in v0.2.0. + """ + raise NotImplementedError( + "Generator.from_recipe() is not yet implemented. Coming in v0.2.0." + ) + + def generate( + self, + *, + n_accounts: int | None = None, + n_contacts: int | None = None, + n_leads: int | None = None, + difficulty: str | DifficultyProfile = DifficultyProfile.intermediate, + **kwargs: Any, + ) -> WorldBundle: + """Run the world simulation and return a bundle. + + Not yet implemented — available in v0.2.0. + """ + raise NotImplementedError("Generator.generate() is not yet implemented. Coming in v0.2.0.") diff --git a/leadforge/cli/__init__.py b/leadforge/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/cli/commands/__init__.py b/leadforge/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/cli/commands/generate.py b/leadforge/cli/commands/generate.py new file mode 100644 index 0000000..18bdddd --- /dev/null +++ b/leadforge/cli/commands/generate.py @@ -0,0 +1,35 @@ +"""leadforge generate command.""" + +import typer + + +def generate( + recipe: str = typer.Option(..., "--recipe", "-r", help="Recipe ID to use."), + seed: int = typer.Option(..., "--seed", help="Random seed for deterministic generation."), + mode: str = typer.Option( + ..., + "--mode", + help="Exposure mode: student_public or research_instructor.", + ), + out: str = typer.Option(..., "--out", help="Output directory for the generated bundle."), + difficulty: str = typer.Option( + "intermediate", + "--difficulty", + help="Difficulty profile: intro, intermediate, or advanced.", + ), + n_accounts: int | None = typer.Option(None, "--n-accounts", help="Number of accounts."), + n_contacts: int | None = typer.Option(None, "--n-contacts", help="Number of contacts."), + n_leads: int | None = typer.Option(None, "--n-leads", help="Number of leads."), + horizon_days: int | None = typer.Option( + None, "--horizon-days", help="Simulation horizon in days." + ), + override: str | None = typer.Option( + None, "--override", help="Path to a YAML config override file." + ), +) -> None: + """Generate a synthetic CRM dataset bundle from a recipe.""" + typer.echo( + "The 'generate' command is not yet implemented. Coming in v0.2.0.", + err=True, + ) + raise typer.Exit(1) diff --git a/leadforge/cli/commands/inspect.py b/leadforge/cli/commands/inspect.py new file mode 100644 index 0000000..36d6bf9 --- /dev/null +++ b/leadforge/cli/commands/inspect.py @@ -0,0 +1,14 @@ +"""leadforge inspect command.""" + +import typer + + +def inspect( + bundle_path: str = typer.Argument(..., help="Path to a generated bundle directory."), +) -> None: + """Inspect a generated dataset bundle and print a summary.""" + typer.echo( + "The 'inspect' command is not yet implemented. Coming in v0.4.0.", + err=True, + ) + raise typer.Exit(1) diff --git a/leadforge/cli/commands/list_recipes.py b/leadforge/cli/commands/list_recipes.py new file mode 100644 index 0000000..c9034a4 --- /dev/null +++ b/leadforge/cli/commands/list_recipes.py @@ -0,0 +1,34 @@ +"""leadforge list-recipes command.""" + +import typer +from rich.console import Console +from rich.table import Table + +from leadforge.recipes.registry import list_recipes + + +def list_recipes_cmd() -> None: + """List all available generation recipes.""" + recipes = list_recipes() + if not recipes: + typer.echo("No recipes found.") + raise typer.Exit() + + console = Console() + table = Table(title="Available Recipes", show_header=True, header_style="bold cyan") + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Title") + table.add_column("Primary Task", style="green") + table.add_column("Modes") + table.add_column("Difficulty") + + for r in recipes: + table.add_row( + r.get("id", ""), + r.get("title", ""), + r.get("primary_task", ""), + ", ".join(r.get("supported_modes", [])), + ", ".join(r.get("supported_difficulty", [])), + ) + + console.print(table) diff --git a/leadforge/cli/commands/validate.py b/leadforge/cli/commands/validate.py new file mode 100644 index 0000000..461db4a --- /dev/null +++ b/leadforge/cli/commands/validate.py @@ -0,0 +1,14 @@ +"""leadforge validate command.""" + +import typer + + +def validate( + bundle_path: str = typer.Argument(..., help="Path to a generated bundle directory."), +) -> None: + """Run schema and artifact validation on a generated bundle.""" + typer.echo( + "The 'validate' command is not yet implemented. Coming in v0.5.0.", + err=True, + ) + raise typer.Exit(1) diff --git a/leadforge/cli/main.py b/leadforge/cli/main.py new file mode 100644 index 0000000..c1b8070 --- /dev/null +++ b/leadforge/cli/main.py @@ -0,0 +1,42 @@ +"""leadforge CLI entrypoint.""" + +import typer + +from leadforge.cli.commands.generate import generate +from leadforge.cli.commands.inspect import inspect +from leadforge.cli.commands.list_recipes import list_recipes_cmd +from leadforge.cli.commands.validate import validate +from leadforge.version import __version__ + +app = typer.Typer( + name="leadforge", + help="Generate synthetic CRM and GTM datasets from simulated commercial worlds.", + no_args_is_help=True, + pretty_exceptions_enable=False, +) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"leadforge {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: bool = typer.Option( # noqa: FBT001 + False, + "--version", + "-V", + callback=_version_callback, + is_eager=True, + help="Show version and exit.", + ), +) -> None: + pass + + +app.command("list-recipes")(list_recipes_cmd) +app.command("generate")(generate) +app.command("inspect")(inspect) +app.command("validate")(validate) diff --git a/leadforge/core/__init__.py b/leadforge/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/core/enums.py b/leadforge/core/enums.py new file mode 100644 index 0000000..1e3d50c --- /dev/null +++ b/leadforge/core/enums.py @@ -0,0 +1,16 @@ +from enum import StrEnum + + +class ExposureMode(StrEnum): + """Controls how much hidden world truth is published in a bundle.""" + + student_public = "student_public" + research_instructor = "research_instructor" + + +class DifficultyProfile(StrEnum): + """Named difficulty preset for a generation run.""" + + intro = "intro" + intermediate = "intermediate" + advanced = "advanced" diff --git a/leadforge/core/exceptions.py b/leadforge/core/exceptions.py new file mode 100644 index 0000000..1ed7264 --- /dev/null +++ b/leadforge/core/exceptions.py @@ -0,0 +1,26 @@ +class LeadforgeError(Exception): + """Base exception for all leadforge errors.""" + + +class InvalidRecipeError(LeadforgeError): + """Raised when a recipe identifier is unknown or its files are malformed.""" + + +class InvalidConfigError(LeadforgeError): + """Raised when a GenerationConfig fails validation.""" + + +class GraphConstructionError(LeadforgeError): + """Raised when the hidden world graph cannot be constructed or validated.""" + + +class SimulationError(LeadforgeError): + """Raised when world simulation fails.""" + + +class RenderError(LeadforgeError): + """Raised when bundle rendering fails.""" + + +class ValidationError(LeadforgeError): + """Raised when bundle artifact validation fails.""" diff --git a/leadforge/core/ids.py b/leadforge/core/ids.py new file mode 100644 index 0000000..ba1cad7 --- /dev/null +++ b/leadforge/core/ids.py @@ -0,0 +1,16 @@ +"""Entity ID generation. + +Implemented in Milestone 3. All IDs must be stable, opaque, namespace-unique, +and deterministic for a given run. + +Canonical prefixes: + acct_ — Account + cnt_ — Contact + lead_ — Lead + touch_ — Touch + sess_ — Session + act_ — SalesActivity + opp_ — Opportunity + cust_ — Customer + sub_ — Subscription +""" diff --git a/leadforge/core/models.py b/leadforge/core/models.py new file mode 100644 index 0000000..57be139 --- /dev/null +++ b/leadforge/core/models.py @@ -0,0 +1,43 @@ +"""Top-level typed configuration and result models. + +WorldSpec and WorldBundle are stubs in M0; they will be populated in M1+. +""" + +from dataclasses import dataclass, field + +from leadforge.core.enums import DifficultyProfile, ExposureMode + + +@dataclass +class GenerationConfig: + """Fully resolved configuration for a single generation run.""" + + recipe_id: str = "b2b_saas_procurement_v1" + seed: int = 42 + exposure_mode: ExposureMode = ExposureMode.student_public + difficulty: DifficultyProfile = DifficultyProfile.intermediate + n_accounts: int = 1500 + n_contacts: int = 4200 + n_leads: int = 5000 + horizon_days: int = 90 + output_path: str = "./out" + + +@dataclass +class WorldSpec: + """Fully instantiated hidden world specification (post-sampling, pre-simulation). + + Populated in Milestone 1 (config/recipe) through Milestone 6 (mechanisms). + """ + + config: GenerationConfig = field(default_factory=GenerationConfig) + + +@dataclass +class WorldBundle: + """In-memory result of one complete generation run. + + Populated in Milestone 7+ (simulation and rendering). + """ + + spec: WorldSpec = field(default_factory=WorldSpec) diff --git a/leadforge/core/rng.py b/leadforge/core/rng.py new file mode 100644 index 0000000..879b3de --- /dev/null +++ b/leadforge/core/rng.py @@ -0,0 +1,6 @@ +"""Seeded RNG root and deterministic substream utilities. + +Implemented in Milestone 1. Every stochastic component in leadforge must +derive its RNG from a single seeded root so that (recipe, config, seed, +version) fully determines all outputs. +""" diff --git a/leadforge/examples/configs/.gitkeep b/leadforge/examples/configs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/examples/notebooks/.gitkeep b/leadforge/examples/notebooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/exposure/__init__.py b/leadforge/exposure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/mechanisms/__init__.py b/leadforge/mechanisms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/narrative/__init__.py b/leadforge/narrative/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/recipes/__init__.py b/leadforge/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/recipes/b2b_saas_procurement_v1/__init__.py b/leadforge/recipes/b2b_saas_procurement_v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml b/leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml new file mode 100644 index 0000000..44b48ac --- /dev/null +++ b/leadforge/recipes/b2b_saas_procurement_v1/recipe.yaml @@ -0,0 +1,20 @@ +id: b2b_saas_procurement_v1 +title: "Mid-market B2B SaaS — Procurement & AP Automation" +vertical: mid_market_b2b_saas +description: > + A mid-market B2B SaaS company selling procurement and AP workflow + automation software to 200–2,000 employee firms in the US and UK, + through a mixed inbound, SDR-assisted, and partner-driven GTM motion. +primary_task: converted_within_90_days +supported_modes: + - student_public + - research_instructor +supported_difficulty: + - intro + - intermediate + - advanced +default_population: + n_accounts: 1500 + n_contacts: 4200 + n_leads: 5000 +horizon_days: 90 diff --git a/leadforge/recipes/registry.py b/leadforge/recipes/registry.py new file mode 100644 index 0000000..4f47dff --- /dev/null +++ b/leadforge/recipes/registry.py @@ -0,0 +1,53 @@ +"""Recipe registry: discovery and loading of generation recipes.""" + +from pathlib import Path +from typing import Any + +import yaml + +from leadforge.core.exceptions import InvalidRecipeError + +_RECIPES_DIR = Path(__file__).parent + + +def _parse_and_validate(path: Path) -> dict[str, Any]: + """Parse a recipe YAML file and validate it is a well-formed dict.""" + with path.open() as fh: + data = yaml.safe_load(fh) + if not isinstance(data, dict) or "id" not in data: + raise InvalidRecipeError( + f"Recipe file '{path}' is malformed: expected a YAML mapping with an 'id' key." + ) + return data # type: ignore[return-value] + + +def list_recipes() -> list[dict[str, Any]]: + """Return metadata for all available recipes, sorted by ID.""" + recipes = [] + for entry in _RECIPES_DIR.iterdir(): + recipe_file = entry / "recipe.yaml" + if entry.is_dir() and recipe_file.exists(): + recipes.append(_parse_and_validate(recipe_file)) + return sorted(recipes, key=lambda recipe: recipe["id"]) + + +def load_recipe(recipe_id: str) -> dict[str, Any]: + """Load and return a recipe by ID. + + Raises: + InvalidRecipeError: if the recipe does not exist, the ID is invalid, + or the recipe file is malformed. + """ + # Guard against path traversal (e.g. recipe_id = "../secret") + base_dir = _RECIPES_DIR.resolve() + recipe_dir = (base_dir / recipe_id).resolve() + if not recipe_dir.is_relative_to(base_dir): + raise InvalidRecipeError(f"Recipe ID '{recipe_id}' is invalid.") + + recipe_file = recipe_dir / "recipe.yaml" + if not recipe_file.exists(): + raise InvalidRecipeError( + f"Recipe '{recipe_id}' not found. " + f"Run 'leadforge list-recipes' to see available recipes." + ) + return _parse_and_validate(recipe_file) diff --git a/leadforge/render/__init__.py b/leadforge/render/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/sample_data/instructor/.gitkeep b/leadforge/sample_data/instructor/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/sample_data/public/.gitkeep b/leadforge/sample_data/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/schema/__init__.py b/leadforge/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/simulation/__init__.py b/leadforge/simulation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/structure/__init__.py b/leadforge/structure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/validation/__init__.py b/leadforge/validation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leadforge/version.py b/leadforge/version.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/leadforge/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1a4d6b2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "leadforge" +version = "0.1.0" +description = "Opinionated framework for generating synthetic CRM and GTM datasets from simulated commercial worlds" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.11" +authors = [{ name = "leadforge contributors" }] +keywords = ["synthetic data", "CRM", "lead scoring", "machine learning", "simulation"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Education", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "typer[all]>=0.12", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.4", + "mypy>=1.10", + "pre-commit>=3.7", + "types-pyyaml>=6.0", +] + +[project.scripts] +leadforge = "leadforge.cli.main:app" + +[tool.setuptools.packages.find] +where = ["."] +include = ["leadforge*"] + +[tool.setuptools.package-data] +"leadforge" = ["**/*.yaml", "**/*.yml"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "PT", "S"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*" = ["S101", "S108"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_enums.py b/tests/core/test_enums.py new file mode 100644 index 0000000..ce508dd --- /dev/null +++ b/tests/core/test_enums.py @@ -0,0 +1,23 @@ +"""Tests for core enums.""" + +from leadforge.core.enums import DifficultyProfile, ExposureMode + + +def test_exposure_mode_values() -> None: + assert ExposureMode.student_public.value == "student_public" + assert ExposureMode.research_instructor.value == "research_instructor" + + +def test_exposure_mode_from_string() -> None: + assert ExposureMode("student_public") is ExposureMode.student_public + assert ExposureMode("research_instructor") is ExposureMode.research_instructor + + +def test_difficulty_profile_values() -> None: + assert DifficultyProfile.intro.value == "intro" + assert DifficultyProfile.intermediate.value == "intermediate" + assert DifficultyProfile.advanced.value == "advanced" + + +def test_difficulty_profile_from_string() -> None: + assert DifficultyProfile("intermediate") is DifficultyProfile.intermediate diff --git a/tests/core/test_exceptions.py b/tests/core/test_exceptions.py new file mode 100644 index 0000000..af777c4 --- /dev/null +++ b/tests/core/test_exceptions.py @@ -0,0 +1,36 @@ +"""Tests for the exception hierarchy.""" + +import pytest + +from leadforge.core.exceptions import ( + GraphConstructionError, + InvalidConfigError, + InvalidRecipeError, + LeadforgeError, + RenderError, + SimulationError, + ValidationError, +) + + +def test_all_exceptions_are_leadforge_errors() -> None: + for exc_class in ( + InvalidRecipeError, + InvalidConfigError, + GraphConstructionError, + SimulationError, + RenderError, + ValidationError, + ): + assert issubclass(exc_class, LeadforgeError) + + +def test_exceptions_are_catchable_as_base() -> None: + with pytest.raises(LeadforgeError): + raise InvalidRecipeError("unknown-recipe") + + +def test_exception_message_preserved() -> None: + msg = "recipe 'foo' not found" + exc = InvalidRecipeError(msg) + assert str(exc) == msg diff --git a/tests/recipes/__init__.py b/tests/recipes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/recipes/test_registry.py b/tests/recipes/test_registry.py new file mode 100644 index 0000000..e3a4877 --- /dev/null +++ b/tests/recipes/test_registry.py @@ -0,0 +1,39 @@ +"""Tests for the recipe registry.""" + +import pytest + +from leadforge.core.exceptions import InvalidRecipeError +from leadforge.recipes.registry import list_recipes, load_recipe + + +def test_list_recipes_returns_list() -> None: + recipes = list_recipes() + assert isinstance(recipes, list) + assert len(recipes) >= 1 + + +def test_list_recipes_contains_v1() -> None: + ids = [r["id"] for r in list_recipes()] + assert "b2b_saas_procurement_v1" in ids + + +def test_v1_recipe_has_required_fields() -> None: + recipe = load_recipe("b2b_saas_procurement_v1") + for field in ("id", "title", "primary_task", "supported_modes", "supported_difficulty"): + assert field in recipe, f"Missing field: {field}" + + +def test_v1_recipe_primary_task() -> None: + recipe = load_recipe("b2b_saas_procurement_v1") + assert recipe["primary_task"] == "converted_within_90_days" + + +def test_v1_recipe_supported_modes() -> None: + recipe = load_recipe("b2b_saas_procurement_v1") + assert "student_public" in recipe["supported_modes"] + assert "research_instructor" in recipe["supported_modes"] + + +def test_load_unknown_recipe_raises() -> None: + with pytest.raises(InvalidRecipeError, match="not found"): + load_recipe("nonexistent_recipe_xyz") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b97c095 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,45 @@ +"""CLI smoke tests.""" + +from typer.testing import CliRunner + +from leadforge.cli.main import app + +runner = CliRunner() + + +def test_help_exits_clean() -> None: + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_version() -> None: + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "leadforge" in result.output + + +def test_list_recipes_exits_clean() -> None: + result = runner.invoke(app, ["list-recipes"]) + assert result.exit_code == 0 + + +def test_list_recipes_shows_v1_recipe() -> None: + result = runner.invoke(app, ["list-recipes"]) + assert "b2b_saas_procurement_v1" in result.output + + +def test_generate_stub_exits_nonzero() -> None: + result = runner.invoke( + app, ["generate", "--recipe", "x", "--seed", "1", "--mode", "y", "--out", "/tmp"] + ) + assert result.exit_code != 0 + + +def test_inspect_stub_exits_nonzero() -> None: + result = runner.invoke(app, ["inspect", "/nonexistent"]) + assert result.exit_code != 0 + + +def test_validate_stub_exits_nonzero() -> None: + result = runner.invoke(app, ["validate", "/nonexistent"]) + assert result.exit_code != 0