A scheduled automation service that fetches a diet catering provider's weekly menu, filters it down to what a user actually purchased, asks an LLM (via OpenRouter) to score and justify every meal variant, and delivers the result by email and Discord.
Before processing any user, the run verifies the configured LLM model (and
any configured llm.fallback_models) support structured JSON outputs on
OpenRouter, failing the whole run early if any of them don't.
For each configured user, per run:
- Fetch the provider's menu for the target week (Monday–Friday).
- Normalize the raw provider response into a compact canonical menu (purchased meal types and sizes only).
- Build a prompt from the app-level rules (
prompts/app.md), the user's own instructions, and the canonical menu. - Send the prompt to an LLM through OpenRouter, requesting a structured assessment (score + justifications for every meal variant), retrying with feedback if the response is malformed or incomplete.
- Render the assessment as plain text and email it, and post a Discord notification.
A run fetches every user's menu (step 1) sequentially, one at a time — this
is deliberate, so a growing number of users never sends concurrent requests
to the menu provider. Once a user's menu is fetched, the remaining steps
(2-5) run in parallel across users, bounded by runtime.max_concurrent_users
(default 5). The run also sends an operational Discord notification per user
summarizing success/failure (batch mode, see Known limitations, instead
summarizes successes into one message and only pages per-user on failure).
If a user's menu isn't published yet for the target week, that user's
LLM/email/delivery steps are skipped and a status notification is sent
instead of treating it as an error.
- Multi-user configuration, run all users or a single one (
--user). - Pluggable provider adapters;
ntfyis the working integration, plus a minimalexample_providerused for tests and as a template. - OpenRouter LLM client requesting structured JSON output (per-variant score
and justifications), with configurable model, timeout, and retries; empty,
provider-error, malformed, or incomplete completions retry (with feedback
telling the model what to fix), while filtered or truncated completions
stop the user's workflow without delivery. A capability check rejects
models without structured-output support before any user is processed. An
optional
llm.dry_run_modelis used instead ofllm.modelduring--dry-runruns, so validation runs can use a cheaper model. An optionalllm.fallback_modelslist gives each configured model its own full retry budget in order: once the primary model's retries are exhausted, the next model is tried the same way, and so on, instead of failing the whole run. (OpenRouter's ownmodelsarray looked like a simpler way to do this, but it doesn't fail over for the embedded-error-in-a-200-response shape rate limits actually use, so the client drives the switch itself.) Fallback is omitted during--dry-runruns so a rate-limited dry run can't escalate pastdry_run_model's cost tier. - Email delivery via Resend, Discord notifications via webhooks (per-user and operational), both optional and independently configurable.
--dry-runmode that runs the full pipeline (including the LLM call) without sending email or Discord messages.- Debug artifacts under
artifacts/<run_id>/, one directory per run: a run-levelmetadata.json(mode, week, participating users, and — for batch runs — the batch id/status and OpenRouter's aggregate cost/usage, since OpenRouter only ever reports cost once per batch, not per row), plus a<user_id>/subdirectory per participating user (raw provider response, canonical menu, LLM request/response, per-attempt LLM trail including rejected attempts, and that user's ownmetadata.jsonwith LLM diagnostics and the failed step). When batch mode is used, the raw OpenRouter batch response (every row's output) is also saved once per run as<run_id>/batch_result.jsonrather than logged. Cleanup is retention-based (retention_days) and keeps the most recentmax_runsrun directories. - Structured JSON logging to stdout.
src/meal_orchestrator/
cli.py entrypoint: argument parsing, env var checks
orchestrator.py run-level orchestration: user selection, target week, sequential menu fetch then bounded-parallel remainder, operational notifications
workflow.py per-user workflow: fetch -> normalize -> prompt -> LLM -> email -> Discord
batch_coordinator.py OpenRouter batch subsystem: submit/resume, run lock, poll-with-timeout-fallback, per-row delivery
batch_runner.py batch subsystem mechanism: durable pending-batch state, cross-process lock file, generic poll loop
worker_pool.py generic bounded thread pool shared by the plain and batch delivery paths
ops_notifications.py formats and sends the per-user and capability-check-failure operational Discord notifications
config/ YAML loading and config dataclasses
domain/ shared dataclasses (canonical menu, requests/results, workflow status)
providers/ provider adapters, one package per provider
llm/ OpenRouter client, model capability check, OpenRouter batch API client (openrouter_batch.py)
rendering/ renders a structured LLM assessment to plain text
delivery/ Resend email client, Discord webhook client
observability/ structured logging setup
artifacts.py per-run debug artifact persistence
retries.py shared retry/backoff helper
http.py shared HTTP request helper
tests/
unit/ unit tests, mocked HTTP, fakes for delivery clients
fixtures/ captured raw/canonical provider payloads
config/
app.example.yaml runtime settings
users.example.yaml per-user settings
prompts/
app.md common rules sent to the LLM for every user
example.md template for a per-user prompt file
{user}.local.md per-user prompt files (gitignored), referenced from users.yaml
Copy the example files and edit them:
config/app.example.yaml— timezone, max concurrent users, LLM model/timeout/retries/fallback models, default provider, delivery settings, artifact retention.config/users.example.yaml— one entry per user: provider, provider offering id, email, Discord ids, prompt file, and purchased meals (type + size).prompts/example.md— template for a user's own prompt file (their personal context and preferences, in any language); copy it toprompts/<user>.local.mdand point that user'sprompt_fileat it.
Secrets are read from environment variables, never from YAML:
OPENROUTER_API_KEY— required; the process exits before doing anything else if it's missing.RESEND_API_KEY— optional; email delivery is skipped when absent.DISCORD_OPS_WEBHOOK_URL— optional; operational notifications are skipped when absent.- Each user's
discord_webhook_env(referenced by name fromusers.yaml) — optional; that user's Discord notification is skipped when absent.
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"meal-orchestrator --config config/app.example.yaml --users config/users.example.yaml --dry-runFlags:
| Flag | Purpose |
|---|---|
--config |
Path to the app config YAML (default config/app.example.yaml) |
--users |
Path to the users config YAML (default config/users.example.yaml) |
--user |
Run a single user by id instead of all enabled users |
--provider |
Override the configured provider for this run |
--week-start |
Run against a specific week (YYYY-MM-DD), instead of the nearest upcoming Monday |
--dry-run |
Run the full pipeline including the LLM call, but skip email/Discord delivery |
--llm-model |
Override the configured OpenRouter model |
--max-concurrent-users |
Override runtime.max_concurrent_users for this run |
--log-level |
Log level (default INFO) |
Exit code is 0 if every user's workflow completed (including expected
menu-unavailable outcomes), 1 if any user's workflow failed.
ruff check .
pytestProvider normalizers are tested against captured fixture payloads; delivery and LLM clients are tested against mocked HTTP responses, not real network calls.
docker compose run --rm meal-orchestrator --dry-runCI builds the image on every push/PR to validate it, but only publishes it
when a version tag is released. Released images are published to both GHCR
and Docker Hub as ghcr.io/malpiszon/meal-orchestrator and
malpiszon/meal-orchestrator, tagged <major>.<minor>.<patch>,
<major>.<minor>, <major>, and latest:
docker run --rm ghcr.io/malpiszon/meal-orchestrator:latest --helpThe application version is derived entirely from Git tags via
setuptools_scm — there is no version string to bump in the codebase.
Pushing a tag matching vX.Y.Z triggers the release workflow, which builds
and publishes the Docker image and creates the GitHub Release with
auto-generated notes.
- Add a
providers/<name>/package with a client (raw HTTP fetch, with retry for transient failures) and a normalizer (raw response -> canonical menu). - Implement a class extending the
ProviderAdapterbase class inproviders/__init__.py: aprovider_idattribute andget_canonical_week_menu(request) -> ProviderResult. Optionally overrideexpected_variants_per_meal(meal_type)if the provider guarantees a fixed dish-variant count per meal type (default: no check). - Raise
MenuUnavailableErrorfor expected non-availability (e.g. the provider hasn't published a given week/size yet) andProviderNormalizationErrorfor malformed/unexpected data — these are handled differently by the workflow (status notification vs. failure). - Register the provider id in
build_provider_adapter()inproviders/__init__.py. - Add normalizer tests against fixture payloads, and point a user's
providerfield at the new id.
- Sequential menu fetch, bounded-parallel remainder. One process, one CLI
entrypoint. Menu fetching always runs sequentially, one user at a time, to
avoid bursting the menu provider as the user count grows. Once a user's
menu is fetched, the rest of that user's pipeline (prompt, LLM, email,
Discord) runs on a thread pool bounded by
runtime.max_concurrent_users, independently of other users. Threads (not asyncio) were chosen because the workload is I/O-bound and the codebase has no existing async code — seeUserWorkflowExecutor.fetch_menu/execute_from_menuinworkflow.pyand the two-phase loop inRunOrchestrator.run. - Provider-specific normalizers. Each provider owns its own raw-response parsing and canonical transformation rather than sharing a generic parser — provider APIs are inconsistent enough that a shared abstraction would be premature.
- Direct OpenRouter boundary. No generic
LlmClientinterface — the workflow callsOpenRouterClientdirectly. OpenRouter already abstracts over multiple model providers, so the extra layer isn't worth it until batch/async execution is needed. - Batch mechanism vs. policy, split across three files.
batch_runner.pyholds only durable-state I/O, cross-process locking, and a generic poll-until-terminal loop — no OpenRouter or orchestration knowledge.batch_coordinator.py'sBatchCoordinatorowns the actual batch policy (submit-or-resume, timeout fallback, per-row delivery/retry) and is a collaboratorRunOrchestratordelegates to once menus are fetched, the same way it already delegates per-user work toUserWorkflowExecutor.worker_pool.py'srun_poolis the bounded-thread-pool driver shared by the plain and batch delivery paths, since both need identical "one result per user, a worker exception becomes a FAILED result" semantics. - Menu unavailability is an expected outcome, not an error. It short- circuits a user's workflow (skip LLM/email, send a status notification) rather than failing the run.
- Retry vs. fail-fast vs. best-effort, chosen per step. Provider fetch and OpenRouter calls retry transient errors with backoff; config loading and normalization fail fast; email delivery retries and blocks the workflow on exhaustion; Discord notifications are best-effort and never block or fail a run.
- File-based configuration. YAML plus environment variables for secrets; no database — adequate for a small, fixed set of users on a weekly schedule.
- English-only messaging for now. Logs and Discord messages are English to avoid taking on i18n before it's needed.
- No web UI or database; everything is driven by CLI + YAML config.
- Optional OpenRouter batch mode (
llm.batch.enabled, beta on OpenRouter's side, disabled by default, always bypassed for dry runs) submits every user's LLM request as one batch for a ~50% token-price discount. A run blocks internally (backoff-polling) for up tollm.batch.max_wait_hoursinstead of returning within minutes, so the scheduler/container running it must allow that long an execution. On timeout or failure it falls back to synchronous per-user calls (with an ops Discord alert); a batch row that individually fails or is missing gets that same synchronous retry + fallback_models resilience (and the same real-time per-user notification plain sync mode gets), with one aggregate alert if any row needed it. Rows actually delivered from the batch only page immediately per-user on failure; successes are folded into one "Batch run summary" notification sent once delivery finishes, since the batch resolves every row together rather than at genuinely different times.llm.batch.state_dir(required when enabled) holds the durable resume state and cross-process lock — point it at a persistent mount, the same oneartifacts.pathuses, not the container's ephemeral working directory, or a crash loses the ability to resume. OpenRouter has no cancel-batch endpoint, so an abandoned batch keeps billing after a timeout fallback — keepmax_wait_hourscomfortably above typical turnaround to make that rare. - If any purchased meal is missing for any day in the target week, that user's entire run is treated as menu-unavailable — there's no partial-week handling.
- No exactly-once delivery guarantee for email/Discord.
- No internal scheduler — a weekly run must be triggered externally (cron, systemd timer, CI schedule, etc.).