diff --git a/docs/admin-guide.md b/docs/admin-guide.md index d8d91e64..e3492e3e 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. `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/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_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 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"