mukul-chauhan-methdai Claude Opus 4.8 commited on
Commit
8db7d9a
Β·
1 Parent(s): 5d9a329

Allow unlocking personality editing from Settings (v0.3.4)

Browse files

The app ships LOCKED to the receptionist profile (Personality Studio Apply returns profile_locked) so a live lobby robot can't have its flow broken at runtime. But an owner had no way to experiment without editing config.py, and the studio UI invited edits it then blocked β€” which confused testers.

Now the lock is env-driven with a safe default: config.profile_locked() = locked unless RECEPTION_PROFILE_UNLOCK=true. Exposed as a restart-required enum dropdown under Settings -> Advanced (default 'locked') with an info tooltip. Unlock and the Personality Studio's Save / Apply / Use-on-startup all work. Gates updated in config, console (persist + startup re-apply), and the apply/list endpoints. Default stays locked, so lobby deployments are unaffected.

Docs: .env.example + README document RECEPTION_PROFILE_UNLOCK and the Gmail email backend (EMAIL_BACKEND/GMAIL_*).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.env.example CHANGED
@@ -11,9 +11,19 @@ HF_HOME=./cache
11
  # Hugging Face token for accessing datasets/models
12
  HF_TOKEN=
13
 
14
- # Profile selection (ignored when LOCKED_PROFILE is set in config.py)
15
  # REACHY_MINI_CUSTOM_PROFILE="example"
16
 
 
 
 
 
 
 
 
 
 
 
17
  # Skip loading .env if you prefer environment-only configuration.
18
  # REACHY_MINI_SKIP_DOTENV=1
19
 
@@ -26,11 +36,26 @@ HF_TOKEN=
26
  # This is convenient for downloaded tools used with built-in/default profiles.
27
  # AUTOLOAD_EXTERNAL_TOOLS=1
28
 
29
- # Resend transactional email API for the send_email tool.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  # Get a free API key at https://resend.com/api-keys (3000 emails/month free).
31
- # Without RESEND_API_KEY, send_email writes to the in-memory outbox only
32
- # (visible in the dashboard's Mailbox Out panel, but nothing actually leaves
33
- # the robot).
34
  #
35
  # RESEND_FROM defaults to "onboarding@resend.dev" β€” Resend's sandbox sender
36
  # that ONLY delivers to the email address registered on your Resend account.
 
11
  # Hugging Face token for accessing datasets/models
12
  HF_TOKEN=
13
 
14
+ # Profile selection (ignored while the profile is locked β€” see below)
15
  # REACHY_MINI_CUSTOM_PROFILE="example"
16
 
17
+ # ---- Personality lock ----
18
+ # The app ships LOCKED to the receptionist profile so a live lobby robot
19
+ # can't have its reception flow swapped or broken at runtime (the dashboard's
20
+ # Personality Studio shows the editor but Apply is disabled). On a unit you
21
+ # own and want to experiment with, set this to true to UNLOCK editing: then
22
+ # Advanced -> Personality studio's Save / Apply / "Use on startup" all work.
23
+ # Restart required. Leave locked for production. Also togglable from the
24
+ # dashboard: Settings -> Advanced.
25
+ # RECEPTION_PROFILE_UNLOCK=false
26
+
27
  # Skip loading .env if you prefer environment-only configuration.
28
  # REACHY_MINI_SKIP_DOTENV=1
29
 
 
36
  # This is convenient for downloaded tools used with built-in/default profiles.
37
  # AUTOLOAD_EXTERNAL_TOOLS=1
38
 
39
+ # ---- Email delivery (host notifications) ----
40
+ # The send_email tool delivers via EITHER Gmail SMTP or Resend, chosen by
41
+ # EMAIL_BACKEND. Leave EMAIL_BACKEND unset to auto-detect: Gmail if its creds
42
+ # are set, else Resend, else outbox-only (recorded in the dashboard's Mailbox
43
+ # Out panel, but nothing leaves the robot). Also configurable from the
44
+ # dashboard: Settings.
45
+ # EMAIL_BACKEND=gmail # or "resend"
46
+
47
+ # -- Gmail SMTP (delivers to ANY recipient β€” no domain to verify) --
48
+ # Needs a Google App Password (16 chars) with 2-Step Verification enabled:
49
+ # https://myaccount.google.com/apppasswords (spaces in the password are OK)
50
+ # GMAIL_ADDRESS=you@gmail.com
51
+ # GMAIL_APP_PASSWORD=abcd efgh ijkl mnop
52
+ # GMAIL_FROM_NAME=Reachy Reception
53
+
54
+ # -- Resend (transactional HTTP API) --
55
  # Get a free API key at https://resend.com/api-keys (3000 emails/month free).
56
+ # Without RESEND_API_KEY (and no Gmail creds), send_email writes to the
57
+ # in-memory outbox only (visible in the dashboard's Mailbox Out panel, but
58
+ # nothing actually leaves the robot).
59
  #
60
  # RESEND_FROM defaults to "onboarding@resend.dev" β€” Resend's sandbox sender
61
  # that ONLY delivers to the email address registered on your Resend account.
README.md CHANGED
@@ -78,7 +78,7 @@ The welcome banner on first launch tells you which keys are missing. Click each
78
  | Setting | Why | Where to get it |
79
  |---|---|---|
80
  | `GEMINI_API_KEY` | Voice (default backend) | [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) β€” free tier works |
81
- | `RESEND_API_KEY` | Send emails to hosts | [resend.com](https://resend.com) β€” free 3000 emails/month |
82
  | `RECEPTION_ICS_URL` | Read today's appointments | Google Calendar β†’ Settings β†’ **Secret address in iCal format** |
83
 
84
  ### Optional
@@ -88,7 +88,10 @@ The welcome banner on first launch tells you which keys are missing. Click each
88
  | `VOICE_BACKEND` | Switch between `gemini` (default) and `openai` |
89
  | `GEMINI_LIVE_VOICE` | Pick a voice β€” Puck, Charon, Kore, Aoede, etc. |
90
  | `OPENAI_API_KEY` | Required only if `VOICE_BACKEND=openai` |
91
- | `RESEND_FROM` | Sender address β€” defaults to Resend's sandbox sender (delivers only to your Resend account); set to `reception@yourdomain.com` after verifying your domain at Resend |
 
 
 
92
  | `FACE_TTL_DAYS` | How long a registered face is remembered (default 90 days) |
93
  | `VISITOR_LOG_RETENTION_DAYS` | How long visit records are kept (default 365 days) |
94
 
 
78
  | Setting | Why | Where to get it |
79
  |---|---|---|
80
  | `GEMINI_API_KEY` | Voice (default backend) | [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey) β€” free tier works |
81
+ | Email delivery | Notify hosts a visitor arrived | **Gmail** β€” a [Google App Password](https://myaccount.google.com/apppasswords) (delivers to anyone, no domain setup) β€” or **Resend** ([resend.com](https://resend.com), free 3000/mo). Without either, emails are recorded in the dashboard's Mailbox Out only |
82
  | `RECEPTION_ICS_URL` | Read today's appointments | Google Calendar β†’ Settings β†’ **Secret address in iCal format** |
83
 
84
  ### Optional
 
88
  | `VOICE_BACKEND` | Switch between `gemini` (default) and `openai` |
89
  | `GEMINI_LIVE_VOICE` | Pick a voice β€” Puck, Charon, Kore, Aoede, etc. |
90
  | `OPENAI_API_KEY` | Required only if `VOICE_BACKEND=openai` |
91
+ | `EMAIL_BACKEND` | `gmail` or `resend` β€” unset auto-detects (prefers Gmail if its creds are set) |
92
+ | `GMAIL_ADDRESS` / `GMAIL_APP_PASSWORD` | Send via your Gmail/Workspace account (16-char [App Password](https://myaccount.google.com/apppasswords), 2-Step Verification on); `GMAIL_FROM_NAME` sets the display name |
93
+ | `RESEND_FROM` | Resend sender address β€” defaults to Resend's sandbox sender (delivers only to your Resend account); set to `reception@yourdomain.com` after verifying your domain at Resend |
94
+ | `RECEPTION_PROFILE_UNLOCK` | `true` unlocks personality editing (Advanced β†’ Personality studio). **Default locked** β€” keep it locked on a production lobby robot so the reception flow can't be changed at runtime |
95
  | `FACE_TTL_DAYS` | How long a registered face is remembered (default 90 days) |
96
  | `VISITOR_LOG_RETENTION_DAYS` | How long visit records are kept (default 365 days) |
97
 
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "reachy_mini_receptionist"
7
- version = "0.3.3"
8
  description = "Reachy Mini AI receptionist app with realtime voice, guest recognition, and dashboard tools."
9
  readme = "README.md"
10
  requires-python = ">=3.10"
 
4
 
5
  [project]
6
  name = "reachy_mini_receptionist"
7
+ version = "0.3.4"
8
  description = "Reachy Mini AI receptionist app with realtime voice, guest recognition, and dashboard tools."
9
  readme = "README.md"
10
  requires-python = ">=3.10"
src/reachy_mini_receptionist/config.py CHANGED
@@ -34,6 +34,19 @@ def _env_flag(name: str, default: bool = False) -> bool:
34
  return default
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def _collect_profile_names(profiles_root: Path) -> set[str]:
38
  """Return profile folder names from a profiles root directory."""
39
  if not profiles_root.exists() or not profiles_root.is_dir():
@@ -122,7 +135,13 @@ class Config:
122
  _tools_directory_env = os.getenv("REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY")
123
  TOOLS_DIRECTORY = Path(_tools_directory_env) if _tools_directory_env else None
124
  AUTOLOAD_EXTERNAL_TOOLS = _env_flag("AUTOLOAD_EXTERNAL_TOOLS", default=False)
125
- REACHY_MINI_CUSTOM_PROFILE = LOCKED_PROFILE or os.getenv("REACHY_MINI_CUSTOM_PROFILE")
 
 
 
 
 
 
126
 
127
  logger.debug(f"Custom Profile: {REACHY_MINI_CUSTOM_PROFILE}")
128
 
@@ -199,7 +218,7 @@ def set_custom_profile(profile: str | None) -> None:
199
  This ensures modules that read `config` and code that inspects the
200
  environment see a consistent value.
201
  """
202
- if LOCKED_PROFILE is not None:
203
  return
204
  try:
205
  config.REACHY_MINI_CUSTOM_PROFILE = profile
 
34
  return default
35
 
36
 
37
+ def profile_locked() -> bool:
38
+ """Whether profile switching / prompt-applying is currently locked.
39
+
40
+ The app ships LOCKED to ``LOCKED_PROFILE`` for production safety so a
41
+ live receptionist can't have its reception flow swapped at runtime. A
42
+ user who owns the installed app can UNLOCK it from the dashboard Settings
43
+ by setting ``RECEPTION_PROFILE_UNLOCK=true`` (restart required). Once
44
+ unlocked, the Personality Studio's Save / Apply / "Use on startup" all
45
+ work and they can craft their own personalities.
46
+ """
47
+ return LOCKED_PROFILE is not None and not _env_flag("RECEPTION_PROFILE_UNLOCK", default=False)
48
+
49
+
50
  def _collect_profile_names(profiles_root: Path) -> set[str]:
51
  """Return profile folder names from a profiles root directory."""
52
  if not profiles_root.exists() or not profiles_root.is_dir():
 
135
  _tools_directory_env = os.getenv("REACHY_MINI_EXTERNAL_TOOLS_DIRECTORY")
136
  TOOLS_DIRECTORY = Path(_tools_directory_env) if _tools_directory_env else None
137
  AUTOLOAD_EXTERNAL_TOOLS = _env_flag("AUTOLOAD_EXTERNAL_TOOLS", default=False)
138
+ # Startup profile: forced to the locked profile while locked. Once the
139
+ # owner unlocks (RECEPTION_PROFILE_UNLOCK=true) honour their saved choice,
140
+ # still defaulting to the receptionist profile when they haven't picked one.
141
+ if profile_locked():
142
+ REACHY_MINI_CUSTOM_PROFILE = LOCKED_PROFILE or os.getenv("REACHY_MINI_CUSTOM_PROFILE")
143
+ else:
144
+ REACHY_MINI_CUSTOM_PROFILE = os.getenv("REACHY_MINI_CUSTOM_PROFILE") or LOCKED_PROFILE
145
 
146
  logger.debug(f"Custom Profile: {REACHY_MINI_CUSTOM_PROFILE}")
147
 
 
218
  This ensures modules that read `config` and code that inspects the
219
  environment see a consistent value.
220
  """
221
+ if profile_locked():
222
  return
223
  try:
224
  config.REACHY_MINI_CUSTOM_PROFILE = profile
src/reachy_mini_receptionist/console.py CHANGED
@@ -22,7 +22,7 @@ from scipy.signal import resample
22
 
23
  from reachy_mini import ReachyMini
24
  from reachy_mini.media.media_manager import MediaBackend
25
- from reachy_mini_receptionist.config import LOCKED_PROFILE, config
26
  from reachy_mini_receptionist.openai_realtime import OpenaiRealtimeHandler
27
  from reachy_mini_receptionist.headless_personality_ui import mount_personality_routes
28
 
@@ -162,7 +162,7 @@ class LocalStream:
162
 
163
  def _persist_personality(self, profile: Optional[str]) -> None:
164
  """Persist the startup personality to the instance .env and config."""
165
- if LOCKED_PROFILE is not None:
166
  return
167
  selection = (profile or "").strip() or None
168
  try:
@@ -340,7 +340,7 @@ class LocalStream:
340
  config.OPENAI_API_KEY = new_key
341
  except Exception:
342
  pass
343
- if LOCKED_PROFILE is None:
344
  new_profile = os.getenv("REACHY_MINI_CUSTOM_PROFILE")
345
  if new_profile is not None:
346
  try:
 
22
 
23
  from reachy_mini import ReachyMini
24
  from reachy_mini.media.media_manager import MediaBackend
25
+ from reachy_mini_receptionist.config import config, profile_locked
26
  from reachy_mini_receptionist.openai_realtime import OpenaiRealtimeHandler
27
  from reachy_mini_receptionist.headless_personality_ui import mount_personality_routes
28
 
 
162
 
163
  def _persist_personality(self, profile: Optional[str]) -> None:
164
  """Persist the startup personality to the instance .env and config."""
165
+ if profile_locked():
166
  return
167
  selection = (profile or "").strip() or None
168
  try:
 
340
  config.OPENAI_API_KEY = new_key
341
  except Exception:
342
  pass
343
+ if not profile_locked():
344
  new_profile = os.getenv("REACHY_MINI_CUSTOM_PROFILE")
345
  if new_profile is not None:
346
  try:
src/reachy_mini_receptionist/headless_personality_ui.py CHANGED
@@ -13,7 +13,7 @@ from typing import Any, Callable, Optional
13
 
14
  from fastapi import FastAPI
15
 
16
- from .config import LOCKED_PROFILE, config
17
  from .openai_realtime import OpenaiRealtimeHandler
18
  from .headless_personality import (
19
  DEFAULT_OPTION,
@@ -76,12 +76,13 @@ def mount_personality_routes(
76
  @app.get("/personalities")
77
  def _list() -> dict: # type: ignore
78
  choices = [DEFAULT_OPTION, *list_personalities()]
 
79
  return {
80
  "choices": choices,
81
  "current": _current_choice(),
82
  "startup": _startup_choice(),
83
- "locked": LOCKED_PROFILE is not None,
84
- "locked_to": LOCKED_PROFILE,
85
  }
86
 
87
  @app.get("/personalities/load")
@@ -212,9 +213,14 @@ def mount_personality_routes(
212
  persist: Optional[bool] = None,
213
  request: Optional[Request] = None,
214
  ) -> dict: # type: ignore
215
- if LOCKED_PROFILE is not None:
216
  return JSONResponse(
217
- {"ok": False, "error": "profile_locked", "locked_to": LOCKED_PROFILE},
 
 
 
 
 
218
  status_code=403,
219
  ) # type: ignore
220
  loop = get_loop()
 
13
 
14
  from fastapi import FastAPI
15
 
16
+ from .config import LOCKED_PROFILE, config, profile_locked
17
  from .openai_realtime import OpenaiRealtimeHandler
18
  from .headless_personality import (
19
  DEFAULT_OPTION,
 
76
  @app.get("/personalities")
77
  def _list() -> dict: # type: ignore
78
  choices = [DEFAULT_OPTION, *list_personalities()]
79
+ _locked = profile_locked()
80
  return {
81
  "choices": choices,
82
  "current": _current_choice(),
83
  "startup": _startup_choice(),
84
+ "locked": _locked,
85
+ "locked_to": LOCKED_PROFILE if _locked else None,
86
  }
87
 
88
  @app.get("/personalities/load")
 
213
  persist: Optional[bool] = None,
214
  request: Optional[Request] = None,
215
  ) -> dict: # type: ignore
216
+ if profile_locked():
217
  return JSONResponse(
218
+ {
219
+ "ok": False,
220
+ "error": "profile_locked",
221
+ "locked_to": LOCKED_PROFILE,
222
+ "hint": "Set RECEPTION_PROFILE_UNLOCK=true in Settings (restart) to edit personalities.",
223
+ },
224
  status_code=403,
225
  ) # type: ignore
226
  loop = get_loop()
src/reachy_mini_receptionist/main.py CHANGED
@@ -905,6 +905,7 @@ def _mount_dashboard_api(
905
  "GEMINI_LIVE_VOICE": {"secret": False, "restart": True},
906
  "OPENAI_API_KEY": {"secret": True, "restart": True},
907
  "GEMINI_API_KEY": {"secret": True, "restart": True},
 
908
  "STT_DISABLE_BIAS": {"secret": False, "restart": True},
909
  "STT_LANGUAGE": {"secret": False, "restart": True},
910
  "EMAIL_BACKEND": {"secret": False, "restart": False},
@@ -937,6 +938,7 @@ def _mount_dashboard_api(
937
  "Puck", "Charon", "Kore", "Fenrir", "Aoede", "Leda", "Orus", "Zephyr",
938
  },
939
  "STT_DISABLE_BIAS": {"true", "false"},
 
940
  "STT_LANGUAGE": {
941
  "en", "en-IN", "hi", "es", "fr", "de", "ja", "zh",
942
  },
 
905
  "GEMINI_LIVE_VOICE": {"secret": False, "restart": True},
906
  "OPENAI_API_KEY": {"secret": True, "restart": True},
907
  "GEMINI_API_KEY": {"secret": True, "restart": True},
908
+ "RECEPTION_PROFILE_UNLOCK": {"secret": False, "restart": True},
909
  "STT_DISABLE_BIAS": {"secret": False, "restart": True},
910
  "STT_LANGUAGE": {"secret": False, "restart": True},
911
  "EMAIL_BACKEND": {"secret": False, "restart": False},
 
938
  "Puck", "Charon", "Kore", "Fenrir", "Aoede", "Leda", "Orus", "Zephyr",
939
  },
940
  "STT_DISABLE_BIAS": {"true", "false"},
941
+ "RECEPTION_PROFILE_UNLOCK": {"true", "false"},
942
  "STT_LANGUAGE": {
943
  "en", "en-IN", "hi", "es", "fr", "de", "ja", "zh",
944
  },
src/reachy_mini_receptionist/static/dashboard.html CHANGED
@@ -1563,6 +1563,11 @@
1563
  { value: 'false', label: 'false β€” use STT bias prompt' },
1564
  { value: 'true', label: 'true β€” disable bias prompt' },
1565
  ],
 
 
 
 
 
1566
  'STT_LANGUAGE': [
1567
  { value: '', label: '(default β€” auto-detect, recommended)' },
1568
  { value: 'en', label: 'en β€” English (forces English phonemes)' },
@@ -1612,11 +1617,20 @@
1612
  // ── Advanced settings (collapsed by default) ─────────────────
1613
  const _ADVANCED_KEYS = new Set([
1614
  'FACE_TTL_DAYS', 'VISITOR_LOG_RETENTION_DAYS', 'FACE_LBPH_THRESHOLD',
 
1615
  ]);
1616
 
1617
  // ── Info tooltips per key ────────────────────────────────────
1618
  // Shown via an β“˜ hover button next to the key name. HTML allowed.
1619
  const _INFO_TIPS = {
 
 
 
 
 
 
 
 
1620
  'FACE_TTL_DAYS':
1621
  '<strong>How long a registered face is kept in the guest DB.</strong><br>'
1622
  + 'After this many days of no visit, the face is auto-deleted on next startup, '
 
1563
  { value: 'false', label: 'false β€” use STT bias prompt' },
1564
  { value: 'true', label: 'true β€” disable bias prompt' },
1565
  ],
1566
+ 'RECEPTION_PROFILE_UNLOCK': [
1567
+ { value: '', label: '(default β€” locked)' },
1568
+ { value: 'false', label: 'false β€” locked (receptionist protected)' },
1569
+ { value: 'true', label: 'true β€” unlock personality editing' },
1570
+ ],
1571
  'STT_LANGUAGE': [
1572
  { value: '', label: '(default β€” auto-detect, recommended)' },
1573
  { value: 'en', label: 'en β€” English (forces English phonemes)' },
 
1617
  // ── Advanced settings (collapsed by default) ─────────────────
1618
  const _ADVANCED_KEYS = new Set([
1619
  'FACE_TTL_DAYS', 'VISITOR_LOG_RETENTION_DAYS', 'FACE_LBPH_THRESHOLD',
1620
+ 'RECEPTION_PROFILE_UNLOCK',
1621
  ]);
1622
 
1623
  // ── Info tooltips per key ────────────────────────────────────
1624
  // Shown via an β“˜ hover button next to the key name. HTML allowed.
1625
  const _INFO_TIPS = {
1626
+ 'RECEPTION_PROFILE_UNLOCK':
1627
+ '<strong>Unlock the robot\'s personality for editing.</strong><br>'
1628
+ + 'The app ships <strong>locked</strong> to the receptionist profile so the '
1629
+ + 'reception flow can\'t be broken at runtime.<br><br>'
1630
+ + 'Set to <code>true</code> to unlock the <strong>Advanced β†’ Personality studio</strong> '
1631
+ + '(edit the prompt, tools, and voice; Save / Apply / Use-on-startup all work).<br><br>'
1632
+ + '⚠️ Restart required. Leave <code>locked</code> for a production lobby robot; '
1633
+ + 'unlock only on a unit you\'re free to experiment with.',
1634
  'FACE_TTL_DAYS':
1635
  '<strong>How long a registered face is kept in the guest DB.</strong><br>'
1636
  + 'After this many days of no visit, the face is auto-deleted on next startup, '