Apply configurable-demo-persona: per-instance demo identity + voice

Adds DEMO_BOT_PERSONA env var (inline JSON or file:<path>) so
self-hosted instances can replace Bruno-in-Berlin with their own
demo account identity and content pools. No-op for trails.cool — the
built-in Bruno persona remains the default.

- DemoPersona type + Zod schema validation
- loadPersona() cached at boot; falls back to default on any failure
- ensureDemoUser throws DemoPersonaUsernameClashError when the
  persona username is already a real user; server declines to
  schedule demo jobs for that process
- isDemoUser flag moved from hardcoded "bruno" check to loader-supplied
  boolean computed from the running persona
- docs/demo-persona.md explains the schema + operator flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 10:30:32 +02:00
parent f447852d70
commit fc4485f6ef
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 877 additions and 113 deletions

87
docs/demo-persona.md Normal file
View file

@ -0,0 +1,87 @@
# Demo persona
The Journal's demo bot (see the `demo-activity-bot` spec) ships with a default Bruno-in-Berlin persona. Self-hosted instances can override the identity and voice without touching code by supplying `DEMO_BOT_PERSONA` to the journal container.
Region is separate (`DEMO_BOT_REGION`) — an operator may mix-and-match a non-Berlin region with the default Bruno persona, or vice versa, though it usually reads better to keep persona and region stylistically consistent.
## Supplying a persona
Two forms are accepted:
1. **Inline JSON**`DEMO_BOT_PERSONA='{"username":"hamish",...}'`. Workable for short personas; gets ugly fast once the pools are realistic.
2. **File path**`DEMO_BOT_PERSONA=file:/etc/trails-cool/persona.json`. The journal container reads the file once at boot; mount the file via a volume or Docker secret.
A missing / invalid / unreadable persona falls back to the built-in default with a single warn log line. The bot never crashes over a bad persona.
## Schema
```json
{
"username": "<kebab-case, 232 chars, matches /^[a-z0-9][a-z0-9_-]{1,30}$/>",
"displayName": "<string, 1200 chars>",
"bio": "<string, 0200 chars>",
"locales": ["en"] | ["de"] | ["en","de"],
"content": {
"names": {
"en": ["<3 to 50 non-empty strings>"],
"de": ["<3 to 50 non-empty strings>"]
},
"descriptions": {
"en": ["<3 to 50 non-empty strings>"],
"de": ["<3 to 50 non-empty strings>"]
}
}
}
```
Every locale listed in `locales` MUST have both a `names` pool and a `descriptions` pool. Locales omitted from `locales` may also omit their pools.
Sensible pool size is 1015 entries per pool. Fewer than 3 is rejected; more than 50 is rejected. The generator samples deterministically from the walk's start-time, so very small pools produce visibly-repeating copy in the daily feed.
## Example: Hamish the English-only Scottish collie
```json
{
"username": "hamish",
"displayName": "Hamish",
"bio": "Border collie. Arthur's Seat regular.",
"locales": ["en"],
"content": {
"names": {
"en": [
"Arthur's Seat summit patrol",
"Holyrood Park morning rounds",
"Royal Mile crumb inspection",
"Dean Village bridge audit",
"Inverleith perimeter check",
"Blackford Hill squirrel count",
"Water of Leith investigation",
"Hamish vs. the seagulls",
"Portobello beach reconnaissance",
"Cramond causeway survey"
]
},
"descriptions": {
"en": [
"Sniffed an impressive number of bins. All catalogued.",
"Weather: dreich. Ears: flat.",
"One dropped scone located. Consumed as evidence.",
"Diplomacy established with the resident Labrador.",
"Route completed. Biscuits expected.",
"Found three sticks. Returned with one."
]
}
}
}
```
## Rollout
1. Write `persona.json`, mount it via your compose override or Docker secret.
2. Set `DEMO_BOT_PERSONA=file:/path/to/persona.json` in your SOPS env.
3. Set `DEMO_BOT_ENABLED=true`.
4. Restart the journal container.
On boot, the worker logs `demo-bot user ensured` with the persona's user id. If that username is already in use by a real human user, the worker logs a `demo persona username clash` error and declines to schedule the demo jobs — pick a different username and restart.
Rolling back is the same in reverse: unset `DEMO_BOT_PERSONA`, restart. The built-in default returns.