From e5b4f54cd89c3e1ccf02a192a3fbab8e9d26f847 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 17 Jul 2026 22:12:44 +0200 Subject: [PATCH] Scaffold backend (FastAPI/uv) and frontend (SvelteKit/bun) Backend: FastAPI app with health check, AppError hierarchy, stderr logging via LOG_LEVEL, ruff+pyright(strict) config, pytest passing. Frontend: SvelteKit (TS) with prettier, eslint, vitest (unit + component), adapter-node for self-hosted deployment on Zeus. Co-Authored-By: Claude --- .gitignore | 1 + README.md | 21 ++++++-- backend/.python-version | 1 + backend/README.md | 17 +++++++ backend/app/__init__.py | 0 backend/app/errors.py | 41 +++++++++++++++ backend/app/logging_config.py | 25 ++++++++++ backend/app/main.py | 33 ++++++++++++ backend/pyproject.toml | 50 +++++++++++++++++++ backend/secrets.toml.example | 9 ++++ backend/tests/__init__.py | 0 backend/tests/test_health.py | 11 ++++ frontend/.gitignore | 23 +++++++++ frontend/.npmrc | 1 + frontend/.prettierignore | 9 ++++ frontend/.vscode/extensions.json | 3 ++ frontend/README.md | 42 ++++++++++++++++ frontend/eslint.config.js | 41 +++++++++++++++ frontend/package.json | 40 +++++++++++++++ frontend/prettier.config.js | 11 ++++ frontend/src/app.d.ts | 13 +++++ frontend/src/app.html | 12 +++++ frontend/src/lib/assets/favicon.svg | 1 + frontend/src/lib/index.ts | 1 + .../src/lib/vitest-examples/Welcome.svelte | 8 +++ .../vitest-examples/Welcome.svelte.spec.ts | 15 ++++++ .../src/lib/vitest-examples/greet.spec.ts | 8 +++ frontend/src/lib/vitest-examples/greet.ts | 3 ++ frontend/src/routes/+layout.svelte | 11 ++++ frontend/src/routes/+page.svelte | 2 + frontend/static/robots.txt | 3 ++ frontend/tsconfig.json | 20 ++++++++ frontend/vite.config.ts | 45 +++++++++++++++++ 33 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 backend/.python-version create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/errors.py create mode 100644 backend/app/logging_config.py create mode 100644 backend/app/main.py create mode 100644 backend/pyproject.toml create mode 100644 backend/secrets.toml.example create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_health.py create mode 100644 frontend/.gitignore create mode 100644 frontend/.npmrc create mode 100644 frontend/.prettierignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/package.json create mode 100644 frontend/prettier.config.js create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/lib/assets/favicon.svg create mode 100644 frontend/src/lib/index.ts create mode 100644 frontend/src/lib/vitest-examples/Welcome.svelte create mode 100644 frontend/src/lib/vitest-examples/Welcome.svelte.spec.ts create mode 100644 frontend/src/lib/vitest-examples/greet.spec.ts create mode 100644 frontend/src/lib/vitest-examples/greet.ts create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/static/robots.txt create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore index 3a3a2bc..29eba78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Lock files uv.lock bun.lockb +bun.lock # Secrets & live config (commit *.toml.example only) secrets.toml diff --git a/README.md b/README.md index 8e50a2f..e875a18 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,26 @@ See [SPEC.md](SPEC.md) for the full spec, architecture, and design areas. ## Status -Spec-only. Implementation not started yet. +Scaffolded, no features implemented yet. `backend/` and `frontend/` both run +and have a passing test suite; actual simulation/narration/TTS logic is next. -## Stack (planned) +## Stack -- Frontend: SvelteKit -- Backend: FastAPI (Python, `uv`) +- Frontend: SvelteKit (`frontend/`) — `bun` +- Backend: FastAPI (`backend/`) — `uv` - TTS: Piper (server-side) - Narration: Anthropic Haiku API (server-side) - Reverse proxy: Caddy - Deployment target: Zeus, `sagaphone.orbitstack.casa` + +## Dev + +```bash +# backend +cd backend && uv sync && uv run uvicorn app.main:app --reload + +# frontend +cd frontend && bun install && bun run dev +``` + +Each subproject's own README has more detail (lint/test/typecheck commands). diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..63bdf12 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,17 @@ +# sagaphone-backend + +FastAPI backend for Sagaphone: generates the family tree, calls the Haiku API +for narration, streams Piper TTS audio to the frontend. + +## Dev + +```bash +uv sync +uv run uvicorn app.main:app --reload +uv run pytest +uv run ruff check . +uv run pyright +``` + +See `secrets.toml.example` for required config — copy to `secrets.toml` +(git-ignored) and fill in real values. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/errors.py b/backend/app/errors.py new file mode 100644 index 0000000..2c9d04e --- /dev/null +++ b/backend/app/errors.py @@ -0,0 +1,41 @@ +"""Base exception hierarchy for the Sagaphone backend. + +Errors propagate normally; only the API edge (see main.py's exception +handler) catches them and translates them into an HTTP response. +""" + +from __future__ import annotations + + +class AppError(Exception): + """Base class for all application errors.""" + + status_code: int = 500 + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +class NotFoundError(AppError): + """Requested resource does not exist.""" + + status_code = 404 + + +class ConfigError(AppError): + """Missing or invalid configuration (e.g. secrets.toml).""" + + status_code = 500 + + +class NarrationError(AppError): + """The narration (Haiku) call failed or returned something unusable.""" + + status_code = 502 + + +class TTSError(AppError): + """The TTS (Piper) synthesis step failed.""" + + status_code = 502 diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py new file mode 100644 index 0000000..0d2ba98 --- /dev/null +++ b/backend/app/logging_config.py @@ -0,0 +1,25 @@ +"""Logging setup for the backend. + +Follows the project's output contract: all log output goes to stderr, +level controlled via the LOG_LEVEL env var (default INFO). This is server +logging, not the request/response payload — that stays in FastAPI's +responses. +""" + +from __future__ import annotations + +import logging +import os +import sys + + +def configure_logging() -> None: + level_name = os.environ.get("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s %(name)s: %(message)s")) + + root = logging.getLogger() + root.setLevel(level) + root.handlers = [handler] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..85e109f --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,33 @@ +"""Sagaphone FastAPI entrypoint. + +Routes are added as features land (see ../SPEC.md and ../spec/*.md). +This currently only wires up app creation, logging, and a health check +so the scaffold is runnable end to end. +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app.errors import AppError +from app.logging_config import configure_logging + +configure_logging() +logger = logging.getLogger(__name__) + +app = FastAPI(title="Sagaphone", version="0.1.0") + + +@app.exception_handler(AppError) +async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse: + """Edge-catch: translate AppError subclasses into clean HTTP responses.""" + logger.error("request failed: %s", exc.message) + return JSONResponse(status_code=exc.status_code, content={"error": exc.message}) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..021f7f8 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "sagaphone-backend" +version = "0.1.0" +description = "Sagaphone backend — family-tree simulation, narration, and TTS streaming API." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.32", + "anthropic>=0.40", +] + +[dependency-groups] +dev = [ + "ruff>=0.8", + "pyright>=1.1.390", + "pytest>=8.3", + "pytest-asyncio>=0.24", + "httpx>=0.27", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pyright] +include = ["app", "tests"] +typeCheckingMode = "strict" +pythonVersion = "3.12" + +[[tool.pyright.executionEnvironments]] +root = "tests" +reportUnknownMemberType = false +reportUnknownVariableType = false +reportUnknownParameterType = false +reportMissingParameterType = false +reportMissingTypeStubs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] diff --git a/backend/secrets.toml.example b/backend/secrets.toml.example new file mode 100644 index 0000000..b0b9229 --- /dev/null +++ b/backend/secrets.toml.example @@ -0,0 +1,9 @@ +# Copy to secrets.toml and fill in real values. secrets.toml is git-ignored. + +[anthropic] +api_key = "sk-ant-..." # Haiku API key, server-side only, never sent to the client + +[server] +host = "0.0.0.0" +port = 8000 +log_level = "INFO" # DEBUG | INFO | WARN | ERROR diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..010f2bf --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,11 @@ +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_health() -> None: + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..4d5e64f --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1672723 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.16.3 create --template minimal --types ts --no-install frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ed35999 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,41 @@ +import prettier from 'eslint-config-prettier'; +import path from 'node:path'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig, includeIgnoreFile } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; + +const gitignorePath = path.resolve(import.meta.dirname, '.gitignore'); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ts.configs.recommended, + svelte.configs.recommended, + prettier, + svelte.configs.prettier, + { + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser + } + } + }, + { + // Override or add rule settings here, such as: + // 'svelte/button-has-type': 'error' + rules: {} + } +); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a05470d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,40 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write .", + "test:unit": "vitest", + "test": "bun run test:unit -- --run" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^22", + "@vitest/browser-playwright": "^4.1.8", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.19.0", + "globals": "^17.6.0", + "playwright": "^1.60.0", + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^4.1.0", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.1", + "vite": "^8.0.16", + "vitest": "^4.1.8", + "vitest-browser-svelte": "^2.1.1" + } +} diff --git a/frontend/prettier.config.js b/frontend/prettier.config.js new file mode 100644 index 0000000..718ddb8 --- /dev/null +++ b/frontend/prettier.config.js @@ -0,0 +1,11 @@ +/** @type {import("prettier").Config} */ +const config = { + useTabs: true, + singleQuote: true, + trailingComma: 'none', + printWidth: 100, + plugins: ['prettier-plugin-svelte'], + overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }] +}; + +export default config; diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/vitest-examples/Welcome.svelte b/frontend/src/lib/vitest-examples/Welcome.svelte new file mode 100644 index 0000000..c7b8460 --- /dev/null +++ b/frontend/src/lib/vitest-examples/Welcome.svelte @@ -0,0 +1,8 @@ + + +

{greet(host)}

+

{greet(guest)}

diff --git a/frontend/src/lib/vitest-examples/Welcome.svelte.spec.ts b/frontend/src/lib/vitest-examples/Welcome.svelte.spec.ts new file mode 100644 index 0000000..e6e648c --- /dev/null +++ b/frontend/src/lib/vitest-examples/Welcome.svelte.spec.ts @@ -0,0 +1,15 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import Welcome from './Welcome.svelte'; + +describe('Welcome.svelte', () => { + it('renders greetings for host and guest', async () => { + render(Welcome, { host: 'SvelteKit', guest: 'Vitest' }); + + await expect + .element(page.getByRole('heading', { level: 1 })) + .toHaveTextContent('Hello, SvelteKit!'); + await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/vitest-examples/greet.spec.ts b/frontend/src/lib/vitest-examples/greet.spec.ts new file mode 100644 index 0000000..f2d6b12 --- /dev/null +++ b/frontend/src/lib/vitest-examples/greet.spec.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { greet } from './greet'; + +describe('greet', () => { + it('returns a greeting', () => { + expect(greet('Svelte')).toBe('Hello, Svelte!'); + }); +}); diff --git a/frontend/src/lib/vitest-examples/greet.ts b/frontend/src/lib/vitest-examples/greet.ts new file mode 100644 index 0000000..304b482 --- /dev/null +++ b/frontend/src/lib/vitest-examples/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return 'Hello, ' + name + '!'; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..9cebde5 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + + + + + +{@render children()} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..cc88df0 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b9d1898 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import adapter from '@sveltejs/adapter-node'; +import { sveltekit } from '@sveltejs/kit/vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + adapter: adapter() + }) + ], + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'client', + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'] + } + }, + + { + extends: './vite.config.ts', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +});