feat: scaffold tolkien v0 — local-LLM classifier wrapper + CLI + eval
Initial skeleton for the deterministic compound-AI pipeline that turns `tolkien deploy <name>` into a valinor scaffold. v0 wires one classifier (`db_kind`) end-to-end against the Ollama-on-radagast backend so we can iterate prompts with confidence before adding the rest. - pyproject.toml: uv project, Python 3.13, httpx/pydantic/typer/pyyaml. - src/tolkien/llm.py: single-turn /api/chat wrapper. JSON-mode + temp 0 + 30m keep-alive. Pydantic schema validation on parse. - src/tolkien/cli.py: `tolkien classify db-kind --doc <file>` runs the classifier and prints JSON. `tolkien deploy <name>` is stubbed. - src/tolkien/prompts/db_kind.md: tight system prompt with 1-shot example and explicit "return ONLY JSON" guard. - src/tolkien/schemas.py: DbKindResult pydantic model. - eval/cases/mealie.yml + run_eval.py: regression harness. Currently one case (mealie); failures print field-level diffs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# uv / build
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Tolkien-specific
|
||||||
|
out/ # scaffold output directory (v0)
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# tolkien
|
||||||
|
|
||||||
|
Local-LLM orchestrator that turns `tolkien deploy mealie` into a complete valinor
|
||||||
|
GitOps scaffold (config.yaml, values.yaml, CNPG cluster, Vault secrets, Terraform
|
||||||
|
stubs) ready for the operator to review and PR.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Compound-AI pipeline, not a ReAct loop. Each step in the deploy workflow is a
|
||||||
|
single LLM call that produces structured JSON — the surrounding logic is plain
|
||||||
|
Python. This shape works reliably on a CPU-served 7B model where a free-form
|
||||||
|
agent loop would drift.
|
||||||
|
|
||||||
|
```
|
||||||
|
deploy <name>
|
||||||
|
├── search.find_helm_chart()
|
||||||
|
├── classify.has_chart(results) — LLM, JSON
|
||||||
|
├── fetch.app_docs(url)
|
||||||
|
├── classify.db_kind(docs) — LLM, JSON
|
||||||
|
├── classify.needs_object_storage(docs)— LLM, JSON
|
||||||
|
├── classify.secrets_vs_config(env) — LLM, JSON
|
||||||
|
├── classify.storage_class(docs) — LLM, JSON
|
||||||
|
└── render.scaffold(facts) — Jinja2 → ./out/<name>/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Ollama running on radagast (`http://radagast.jpnadas.xyz:11434`), serving
|
||||||
|
Qwen2.5-Coder-7B-Instruct Q4_K_M. See valinor/`ansible/playbooks/radagast-setup.yml`
|
||||||
|
for the host setup.
|
||||||
|
|
||||||
|
## v0 scope
|
||||||
|
|
||||||
|
- Single classifier (`db_kind`) wired end-to-end against the live model
|
||||||
|
- CLI: `tolkien classify db-kind --doc <file>` prints JSON
|
||||||
|
- Eval harness with one case (mealie)
|
||||||
|
- Output to stdout / `./out/`; no git push, no FastAPI, no Redis
|
||||||
|
|
||||||
|
v1 layers on FastAPI + Redis + a Gitea bot to push branches; v2 wires a Gitea
|
||||||
|
webhook for issue-comment triggers.
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run tolkien --help
|
||||||
|
uv run pytest
|
||||||
|
uv run ruff check .
|
||||||
|
```
|
||||||
36
eval/cases/mealie.yml
Normal file
36
eval/cases/mealie.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Eval case for the `db_kind` classifier.
|
||||||
|
# The pipeline's correctness on real apps is gated on this kind of fixture —
|
||||||
|
# re-run `eval/run_eval.py` after every prompt change.
|
||||||
|
|
||||||
|
name: mealie
|
||||||
|
input: |
|
||||||
|
App: mealie (recipe manager). Excerpt from official docker-compose docs:
|
||||||
|
|
||||||
|
environment:
|
||||||
|
DB_ENGINE: postgres
|
||||||
|
POSTGRES_USER: mealie
|
||||||
|
POSTGRES_PASSWORD: change-me
|
||||||
|
POSTGRES_SERVER: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: mealie
|
||||||
|
ALLOW_SIGNUP: "true"
|
||||||
|
BASE_URL: https://mealie.example.com
|
||||||
|
OPENAI_API_KEY: sk-... # optional, enables recipe parsing via OpenAI
|
||||||
|
TZ: Europe/Amsterdam
|
||||||
|
|
||||||
|
expected:
|
||||||
|
needs_postgres: true
|
||||||
|
db_env_vars:
|
||||||
|
- DB_ENGINE
|
||||||
|
- POSTGRES_USER
|
||||||
|
- POSTGRES_PASSWORD
|
||||||
|
- POSTGRES_SERVER
|
||||||
|
- POSTGRES_PORT
|
||||||
|
- POSTGRES_DB
|
||||||
|
secrets:
|
||||||
|
- POSTGRES_PASSWORD
|
||||||
|
- OPENAI_API_KEY
|
||||||
|
config:
|
||||||
|
- ALLOW_SIGNUP
|
||||||
|
- BASE_URL
|
||||||
|
- TZ
|
||||||
70
eval/run_eval.py
Normal file
70
eval/run_eval.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Run all eval cases and report which pass/fail.
|
||||||
|
|
||||||
|
Each case has a `name`, an `input` string, and an `expected` dict. The current
|
||||||
|
v0 only exercises the `db_kind` classifier. As more classifiers come online,
|
||||||
|
each case will grow expected sections for them too.
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
|
||||||
|
uv run python eval/run_eval.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from tolkien.llm import classify
|
||||||
|
from tolkien.schemas import DbKindResult
|
||||||
|
|
||||||
|
CASES_DIR = Path(__file__).parent / "cases"
|
||||||
|
|
||||||
|
|
||||||
|
def _diff_fields(expected: dict, actual: dict) -> list[str]:
|
||||||
|
"""Return human-readable lines describing field-level mismatches."""
|
||||||
|
diffs: list[str] = []
|
||||||
|
for key, want in expected.items():
|
||||||
|
got = actual.get(key)
|
||||||
|
if isinstance(want, list) and isinstance(got, list):
|
||||||
|
want_set, got_set = set(want), set(got)
|
||||||
|
missing = want_set - got_set
|
||||||
|
extra = got_set - want_set
|
||||||
|
if missing or extra:
|
||||||
|
diffs.append(f" {key}: missing={sorted(missing)} extra={sorted(extra)}")
|
||||||
|
elif want != got:
|
||||||
|
diffs.append(f" {key}: want={want!r} got={got!r}")
|
||||||
|
return diffs
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
cases = sorted(CASES_DIR.glob("*.yml"))
|
||||||
|
if not cases:
|
||||||
|
print(f"No cases found in {CASES_DIR}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
failures = 0
|
||||||
|
for case_path in cases:
|
||||||
|
case = yaml.safe_load(case_path.read_text())
|
||||||
|
name = case["name"]
|
||||||
|
result = classify("db_kind", case["input"], DbKindResult)
|
||||||
|
actual = result.model_dump()
|
||||||
|
diffs = _diff_fields(case["expected"], actual)
|
||||||
|
if diffs:
|
||||||
|
failures += 1
|
||||||
|
print(f"FAIL {name}")
|
||||||
|
for line in diffs:
|
||||||
|
print(line)
|
||||||
|
else:
|
||||||
|
print(f"PASS {name}")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"\n{failures}/{len(cases)} cases failed.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"\nAll {len(cases)} cases passed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "tolkien"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Local-LLM orchestrator that scaffolds valinor app deployments from a one-line spec."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27",
|
||||||
|
"pydantic>=2.8",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"typer>=0.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tolkien = "tolkien.cli:app"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/tolkien"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||||
1
src/tolkien/__init__.py
Normal file
1
src/tolkien/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
4
src/tolkien/__main__.py
Normal file
4
src/tolkien/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
from tolkien.cli import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
46
src/tolkien/cli.py
Normal file
46
src/tolkien/cli.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Tolkien CLI — v0 surface area.
|
||||||
|
|
||||||
|
v0 exposes the building blocks (classify) directly so we can iterate prompts
|
||||||
|
against the live model. The full `deploy` pipeline lands once the individual
|
||||||
|
classifiers are reliable on the eval cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from tolkien.llm import LLMError, classify
|
||||||
|
from tolkien.schemas import DbKindResult
|
||||||
|
|
||||||
|
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
||||||
|
classify_app = typer.Typer(no_args_is_help=True)
|
||||||
|
app.add_typer(classify_app, name="classify", help="Run a single classifier against the LLM.")
|
||||||
|
|
||||||
|
|
||||||
|
@classify_app.command("db-kind")
|
||||||
|
def cli_classify_db_kind(
|
||||||
|
doc: Annotated[Path, typer.Option("--doc", help="Path to the documentation snippet.")],
|
||||||
|
) -> None:
|
||||||
|
"""Classify the DB requirements + env vars of an app from its docs."""
|
||||||
|
user_input = doc.read_text()
|
||||||
|
try:
|
||||||
|
result = classify("db_kind", user_input, DbKindResult)
|
||||||
|
except LLMError as e:
|
||||||
|
typer.echo(f"LLM error: {e}", err=True)
|
||||||
|
raise typer.Exit(1) from e
|
||||||
|
typer.echo(json.dumps(result.model_dump(), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def deploy(name: Annotated[str, typer.Argument(help="App name, e.g. 'mealie'.")]) -> None:
|
||||||
|
"""Generate a valinor scaffold for the given app. (Stubbed — coming next.)"""
|
||||||
|
typer.echo(f"deploy {name}: pipeline not yet implemented.", err=True)
|
||||||
|
raise typer.Exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
70
src/tolkien/llm.py
Normal file
70
src/tolkien/llm.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Thin Ollama /api/chat wrapper with JSON-mode + pydantic schema validation.
|
||||||
|
|
||||||
|
Each call is a single turn: system prompt + user message in, structured JSON out.
|
||||||
|
No streaming, no tool use, no multi-turn — those are footguns at 7B-class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
OLLAMA_HOST = "http://radagast.jpnadas.xyz:11434"
|
||||||
|
MODEL = "qwen2.5-coder:7b-instruct"
|
||||||
|
KEEP_ALIVE = "30m"
|
||||||
|
TIMEOUT_SECONDS = 120.0
|
||||||
|
|
||||||
|
PROMPTS_DIR = Path(__file__).parent / "prompts"
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""LLM produced output that couldn't be parsed or validated."""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_prompt(name: str) -> str:
|
||||||
|
return (PROMPTS_DIR / f"{name}.md").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def classify[T: BaseModel](
|
||||||
|
prompt_name: str,
|
||||||
|
user_input: str,
|
||||||
|
schema: type[T],
|
||||||
|
*,
|
||||||
|
host: str = OLLAMA_HOST,
|
||||||
|
model: str = MODEL,
|
||||||
|
) -> T:
|
||||||
|
"""Run a single-turn classify call and validate the output against `schema`.
|
||||||
|
|
||||||
|
Raises LLMError if the model output isn't valid JSON or doesn't match the schema.
|
||||||
|
"""
|
||||||
|
system_prompt = _read_prompt(prompt_name)
|
||||||
|
|
||||||
|
response = httpx.post(
|
||||||
|
f"{host}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"stream": False,
|
||||||
|
"format": "json",
|
||||||
|
"keep_alive": KEEP_ALIVE,
|
||||||
|
"options": {"temperature": 0},
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_input},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timeout=TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
raw = response.json()["message"]["content"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
return schema.model_validate_json(raw)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise LLMError(
|
||||||
|
f"Schema validation failed for prompt '{prompt_name}'.\n"
|
||||||
|
f"Raw output:\n{raw}\n\n"
|
||||||
|
f"Errors:\n{json.dumps(e.errors(), indent=2)}"
|
||||||
|
) from e
|
||||||
19
src/tolkien/prompts/db_kind.md
Normal file
19
src/tolkien/prompts/db_kind.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
You analyze an app's deployment documentation and emit JSON describing what it needs.
|
||||||
|
|
||||||
|
Schema (return EXACTLY these keys):
|
||||||
|
- needs_postgres: boolean. True only if PostgreSQL is required (not optional, not SQLite, not MariaDB).
|
||||||
|
- db_env_vars: array of env-var names that configure the database connection.
|
||||||
|
- secrets: array of env-var names that hold sensitive values (passwords, tokens, keys). Strict — only include vars that are clearly secret.
|
||||||
|
- config: array of env-var names that are plain configuration (URLs, booleans, ints).
|
||||||
|
|
||||||
|
Example input:
|
||||||
|
environment:
|
||||||
|
DB_TYPE: sqlite
|
||||||
|
WORKERS: 4
|
||||||
|
SECRET_KEY: change-me
|
||||||
|
BASE_URL: https://app.example.com
|
||||||
|
|
||||||
|
Example output:
|
||||||
|
{"needs_postgres": false, "db_env_vars": ["DB_TYPE"], "secrets": ["SECRET_KEY"], "config": ["WORKERS", "BASE_URL"]}
|
||||||
|
|
||||||
|
Return ONLY the JSON object. No prose, no markdown fences.
|
||||||
19
src/tolkien/schemas.py
Normal file
19
src/tolkien/schemas.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DbKindResult(BaseModel):
|
||||||
|
needs_postgres: bool = Field(
|
||||||
|
description="True only if PostgreSQL is required (not optional, not SQLite, not MariaDB)."
|
||||||
|
)
|
||||||
|
db_env_vars: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Env var names that configure the database connection.",
|
||||||
|
)
|
||||||
|
secrets: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Env var names that hold sensitive values (passwords, tokens, keys).",
|
||||||
|
)
|
||||||
|
config: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Env var names that are plain non-sensitive configuration.",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user