From c71194f94523cd964373ad0db0f94e7d06c0f264 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 16:37:55 +0200 Subject: [PATCH 1/2] feat(install): ask for the family's language during install The installer guessed the family's language from the time zone without saying so, and always wrote English as the language the stack speaks. The family language decides the language the bots answer in, the document categories seeded into Paperless and the language voice messages are transcribed in; the categories stay behind when it is changed later. The wizard now asks right after the family name, proposing the language of the time zone. `en` and `de` are offered, the two languages the document categories exist in. The answer is written to `[core] language` and, as a starting value, to `[ai] language`, the language of the voice that reads answers aloud, which can be set on its own. The summary before the install starts shows both. Closes: #153 --- docs/admin-guide.md | 17 ++++---- lib/stack/installer.py | 37 +++++++++++++--- stack.example.toml | 4 +- stacklets/core/stacklet.toml | 9 ++-- tests/e2e/install/test_first_install.py | 8 ++++ tests/unit/framework/test_install_language.py | 43 +++++++++++++++++++ 6 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 tests/unit/framework/test_install_language.py diff --git a/docs/admin-guide.md b/docs/admin-guide.md index d8d91e64..d3284d9b 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -180,15 +180,16 @@ What it does, in order: 1. **Checks Homebrew.** If missing, prints the install one-liner and waits. 2. **Checks Docker.** If missing, offers to install OrbStack via Homebrew. If installed but not running, asks you to start it and waits. 3. **Asks for a family name.** Becomes your Matrix server identity. Permanent. Pick something short and lowercase-friendly. `mueller` is fine; "The Müller-Schmidt Family" gets sanitized to `mueller-schmidt`. -4. **Asks for your first name.** Becomes the admin account on every stacklet. Default password is your first name in lowercase. Change it at first login. -5. **Asks for additional family members.** Empty input ends the loop. Each member gets their own account on each stacklet. -6. **Writes config.** Three files appear: +4. **Asks for your family's language.** `en` or `de`, proposed from the Mac's time zone. The bots answer in it, documents are tagged in it, and voice messages are transcribed in it. Choose it deliberately: changing it later leaves the first set of document tags behind. +5. **Asks for your first name.** Becomes the admin account on every stacklet. Default password is your first name in lowercase. Change it at first login. +6. **Asks for additional family members.** Empty input ends the loop. Each member gets their own account on each stacklet. +7. **Writes config.** Three files appear: - `stack.toml`: central config, gitignored, yours to edit. - `users.toml`: family roster, gitignored. - `.stack/secrets.toml`: auto-generated passwords for service accounts. Treat like a password manager export. -7. **Brings up `messages` and `core`.** Pulls Synapse, Element and Postgres images (about 600 MB), starts them, creates Matrix accounts, seeds two default rooms (`#famchat`, `#famstack`). -8. **Installs the `famstack` command.** A small script in Homebrew's `bin` directory that runs this checkout's `./stack`, so `famstack status` works from any directory. If another command called `famstack` is already on your PATH, the installer leaves it alone and the final screen says `./stack` instead. -9. **Prints a sign-in URL.** Something like `http://192.168.1.42:42030`. +8. **Brings up `messages` and `core`.** Pulls Synapse, Element and Postgres images (about 600 MB), starts them, creates Matrix accounts, seeds two default rooms (`#famchat`, `#famstack`). +9. **Installs the `famstack` command.** A small script in Homebrew's `bin` directory that runs this checkout's `./stack`, so `famstack status` works from any directory. If another command called `famstack` is already on your PATH, the installer leaves it alone and the final screen says `./stack` instead. +10. **Prints a sign-in URL.** Something like `http://192.168.1.42:42030`. Total time on a fresh Mac with a decent connection: 5 to 10 minutes. @@ -588,7 +589,7 @@ schedule = "0 0 3 * * *" # Watchtower nightly image updates [ai] default = "mlx-community/Qwen3.5-9B-MLX-4bit" # change to match your RAM -language = "en" # "de" for German voice/transcription +language = "en" # the voice that reads answers aloud; can differ from [core] [memory] wiki_auto_rebuild = true # curator refreshes member pages after filings @@ -602,7 +603,7 @@ Key things to know: - **dns_provider**: with a domain, `"hetzner"` or `"cloudflare"` serves everything over HTTPS, with certificates Caddy obtains through the provider's API. Empty serves plain HTTP. The API token is stored with `./stack infra dns-token`, never in `stack.toml`. - **host**: the hostname used in port-mode URLs and hints. Empty = auto-detect the LAN IP (right for households: phones need it). Set to `localhost` for a single-machine setup, or to a fixed name like `mac-mini.local` if the LAN IP keeps shifting. - **data_dir**: where all persistent data lives. Back this up. Outside the git repo. -- **language**: detected from your timezone. Controls which document categories get seeded (German or English). Change it and run `./stack restart docs` to seed missing tags. +- **language**: asked during install, proposed from your timezone. `[core] language` is the family's language: the bots answer in it, document categories are seeded in it, voice messages are transcribed in it. Changing it later seeds the other set of tags alongside the first with `./stack restart docs`; the first set stays. `[ai] language` is the language the stack speaks to you, the voice that reads answers aloud. The installer sets it to the family language; set it to `en` for an English voice in a German household. - **AI model**: installer picks one for your RAM tier. Alternatives are listed as comments in `stack.toml`. Switch by uncommenting a different line and running `./stack setup ai`. ### users.toml diff --git a/lib/stack/installer.py b/lib/stack/installer.py index de38dd53..0fe6fc97 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -98,6 +98,16 @@ def detect_language(timezone: str) -> str: return _TZ_LANGUAGE.get(timezone, "en") +LANGUAGES = {"en": "English", "de": "Deutsch"} + + +def validate_language(value: str) -> str | None: + """The languages the seeded taxonomy and the bots' texts exist in.""" + if value in LANGUAGES: + return None + return "Choose " + " or ".join(LANGUAGES) + + # (min_ram_gb, model_id, label) # Qwen3.6 only shipped a 27B dense and a 35B-A3B MoE — no small variant — so # the lightweight tier stays on Qwen3.5-9B (3.6's smallest 4bit is 16 GB of @@ -297,7 +307,10 @@ def write_stack_toml(family_name, server_name, timezone, language="en"): provider = "" openai_url = "" openai_key = "" -language = "en" +# The language the stack speaks to you: the voice that reads answers +# aloud. Starts as the family language; it can differ. Transcription and +# document tags follow [core] language. +language = "{language}" {model_block} [messages] @@ -372,8 +385,10 @@ def show_existing_config(): kv("Domain", core["domain"]) if ai.get("openai_url"): kv("AI server", ai["openai_url"]) + if core.get("language"): + kv("Language", core["language"]) if ai.get("language"): - kv("AI language", ai["language"]) + kv("Voice", ai["language"]) if messages.get("server_name"): kv("Chat server", messages["server_name"]) nl() @@ -467,6 +482,20 @@ def wizard(): server_name = sanitize_server_name(family_name) + # ── Language ─────────────────────────────────────────────────────── + # Asked, not only guessed: it names the document categories seeded + # into Paperless, and changing it later leaves the first set behind. + + timezone = detect_timezone() + nl() + out("Which language does your family read? The bots answer in it,") + out("documents are tagged in it, and voice messages are transcribed in it.") + nl() + language = ask(f"Language ({', '.join(LANGUAGES)})", + default=detect_language(timezone), validate=validate_language) + if not language: + return None + # ── Admin ────────────────────────────────────────────────────────── nl() @@ -506,6 +535,7 @@ def wizard(): rule() nl() bold(f"The {ORANGE}{family_plural(family_name)}{RESET}") + dim(f"Language: {LANGUAGES[language]}") nl() for u in users: uid = user_id(u) @@ -547,9 +577,6 @@ def wizard(): section("Setting up", "Writing config and starting messages") nl() - timezone = detect_timezone() - language = detect_language(timezone) - with Spinner("Writing stack.toml"): write_stack_toml(family_name, server_name, timezone, language) diff --git a/stack.example.toml b/stack.example.toml index 399e32e4..fb55e84f 100644 --- a/stack.example.toml +++ b/stack.example.toml @@ -115,8 +115,8 @@ whisper_url = "http://localhost:42062/v1" # whisper_key = "" # only for a speech server that needs one # whisper_model = "whisper-1" # the speech model to ask the server for -# Language for AI interactions — controls TTS voice selection and -# default Whisper transcription language. +# The language the stack speaks to you: the voice that reads answers +# aloud. Can differ from [core] language, which decides transcription. # "en" = English (alloy voice), "de" = German (onyx voice) language = "en" diff --git a/stacklets/core/stacklet.toml b/stacklets/core/stacklet.toml index dd21ef8e..333aa2af 100644 --- a/stacklets/core/stacklet.toml +++ b/stacklets/core/stacklet.toml @@ -129,10 +129,11 @@ TOOLS_PORT = "42000" # midnight is filed under the previous day. TIMEZONE = "{timezone}" -# Household language — drives bot-side i18n, ontology rendering, and -# any prompt that should be in the family's primary language. Distinct -# from `[ai].language` (`{ai_language}`), which controls AI subsystem -# settings like TTS voice and Whisper transcription language. +# Household language — drives bot-side i18n, ontology rendering, any +# prompt that should be in the family's primary language, and the +# language voice messages are transcribed in. Distinct from +# `[ai].language` (`{ai_language}`), the language the stack speaks to +# you: it picks the voice that reads answers aloud. LANGUAGE = "{language}" AI_DEFAULT_MODEL = "{ai_default_model}" AI_MODELS_JSON = "{ai_models_json}" diff --git a/tests/e2e/install/test_first_install.py b/tests/e2e/install/test_first_install.py index 18d833dc..52f59822 100644 --- a/tests/e2e/install/test_first_install.py +++ b/tests/e2e/install/test_first_install.py @@ -59,6 +59,7 @@ def _answers() -> dict[str, list[str]]: return { "Family name": [FAMILY], + "Language (en, de)": ["en"], "Your first name": [ADMIN], "Name (leave empty to continue)": [*MEMBERS, ""], "Ready?": ["y"], @@ -389,6 +390,13 @@ def test_the_secrets_file_holds_the_service_admin_password(install): login(homeserver(printed_url(install)), account[1], secrets["global__ADMIN_PASSWORD"]) +def test_the_answered_language_is_the_family_language(install): + # Answered "en" on a Mac whose time zone proposes "de": the answer wins, + # for the family and as the first voice language. + cfg = tomllib.loads((Path(CLONE) / "stack.toml").read_text()) + assert (cfg["core"]["language"], cfg["ai"]["language"]) == ("en", "en") + + def test_the_printed_commands_name_real_commands(install): """Run from the checkout as `./stack`, each printed command parses. diff --git a/tests/unit/framework/test_install_language.py b/tests/unit/framework/test_install_language.py new file mode 100644 index 00000000..b468a492 --- /dev/null +++ b/tests/unit/framework/test_install_language.py @@ -0,0 +1,43 @@ +"""The family's language, chosen once in the install wizard. + +It names the document categories seeded into Paperless, the language the +bots answer in and the language voice messages are transcribed in. The +wizard proposes one from the Mac's time zone and asks; the answer is +`[core] language`, and also the first value of `[ai] language`, the +language the stack speaks to you, which can be changed on its own. +""" + +import tomllib + +from stack import installer + + +def test_the_proposal_follows_the_time_zone(): + assert installer.detect_language("Europe/Berlin") == "de" + assert installer.detect_language("America/New_York") == "en" + + +def test_only_languages_with_a_seeded_taxonomy_are_accepted(): + assert installer.validate_language("de") is None + assert installer.validate_language("en") is None + assert installer.validate_language("fr") == "Choose en or de" + + +def test_the_chosen_language_is_the_family_language_and_the_first_voice(tmp_path, monkeypatch): + monkeypatch.setattr(installer, "REPO_ROOT", tmp_path) + + installer.write_stack_toml("Simpson", "simpson", "Europe/Berlin", "de") + + cfg = tomllib.loads((tmp_path / "stack.toml").read_text()) + assert cfg["core"]["language"] == "de" + # The voice starts in the same language and can be changed on its own. + assert cfg["ai"]["language"] == "de" + + +def test_an_english_choice_in_a_german_time_zone_is_kept(tmp_path, monkeypatch): + monkeypatch.setattr(installer, "REPO_ROOT", tmp_path) + + installer.write_stack_toml("Simpson", "simpson", "Europe/Berlin", "en") + + cfg = tomllib.loads((tmp_path / "stack.toml").read_text()) + assert cfg["core"]["language"] == "en" From c81120996b1f23ec71d8b775708185fd836545a1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 16:46:40 +0200 Subject: [PATCH 2/2] feat(doctor): warn when an install has no family language Installs from before the installer wrote `[core] language` have none. Everything that reads the family language then falls back to `[ai] language`, the language the stack speaks to you, so the bots, document tags and transcription silently follow the voice. `stack doctor` now warns when `[core] language` is missing and names the value in use. A voice in another language than the family's is a valid setup and is not reported. --- docs/admin-guide.md | 2 +- lib/stack/cli.py | 5 +++++ lib/stack/doctor.py | 25 +++++++++++++++++++++++++ tests/unit/framework/test_doctor.py | 22 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index d3284d9b..e3492e3e 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -603,7 +603,7 @@ Key things to know: - **dns_provider**: with a domain, `"hetzner"` or `"cloudflare"` serves everything over HTTPS, with certificates Caddy obtains through the provider's API. Empty serves plain HTTP. The API token is stored with `./stack infra dns-token`, never in `stack.toml`. - **host**: the hostname used in port-mode URLs and hints. Empty = auto-detect the LAN IP (right for households: phones need it). Set to `localhost` for a single-machine setup, or to a fixed name like `mac-mini.local` if the LAN IP keeps shifting. - **data_dir**: where all persistent data lives. Back this up. Outside the git repo. -- **language**: asked during install, proposed from your timezone. `[core] language` is the family's language: the bots answer in it, document categories are seeded in it, voice messages are transcribed in it. Changing it later seeds the other set of tags alongside the first with `./stack restart docs`; the first set stays. `[ai] language` is the language the stack speaks to you, the voice that reads answers aloud. The installer sets it to the family language; set it to `en` for an English voice in a German household. +- **language**: asked during install, proposed from your timezone. `[core] language` is the family's language: the bots answer in it, document categories are seeded in it, voice messages are transcribed in it. Changing it later seeds the other set of tags alongside the first with `./stack restart docs`; the first set stays. `[ai] language` is the language the stack speaks to you, the voice that reads answers aloud. The installer sets it to the family language; set it to `en` for an English voice in a German household. `stack doctor` warns when `[core] language` is missing, which only happens on installs from before the installer wrote it. - **AI model**: installer picks one for your RAM tier. Alternatives are listed as comments in `stack.toml`. Switch by uncommenting a different line and running `./stack setup ai`. ### users.toml diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 0dd98aa8..d3380840 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -1114,6 +1114,11 @@ def missing_secrets(stacklet_id): running={sid for sid in stacklets if docker.containers_for(sid)}, ) + language = doctor.check_language(stck._cfg("core", "language", ""), + stck._cfg("ai", "language", "")) + if language: + findings.append(language) + # What this instance is running, and whether a release has passed it. # No fetch: doctor is run often, and often when something is # unreachable, so it answers from the tags this clone already has. diff --git a/lib/stack/doctor.py b/lib/stack/doctor.py index 9f1a44ca..3e69d702 100644 --- a/lib/stack/doctor.py +++ b/lib/stack/doctor.py @@ -194,6 +194,31 @@ def check_release(position: str, latest: str, up_to_date: bool) -> Finding | Non ) +def check_language(core_language: str, ai_language: str) -> Finding | None: + """Whether the family's language, `[core] language`, is set. + + Installs from before the installer wrote it have none. Everything that + reads the family language then falls back to `[ai] language`, the + language the stack speaks to you, which may be a different one: the + bots, the document categories and transcription follow the voice. + A `[core] language` different from `[ai] language` is a valid setup + and not reported. + """ + if core_language: + return None + fallback = ai_language or "en" + return Finding( + level=WARN, + title="no family language set", + detail=(f"stack.toml has no `language` under [core]. The bots, document " + f"tags and transcription fall back to \"{fallback}\"" + + (", the language of the voice under [ai]." if ai_language else ".")), + fix=(f'add language = "{fallback}" under [core] in stack.toml (or the ' + f"language your family reads), then restart what is running: " + f"stack restart ..."), + ) + + def check_endpoint(name: str, url: str, reachable: bool) -> Finding | None: """A configured endpoint that does not answer. diff --git a/tests/unit/framework/test_doctor.py b/tests/unit/framework/test_doctor.py index 72ea3f83..5d2fd377 100644 --- a/tests/unit/framework/test_doctor.py +++ b/tests/unit/framework/test_doctor.py @@ -7,6 +7,7 @@ from __future__ import annotations from stack.doctor import ( + check_language, ERROR, Finding, check_endpoint, @@ -438,3 +439,24 @@ def test_a_second_provider_is_ignored_and_doctor_says_which(self): [found] = check_oidc(["id", "sso"], {}, running={"id"}) assert found.level == WARN assert "id" in found.detail and "sso" in found.detail + + +class TestLanguage: + """`[core] language` is the family's language; `[ai] language` the + language the stack speaks to you. Early installs have only the second, + and everything silently falls back to it. Doctor says so.""" + + def test_an_install_without_a_family_language_is_told_to_set_one(self): + finding = check_language("", "de") + + assert finding.title == "no family language set" + assert 'language = "de"' in finding.fix + assert "voice" in finding.detail + assert finding.is_error is False + + def test_without_either_it_falls_back_to_english(self): + assert 'language = "en"' in check_language("", "").fix + + def test_a_voice_in_another_language_is_a_valid_setup(self): + """German documents, an English voice: nothing to report.""" + assert check_language("de", "en") is None