diff --git a/soleprint/run.py b/soleprint/run.py
index 7c84d5d..68cd2d1 100644
--- a/soleprint/run.py
+++ b/soleprint/run.py
@@ -519,6 +519,18 @@ try:
except ImportError as e:
print(f"Warning: Could not load tester router: {e}")
+try:
+ from station.tools.graphgen.api import router as graphgen_router
+ app.include_router(graphgen_router, prefix="/station")
+except ImportError as e:
+ print(f"Warning: Could not load graphgen router: {e}")
+
+try:
+ from station.tools.datagen.api import router as datagen_router
+ app.include_router(datagen_router, prefix="/station")
+except ImportError as e:
+ print(f"Warning: Could not load datagen router: {e}")
+
@app.get("/station/{path:path}")
def station_route(path: str):
diff --git a/soleprint/station/tools/datagen/__init__.py b/soleprint/station/tools/datagen/__init__.py
index 9b63caa..aef571b 100644
--- a/soleprint/station/tools/datagen/__init__.py
+++ b/soleprint/station/tools/datagen/__init__.py
@@ -1 +1,5 @@
-"""Datagen - Test data generator for Amar domain models."""
+"""Datagen — test data generator. Room generators extend BaseDataGenerator."""
+
+from .base import BaseDataGenerator
+
+__all__ = ["BaseDataGenerator"]
diff --git a/soleprint/station/tools/datagen/api.py b/soleprint/station/tools/datagen/api.py
new file mode 100644
index 0000000..ef501c8
--- /dev/null
+++ b/soleprint/station/tools/datagen/api.py
@@ -0,0 +1,180 @@
+"""FastAPI router for datagen — test data generator."""
+
+import importlib.util
+import logging
+from pathlib import Path
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+
+log = logging.getLogger(__name__)
+router = APIRouter(prefix="/tools/datagen", tags=["datagen"])
+
+# soleprint/ root
+SPR_ROOT = Path(__file__).parents[3]
+
+# Loaded generators: name → instance
+_generators: dict[str, Any] = {}
+
+
+# ─────────────────────────────────────────────────────
+# Generator loading
+# ─────────────────────────────────────────────────────
+
+def _load_generators() -> dict[str, Any]:
+ """
+ Scan station/tools/datagen/ for generator classes and instantiate them.
+
+ A generator file should define a class whose name ends in "Generator".
+ """
+ datagen_dir = SPR_ROOT / "station" / "tools" / "datagen"
+ result = {}
+
+ for py_file in sorted(datagen_dir.glob("*.py")):
+ if py_file.name.startswith("_") or py_file.stem in ("base", "api"):
+ continue
+ try:
+ spec = importlib.util.spec_from_file_location(py_file.stem, py_file)
+ if not spec or not spec.loader:
+ continue
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+
+ # Find generator class (ends with Generator, not the base)
+ for name, obj in vars(mod).items():
+ if (
+ isinstance(obj, type)
+ and name.endswith("Generator")
+ and name != "BaseDataGenerator"
+ ):
+ result[py_file.stem] = obj()
+ log.info(f"datagen: loaded {name} from {py_file.name}")
+ break
+ except Exception as e:
+ log.warning(f"datagen: could not load {py_file.name}: {e}")
+
+ return result
+
+
+def _get_generators() -> dict[str, Any]:
+ """Return cached generators, loading on first call."""
+ if not _generators:
+ _generators.update(_load_generators())
+ return _generators
+
+
+def _pick_generator(name: str | None) -> tuple[str, Any]:
+ """
+ Pick a generator by name or use the only available one.
+
+ Returns (name, instance). Raises HTTPException if not found.
+ """
+ gens = _get_generators()
+ if not gens:
+ raise HTTPException(status_code=503, detail="No generators loaded")
+
+ if name:
+ if name not in gens:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Generator '{name}' not found. Available: {list(gens)}"
+ )
+ return name, gens[name]
+
+ # Default: first (usually only) generator
+ key = next(iter(gens))
+ return key, gens[key]
+
+
+# ─────────────────────────────────────────────────────
+# Pydantic models
+# ─────────────────────────────────────────────────────
+
+class GenerateRequest(BaseModel):
+ model: str
+ count: int = 1
+ generator: str | None = None
+ kwargs: dict = {}
+
+
+# ─────────────────────────────────────────────────────
+# Routes
+# ─────────────────────────────────────────────────────
+
+@router.get("/", response_class=HTMLResponse)
+def index():
+ html = Path(__file__).parent / "templates" / "index.html"
+ return HTMLResponse(html.read_text() if html.exists() else "
datagen
")
+
+
+@router.get("/health")
+def health():
+ return {"status": "ok", "tool": "datagen"}
+
+
+@router.get("/api/generators")
+def list_generators():
+ """List loaded generator files."""
+ gens = _get_generators()
+ return {
+ "generators": [
+ {
+ "name": k,
+ "models": v.available_models() if hasattr(v, "available_models") else [],
+ }
+ for k, v in gens.items()
+ ]
+ }
+
+
+@router.get("/api/models")
+def list_models(generator: str | None = None):
+ """List available model generators."""
+ _, gen = _pick_generator(generator)
+ if hasattr(gen, "available_models"):
+ models = gen.available_models()
+ else:
+ models = [
+ n for n in dir(gen)
+ if not n.startswith("_") and callable(getattr(gen, n))
+ ]
+ return {"models": models}
+
+
+@router.post("/api/generate")
+def generate(req: GenerateRequest):
+ """Generate test data for a model."""
+ gen_name, gen = _pick_generator(req.generator)
+ try:
+ if hasattr(gen, "generate"):
+ items = gen.generate(req.model, req.count, **req.kwargs)
+ else:
+ method = getattr(gen, req.model.lower(), None)
+ if not method:
+ raise ValueError(f"No method '{req.model.lower()}' on generator")
+ items = [method(**req.kwargs) for _ in range(req.count)]
+ except (ValueError, TypeError) as e:
+ raise HTTPException(status_code=400, detail=str(e))
+ except Exception as e:
+ log.exception(f"datagen: generate error")
+ raise HTTPException(status_code=500, detail=str(e))
+
+ return {
+ "generator": gen_name,
+ "model": req.model,
+ "count": len(items),
+ "items": items,
+ }
+
+
+@router.get("/api/schema")
+def get_schema(generator: str | None = None):
+ """Return graphgen-compatible schema from the generator (if it provides one)."""
+ _, gen = _pick_generator(generator)
+ if hasattr(gen, "schema"):
+ s = gen.schema()
+ if s:
+ return s
+ return {"models": {}, "note": "This generator does not expose a schema."}
diff --git a/soleprint/station/tools/datagen/base.py b/soleprint/station/tools/datagen/base.py
new file mode 100644
index 0000000..2d092b5
--- /dev/null
+++ b/soleprint/station/tools/datagen/base.py
@@ -0,0 +1,80 @@
+"""
+BaseDataGenerator — base class for room-specific data generators.
+
+Room generators extend this class and define methods named after their models:
+
+ class MyGenerator(BaseDataGenerator):
+ def user(self, **kwargs):
+ return {"id": uuid4(), "name": fake.name(), **kwargs}
+
+ def product(self, category=None, **kwargs):
+ return {"id": uuid4(), "name": fake.word(), "category": category, **kwargs}
+
+The base class provides:
+- generate(model, count, **kwargs) — call the right method N times
+- available_models() — list of method names (= model names)
+- schema() — override to return graphgen-compatible schema
+"""
+
+
+class BaseDataGenerator:
+ """Base class for room-specific data generators."""
+
+ # Attribute names that are NOT model generators
+ _RESERVED = frozenset({"generate", "available_models", "schema"})
+
+ def generate(self, model: str, count: int = 1, **kwargs) -> list[dict]:
+ """
+ Generate `count` instances of the given model.
+
+ Args:
+ model: Model name (case-insensitive, matches method name).
+ count: Number of items to generate.
+ **kwargs: Passed through to the model method on every call.
+
+ Returns:
+ List of generated dicts.
+
+ Raises:
+ ValueError if no generator exists for the model.
+ """
+ method = getattr(self, model.lower(), None)
+ if method is None or not callable(method):
+ available = self.available_models()
+ raise ValueError(
+ f"No generator for '{model}'. "
+ f"Available: {available if available else '(none)'}"
+ )
+ return [method(**kwargs) for _ in range(count)]
+
+ def available_models(self) -> list[str]:
+ """Return sorted list of model names this generator supports."""
+ return sorted(
+ name for name in dir(self)
+ if not name.startswith("_")
+ and name not in self._RESERVED
+ and callable(getattr(self, name))
+ )
+
+ def schema(self) -> dict | None:
+ """
+ Optional: return a graphgen-compatible schema dict.
+
+ Override to integrate with graphgen's /api/schema — datagen will
+ surface this via /tools/datagen/api/schema.
+
+ Expected format:
+ {
+ "models": {
+ "User": {
+ "doc": "...",
+ "fields": {
+ "id": {"type": "UUID", "pk": true},
+ "email": {"type": "str"},
+ "org_id": {"type": "FK:Organization"}
+ }
+ }
+ }
+ }
+ """
+ return None
diff --git a/soleprint/station/tools/datagen/templates/index.html b/soleprint/station/tools/datagen/templates/index.html
new file mode 100644
index 0000000..0640694
--- /dev/null
+++ b/soleprint/station/tools/datagen/templates/index.html
@@ -0,0 +1,497 @@
+
+
+
+
+
+ datagen — Test Data Generator
+
+
+
+
+
datagen
+ —
+ loading…
+
+
+
+
+
+
+
Model
+
+
+
+
+
+
+
+
+
Override fields (optional)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select a model and click Generate
+
Results appear here as formatted JSON
+
+
+
+
+
+
+
+
+
diff --git a/soleprint/station/tools/graphgen/__init__.py b/soleprint/station/tools/graphgen/__init__.py
new file mode 100644
index 0000000..dcfd0e7
--- /dev/null
+++ b/soleprint/station/tools/graphgen/__init__.py
@@ -0,0 +1 @@
+"""Graphgen — interactive DB schema visualization."""
diff --git a/soleprint/station/tools/graphgen/api.py b/soleprint/station/tools/graphgen/api.py
new file mode 100644
index 0000000..e6f730c
--- /dev/null
+++ b/soleprint/station/tools/graphgen/api.py
@@ -0,0 +1,50 @@
+"""FastAPI router for graphgen — schema graph explorer."""
+
+import logging
+from pathlib import Path
+
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse
+
+from .schema import load_graph_schema
+
+log = logging.getLogger(__name__)
+router = APIRouter(prefix="/tools/graphgen", tags=["graphgen"])
+
+# soleprint/ root (this file: soleprint/station/tools/graphgen/api.py)
+SPR_ROOT = Path(__file__).parents[3]
+
+
+def _schema_paths() -> list[Path]:
+ """Return ordered list of directories to search for schema."""
+ paths = []
+
+ # cfg//station/tools/graphgen/ (room-specific, highest priority)
+ cfg_dir = SPR_ROOT / "cfg"
+ if cfg_dir.exists():
+ for room in sorted(cfg_dir.iterdir()):
+ if room.is_dir() and not room.name.startswith(("_", ".")):
+ paths.append(room / "station" / "tools" / "graphgen")
+
+ # station/tools/modelgen/ (shared modelgen schema/)
+ paths.append(SPR_ROOT / "station" / "tools" / "modelgen")
+
+ return paths
+
+
+@router.get("/", response_class=HTMLResponse)
+def index():
+ """Serve the graph viewer UI."""
+ html = Path(__file__).parent / "templates" / "index.html"
+ return HTMLResponse(html.read_text() if html.exists() else "graphgen
")
+
+
+@router.get("/health")
+def health():
+ return {"status": "ok", "tool": "graphgen"}
+
+
+@router.get("/api/schema")
+def get_schema():
+ """Return graph-ready schema: {models, relationships, source}."""
+ return load_graph_schema(_schema_paths())
diff --git a/soleprint/station/tools/graphgen/schema.py b/soleprint/station/tools/graphgen/schema.py
new file mode 100644
index 0000000..f1cd0bb
--- /dev/null
+++ b/soleprint/station/tools/graphgen/schema.py
@@ -0,0 +1,174 @@
+"""
+Schema reader for graphgen.
+
+Loads schema from multiple sources (priority order):
+1. schema.json in a cfg/*/station/tools/graphgen/ directory
+2. modelgen schema/ folder (Python dataclasses)
+
+Output format:
+{
+ "models": [{"id", "name", "doc", "fields": [{"name", "type", "pk", "fk", "m2m", "nullable"}]}],
+ "relationships": [{"from_model", "from_field", "to_model", "type"}],
+ "source": "path or description"
+}
+"""
+
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
+log = logging.getLogger(__name__)
+
+
+def load_graph_schema(search_paths: list[Path]) -> dict:
+ """Search for schema in given paths and return graph-ready data."""
+ for base in search_paths:
+ if not base.exists():
+ continue
+
+ # schema.json (JSON format)
+ json_path = base / "schema.json"
+ if json_path.exists():
+ log.info(f"graphgen: loading schema from {json_path}")
+ return _load_json_schema(json_path)
+
+ # modelgen schema/ folder
+ py_schema = base / "schema"
+ if (py_schema / "__init__.py").exists():
+ log.info(f"graphgen: loading Python schema from {py_schema}")
+ return _load_py_schema(py_schema)
+
+ return {"models": [], "relationships": [], "source": "empty"}
+
+
+def _load_json_schema(path: Path) -> dict:
+ """Load from schema.json — simple declarative format."""
+ try:
+ data = json.loads(path.read_text())
+ except Exception as e:
+ log.warning(f"graphgen: could not parse {path}: {e}")
+ return {"models": [], "relationships": [], "source": str(path), "error": str(e)}
+
+ models = []
+ relationships = []
+
+ for model_name, model_data in data.get("models", {}).items():
+ fields = []
+ for field_name, field_data in model_data.get("fields", {}).items():
+ field_type = field_data.get("type", "str")
+ fk_target = None
+ m2m = False
+
+ if field_type.startswith("FK:"):
+ fk_target = field_type[3:]
+ field_type = "FK"
+ relationships.append({
+ "from_model": model_name,
+ "from_field": field_name,
+ "to_model": fk_target,
+ "type": "FK",
+ })
+ elif field_type.startswith("M2M:"):
+ fk_target = field_type[4:]
+ field_type = "M2M"
+ m2m = True
+ relationships.append({
+ "from_model": model_name,
+ "from_field": field_name,
+ "to_model": fk_target,
+ "type": "M2M",
+ })
+
+ fields.append({
+ "name": field_name,
+ "type": field_type,
+ "pk": field_data.get("pk", False),
+ "fk": fk_target,
+ "m2m": m2m,
+ "nullable": field_data.get("nullable", False),
+ })
+
+ models.append({
+ "id": model_name,
+ "name": model_name,
+ "doc": model_data.get("doc", ""),
+ "fields": fields,
+ })
+
+ return {
+ "models": models,
+ "relationships": relationships,
+ "source": str(path),
+ }
+
+
+def _load_py_schema(schema_dir: Path) -> dict:
+ """Load from modelgen Python dataclasses schema/ folder."""
+ try:
+ from ..modelgen.loader.schema import SchemaLoader
+
+ loader = SchemaLoader(schema_dir).load()
+ return _convert_modelgen(loader, str(schema_dir))
+ except Exception as e:
+ log.warning(f"graphgen: Python schema load failed: {e}")
+ return {
+ "models": [],
+ "relationships": [],
+ "source": str(schema_dir),
+ "error": str(e),
+ }
+
+
+def _convert_modelgen(loader: Any, source: str) -> dict:
+ """Convert modelgen SchemaLoader output to graph data."""
+ all_names = {m.name for m in loader.models}
+ models = []
+ relationships = []
+
+ for model_def in loader.models:
+ fields = []
+ for field in model_def.fields:
+ type_str = _type_str(field.type_hint)
+ fk_target = None
+
+ # FK: type name that matches another model
+ if type_str in all_names:
+ fk_target = type_str
+ relationships.append({
+ "from_model": model_def.name,
+ "from_field": field.name,
+ "to_model": fk_target,
+ "type": "FK",
+ })
+ # Django extractor produces "FK" / "M2M" strings
+ elif type_str == "FK":
+ fk_target = None # target unknown from extractor
+
+ fields.append({
+ "name": field.name,
+ "type": type_str,
+ "pk": field.name == "id",
+ "fk": fk_target,
+ "m2m": type_str == "M2M",
+ "nullable": field.optional,
+ })
+
+ models.append({
+ "id": model_def.name,
+ "name": model_def.name,
+ "doc": model_def.docstring or "",
+ "fields": fields,
+ })
+
+ return {"models": models, "relationships": relationships, "source": source}
+
+
+def _type_str(t: Any) -> str:
+ if t is None:
+ return "Any"
+ if isinstance(t, str):
+ return t
+ if hasattr(t, "__name__"):
+ return t.__name__
+ return str(t)
diff --git a/soleprint/station/tools/graphgen/templates/index.html b/soleprint/station/tools/graphgen/templates/index.html
new file mode 100644
index 0000000..c3edb0e
--- /dev/null
+++ b/soleprint/station/tools/graphgen/templates/index.html
@@ -0,0 +1,729 @@
+
+
+
+
+
+ graphgen — Schema Explorer
+
+
+
+
+
graphgen
+
—
+
loading…
+
+
+
+
+
+
+
+
+
+
+
No schema found
+
Add a schema.json to
cfg/<room>/station/tools/graphgen/
+
+
100%
+
+
+
+
+
+
+
+