mikeboone Claude Fable 5.1 commited on
Commit
0d02061
·
1 Parent(s): aae5297

feat(mcp): settings document on build_demo_from_brief replaces per-run Supabase settings reads

Browse files

A separate TSE app owns an SE's DemoPrep settings and Agent Q hands them to the
build as one JSON document (schema 1.0, the `settings` keyword argument). When
present, the build takes model, data size, column naming style, object prefix,
liveboard name, tag, share-with and geo scope from it and reads nothing from the
SE's Supabase settings: the controller skips its startup read and the three
mid-run re-reads are guarded. Explicit owner_email / ts_url still win; a blank
ts_url falls back to the document's URL, then its environment label through the
TS_ENV table. A bad document is refused before a slot is taken, naming the key.

- demoprep_app/settings/payload.py: the one parser and apply step (the data-size
presets live here now)
- chat_interface: ChatDemoInterface(settings_from_supabase=...), _settings_source
- run record carries settings_source and settings_schema_version
- docs: settings inventory with decisions and the phase-2 Supabase scope, the
example document, the brief for the settings-app builder, the Agent Q flag
registry row, the sprint entry
- tests: test_settings_payload (30), test_mcp_settings_document (12); the two
signature-mirror tests and one loader stub updated for the new parameter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

chat_interface.py CHANGED
@@ -408,11 +408,17 @@ class ChatDemoInterface:
408
  New chat-based interface for demo creation
409
  """
410
 
411
- def __init__(self, user_email: str = None):
412
  self.user_email = user_email # Must be set by login system
413
  self.demo_builder = None
414
  self.conversation_history = []
415
- self.settings = self.load_default_settings()
 
 
 
 
 
 
416
  self.ai_feedback_log = []
417
  self.ddl_code = ""
418
  self.population_code = ""
@@ -685,8 +691,11 @@ class ChatDemoInterface:
685
  print(f"[Auth] Unable to check temporary-password status: {e}")
686
  return ""
687
 
688
- def load_default_settings(self):
689
- """Load settings from Supabase or defaults"""
 
 
 
690
  # Fallback defaults (ONLY used if settings not found)
691
  defaults = {
692
  'company': '',
@@ -700,6 +709,9 @@ class ChatDemoInterface:
700
  'geo_scope': 'USA Only', # USA Only or International
701
  }
702
 
 
 
 
703
  try:
704
  # Try to load from Supabase
705
  user_email = self._get_effective_user_email()
@@ -1269,10 +1281,14 @@ Try something like:
1269
 
1270
  # Check validation_mode setting. Local launcher runs without Gradio
1271
  # auth, so fall back to the controller's in-memory settings when
1272
- # user-scoped Supabase settings are unavailable.
 
1273
  try:
1274
  from supabase_client import load_gradio_settings
1275
- settings = load_gradio_settings(self._get_effective_user_email())
 
 
 
1276
  except Exception as settings_error:
1277
  print(f"[Settings] Using in-memory settings for validation_mode: {settings_error}", flush=True)
1278
  settings = self.settings
@@ -4391,9 +4407,14 @@ LegitData will generate realistic, AI-powered data.
4391
  log_progress("SNOWFLAKE DEPLOYMENT COMPLETE")
4392
  log_progress("=" * 60)
4393
 
4394
- # Check validation_mode setting to decide whether to auto-continue
 
 
4395
  from supabase_client import load_gradio_settings, get_admin_setting
4396
- settings = load_gradio_settings(self._get_effective_user_email())
 
 
 
4397
  validation_mode = settings.get('validation_mode', 'Off')
4398
 
4399
  if validation_mode == 'On':
@@ -4739,8 +4760,13 @@ This chat will update when complete."""
4739
  safe_print("="*60, flush=True)
4740
  safe_print("Watch Live Progress tab for real-time updates...\n", flush=True)
4741
 
4742
- # Load settings
4743
- settings = load_gradio_settings(self._get_effective_user_email())
 
 
 
 
 
4744
  liveboard_name = self.settings.get('liveboard_name', '')
4745
 
4746
  # Default liveboard name to company name (without .com) if blank
 
408
  New chat-based interface for demo creation
409
  """
410
 
411
+ def __init__(self, user_email: str = None, *, settings_from_supabase: bool = True):
412
  self.user_email = user_email # Must be set by login system
413
  self.demo_builder = None
414
  self.conversation_history = []
415
+ # Where this run's settings come from. 'supabase' = the per-key
416
+ # user_settings rows (App/Chat tabs, legacy MCP). 'payload' = a settings
417
+ # document the caller applies onto self.settings (MCP with `settings`);
418
+ # then NOTHING in the run reads Supabase settings, including the mid-run
419
+ # re-reads in run_deployment_streaming / _run_thoughtspot_deployment.
420
+ self._settings_source = 'supabase' if settings_from_supabase else 'payload'
421
+ self.settings = self.load_default_settings(from_supabase=settings_from_supabase)
422
  self.ai_feedback_log = []
423
  self.ddl_code = ""
424
  self.population_code = ""
 
691
  print(f"[Auth] Unable to check temporary-password status: {e}")
692
  return ""
693
 
694
+ def load_default_settings(self, from_supabase: bool = True):
695
+ """Load settings from Supabase or defaults.
696
+
697
+ from_supabase=False returns the built-in defaults only (the caller is
698
+ about to apply a settings document on top; see _settings_source)."""
699
  # Fallback defaults (ONLY used if settings not found)
700
  defaults = {
701
  'company': '',
 
709
  'geo_scope': 'USA Only', # USA Only or International
710
  }
711
 
712
+ if not from_supabase:
713
+ return defaults
714
+
715
  try:
716
  # Try to load from Supabase
717
  user_email = self._get_effective_user_email()
 
1281
 
1282
  # Check validation_mode setting. Local launcher runs without Gradio
1283
  # auth, so fall back to the controller's in-memory settings when
1284
+ # user-scoped Supabase settings are unavailable. A run driven by a
1285
+ # settings document never consults Supabase at all.
1286
  try:
1287
  from supabase_client import load_gradio_settings
1288
+ if self._settings_source == 'payload':
1289
+ settings = self.settings
1290
+ else:
1291
+ settings = load_gradio_settings(self._get_effective_user_email())
1292
  except Exception as settings_error:
1293
  print(f"[Settings] Using in-memory settings for validation_mode: {settings_error}", flush=True)
1294
  settings = self.settings
 
4407
  log_progress("SNOWFLAKE DEPLOYMENT COMPLETE")
4408
  log_progress("=" * 60)
4409
 
4410
+ # Check validation_mode setting to decide whether to auto-continue.
4411
+ # A run driven by a settings document never consults Supabase: its
4412
+ # settings dict is complete (validation_mode is 'Off' for headless).
4413
  from supabase_client import load_gradio_settings, get_admin_setting
4414
+ if self._settings_source == 'payload':
4415
+ settings = self.settings
4416
+ else:
4417
+ settings = load_gradio_settings(self._get_effective_user_email())
4418
  validation_mode = settings.get('validation_mode', 'Off')
4419
 
4420
  if validation_mode == 'On':
 
4760
  safe_print("="*60, flush=True)
4761
  safe_print("Watch Live Progress tab for real-time updates...\n", flush=True)
4762
 
4763
+ # Load settings. Tag and prefix below fall back to the SE's stored
4764
+ # values; a run driven by a settings document has them in
4765
+ # self.settings already and must not read Supabase.
4766
+ if self._settings_source == 'payload':
4767
+ settings = self.settings
4768
+ else:
4769
+ settings = load_gradio_settings(self._get_effective_user_email())
4770
  liveboard_name = self.settings.get('liveboard_name', '')
4771
 
4772
  # Default liveboard name to company name (without .com) if blank
demoprep_app/settings/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Settings payload: the SE's DemoPrep settings as one versioned JSON document.
2
+
3
+ See demoprep_app.settings.payload. The document is produced by the TSE settings
4
+ app, carried by Agent Q on build_demo_from_brief(settings=...), and later may be
5
+ stored one-row-per-SE in Supabase; this package is the only reader either way.
6
+ """
demoprep_app/settings/payload.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The DemoPrep settings payload (schema 1.x) and its one reader.
2
+
3
+ Contract: docs/demoprep_settings_payload.example.json and
4
+ docs/demoprep_settings_inventory.md. A separate TSE app owns the settings and
5
+ emits this document per SE. Agent Q passes it to build_demo_from_brief as
6
+ `settings`; a later phase stores the same document one-row-per-SE in Supabase.
7
+ Whichever way it arrives, `parse_settings_payload` is the single place its
8
+ rules live, and `apply_build_settings` is the single place it meets the
9
+ controller's settings dict — so the MCP path, the Supabase-document path and
10
+ any future reader agree by construction.
11
+
12
+ Rules (CLAUDE.md "no silent fallbacks"):
13
+
14
+ * An unknown top-level key or an unknown key under `build` is refused: a typo
15
+ must not silently become "use the default".
16
+ * A value outside its allowed set (model, data size, naming style, geo scope,
17
+ log level) is refused with the allowed values in the message.
18
+ * A key that is absent takes the documented default — the same default the
19
+ Settings tab shows — which is what "absent" means in the app today.
20
+ * Secrets never appear in the payload and are never read from it.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Any, Mapping, MutableMapping
26
+
27
+ from llm_config import UI_MODEL_CHOICES, DEFAULT_LLM_MODEL
28
+
29
+ # The version this reader understands. Same major = compatible; a payload with a
30
+ # different major is refused outright, a payload with a newer minor is refused
31
+ # too (it may carry a key this server would otherwise drop on the floor).
32
+ SCHEMA_VERSION = "1.0"
33
+
34
+ # Data Size preset -> (fact rows, dim rows). The ONE copy; the App tab GO path and
35
+ # the MCP build both used to carry their own.
36
+ DATA_SIZE_PRESETS: dict[str, tuple[int, int]] = {
37
+ "Small": (1000, 50),
38
+ "Medium": (10000, 500),
39
+ }
40
+ COLUMN_NAMING_STYLES = ("Regular Case", "snake_case", "camelCase", "PascalCase",
41
+ "UPPER_CASE", "original")
42
+ GEO_SCOPES = ("USA Only", "International")
43
+ LOG_LEVELS = ("off", "regular", "verbose")
44
+
45
+ _TOP_LEVEL_KEYS = frozenset({
46
+ "schema_version", "generated_at", "source", "owner_email",
47
+ "thoughtspot", "build", "ui_defaults", "admin",
48
+ })
49
+ _BUILD_KEYS = frozenset({
50
+ "model", "data_size", "fact_table_rows", "dim_table_rows",
51
+ "column_naming_style", "object_naming_prefix", "liveboard_name", "tag_name",
52
+ "share_with", "geo_scope", "existing_model_guid",
53
+ })
54
+ _THOUGHTSPOT_KEYS = frozenset({"environment_label", "url"})
55
+ _ADMIN_KEYS = frozenset({"share_with_default", "log_level", "snowflake"})
56
+ _ADMIN_SNOWFLAKE_KEYS = frozenset({"account", "kp_user", "role", "warehouse", "database", "sso_user"})
57
+
58
+
59
+ class SettingsPayloadError(ValueError):
60
+ """The payload cannot be used. The message says exactly which key and why."""
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class BuildSettings:
65
+ """A parsed, validated payload. Every field has a concrete value."""
66
+ schema_version: str
67
+ source: str
68
+ owner_email: str # "" when the payload carries none (the tool arg must then)
69
+ ts_url: str # "" when not given; resolved by the MCP server
70
+ ts_environment_label: str # "" when not given; informational unless url is blank
71
+ model: str
72
+ data_size: str
73
+ fact_table_rows: int
74
+ dim_table_rows: int
75
+ column_naming_style: str
76
+ object_naming_prefix: str
77
+ liveboard_name: str
78
+ tag_name: str
79
+ share_with: tuple[str, ...]
80
+ geo_scope: str
81
+ existing_model_guid: str
82
+ admin_share_with_default: tuple[str, ...]
83
+ admin_log_level: str # "" when not given
84
+ admin_snowflake: dict[str, str] # validated, carried on the run record; NOT applied per build (see docs)
85
+
86
+ @property
87
+ def effective_share_with(self) -> str:
88
+ """Comma-separated share target the deployer expects: the SE's own list,
89
+ else the admin default from the payload, else "" (the server decides)."""
90
+ return ",".join(self.share_with or self.admin_share_with_default)
91
+
92
+
93
+ # ----------------------------------------------------------------- helpers
94
+
95
+ def _section(doc: Mapping[str, Any], name: str, allowed: frozenset[str]) -> dict:
96
+ raw = doc.get(name)
97
+ if raw is None:
98
+ return {}
99
+ if not isinstance(raw, Mapping):
100
+ raise SettingsPayloadError(f"{name}: must be an object, got {type(raw).__name__}")
101
+ unknown = sorted(k for k in raw if k not in allowed and not str(k).startswith("$"))
102
+ if unknown:
103
+ raise SettingsPayloadError(f"{name}: unknown key(s) {unknown}; allowed: {sorted(allowed)}")
104
+ return dict(raw)
105
+
106
+
107
+ def _str(section: Mapping[str, Any], key: str, default: str = "", *, where: str) -> str:
108
+ val = section.get(key, default)
109
+ if val is None:
110
+ return default
111
+ if not isinstance(val, str):
112
+ raise SettingsPayloadError(f"{where}.{key}: must be a string, got {type(val).__name__}")
113
+ return val.strip()
114
+
115
+
116
+ def _choice(section: Mapping[str, Any], key: str, choices: tuple[str, ...], default: str,
117
+ *, where: str) -> str:
118
+ val = _str(section, key, default, where=where) or default
119
+ if val not in choices:
120
+ raise SettingsPayloadError(f"{where}.{key}: {val!r} is not one of {list(choices)}")
121
+ return val
122
+
123
+
124
+ def _rows(section: Mapping[str, Any], key: str, *, where: str) -> int | None:
125
+ val = section.get(key)
126
+ if val is None or val == "":
127
+ return None
128
+ if isinstance(val, bool) or not isinstance(val, int):
129
+ # bool is an int subclass; refuse it explicitly. Strings are refused too:
130
+ # the settings app owns typing, a "10000" here is a producer bug.
131
+ raise SettingsPayloadError(f"{where}.{key}: must be an integer, got {type(val).__name__}")
132
+ if val <= 0:
133
+ raise SettingsPayloadError(f"{where}.{key}: must be a positive integer, got {val}")
134
+ return val
135
+
136
+
137
+ def _people(section: Mapping[str, Any], key: str, *, where: str) -> tuple[str, ...]:
138
+ """A list of user emails / group names, or the comma-separated string the app
139
+ stores today. Normalised to a tuple of non-blank entries."""
140
+ val = section.get(key)
141
+ if val is None:
142
+ return ()
143
+ if isinstance(val, str):
144
+ parts = [p.strip() for p in val.split(",")]
145
+ elif isinstance(val, (list, tuple)):
146
+ if not all(isinstance(p, str) for p in val):
147
+ raise SettingsPayloadError(f"{where}.{key}: every entry must be a string")
148
+ parts = [p.strip() for p in val]
149
+ else:
150
+ raise SettingsPayloadError(f"{where}.{key}: must be a list of strings or a comma-separated string")
151
+ return tuple(p for p in parts if p)
152
+
153
+
154
+ def _check_version(doc: Mapping[str, Any]) -> str:
155
+ raw = doc.get("schema_version")
156
+ if not isinstance(raw, str) or not raw.strip():
157
+ raise SettingsPayloadError("schema_version is required (e.g. \"1.0\")")
158
+ raw = raw.strip()
159
+ try:
160
+ major, minor = (int(p) for p in raw.split("."))
161
+ except ValueError:
162
+ raise SettingsPayloadError(f"schema_version: {raw!r} is not MAJOR.MINOR") from None
163
+ ours_major, ours_minor = (int(p) for p in SCHEMA_VERSION.split("."))
164
+ if major != ours_major or minor > ours_minor:
165
+ raise SettingsPayloadError(
166
+ f"schema_version {raw} is not understood by this server (it reads {SCHEMA_VERSION}); "
167
+ f"deploy DemoPrep first, or send a {SCHEMA_VERSION} payload")
168
+ return raw
169
+
170
+
171
+ # ------------------------------------------------------------------ parse
172
+
173
+ def parse_settings_payload(doc: Mapping[str, Any]) -> BuildSettings:
174
+ """Validate a settings document and return the concrete values a build uses.
175
+
176
+ Raises SettingsPayloadError naming the offending key. Never returns a partial
177
+ result: a payload is either usable in full or refused.
178
+ """
179
+ if not isinstance(doc, Mapping):
180
+ raise SettingsPayloadError(f"settings must be an object, got {type(doc).__name__}")
181
+ unknown = sorted(k for k in doc if k not in _TOP_LEVEL_KEYS and not str(k).startswith("$"))
182
+ if unknown:
183
+ raise SettingsPayloadError(f"unknown top-level key(s) {unknown}; allowed: {sorted(_TOP_LEVEL_KEYS)}")
184
+
185
+ version = _check_version(doc)
186
+ source = _str(doc, "source", where="payload")
187
+ owner_email = _str(doc, "owner_email", where="payload").lower()
188
+
189
+ ts = _section(doc, "thoughtspot", _THOUGHTSPOT_KEYS)
190
+ build = _section(doc, "build", _BUILD_KEYS)
191
+ _section(doc, "ui_defaults", frozenset({
192
+ "use_default_inputs", "vertical", "line", "function", "company_url",
193
+ "validation_mode", "data_adjuster_url",
194
+ })) # accepted for the App tab; a headless build reads none of it
195
+ admin = _section(doc, "admin", _ADMIN_KEYS)
196
+ admin_snowflake = _section(admin, "snowflake", _ADMIN_SNOWFLAKE_KEYS)
197
+ for k, v in admin_snowflake.items():
198
+ if not isinstance(v, str):
199
+ raise SettingsPayloadError(f"admin.snowflake.{k}: must be a string")
200
+
201
+ model = _choice(build, "model", tuple(UI_MODEL_CHOICES), DEFAULT_LLM_MODEL, where="build")
202
+ data_size = _choice(build, "data_size", tuple(DATA_SIZE_PRESETS), "Medium", where="build")
203
+ preset_fact, preset_dim = DATA_SIZE_PRESETS[data_size]
204
+ fact_rows = _rows(build, "fact_table_rows", where="build")
205
+ dim_rows = _rows(build, "dim_table_rows", where="build")
206
+
207
+ return BuildSettings(
208
+ schema_version=version,
209
+ source=source,
210
+ owner_email=owner_email,
211
+ ts_url=_str(ts, "url", where="thoughtspot").rstrip("/"),
212
+ ts_environment_label=_str(ts, "environment_label", where="thoughtspot"),
213
+ model=model,
214
+ data_size=data_size,
215
+ fact_table_rows=fact_rows if fact_rows is not None else preset_fact,
216
+ dim_table_rows=dim_rows if dim_rows is not None else preset_dim,
217
+ column_naming_style=_choice(build, "column_naming_style", COLUMN_NAMING_STYLES,
218
+ "Regular Case", where="build"),
219
+ object_naming_prefix=_str(build, "object_naming_prefix", where="build"),
220
+ liveboard_name=_str(build, "liveboard_name", where="build"),
221
+ tag_name=_str(build, "tag_name", where="build"),
222
+ share_with=_people(build, "share_with", where="build"),
223
+ geo_scope=_choice(build, "geo_scope", GEO_SCOPES, "USA Only", where="build"),
224
+ existing_model_guid=_str(build, "existing_model_guid", where="build"),
225
+ admin_share_with_default=_people(admin, "share_with_default", where="admin"),
226
+ admin_log_level=(_choice(admin, "log_level", LOG_LEVELS, "regular", where="admin")
227
+ if admin.get("log_level") not in (None, "") else ""),
228
+ admin_snowflake={k: v.strip() for k, v in admin_snowflake.items()},
229
+ )
230
+
231
+
232
+ # ------------------------------------------------------------------ apply
233
+
234
+ def apply_build_settings(target: MutableMapping[str, Any], s: BuildSettings,
235
+ *, share_with_fallback: str = "") -> dict[str, Any]:
236
+ """Write the payload onto a controller's settings dict, using the exact keys
237
+ the pipeline reads today (chat_interface / thoughtspot_deployer). Returns
238
+ what was written, for the run log.
239
+
240
+ `share_with_fallback` is the server's own policy (MCP_SHARE_WITH) used only
241
+ when neither the SE nor the admin default names anyone.
242
+ """
243
+ applied = {
244
+ "model": s.model,
245
+ "fact_table_size": str(s.fact_table_rows),
246
+ "dim_table_size": str(s.dim_table_rows),
247
+ "column_naming_style": s.column_naming_style,
248
+ "object_naming_prefix": s.object_naming_prefix,
249
+ "liveboard_name": s.liveboard_name,
250
+ "tag_name": s.tag_name,
251
+ "share_with": s.effective_share_with or share_with_fallback,
252
+ "geo_scope": s.geo_scope,
253
+ "use_existing_model": bool(s.existing_model_guid),
254
+ "existing_model_guid": s.existing_model_guid,
255
+ # Headless builds never pause at checkpoints, whatever the SE's App-tab
256
+ # preference is (the App tab reads ui_defaults.validation_mode itself).
257
+ "validation_mode": "Off",
258
+ }
259
+ target.update(applied)
260
+ return applied
docs/agent_q_flags.md CHANGED
@@ -1,6 +1,6 @@
1
  # Agent Q → DemoPrep: what to send (flag registry)
2
 
3
- *Living document, owned by the release manager. Every change to the `build_demo_from_brief` arguments or the MCP tool list is recorded here first. Last updated 2026-10-21.*
4
 
5
  **Rule (boone, 2026-10-14 / 10-20):** every feature is **OFF unless Agent Q sends it**. If an argument is omitted, false or blank, the build is exactly what it was before the feature existed.
6
 
@@ -36,9 +36,12 @@ Status key:
36
  | `tiers` | `True` | PROD |
37
  | `pinning` | `False` | PROD |
38
  | `homepage_screenshot` (also on `build_portal`, `update_portal`) | `""` (none) | BUILT (`feature/home-page-screenshot-wayback`) |
 
39
 
40
  `homepage_screenshot` is an **input**, not an off-by-default feature: a screenshot of the prospect's home page as an https image URL or a base64 data URI (`data:image/png;base64,...`; png, jpeg or webp, up to 10 MB, at least 320x240; a full-page capture is sliced). The portal's cloned home page (its sign-in page) is built from it instead of the live site. Send it when the site has a bot wall ("Access Denied"). Without it the live site is captured, then, if that is blocked, its latest Internet Archive copy, then the template login. A bad image never fails a build: a warning says why and the next source is used. The result's `home_page.source` says which: `screenshot`, `live`, `wayback (YYYY-MM-DD)` or `template`.
41
 
 
 
42
  ## 3. Feature flags: OFF unless sent
43
 
44
  | Flag (what to send) | When omitted | What it turns on | Status | Ref |
@@ -66,6 +69,7 @@ Flag names: boone said "tile=1" / "logo=1". The arguments are named `insight_til
66
  AgentSpot caches the tool list. **Refresh the tools in Agent Q** after every push that changes this page, or new arguments are invisible and newly required ones fail.
67
 
68
  ## Change log
 
69
  - 2026-10-21: `check_homepage(url)` tool: the build's own live-then-Wayback home page capture, run on its own so Agent Q can ask the SE for a screenshot before building (`needs_screenshot`, `message_for_se`).
70
  - 2026-10-21: `homepage_screenshot` (input on `build_demo_from_brief`, `build_portal`, `update_portal`) and the Internet Archive fallback for a blocked home page; `home_page.source` reports screenshot / live / wayback / template.
71
  - 2026-10-20: `insight_tile`, `logo_tile` started (ISS-24/25). Jack PR #1 (`salesforce_opportunity_id` + `environment` required) on TEST `2cea704`. Portal editor, chart repair and `deploy_existing_schema` tools on TEST.
 
1
  # Agent Q → DemoPrep: what to send (flag registry)
2
 
3
+ *Living document, owned by the release manager. Every change to the `build_demo_from_brief` arguments or the MCP tool list is recorded here first. Last updated 2026-10-22.*
4
 
5
  **Rule (boone, 2026-10-14 / 10-20):** every feature is **OFF unless Agent Q sends it**. If an argument is omitted, false or blank, the build is exactly what it was before the feature existed.
6
 
 
36
  | `tiers` | `True` | PROD |
37
  | `pinning` | `False` | PROD |
38
  | `homepage_screenshot` (also on `build_portal`, `update_portal`) | `""` (none) | BUILT (`feature/home-page-screenshot-wayback`) |
39
+ | `settings` | none: the SE's settings are read from Supabase as before | BUILT (`claude/demo-prep-settings-json-547af4`) |
40
 
41
  `homepage_screenshot` is an **input**, not an off-by-default feature: a screenshot of the prospect's home page as an https image URL or a base64 data URI (`data:image/png;base64,...`; png, jpeg or webp, up to 10 MB, at least 320x240; a full-page capture is sliced). The portal's cloned home page (its sign-in page) is built from it instead of the live site. Send it when the site has a bot wall ("Access Denied"). Without it the live site is captured, then, if that is blocked, its latest Internet Archive copy, then the template login. A bad image never fails a build: a warning says why and the next source is used. The result's `home_page.source` says which: `screenshot`, `live`, `wayback (YYYY-MM-DD)` or `template`.
42
 
43
+ `settings` is the SE's DemoPrep settings as **one JSON object** (schema `1.0`, shape in `docs/demoprep_settings_payload.example.json`), produced by the TSE settings app. Agent Q fetches the SE's document and passes it verbatim. When sent, the build takes model, data size, column naming style, object prefix, liveboard name, tag, share-with and geo scope from it and reads nothing from the SE's Supabase settings. An explicit `owner_email` / `ts_url` on the call still wins over the document; a blank `ts_url` falls back to the document's `thoughtspot.url`, then its `environment_label`. A document with an unknown key, a value outside its allowed set, or a `schema_version` the server does not read is refused before anything is built, and the error names the key. Never put secrets in it. Brief for the app's builder: `docs/settings_app_agent_brief.md`.
44
+
45
  ## 3. Feature flags: OFF unless sent
46
 
47
  | Flag (what to send) | When omitted | What it turns on | Status | Ref |
 
69
  AgentSpot caches the tool list. **Refresh the tools in Agent Q** after every push that changes this page, or new arguments are invisible and newly required ones fail.
70
 
71
  ## Change log
72
+ - 2026-10-22: `settings` (one JSON document per SE from the TSE settings app) on `build_demo_from_brief`; replaces the SE's Supabase settings for that run. Built on `claude/demo-prep-settings-json-547af4` off `b918bd8`.
73
  - 2026-10-21: `check_homepage(url)` tool: the build's own live-then-Wayback home page capture, run on its own so Agent Q can ask the SE for a screenshot before building (`needs_screenshot`, `message_for_se`).
74
  - 2026-10-21: `homepage_screenshot` (input on `build_demo_from_brief`, `build_portal`, `update_portal`) and the Internet Archive fallback for a blocked home page; `home_page.source` reports screenshot / live / wayback / template.
75
  - 2026-10-20: `insight_tile`, `logo_tile` started (ISS-24/25). Jack PR #1 (`salesforce_opportunity_id` + `environment` required) on TEST `2cea704`. Portal editor, chart repair and `deploy_existing_schema` tools on TEST.
docs/demoprep_settings_inventory.md ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DemoPrep Settings Inventory and Agent Q Payload (draft 2026-10-13, decisions 2026-10-22)
2
+
3
+ Companion to `docs/demoprep_settings_payload.example.json`.
4
+
5
+ **Purpose.** A separate TSE app will own DemoPrep settings. Agent Q will hand a settings
6
+ JSON to DemoPrep's MCP build so DemoPrep no longer reads settings from Supabase per run.
7
+ This doc lists every setting DemoPrep reads today, where it lives, who consumes it, whether
8
+ the MCP path honors it, and whether it belongs in the payload.
9
+
10
+ **Code audited:** `deploy/mcp` at `4162a45` (the test MCP line; `prod/mcp` differs only in
11
+ `mcp_server.py` plumbing, not in settings). The worktree branch this was written on is
12
+ behind `deploy/mcp` on every settings file, so line numbers below refer to `deploy/mcp`.
13
+
14
+ **Live data audited:** Supabase `user_settings` on 2026-10-13: 744 rows, 46 users
15
+ (45 SEs + `__admin__`).
16
+
17
+ ---
18
+
19
+ ## 1. The four layers of settings today
20
+
21
+ | Layer | Where | Read by | Keyed by |
22
+ |---|---|---|---|
23
+ | Per-user settings | Supabase `user_settings` (one row per key, `category='defaults'`) | `load_gradio_settings(email)`, `ChatDemoInterface.load_default_settings()` | SE email |
24
+ | Admin settings | Supabase `user_settings` where `user_email='__admin__'`, `category='admin'` | `get_admin_setting(KEY)`, `inject_admin_settings_to_env()` at boot | system-wide |
25
+ | Environment | HF Space secrets + variables, local `.env` | `os.getenv` | per Space |
26
+ | Per-call arguments | `build_demo_from_brief(...)` / `build_portal(...)` | MCP tool args from Agent Q | per build |
27
+
28
+ The payload replaces layer 1 and the non-secret half of layer 2. Layer 3 stays on the
29
+ Space. Layer 4 is unchanged.
30
+
31
+ ---
32
+
33
+ ## 2. Per-user settings (Supabase `user_settings`, category `defaults`)
34
+
35
+ Source of truth for the key list: `SETTINGS_SCHEMA` in `chat_interface.py:145`.
36
+ "MCP honors?" means: does a headless Agent Q build today pick up the SE's saved value.
37
+
38
+ | Key | Default | Choices | Consumer | MCP honors? | Payload |
39
+ |---|---|---|---|---|---|
40
+ | `default_llm` | `claude-opus-5` | `claude-opus-5`, `claude-sonnet-5` | `controller.settings['model']`; `resolve_model_name()` clamps anything else to default | Yes, if in the allowed list | `build.model` |
41
+ | `default_ts_env` | `secloud - primary` | labels from `TS_ENV_<n>_LABEL` | App tab only: seeds the TS Environment dropdown, which resolves URL + trusted-auth key | No (MCP uses `ts_url` arg or `TS_ENV_URL_DEFAULT`) | `thoughtspot.environment_label` |
42
+ | `liveboard_name` | `""` | free text | deploy stage; blank = `"{Company} - {use case}"` | **No**, never loaded headless | `build.liveboard_name` |
43
+ | `default_data_size` | `Medium` | `Small`, `Medium` | Small = 1,000 fact / 50 dim rows; Medium = 10,000 / 500 | Yes (`mcp_server.py:717`) | `build.data_size` |
44
+ | `fact_table_size`, `dim_table_size` | `1000`, `100` | hidden legacy | overwritten by the data-size preset at GO and in MCP | Superseded | `build.fact_table_rows`, `build.dim_table_rows` (explicit override, optional) |
45
+ | `geo_scope` | `USA Only` | `USA Only`, `International` | **None.** Stored and copied into `controller.settings`, but no pipeline stage reads it | n/a (dead) | `build.geo_scope` (**keep, list it**, decided 10-22) |
46
+ | `tag_name` | `""` | free text | tags model + liveboard after build | Yes | `build.tag_name` |
47
+ | `column_naming_style` | `Regular Case` | `Regular Case`, `snake_case`, `camelCase`, `PascalCase`, `UPPER_CASE`, `original` | `deployer.column_naming_style` (`chat_interface.py:4420`) | **No**, headless builds always get Regular Case | `build.column_naming_style` |
48
+ | `object_naming_prefix` | `""` | free text | DDL + deploy object names | Yes, via the mid-run Supabase re-read at `chat_interface.py:4467` | `build.object_naming_prefix` |
49
+ | `share_with` | `""` | comma-separated users / groups | share model + liveboard; falls back to admin `SHARE_WITH` | **No**, env `MCP_SHARE_WITH` overrides (default `mike.boone@thoughtspot.com`) | `build.share_with` (list) |
50
+ | `validation_mode` | `Off` | `On`, `Off` | App tab checkpoint pauses only | No, headless is always auto-run | `ui_defaults.validation_mode` |
51
+ | `use_default_inputs` | `false` | bool | App tab pre-fill switch | No | `ui_defaults.use_default_inputs` |
52
+ | `default_vertical`, `default_line`, `default_function`, `default_company_url` | `""` | matrix values / URL | App tab pre-fill | No (Agent Q passes `use_case` + `company_url` per call) | `ui_defaults.*` |
53
+ | `default_use_case` | `Sales Analytics` | hidden legacy | seeds `controller.settings['use_case']`, overridden by the per-call arg | Superseded | not needed |
54
+ | `use_existing_model`, `existing_model_guid` | `false`, `""` | hidden legacy | skips model creation, builds a liveboard on an existing model (`chat_interface.py:4247`) | No, never loaded headless | `build.existing_model_guid` (blank = build a model) |
55
+ | `data_adjuster_url` | `""` | URL | Settings tab shortcut into Data Adjuster | No | `ui_defaults.data_adjuster_url` |
56
+ | `temperature`, `max_tokens`, `batch_size`, `thread_count` | `0.3`, `4000`, `5000`, `4` | "Advanced AI Settings (Admin)" accordion | **None.** Stored, never read by the pipeline | n/a (dead) | **drop** (decided 10-22) |
57
+ | `snowflake_account`, `snowflake_user`, `snowflake_role`, `default_warehouse`, `default_database`, `default_schema` | `""`, `""`, `ACCOUNTADMIN`, `COMPUTE_WH`, `DEMO_DB`, `PUBLIC` | "Database Connections (Admin)" accordion | **None.** Pipeline uses the admin `SNOWFLAKE_*` keys | n/a (dead) | **drop** (decided 10-22) |
58
+ | `thoughtspot_username` | `""` | text | **None.** The deployer authenticates as the owner email | n/a (dead) | **drop** (decided 10-22); owner is `owner_email` |
59
+
60
+ ### What the live data says
61
+
62
+ - Every SE is on the defaults: 44/44 `Medium`, 45/45 `USA Only`, 45/45 `Regular Case`, 44/45 `Off`.
63
+ - `default_llm`: 20 on `claude-sonnet-4-6`, 13 on `claude-opus-5`, **12 still on `gpt-5.5`**. All non-allowed values silently resolve to `claude-opus-5` at run time.
64
+ - `default_ts_env`: 42 `secloud - primary`, 2 `sebe - se`.
65
+ - `share_with` is set for only 4 users; everyone else inherits admin `SHARE_WITH`.
66
+ - 4 old accounts carry pre-redesign keys with a null category (`company_size`,
67
+ `default_data_volume`, `liveboard_method`, `naming_prefix`, per-user `snowflake_*`). All dead.
68
+
69
+ ### What `geo_scope` was meant to do, and why it does nothing
70
+
71
+ The "Geographic Scope" dropdown (right panel and Settings tab) offers `USA Only` or
72
+ `International`, with the hint "US states/cities/USD vs global". It was meant to steer the
73
+ generated data. It never reached the current generator:
74
+
75
+ - The current pipeline (`build_demo()` in `demoprep_app/pipeline/build_demo.py`) and every
76
+ module under `demoprep_app/dataset` contain no reference to the setting. Region values are
77
+ hard-coded per vertical in `template_generator.py:928` and already mix US and
78
+ international names (`North America`, `EMEA`, `APAC`, `Latin America`).
79
+ - The only "USA Only" guidance in the codebase is a hard-coded prompt block in the legacy
80
+ `demo_prep.py` interface (`create_demo_prep_interface`, around line 1677). It is fixed text,
81
+ not driven by the dropdown, and that interface is not the App-tab or MCP path.
82
+ - `git log -S geo_scope` over the generator modules is empty: no consumer was removed. It was
83
+ never wired.
84
+
85
+ So every build today ignores the dropdown, and 45 of 45 SEs have it on `USA Only` while
86
+ the generated data is already mixed-geography. **Decision 10-22: keep it and list it** in the
87
+ payload (`build.geo_scope`). Wiring it into the generator is a separate generator feature.
88
+
89
+ ### Keys that matter for a headless build
90
+
91
+ Only these per-user keys change what an Agent Q build produces, and today the MCP path
92
+ honors just three of them: **`default_llm`, `default_data_size`, `tag_name`,
93
+ `object_naming_prefix`** are honored; **`column_naming_style`, `liveboard_name`,
94
+ `share_with`** are ignored. The payload fixes that gap for free.
95
+
96
+ ---
97
+
98
+ ## 3. Admin settings (Supabase `__admin__`, category `admin`)
99
+
100
+ Registry: `ADMIN_SETTINGS_KEYS` in `supabase_client.py:618`. Loaded once at boot by
101
+ `inject_admin_settings_to_env()` and read on demand via `get_admin_setting()`.
102
+
103
+ | Key | Live value | Secret? | Consumer | Payload |
104
+ |---|---|---|---|---|
105
+ | `SNOWFLAKE_ACCOUNT` | `THOUGHTSPOT_PARTNER` | no | `snowflake_auth.py:157`, deployer connection create | `admin.snowflake.account` |
106
+ | `SNOWFLAKE_KP_USER` | `SE_DEMO_KP` | no | key-pair user | `admin.snowflake.kp_user` |
107
+ | `SNOWFLAKE_KP_PK` | set | **yes** | `snowflake_auth.py:112` | **never**; leave blank / omitted |
108
+ | `SNOWFLAKE_KP_PASSPHRASE` | set | **yes** | `snowflake_auth.py:121` | **never**; leave blank / omitted |
109
+ | `SNOWFLAKE_ROLE` | `SE_ROLE` | no | connection role | `admin.snowflake.role` |
110
+ | `SNOWFLAKE_WAREHOUSE` | `SE_DEMO_WH` | no | connection warehouse | `admin.snowflake.warehouse` |
111
+ | `SNOWFLAKE_DATABASE` | `DEMOBUILD` | no | base name; `get_demo_database()` rotates to `DEMOBUILD_YYYY_MM` | `admin.snowflake.database` |
112
+ | `SNOWFLAKE_SSO_USER` | `mike.boone@thoughtspot.com` | no | Snowflake browser auth (Data Adjuster) | `admin.snowflake.sso_user` |
113
+ | `SNOWFLAKE_SCHEMA` | not set (falls to `PUBLIC`) | no | read via `get_admin_setting` but not in the registry | omit |
114
+ | `SHARE_WITH` | `mike.boone@thoughtspot.com` | no | fallback share target when the user's `share_with` is blank | `admin.share_with_default` |
115
+ | `LOG_LEVEL` | not set (falls to `regular`) | no | `session_logger.py:266`: `off` / `regular` / `verbose` | `admin.log_level` |
116
+ | `THOUGHTSPOT_URL` | not set | no | **Dead.** Replaced by the `TS_ENV_<n>` table; hidden in the Admin tab | omit |
117
+ | `OPENAI_API_KEY`, `GOOGLE_API_KEY` | not set | yes | **Dead in Supabase.** Explicitly skipped by `inject_admin_settings_to_env()`; env only | omit |
118
+
119
+ **Decided 10-22:** the non-secret Snowflake block travels in the payload as
120
+ `admin.snowflake`. The Snowflake key and passphrase are never in the payload; where they are
121
+ kept is out of scope for this doc and does not block the settings-app move. (For the record:
122
+ today the code reads them through `get_admin_setting()`, i.e. the Supabase admin row.)
123
+
124
+ ---
125
+
126
+ ## 4. Environment (HF Space secrets and variables; stays server-side)
127
+
128
+ Nothing in this section belongs in the payload. Listed so the settings app knows what it
129
+ does **not** own.
130
+
131
+ | Group | Variables | Default |
132
+ |---|---|---|
133
+ | Bootstrap | `SUPABASE_URL`, `SUPABASE_ANON_KEY` | required until the payload replaces Supabase |
134
+ | LLM keys | `ANTHROPIC_API_KEY` (required), `OPENAI_API_KEY`, `GOOGLE_API_KEY` | env only |
135
+ | ThoughtSpot env table | `TS_ENV_<n>_LABEL`, `TS_ENV_<n>_URL`, `TS_ENV_<n>_KEY_VAR` (value or `ENV:<NAME>` indirection) | 1 = `secloud - primary`, 2 = `sebe - se`, 3 = `techpartners` |
136
+ | ThoughtSpot defaults | `TS_ENV_URL_DEFAULT`, `TS_USER_DEFAULT`, `TS_AUTH_KEY` (direct key override) | per Space |
137
+ | MCP | `MCP_ACCESS_TOKEN`, `MCP_TRANSPORT`, `MCP_HTTP_PORT`, `MCP_MAX_CONCURRENT_BUILDS`, `MCP_SHARE_WITH`, `MCP_HIT_LOG`, `MCP_USE_DIRECT_API` | `http`, `7860`, `3`, `mike.boone@thoughtspot.com`, unset, `true` |
138
+ | Feature flags | `MUZE_ENGINE_MAIN_TABS`, `USE_ADVANCED_TML` | unset = `true`, `true` |
139
+ | ThoughtSpot retry tuning | `TS_AUTH_MAX_ATTEMPTS`, `TS_AUTH_RETRY_WAIT_SECONDS`, `TS_MODEL_READY_TIMEOUT_SECONDS`, `TS_MODEL_READY_POLL_INTERVAL_SECONDS`, `TS_CONNECTION_CREATE_MAX_ATTEMPTS`, `TS_CONNECTION_CREATE_RETRY_WAIT_SECONDS`, `TS_TML_ASYNC_POLL_INTERVAL_SECONDS`, `TS_TML_ASYNC_TIMEOUT_SECONDS` | `3`, `5`, `300`, `30`, `3`, `20`, `5`, `900` |
140
+ | Slack | `SLACK_BOT_TOKEN`, `SLACK_DEPLOYMENT_CHANNEL_ID` (or `SLACK_CHANNEL_ID`), `SLACK_NOTIFICATIONS_ENABLED` | `true` |
141
+ | Portal / HF | `HF_TOKEN`, `SPACE_ID`, `SPACE_HOST`, `GIT_COMMIT` | set by HF |
142
+ | App | `APP_ENV`, `DEMOPREP_NO_AUTH`, `DEMOPREP_DEV_USER_EMAIL`, `DEMOPREP_APP_URL` / `PUBLIC_APP_URL`, `DEBUG`, `PORT` | `production`, local dev only |
143
+
144
+ Two stale spots found on the way: the README documents `MCP_OWNER_EMAIL` and
145
+ `MCP_TS_ENV_LABEL`, but the code reads `TS_USER_DEFAULT` and `TS_ENV_URL_DEFAULT`; and
146
+ `validate_required_settings()` still demands `OPENAI_API_KEY` although the pipeline runs on
147
+ Anthropic.
148
+
149
+ ---
150
+
151
+ ## 5. Per-call arguments Agent Q already passes (unchanged)
152
+
153
+ `build_demo_from_brief(brief, company_name, use_case, company_url, ts_url, owner_email,
154
+ with_portal)` and `build_portal(...)` with its ~40 styling arguments. These are per-demo
155
+ inputs, not settings, and stay as they are. The payload adds one more argument.
156
+
157
+ ---
158
+
159
+ ## 6. Implementation (built 2026-10-22, branch `claude/demo-prep-settings-json-547af4` off `b918bd8` = test and prod tip; not pushed)
160
+
161
+ 1. **New tool argument.** `build_demo_from_brief(..., settings: dict | None = None)`,
162
+ keyword-only, optional. Agent Q fetches the SE's document from the settings app and
163
+ passes it through verbatim. Without it the build behaves exactly as before.
164
+ 2. **One reader.** `demoprep_app/settings/payload.py`: `parse_settings_payload()` validates
165
+ the document (schema major 1, allowed values, no unknown keys, no partial results) and
166
+ `apply_build_settings()` writes it onto the controller's settings dict under the exact
167
+ keys the pipeline reads today. The data-size preset table lives there now.
168
+ 3. **Precedence.** Explicit `owner_email` / `ts_url` on the call win; otherwise they come
169
+ from the document (`thoughtspot.url`, then `thoughtspot.environment_label` resolved
170
+ through the `TS_ENV_<n>` table by `_ts_url_for_label()`). Everything under `build`
171
+ comes from the document. A bad document is refused before the ThoughtSpot target is
172
+ resolved or a build slot is taken; the error names the key.
173
+ 4. **Supabase is not consulted for a document-driven run.** The controller is built with
174
+ `settings_from_supabase=False` (new keyword on `ChatDemoInterface`), and the three
175
+ mid-run re-reads of `load_gradio_settings()` in chat_interface (chat path, Snowflake
176
+ stage, ThoughtSpot stage) are guarded by `_settings_source == 'payload'`.
177
+ 5. **Provenance.** The run record carries `settings_source` (`payload` / `supabase`) and
178
+ `settings_schema_version`; the server log line shows every applied value.
179
+ 6. **Tests.** `tests/test_settings_payload.py` (30, pure) and
180
+ `tests/test_mcp_settings_document.py` (12: tool schema, refusals, precedence, label
181
+ resolution, controller and guards). One existing fixture stub updated to the new loader
182
+ signature (`tests/test_custom_mcp_demo_pack.py`).
183
+
184
+ Mapping from document to what the pipeline reads:
185
+
186
+ | Document field | Applied as |
187
+ |---|---|
188
+ | `owner_email` | `_resolve_owner()` unless the call passes `owner_email` |
189
+ | `thoughtspot.url`, then `thoughtspot.environment_label` | `_resolve_ts_target()` unless the call passes `ts_url` |
190
+ | `build.model` | `controller.settings['model']` |
191
+ | `build.data_size` / `fact_table_rows` / `dim_table_rows` | `fact_table_size`, `dim_table_size` (strings, as the App tab stores them) |
192
+ | `build.column_naming_style` | `deployer.column_naming_style` via `controller.settings` |
193
+ | `build.object_naming_prefix`, `liveboard_name`, `tag_name` | same keys on `controller.settings` |
194
+ | `build.share_with`, else `admin.share_with_default`, else env `MCP_SHARE_WITH` | `controller.settings['share_with']` (comma-joined) |
195
+ | `build.existing_model_guid` | `use_existing_model = bool(guid)`, `existing_model_guid` |
196
+ | `build.geo_scope` | `controller.settings['geo_scope']` (no consumer yet, listed per boone) |
197
+ | `ui_defaults.*` | validated, ignored by a headless build; `validation_mode` is forced `Off` |
198
+ | `admin.log_level`, `admin.snowflake.*` | validated and recorded on the run; **not applied per build** (see phase 2) |
199
+
200
+ `with_portal` was dropped from the document: it is a per-demo choice Agent Q already
201
+ passes on the call, not a persistent setting.
202
+
203
+ ### Phase 2 scope: the document in Supabase (not built)
204
+
205
+ Boone 10-22: "pretty soon we may just do it in the database". Same document, stored, one
206
+ row per SE. DemoPrep reads it when a build arrives without `settings`.
207
+
208
+ ```sql
209
+ create table if not exists demoprep_settings (
210
+ owner_email text primary key,
211
+ schema_version text not null,
212
+ document jsonb not null,
213
+ updated_at timestamptz not null default now(),
214
+ updated_by text not null
215
+ );
216
+ ```
217
+
218
+ Code, when the time comes (small, because the parser already exists):
219
+
220
+ 1. `supabase_client.load_settings_document(owner_email) -> dict | None` using the cached
221
+ `_reader()`; raises when Supabase is unconfigured (a read that silently returns nothing
222
+ would be reported as "no settings").
223
+ 2. In `build_demo_from_brief`: `doc = settings if settings is not None else
224
+ load_settings_document(owner_email)`; then the existing parse-and-apply path. Order:
225
+ call argument, then the Supabase row, then (until cutover) today's per-key behaviour.
226
+ After cutover the per-key path is deleted and "no row" fails loud.
227
+ 3. `admin.*` moves with it: `inject_admin_settings_to_env()` reads the admin document at
228
+ boot instead of the `__admin__` per-key rows. Per-build application of
229
+ `admin.snowflake` is deliberately not done: `SNOWFLAKE_*` are process-wide environment
230
+ values and concurrent builds share the process.
231
+ 4. The settings app writes with a service-role key; DemoPrep reads with the anon key it
232
+ already holds. The Gradio Settings tab either becomes read-only or writes the same row.
233
+
234
+ ## 7. Decisions (boone, 2026-10-22)
235
+
236
+ | # | Question | Decision |
237
+ |---|---|---|
238
+ | 1 | Does the App tab keep reading Supabase? | Settings stay in Supabase for the App tab (read as boone's "the settings are in Supabase"; confirm if that was not the intent) |
239
+ | 2 | Non-secret Snowflake block: payload or Space secrets? | Payload (`admin.snowflake`). Key and passphrase never in the payload; their storage is out of scope here |
240
+ | 3 | `geo_scope`: wire or drop? | **Keep, list it.** Stays in the payload as `build.geo_scope`; generator wiring is separate work |
241
+ | 4 | `share_with` list or string? | No preference; keeping the list as drafted |
242
+ | 5 | Drop the dead per-user fields? | **Yes.** `temperature`, `max_tokens`, `batch_size`, `thread_count`, per-user `snowflake_*`, `thoughtspot_username` are out of the new app; the parser refuses them as unknown keys |
243
+ | 6 | Schema version check? | Needed explaining. Plain version: the JSON carries `schema_version` (already in the example). When the settings app later adds a field, a DemoPrep Space that predates it compares the number and says "payload is newer than I understand" instead of silently ignoring the field. One `if` on the DemoPrep side; recommend yes |
244
+ | 7 | `with_portal` in the document? | **No** (10-22, DemoPrep side): it is a per-demo call argument, not a setting; removed from the example |
245
+ | 8 | Deliverables for the other side | `docs/settings_app_agent_brief.md` (paste to the agent building the settings app); DemoPrep reader built, see section 6 |
docs/demoprep_settings_payload.example.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$comment": "DemoPrep settings document, schema 1.0 (2026-10-22). One per SE, produced by the TSE settings app, handed to DemoPrep by Agent Q as build_demo_from_brief(settings=...), later stored one row per SE in Supabase. Secrets never travel here (Snowflake key/passphrase, ThoughtSpot trusted-auth keys, LLM keys, MCP bearer, Supabase creds). Field rules: docs/demoprep_settings_inventory.md; reader: demoprep_app/settings/payload.py",
3
+ "schema_version": "1.0",
4
+ "generated_at": "2026-10-13T00:00:00Z",
5
+ "source": "tse-settings-app",
6
+ "owner_email": "se.name@thoughtspot.com",
7
+ "thoughtspot": {
8
+ "environment_label": "secloud - primary",
9
+ "url": "https://se-thoughtspot-cloud.thoughtspot.cloud"
10
+ },
11
+ "build": {
12
+ "model": "claude-opus-5",
13
+ "data_size": "Medium",
14
+ "fact_table_rows": 10000,
15
+ "dim_table_rows": 500,
16
+ "column_naming_style": "Regular Case",
17
+ "object_naming_prefix": "",
18
+ "liveboard_name": "",
19
+ "tag_name": "",
20
+ "share_with": [
21
+ "mike.boone@thoughtspot.com"
22
+ ],
23
+ "geo_scope": "USA Only",
24
+ "existing_model_guid": ""
25
+ },
26
+ "ui_defaults": {
27
+ "use_default_inputs": false,
28
+ "vertical": "",
29
+ "line": "",
30
+ "function": "",
31
+ "company_url": "",
32
+ "validation_mode": "Off",
33
+ "data_adjuster_url": ""
34
+ },
35
+ "admin": {
36
+ "share_with_default": [
37
+ "mike.boone@thoughtspot.com"
38
+ ],
39
+ "log_level": "regular",
40
+ "snowflake": {
41
+ "account": "THOUGHTSPOT_PARTNER",
42
+ "kp_user": "SE_DEMO_KP",
43
+ "role": "SE_ROLE",
44
+ "warehouse": "SE_DEMO_WH",
45
+ "database": "DEMOBUILD",
46
+ "sso_user": "mike.boone@thoughtspot.com"
47
+ }
48
+ }
49
+ }
docs/settings_app_agent_brief.md ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Brief: build the TSE "DemoPrep Settings" app
2
+
3
+ *Paste this whole file to the agent building the settings app. Written 2026-10-22 from the DemoPrep side; DemoPrep's reader for the document described here is already implemented (`demoprep_app/settings/payload.py`).*
4
+
5
+ ## What you are building
6
+
7
+ A small TSE-side app where a Sales Engineer edits their DemoPrep settings. Its output is **one JSON document per SE** in the shape below (schema `1.0`). DemoPrep no longer looks an SE's settings up in Supabase when it receives this document.
8
+
9
+ Two delivery phases. Build phase 1 now; design for phase 2.
10
+
11
+ 1. **Phase 1, file.** The app can hand out the SE's current document on request. Agent Q (the AgentSpot agent that calls DemoPrep's MCP server) fetches it and passes it verbatim as the `settings` argument of DemoPrep's `build_demo_from_brief` tool. Expose it as an authenticated HTTPS endpoint, `GET /demoprep/settings/{owner_email}` returning `application/json`, plus a "download JSON" button for a human. Bearer-protect the endpoint; Agent Q will hold the token.
12
+ 2. **Phase 2, Supabase.** The app writes the same document to DemoPrep's Supabase project, one row per SE, instead of (or as well as) serving it. DemoPrep will read that row when a build arrives without a `settings` argument. Table and reader are specified at the end. Do not write to the existing `user_settings` table; that is the legacy per-key store the Gradio app still uses.
13
+
14
+ ## The document
15
+
16
+ ```json
17
+ {
18
+ "schema_version": "1.0",
19
+ "generated_at": "2026-10-13T00:00:00Z",
20
+ "source": "tse-settings-app",
21
+ "owner_email": "se.name@thoughtspot.com",
22
+ "thoughtspot": {
23
+ "environment_label": "secloud - primary",
24
+ "url": "https://se-thoughtspot-cloud.thoughtspot.cloud"
25
+ },
26
+ "build": {
27
+ "model": "claude-opus-5",
28
+ "data_size": "Medium",
29
+ "fact_table_rows": 10000,
30
+ "dim_table_rows": 500,
31
+ "column_naming_style": "Regular Case",
32
+ "object_naming_prefix": "",
33
+ "liveboard_name": "",
34
+ "tag_name": "",
35
+ "share_with": ["mike.boone@thoughtspot.com"],
36
+ "geo_scope": "USA Only",
37
+ "existing_model_guid": ""
38
+ },
39
+ "ui_defaults": {
40
+ "use_default_inputs": false,
41
+ "vertical": "",
42
+ "line": "",
43
+ "function": "",
44
+ "company_url": "",
45
+ "validation_mode": "Off",
46
+ "data_adjuster_url": ""
47
+ },
48
+ "admin": {
49
+ "share_with_default": ["mike.boone@thoughtspot.com"],
50
+ "log_level": "regular",
51
+ "snowflake": {
52
+ "account": "THOUGHTSPOT_PARTNER",
53
+ "kp_user": "SE_DEMO_KP",
54
+ "role": "SE_ROLE",
55
+ "warehouse": "SE_DEMO_WH",
56
+ "database": "DEMOBUILD",
57
+ "sso_user": "mike.boone@thoughtspot.com"
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ ### Field rules (validate these in the app; DemoPrep refuses anything else)
64
+
65
+ | Field | Type | Allowed / default | Notes |
66
+ |---|---|---|---|
67
+ | `schema_version` | string | `"1.0"` | Required. DemoPrep refuses a version it does not read. |
68
+ | `generated_at` | ISO-8601 string | | When the document was produced. |
69
+ | `source` | string | `"tse-settings-app"` | Recorded on the run. |
70
+ | `owner_email` | string | the SE's ThoughtSpot login email | The build authenticates as this user and they own the created objects. Lower-cased. |
71
+ | `thoughtspot.environment_label` | string | one of DemoPrep's environment labels: `secloud - primary`, `sebe - se`, `techpartners` | Shown to the SE as the "TS Environment" choice. |
72
+ | `thoughtspot.url` | string | the URL for that label (see table below) | **Send both.** DemoPrep uses `url` first and only falls back to the label. |
73
+ | `build.model` | string | `claude-opus-5` (default), `claude-sonnet-5` | Anything else is refused. |
74
+ | `build.data_size` | string | `Small` (1,000 fact / 50 dim rows), `Medium` (10,000 / 500, default) | |
75
+ | `build.fact_table_rows`, `build.dim_table_rows` | integer > 0 | derived from `data_size` when absent | Send them only when the SE overrides the preset. Integers, not strings. |
76
+ | `build.column_naming_style` | string | `Regular Case` (default), `snake_case`, `camelCase`, `PascalCase`, `UPPER_CASE`, `original` | |
77
+ | `build.object_naming_prefix` | string | `""` | Prefix on Snowflake schema and ThoughtSpot object names. |
78
+ | `build.liveboard_name` | string | `""` | Blank = DemoPrep derives it from the company. |
79
+ | `build.tag_name` | string | `""` | ThoughtSpot tag applied to the model and liveboard. |
80
+ | `build.share_with` | list of strings | `[]` | ThoughtSpot users or groups the model and liveboard are shared with. A comma-separated string is also accepted. |
81
+ | `build.geo_scope` | string | `USA Only` (default), `International` | Listed by request; the generator does not act on it yet. |
82
+ | `build.existing_model_guid` | string | `""` | Non-blank = build the liveboard on that existing model instead of creating one. |
83
+ | `ui_defaults.*` | | as shown | Gradio App-tab prefill only; a headless build ignores it. `validation_mode` is `On` or `Off`. |
84
+ | `admin.share_with_default` | list of strings | | Fallback share target when `build.share_with` is empty. Admin-level, same for every SE. |
85
+ | `admin.log_level` | string | `off`, `regular`, `verbose` | |
86
+ | `admin.snowflake.*` | strings | as shown | Non-secret connection identity. DemoPrep records it; it is still applied from the server's own configuration in v1. |
87
+
88
+ Environment labels and URLs today:
89
+
90
+ | Label | URL |
91
+ |---|---|
92
+ | `secloud - primary` | `https://se-thoughtspot-cloud.thoughtspot.cloud` |
93
+ | `sebe - se` | `https://sebe.thoughtspotstaging.cloud` |
94
+ | `techpartners` | `https://techpartners.thoughtspot.cloud` |
95
+
96
+ ### Rules that are not negotiable
97
+
98
+ - **No secrets, ever.** Not the Snowflake private key or passphrase, not ThoughtSpot trusted-auth keys, not LLM keys, not the MCP bearer, not Supabase credentials. The document passes through an LLM as tokens.
99
+ - **Unknown keys are refused**, at the top level and inside `build`, `thoughtspot`, `admin`, `ui_defaults`. A typo must not silently become "default". Keys starting with `$` are comments and ignored.
100
+ - **A value outside its allowed set is refused** with the allowed values in the message. Mirror the same lists in the app's dropdowns so the SE cannot produce a document DemoPrep rejects.
101
+ - **Absent optional keys take the defaults above.** Emit the full document anyway; it makes the record self-explaining.
102
+ - Bump `schema_version` only when a key is added, renamed or its rules change. DemoPrep ships its reader first; a document newer than the server is refused, not partially applied.
103
+
104
+ ## How Agent Q uses it
105
+
106
+ Agent Q already calls `build_demo_from_brief(brief, company_name, salesforce_opportunity_id, environment, owner_email, ts_url, ...)`. The only change: it fetches the SE's document from your endpoint and adds `settings=<document>`. Precedence inside DemoPrep: an explicit `owner_email` or `ts_url` on the call wins over the document; everything under `build` comes from the document; Supabase is not consulted for that run.
107
+
108
+ ## Phase 2: the Supabase row
109
+
110
+ Same document, stored. DemoPrep's Supabase project, new table:
111
+
112
+ ```sql
113
+ create table if not exists demoprep_settings (
114
+ owner_email text primary key,
115
+ schema_version text not null,
116
+ document jsonb not null,
117
+ updated_at timestamptz not null default now(),
118
+ updated_by text not null
119
+ );
120
+ ```
121
+
122
+ - One row per SE; `owner_email` lower-cased; `document` is the full JSON above; `schema_version` duplicated out of the document for cheap filtering.
123
+ - The app upserts on save. Writes use a service-role key held by the app, never the anon key.
124
+ - DemoPrep side (not yours): `supabase_client.load_settings_document(owner_email)` reads the row through the same parser the `settings` argument uses, so the two phases behave identically. A build with no `settings` argument reads the row; no row means the legacy per-key behaviour until the migration is declared done.
125
+
126
+ ## Reference in the DemoPrep repo
127
+
128
+ - `docs/demoprep_settings_payload.example.json` is the canonical example (keep it in sync with what you emit).
129
+ - `docs/demoprep_settings_inventory.md` is the full inventory of every setting, where it lives today and what consumes it.
130
+ - `demoprep_app/settings/payload.py` is the reader; `tests/test_settings_payload.py` pins its rules. If the app and the reader disagree, the reader is the contract.
131
+
132
+ ## Questions to settle with boone before you finish
133
+
134
+ 1. Who may edit `admin.*`: every SE's document carries it, but only an admin should change it. Suggest the app stores admin values once and stamps them into every document on export.
135
+ 2. Authentication for the SE: SSO identity must equal `owner_email`; do not let an SE type an arbitrary owner.
136
+ 3. Endpoint token for Agent Q: who issues and rotates it.
mcp_server.py CHANGED
@@ -111,6 +111,12 @@ from llm_config import BLUEPRINT_LLM_MODEL, DEFAULT_LLM_MODEL
111
  # Which Space this process runs in and which commit it runs — stamped on every
112
  # run record and reported by ping/config_check (resolved once, never raises).
113
  from demoprep_app.deployment_identity import deployment_identity
 
 
 
 
 
 
114
  # The yes/no reader the portal answers already use (blank = not stated), so the
115
  # custom_mcp_demo flag follows the same rule. Imported here so a break fails at boot.
116
  from demoprep_app.portal.builder import _as_bool as _read_yes_no
@@ -216,6 +222,27 @@ def _resolve_ts_target(ts_url: str) -> tuple[str, str]:
216
  )
217
 
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  # (connect, read) seconds — 10 s worst case. Short on purpose: FastMCP runs this
220
  # sync tool on the server's event loop, so the wait stalls status() polls. A
221
  # precheck that gets no answer in time lets the build go ahead (the deployer's
@@ -759,7 +786,8 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
759
  brand: dict | None = None,
760
  custom_mcp_demo: bool | str = False,
761
  header_tiles: dict | None = None,
762
- *, schema_style: str, homepage_screenshot: str = "") -> dict:
 
763
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
764
  already registered the build in _active_builds and holds a _build_sem slot).
765
  With `existing_schema` + `existing_ddl` (deploy_existing_schema) the dataset,
@@ -954,36 +982,53 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
954
  try:
955
  # (i) controller — acting user = the per-build owner (per-instance; no
956
  # process-global write, so concurrent builds never race on the owner).
957
- controller = ChatDemoInterface(user_email=owner)
 
 
 
958
 
959
- # (ii) settings: model + fixed TS env (exact key names per the wiring trace)
960
- controller.settings["model"] = controller.settings.get("model") or DEFAULT_LLM_MODEL
961
  controller.settings["thoughtspot_url"] = ts_target_url
962
  controller.settings["thoughtspot_trusted_auth_key"] = ts_auth_key
963
- # Always share the created model + liveboard with the operator, so every demo is
964
- # visible no matter who ran it. The OWNER is unchanged — whoever was passed as
965
- # owner_email (the build authenticates AS that user, so they own the objects);
966
- # this just adds the operator as a viewer. Override target via MCP_SHARE_WITH.
967
- controller.settings["share_with"] = os.getenv("MCP_SHARE_WITH", "mike.boone@thoughtspot.com")
968
-
969
- # (ii-b) Data Size — resolve exactly like the App tab GO path does
970
- # (chat_interface defined_go, _size_map): default_data_size drives
971
- # fact/dim row counts. load_default_settings() only reads the LEGACY
972
- # fact_table_size field, which is stale ('1000') for users who set
973
- # Data Size after the Settings Redesign — that silently gave every MCP
974
- # build 500-row fact tables while App builds got 3,333 (found 2026-08-25:
975
- # all 3 replay boards graded 42-62/100 with "thin data" the top defect).
976
- _SIZE_MAP = {
977
- "Small": ("1000", "50"),
978
- "Medium": ("10000", "500"),
979
- }
980
- _data_size = str(
981
- load_gradio_settings(owner).get("default_data_size", "")
982
- ).strip() or "Medium"
983
- _ft, _dt = _SIZE_MAP.get(_data_size, _SIZE_MAP["Medium"])
984
- controller.settings["fact_table_size"] = _ft
985
- controller.settings["dim_table_size"] = _dt
986
- _log_hit(f"data size: {_data_size} -> fact_rows={_ft} dim_rows={_dt} (owner setting)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
987
 
988
  # vertical / function / use_case_config exactly as the runner does
989
  controller.vertical, controller.function = parse_use_case(use_case or "")
@@ -1025,6 +1070,9 @@ def _run_build(run_id: str, brief: str, company_name: str, use_case: str, compan
1025
  'schema_style': schema_style,
1026
  # Resolved yes/no (the raw value is on the intake row).
1027
  'custom_mcp_demo': custom_mcp_on,
 
 
 
1028
  }
1029
  # One fact ('simple') or 1-3 ('complex'): read by run_ddl_creation ->
1030
  # build_demo -> author_blueprint (ISS-16).
@@ -1425,6 +1473,9 @@ def build_demo_from_brief(
1425
  # An INPUT, not a feature flag: the SE's picture of the prospect's home
1426
  # page, used for the cloned login before the live site (2026-10-21).
1427
  homepage_screenshot: str = "",
 
 
 
1428
  ) -> dict:
1429
  """Start a full ThoughtSpot demo build from a prospect brief.
1430
 
@@ -1453,6 +1504,17 @@ def build_demo_from_brief(
1453
  environment: REQUIRED. Which environment this demo is for — "prod" or "staging"
1454
  ("production"/"stage" are accepted and normalised). Recorded with the run;
1455
  a build will not start without a valid value.
 
 
 
 
 
 
 
 
 
 
 
1456
  custom_mcp_demo: Optional, OFF by default. Set true to also get a "Custom MCP
1457
  Demo — GitHub Issues → Spotter" walkthrough for this demo: a suggested
1458
  GitHub repo name, 4 GitHub issues written from the deployed model's
@@ -1600,6 +1662,24 @@ def build_demo_from_brief(
1600
  except ValueError as e:
1601
  return {"status": "failed", "errors": [str(e)]}
1602
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1603
  # Resolve the ThoughtSpot target up front (URL from the request or the default,
1604
  # key chosen by instance) so a bad URL / missing key fails fast — before we
1605
  # take the single build slot.
@@ -1687,7 +1767,8 @@ def build_demo_from_brief(
1687
  custom_mcp_demo=custom_mcp_demo,
1688
  header_tiles=header_tiles,
1689
  schema_style=schema_style,
1690
- homepage_screenshot=_shot)
 
1691
  finally:
1692
  _build_sem.release()
1693
 
 
111
  # Which Space this process runs in and which commit it runs — stamped on every
112
  # run record and reported by ping/config_check (resolved once, never raises).
113
  from demoprep_app.deployment_identity import deployment_identity
114
+ # The SE's settings as ONE document (schema 1.x), produced by the TSE settings app
115
+ # and carried by Agent Q (boone 2026-10-22). Parser and apply step live in one
116
+ # place so every reader agrees; see docs/demoprep_settings_inventory.md.
117
+ from demoprep_app.settings.payload import (
118
+ BuildSettings, SettingsPayloadError, apply_build_settings, parse_settings_payload,
119
+ )
120
  # The yes/no reader the portal answers already use (blank = not stated), so the
121
  # custom_mcp_demo flag follows the same rule. Imported here so a break fails at boot.
122
  from demoprep_app.portal.builder import _as_bool as _read_yes_no
 
222
  )
223
 
224
 
225
+ def _ts_url_for_label(label: str) -> str:
226
+ """URL of the TS_ENV_<n> entry whose LABEL matches (case-insensitive).
227
+
228
+ The settings document names the SE's environment the way the App tab's
229
+ dropdown does ("secloud - primary"). Labels are cosmetic on the Space (a
230
+ Space may set URL + KEY_VAR without one, in which case the label is the
231
+ host), so a document should carry thoughtspot.url as well; the label is
232
+ the fallback. Fails loud listing the labels this server knows.
233
+ """
234
+ wanted = (label or "").strip().lower()
235
+ table = _ts_env_table()
236
+ for env_label, url, _key in table:
237
+ if env_label.strip().lower() == wanted and url:
238
+ return url
239
+ known = ", ".join(repr(l) for l, _, _ in table) or "(none)"
240
+ raise RuntimeError(
241
+ f"No TS_ENV entry is labelled {label!r} on this server. Known labels: {known}. "
242
+ f"Send thoughtspot.url in the settings document, or pass ts_url."
243
+ )
244
+
245
+
246
  # (connect, read) seconds — 10 s worst case. Short on purpose: FastMCP runs this
247
  # sync tool on the server's event loop, so the wait stalls status() polls. A
248
  # precheck that gets no answer in time lets the build go ahead (the deployer's
 
786
  brand: dict | None = None,
787
  custom_mcp_demo: bool | str = False,
788
  header_tiles: dict | None = None,
789
+ *, schema_style: str, homepage_screenshot: str = "",
790
+ build_settings: BuildSettings | None = None) -> dict:
791
  """Drive the controller headlessly (runs in a BACKGROUND THREAD; the caller has
792
  already registered the build in _active_builds and holds a _build_sem slot).
793
  With `existing_schema` + `existing_ddl` (deploy_existing_schema) the dataset,
 
982
  try:
983
  # (i) controller — acting user = the per-build owner (per-instance; no
984
  # process-global write, so concurrent builds never race on the owner).
985
+ # With a settings document the controller skips its Supabase settings
986
+ # read entirely: the document is this run's whole settings source.
987
+ controller = ChatDemoInterface(user_email=owner,
988
+ settings_from_supabase=build_settings is None)
989
 
990
+ # (ii) ThoughtSpot target: fixed per build, never read from a settings store.
 
991
  controller.settings["thoughtspot_url"] = ts_target_url
992
  controller.settings["thoughtspot_trusted_auth_key"] = ts_auth_key
993
+
994
+ if build_settings is not None:
995
+ # (ii-a) Settings document (boone 2026-10-22): model, data size, naming
996
+ # style, object prefix, liveboard name, tag and share-with all come
997
+ # from the document. The per-key Supabase lookups in the else branch
998
+ # are not consulted at all for this run.
999
+ applied = apply_build_settings(
1000
+ controller.settings, build_settings,
1001
+ share_with_fallback=os.getenv("MCP_SHARE_WITH", "mike.boone@thoughtspot.com"))
1002
+ _log_hit(f"settings: document schema {build_settings.schema_version} "
1003
+ f"source={build_settings.source!r} -> {applied}")
1004
+ else:
1005
+ # (ii-b) Legacy: per-key Supabase settings (load_default_settings ran in
1006
+ # the constructor) plus the server's own share-with and data-size rules.
1007
+ controller.settings["model"] = controller.settings.get("model") or DEFAULT_LLM_MODEL
1008
+ # Always share the created model + liveboard with the operator, so every demo is
1009
+ # visible no matter who ran it. The OWNER is unchanged — whoever was passed as
1010
+ # owner_email (the build authenticates AS that user, so they own the objects);
1011
+ # this just adds the operator as a viewer. Override target via MCP_SHARE_WITH.
1012
+ controller.settings["share_with"] = os.getenv("MCP_SHARE_WITH", "mike.boone@thoughtspot.com")
1013
+
1014
+ # (ii-b) Data Size — resolve exactly like the App tab GO path does
1015
+ # (chat_interface defined_go, _size_map): default_data_size drives
1016
+ # fact/dim row counts. load_default_settings() only reads the LEGACY
1017
+ # fact_table_size field, which is stale ('1000') for users who set
1018
+ # Data Size after the Settings Redesign — that silently gave every MCP
1019
+ # build 500-row fact tables while App builds got 3,333 (found 2026-08-25:
1020
+ # all 3 replay boards graded 42-62/100 with "thin data" the top defect).
1021
+ _SIZE_MAP = {
1022
+ "Small": ("1000", "50"),
1023
+ "Medium": ("10000", "500"),
1024
+ }
1025
+ _data_size = str(
1026
+ load_gradio_settings(owner).get("default_data_size", "")
1027
+ ).strip() or "Medium"
1028
+ _ft, _dt = _SIZE_MAP.get(_data_size, _SIZE_MAP["Medium"])
1029
+ controller.settings["fact_table_size"] = _ft
1030
+ controller.settings["dim_table_size"] = _dt
1031
+ _log_hit(f"data size: {_data_size} -> fact_rows={_ft} dim_rows={_dt} (owner setting)")
1032
 
1033
  # vertical / function / use_case_config exactly as the runner does
1034
  controller.vertical, controller.function = parse_use_case(use_case or "")
 
1070
  'schema_style': schema_style,
1071
  # Resolved yes/no (the raw value is on the intake row).
1072
  'custom_mcp_demo': custom_mcp_on,
1073
+ # Where this run's settings came from: the Agent Q document or Supabase.
1074
+ 'settings_source': 'payload' if build_settings is not None else 'supabase',
1075
+ 'settings_schema_version': build_settings.schema_version if build_settings else '',
1076
  }
1077
  # One fact ('simple') or 1-3 ('complex'): read by run_ddl_creation ->
1078
  # build_demo -> author_blueprint (ISS-16).
 
1473
  # An INPUT, not a feature flag: the SE's picture of the prospect's home
1474
  # page, used for the cloned login before the live site (2026-10-21).
1475
  homepage_screenshot: str = "",
1476
+ # The SE's settings document (boone 2026-10-22). Optional: without it the
1477
+ # build reads the SE's settings from Supabase as before.
1478
+ settings: dict | None = None,
1479
  ) -> dict:
1480
  """Start a full ThoughtSpot demo build from a prospect brief.
1481
 
 
1504
  environment: REQUIRED. Which environment this demo is for — "prod" or "staging"
1505
  ("production"/"stage" are accepted and normalised). Recorded with the run;
1506
  a build will not start without a valid value.
1507
+ settings: Optional. The SE's DemoPrep settings as ONE JSON object (schema
1508
+ "1.0"; shape in docs/demoprep_settings_payload.example.json), produced
1509
+ by the TSE settings app. When sent, the build takes model, data size,
1510
+ column naming style, object prefix, liveboard name, tag, share-with
1511
+ and geo scope from it and does not read the SE's settings from
1512
+ Supabase at all. An explicit owner_email or ts_url wins over the
1513
+ document's owner_email / thoughtspot.url; a blank ts_url falls back
1514
+ to the document's thoughtspot.url, then its environment_label. A
1515
+ document with an unknown key, a value outside its allowed set, or a
1516
+ schema_version this server does not understand is refused before
1517
+ anything is built (the error names the key).
1518
  custom_mcp_demo: Optional, OFF by default. Set true to also get a "Custom MCP
1519
  Demo — GitHub Issues → Spotter" walkthrough for this demo: a suggested
1520
  GitHub repo name, 4 GitHub issues written from the deployed model's
 
1662
  except ValueError as e:
1663
  return {"status": "failed", "errors": [str(e)]}
1664
 
1665
+ # The SE's settings document (boone 2026-10-22): validated here so a bad one is
1666
+ # refused before a slot is taken, applied in _run_build in place of the
1667
+ # Supabase per-key lookups. Explicit owner_email / ts_url win over it.
1668
+ build_settings = None
1669
+ if settings is not None:
1670
+ try:
1671
+ build_settings = parse_settings_payload(settings)
1672
+ except SettingsPayloadError as e:
1673
+ return {"status": "failed", "errors": [f"settings: {e}"]}
1674
+ owner_email = (owner_email or "").strip() or build_settings.owner_email
1675
+ if not (ts_url or "").strip():
1676
+ try:
1677
+ ts_url = build_settings.ts_url or (
1678
+ _ts_url_for_label(build_settings.ts_environment_label)
1679
+ if build_settings.ts_environment_label else "")
1680
+ except RuntimeError as e:
1681
+ return {"status": "failed", "errors": [f"settings: {e}"]}
1682
+
1683
  # Resolve the ThoughtSpot target up front (URL from the request or the default,
1684
  # key chosen by instance) so a bad URL / missing key fails fast — before we
1685
  # take the single build slot.
 
1767
  custom_mcp_demo=custom_mcp_demo,
1768
  header_tiles=header_tiles,
1769
  schema_style=schema_style,
1770
+ homepage_screenshot=_shot,
1771
+ build_settings=build_settings)
1772
  finally:
1773
  _build_sem.release()
1774
 
sprint_2026_04.md CHANGED
@@ -1613,5 +1613,57 @@ Source-level edits deferred to v2.
1613
 
1614
  ---
1615
 
1616
- *Last updated: October 20, 2026 — portal update tools ON TEST-MCP c0c09a2, verified
1617
- end to end; prod pending boone*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1613
 
1614
  ---
1615
 
1616
+ ## October 22, 2026 — DemoPrep settings document for Agent Q (branch `claude/demo-prep-settings-json-547af4`, off `b918bd8` = test and prod tip)
1617
+
1618
+ A separate TSE app will own DemoPrep settings. Agent Q hands the SE's settings to
1619
+ the build as ONE JSON document, so DemoPrep stops reading an SE's settings from
1620
+ Supabase per run. Goal (boone): get the app moved.
1621
+
1622
+ ### Done
1623
+ - [x] Settings inventory ✅ `docs/demoprep_settings_inventory.md` — all four layers
1624
+ (per-user Supabase, admin Supabase, Space env, per-call args); consumer per key;
1625
+ which the MCP path honoured before (3 of 7 meaningful ones were ignored:
1626
+ `column_naming_style`, `liveboard_name`, `share_with`)
1627
+ - [x] Live-data audit ✅ Supabase `user_settings`: 744 rows / 46 users; everyone on
1628
+ the defaults; 12 users still carry `gpt-5.5` (resolves to Opus at run time)
1629
+ - [x] Decisions (boone, 10-22) ✅ App tab keeps Supabase; non-secret Snowflake block
1630
+ travels as `admin.snowflake`, key never in the document; `geo_scope` keep + list;
1631
+ `share_with` as a list; dead per-user fields dropped; `schema_version` check yes
1632
+ - [x] The document ✅ `docs/demoprep_settings_payload.example.json` (schema 1.0;
1633
+ gitignored by the blanket `*.json` rule — `git add -f`)
1634
+ - [x] Reader ✅ `demoprep_app/settings/payload.py` — `parse_settings_payload()`
1635
+ (schema major 1, allowed values, unknown keys refused, never partial) and
1636
+ `apply_build_settings()` (exact keys the pipeline reads); the data-size preset
1637
+ table lives there now
1638
+ - [x] MCP wiring ✅ `build_demo_from_brief(..., settings: dict | None)`; explicit
1639
+ `owner_email` / `ts_url` win, else the document (`thoughtspot.url`, then
1640
+ `environment_label` via `_ts_url_for_label()`); refused before target resolution
1641
+ or a slot; `_run_build` applies it and records `settings_source` +
1642
+ `settings_schema_version` on the run
1643
+ - [x] No Supabase for a document-driven run ✅ `ChatDemoInterface(...,
1644
+ settings_from_supabase=False)` + the three mid-run `load_gradio_settings` re-reads
1645
+ in chat_interface guarded by `_settings_source == 'payload'`
1646
+ - [x] Tests ✅ `tests/test_settings_payload.py` (30) + `tests/test_mcp_settings_document.py`
1647
+ (12); one fixture stub updated (`test_custom_mcp_demo_pack.py` loader signature);
1648
+ targeted MCP/controller suites green
1649
+ - [x] Brief for the settings-app builder ✅ `docs/settings_app_agent_brief.md`
1650
+ (paste-ready: contract, rules, Agent Q handoff, phase-2 Supabase table)
1651
+ - [x] Flag registry ✅ `docs/agent_q_flags.md` — `settings` row + change log
1652
+
1653
+ ### Open
1654
+ - [ ] Push to test-mcp when boone calls it (Space idle; refresh Agent Q's tool list after)
1655
+ - [ ] Settings app: emit the document per SE (the example file is the contract)
1656
+ - [ ] Agent Q: fetch and pass `settings`
1657
+ - [ ] Phase 2, Supabase: table `demoprep_settings` (DDL in the inventory doc),
1658
+ `load_settings_document()`, order = call arg → row → legacy per-key until cutover;
1659
+ `admin.*` moves to a boot-time document read
1660
+ - [ ] Drop the dead per-user fields from the Settings tab once the app is live
1661
+ - [ ] Found on the way: README env names (`MCP_OWNER_EMAIL` / `MCP_TS_ENV_LABEL` vs the
1662
+ real `TS_USER_DEFAULT` / `TS_ENV_URL_DEFAULT`) and the `OPENAI_API_KEY` check in
1663
+ `validate_required_settings()`
1664
+
1665
+ ---
1666
+
1667
+ *Last updated: October 22, 2026 — settings document (`settings` on
1668
+ `build_demo_from_brief`) built and tested on `claude/demo-prep-settings-json-547af4`;
1669
+ not pushed; settings-app brief written*
tests/test_custom_mcp_demo_pack.py CHANGED
@@ -328,7 +328,10 @@ def offline(monkeypatch):
328
  monkeypatch.setattr(cdw_connector, "SnowflakeDeployer", _FakeSnowflakeDeployer)
329
  monkeypatch.setattr(thoughtspot_deployer, "ThoughtSpotDeployer", _FakeTSDeployer)
330
  monkeypatch.setattr(smart_data_adjuster, "SmartDataAdjuster", _NoAdjuster)
331
- monkeypatch.setattr(CI.ChatDemoInterface, "load_default_settings", lambda self: {})
 
 
 
332
  # The deploy loop polls its worker every 2s; keep the poll, drop the wait.
333
  _real_sleep = time.sleep
334
  monkeypatch.setattr(time, "sleep", lambda s: _real_sleep(min(s, 0.01)))
 
328
  monkeypatch.setattr(cdw_connector, "SnowflakeDeployer", _FakeSnowflakeDeployer)
329
  monkeypatch.setattr(thoughtspot_deployer, "ThoughtSpotDeployer", _FakeTSDeployer)
330
  monkeypatch.setattr(smart_data_adjuster, "SmartDataAdjuster", _NoAdjuster)
331
+ # Mirrors the real signature: the constructor passes from_supabase= (a run
332
+ # driven by a settings document skips the Supabase read entirely).
333
+ monkeypatch.setattr(CI.ChatDemoInterface, "load_default_settings",
334
+ lambda self, from_supabase=True: {})
335
  # The deploy loop polls its worker every 2s; keep the poll, drop the wait.
336
  _real_sleep = time.sleep
337
  monkeypatch.setattr(time, "sleep", lambda s: _real_sleep(min(s, 0.01)))
tests/test_mcp_settings_document.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """`settings` on build_demo_from_brief: the SE's settings document replaces Supabase.
2
+
3
+ Run: pytest tests/test_mcp_settings_document.py --noconftest
4
+
5
+ boone 2026-10-22: a separate TSE app owns DemoPrep settings and Agent Q hands
6
+ the document to the build, so DemoPrep stops reading an SE's settings from
7
+ Supabase per run. These tests pin:
8
+
9
+ * the registered tool takes `settings` as an object and documents it;
10
+ * a bad document is refused before the ThoughtSpot target is resolved or a
11
+ slot is taken, naming the key;
12
+ * owner_email / ts_url come from the document when the call leaves them
13
+ blank, and explicit arguments win;
14
+ * a label-only document resolves through the TS_ENV table;
15
+ * the worker receives the parsed document, and a controller built from it
16
+ never touches Supabase settings (constructor and both mid-run re-reads).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import inspect
21
+ import os
22
+ import re
23
+ import threading
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ os.environ.setdefault("MCP_TRANSPORT", "stdio")
29
+
30
+ import mcp_server as M # noqa: E402 (sets DEMOPREP_NO_AUTH before chat_interface loads)
31
+ import chat_interface as CI # noqa: E402
32
+ from demoprep_app.settings.payload import BuildSettings, SCHEMA_VERSION # noqa: E402
33
+
34
+ REQUIRED = {"brief": "a brief", "company_name": "Acme",
35
+ "salesforce_opportunity_id": "006TV00000ffxVxYAI", "environment": "prod"}
36
+ DOC = {
37
+ "schema_version": SCHEMA_VERSION, "source": "test",
38
+ "owner_email": "doc.owner@thoughtspot.com",
39
+ "thoughtspot": {"environment_label": "secloud - primary",
40
+ "url": "https://se-thoughtspot-cloud.thoughtspot.cloud"},
41
+ "build": {"data_size": "Small", "column_naming_style": "snake_case", "tag_name": "t"},
42
+ }
43
+ ENV_TABLE = {
44
+ "TS_ENV_1_LABEL": "secloud - primary",
45
+ "TS_ENV_1_URL": "https://se-thoughtspot-cloud.thoughtspot.cloud",
46
+ "TS_ENV_1_KEY_VAR": "key-secloud",
47
+ "TS_ENV_2_LABEL": "sebe - se",
48
+ "TS_ENV_2_URL": "https://sebe.thoughtspotstaging.cloud",
49
+ "TS_ENV_2_KEY_VAR": "key-sebe",
50
+ }
51
+
52
+
53
+ @pytest.fixture
54
+ def env_table(monkeypatch):
55
+ for i in range(1, 21):
56
+ for part in ("LABEL", "URL", "KEY_VAR"):
57
+ name = f"TS_ENV_{i}_{part}"
58
+ if name in ENV_TABLE:
59
+ monkeypatch.setenv(name, ENV_TABLE[name])
60
+ else:
61
+ monkeypatch.delenv(name, raising=False)
62
+ monkeypatch.delenv("TS_ENV_URL_DEFAULT", raising=False)
63
+ monkeypatch.delenv("TS_AUTH_KEY", raising=False)
64
+
65
+
66
+ def _precheck_ok(url, key, owner):
67
+ return {"outcome": "ok", "blocking": False, "http_status": 200, "reason": "",
68
+ "host": M._host_of(url), "elapsed_ms": 1, "message": ""}
69
+
70
+
71
+ @pytest.fixture
72
+ def captured(monkeypatch):
73
+ """Run the tool up to the worker; capture what _run_build is handed."""
74
+ monkeypatch.setattr(M, "_login_precheck", _precheck_ok)
75
+ done = threading.Event()
76
+ seen: dict = {}
77
+
78
+ def fake_run_build(*args, **kwargs):
79
+ seen["args"], seen["kwargs"] = args, kwargs
80
+ with M._state_lock:
81
+ M._active_builds.pop(args[0], None)
82
+ done.set()
83
+
84
+ monkeypatch.setattr(M, "_run_build", fake_run_build)
85
+ seen["done"] = done
86
+ return seen
87
+
88
+
89
+ # ------------------------------------------------------------ the parameter
90
+
91
+ def _documented_args(func) -> list[str]:
92
+ names = []
93
+ for line in (inspect.getdoc(func) or "").split("Args:", 1)[1].splitlines():
94
+ if not line.strip():
95
+ continue
96
+ if not line.startswith(" "):
97
+ break
98
+ m = re.match(r"^ ([a-z_][a-z0-9_]*):", line)
99
+ if m:
100
+ names.append(m.group(1))
101
+ return names
102
+
103
+
104
+ def test_it_is_optional_and_documented_for_agent_q():
105
+ p = inspect.signature(M.build_demo_from_brief).parameters["settings"]
106
+ assert p.default is None and p.kind is inspect.Parameter.KEYWORD_ONLY
107
+ assert "settings" in _documented_args(M.build_demo_from_brief)
108
+ doc = inspect.getdoc(M.build_demo_from_brief)
109
+ assert "demoprep_settings_payload.example.json" in doc
110
+
111
+
112
+ def test_the_registered_tool_takes_an_object():
113
+ tool = M.mcp._tool_manager.get_tool("build_demo_from_brief")
114
+ assert "settings" in tool.parameters["properties"]
115
+ model = tool.fn_metadata.arg_model
116
+ assert model.model_validate(REQUIRED).settings is None
117
+ assert model.model_validate({**REQUIRED, "settings": DOC}).settings == DOC
118
+
119
+
120
+ # ---------------------------------------------------------------- refusals
121
+
122
+ def test_a_bad_document_is_refused_before_anything_is_resolved(monkeypatch):
123
+ def boom(*a, **k):
124
+ raise AssertionError("target resolution ran for a refused document")
125
+ monkeypatch.setattr(M, "_resolve_ts_target", boom)
126
+ monkeypatch.setattr(M, "_login_precheck", boom)
127
+ monkeypatch.setattr(M, "_run_build", boom)
128
+ active_before = set(M._active_builds)
129
+ for bad, key in (({**DOC, "schema_version": "2.0"}, "schema_version"),
130
+ ({**DOC, "build": {"data_size": "Large"}}, "build.data_size"),
131
+ ({**DOC, "buidl": {}}, "unknown top-level key")):
132
+ out = M.build_demo_from_brief(**REQUIRED, settings=bad)
133
+ assert out["status"] == "failed" and out["errors"][0].startswith("settings:"), out
134
+ assert key in out["errors"][0]
135
+ assert set(M._active_builds) == active_before
136
+
137
+
138
+ def test_an_unknown_label_with_no_url_is_refused_with_the_known_labels(env_table, monkeypatch):
139
+ monkeypatch.setattr(M, "_run_build", lambda *a, **k: pytest.fail("built"))
140
+ doc = {**DOC, "thoughtspot": {"environment_label": "pmm"}}
141
+ out = M.build_demo_from_brief(**REQUIRED, settings=doc)
142
+ assert out["status"] == "failed"
143
+ assert "labelled 'pmm'" in out["errors"][0] and "secloud - primary" in out["errors"][0]
144
+
145
+
146
+ # ------------------------------------------------------------ precedence
147
+
148
+ def test_owner_and_target_come_from_the_document_when_the_call_is_blank(env_table, captured):
149
+ out = M.build_demo_from_brief(**REQUIRED, settings=DOC)
150
+ assert out["status"] == "started", out
151
+ assert captured["done"].wait(5)
152
+ run_id, brief, company, use_case, company_url, ts_url, key, owner, *_ = captured["args"]
153
+ assert owner == "doc.owner@thoughtspot.com"
154
+ assert ts_url == "https://se-thoughtspot-cloud.thoughtspot.cloud" and key == "key-secloud"
155
+ bs = captured["kwargs"]["build_settings"]
156
+ assert isinstance(bs, BuildSettings)
157
+ assert bs.data_size == "Small" and bs.column_naming_style == "snake_case" and bs.tag_name == "t"
158
+ assert M._build_sem.acquire(blocking=False) and (M._build_sem.release() or True)
159
+
160
+
161
+ def test_explicit_owner_and_ts_url_win_over_the_document(env_table, captured):
162
+ out = M.build_demo_from_brief(**REQUIRED, settings=DOC,
163
+ owner_email="explicit@thoughtspot.com",
164
+ ts_url="https://sebe.thoughtspotstaging.cloud")
165
+ assert out["status"] == "started", out
166
+ assert captured["done"].wait(5)
167
+ _, _, _, _, _, ts_url, key, owner, *_ = captured["args"]
168
+ assert owner == "explicit@thoughtspot.com"
169
+ assert ts_url == "https://sebe.thoughtspotstaging.cloud" and key == "key-sebe"
170
+
171
+
172
+ def test_a_label_only_document_resolves_through_the_env_table(env_table, captured):
173
+ doc = {**DOC, "thoughtspot": {"environment_label": "SEBE - SE"}}
174
+ out = M.build_demo_from_brief(**REQUIRED, settings=doc)
175
+ assert out["status"] == "started", out
176
+ assert captured["done"].wait(5)
177
+ _, _, _, _, _, ts_url, key, *_ = captured["args"]
178
+ assert ts_url == "https://sebe.thoughtspotstaging.cloud" and key == "key-sebe"
179
+
180
+
181
+ def test_without_a_document_nothing_changes(env_table, captured):
182
+ out = M.build_demo_from_brief(**REQUIRED, owner_email="x@thoughtspot.com",
183
+ ts_url="https://sebe.thoughtspotstaging.cloud")
184
+ assert out["status"] == "started", out
185
+ assert captured["done"].wait(5)
186
+ assert captured["kwargs"]["build_settings"] is None
187
+
188
+
189
+ def test_ts_url_for_label_is_case_insensitive_and_fails_loud(env_table):
190
+ assert M._ts_url_for_label("Secloud - Primary") == "https://se-thoughtspot-cloud.thoughtspot.cloud"
191
+ with pytest.raises(RuntimeError, match="Known labels: 'secloud - primary', 'sebe - se'"):
192
+ M._ts_url_for_label("nope")
193
+
194
+
195
+ # ------------------------------------------------- the controller and Supabase
196
+
197
+ def test_a_controller_built_from_a_document_never_reads_supabase_settings(monkeypatch):
198
+ def boom(*a, **k):
199
+ raise AssertionError("load_gradio_settings was called for a document-driven run")
200
+ monkeypatch.setattr(CI, "load_gradio_settings", boom)
201
+ c = CI.ChatDemoInterface(user_email="se@thoughtspot.com", settings_from_supabase=False)
202
+ assert c._settings_source == "payload"
203
+ assert c.settings["model"] and c.settings["validation_mode"] == "Off"
204
+ assert CI.ChatDemoInterface.__init__.__kwdefaults__ == {"settings_from_supabase": True}
205
+
206
+
207
+ def test_the_mid_run_rereads_are_guarded():
208
+ """run_deployment_streaming and _run_thoughtspot_deployment re-read the SE's
209
+ Supabase settings mid-run. Each remaining call must sit under the
210
+ _settings_source guard, or a document-driven run would read Supabase after all."""
211
+ src = Path(CI.__file__).read_text().splitlines()
212
+ calls = [i for i, ln in enumerate(src)
213
+ if "load_gradio_settings(self._get_effective_user_email())" in ln]
214
+ assert calls, "the re-reads moved; update this test"
215
+ for i in calls:
216
+ window = "\n".join(src[max(0, i - 4):i])
217
+ assert "_settings_source == 'payload'" in window, f"unguarded Supabase re-read at line {i + 1}"
218
+
219
+
220
+ def test_the_worker_uses_the_document_and_records_its_source():
221
+ src = inspect.getsource(M._run_build)
222
+ assert "settings_from_supabase=build_settings is None" in src
223
+ assert "apply_build_settings(" in src
224
+ assert "'settings_source': 'payload' if build_settings is not None else 'supabase'" in src
tests/test_payload_capture_is_complete.py CHANGED
@@ -38,7 +38,8 @@ def test_capture_includes_every_parameter_of_the_tool():
38
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
39
  environment="", schema_style="simple", apply_brand_theme=False,
40
  brand_theme=None, custom_mcp_demo=False,
41
- logo_tile=False, insight_tile=False, homepage_screenshot=""):
 
42
  return M._call_payload(inspect.currentframe())
43
 
44
  got = set(sample())
 
38
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
39
  environment="", schema_style="simple", apply_brand_theme=False,
40
  brand_theme=None, custom_mcp_demo=False,
41
+ logo_tile=False, insight_tile=False, homepage_screenshot="",
42
+ settings=None): # settings document, 2026-10-22
43
  return M._call_payload(inspect.currentframe())
44
 
45
  got = set(sample())
tests/test_payload_reaches_the_database.py CHANGED
@@ -125,7 +125,8 @@ def test_tool_signature_survives_the_entire_path():
125
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
126
  environment="", schema_style="simple", apply_brand_theme=False,
127
  brand_theme=None, custom_mcp_demo=False,
128
- logo_tile=False, insight_tile=False, homepage_screenshot=""):
 
129
  return M._call_payload(inspect.currentframe())
130
 
131
  results = captured_row(sample())
 
125
  monetize=True, tiers=True, pinning=False, salesforce_opportunity_id="",
126
  environment="", schema_style="simple", apply_brand_theme=False,
127
  brand_theme=None, custom_mcp_demo=False,
128
+ logo_tile=False, insight_tile=False, homepage_screenshot="",
129
+ settings=None): # settings document, 2026-10-22
130
  return M._call_payload(inspect.currentframe())
131
 
132
  results = captured_row(sample())
tests/test_settings_payload.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The settings document (schema 1.x): parse rules and the controller apply step.
2
+
3
+ Run: pytest tests/test_settings_payload.py --noconftest
4
+
5
+ Pure: imports only demoprep_app.settings.payload and llm_config. The document
6
+ is the contract between the TSE settings app, Agent Q and DemoPrep
7
+ (docs/demoprep_settings_inventory.md); these tests pin what it accepts, what it
8
+ refuses, and exactly which controller keys it writes.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from demoprep_app.settings.payload import (
18
+ DATA_SIZE_PRESETS, SCHEMA_VERSION, SettingsPayloadError,
19
+ apply_build_settings, parse_settings_payload,
20
+ )
21
+ from llm_config import DEFAULT_LLM_MODEL, UI_MODEL_CHOICES
22
+
23
+ EXAMPLE = Path(__file__).resolve().parents[1] / "docs" / "demoprep_settings_payload.example.json"
24
+
25
+
26
+ def _doc(**build) -> dict:
27
+ d = {"schema_version": SCHEMA_VERSION, "source": "test", "owner_email": "se@thoughtspot.com",
28
+ "thoughtspot": {"environment_label": "secloud - primary",
29
+ "url": "https://se-thoughtspot-cloud.thoughtspot.cloud"},
30
+ "build": {}}
31
+ d["build"].update(build)
32
+ return d
33
+
34
+
35
+ # ------------------------------------------------------------- the example
36
+
37
+ def test_the_checked_in_example_parses():
38
+ """docs/demoprep_settings_payload.example.json is what the settings app is
39
+ told to produce; it must always be a valid document."""
40
+ s = parse_settings_payload(json.loads(EXAMPLE.read_text()))
41
+ assert s.schema_version == SCHEMA_VERSION
42
+ assert s.model in UI_MODEL_CHOICES
43
+ assert (s.fact_table_rows, s.dim_table_rows) == DATA_SIZE_PRESETS[s.data_size]
44
+ assert s.admin_snowflake["database"] == "DEMOBUILD"
45
+ assert s.ts_url == "https://se-thoughtspot-cloud.thoughtspot.cloud"
46
+
47
+
48
+ # ---------------------------------------------------------------- defaults
49
+
50
+ def test_absent_build_keys_take_the_settings_tab_defaults():
51
+ s = parse_settings_payload({"schema_version": "1.0"})
52
+ assert s.model == DEFAULT_LLM_MODEL
53
+ assert s.data_size == "Medium"
54
+ assert (s.fact_table_rows, s.dim_table_rows) == (10000, 500)
55
+ assert s.column_naming_style == "Regular Case"
56
+ assert s.geo_scope == "USA Only"
57
+ assert s.share_with == () and s.liveboard_name == "" and s.tag_name == ""
58
+ assert s.owner_email == "" and s.ts_url == "" and s.ts_environment_label == ""
59
+
60
+
61
+ def test_data_size_preset_drives_rows_unless_rows_are_explicit():
62
+ assert parse_settings_payload(_doc(data_size="Small")).fact_table_rows == 1000
63
+ s = parse_settings_payload(_doc(data_size="Small", fact_table_rows=2500))
64
+ assert (s.fact_table_rows, s.dim_table_rows) == (2500, 50)
65
+
66
+
67
+ def test_share_with_accepts_a_list_or_the_comma_string_the_app_stores():
68
+ assert parse_settings_payload(_doc(share_with=["a@x.com", " b@x.com ", ""])).share_with == ("a@x.com", "b@x.com")
69
+ assert parse_settings_payload(_doc(share_with="a@x.com, Group One")).share_with == ("a@x.com", "Group One")
70
+
71
+
72
+ def test_effective_share_with_falls_back_to_the_admin_default_only():
73
+ d = _doc(share_with=[])
74
+ d["admin"] = {"share_with_default": ["mike.boone@thoughtspot.com"]}
75
+ assert parse_settings_payload(d).effective_share_with == "mike.boone@thoughtspot.com"
76
+ d["build"]["share_with"] = ["se@x.com"]
77
+ assert parse_settings_payload(d).effective_share_with == "se@x.com"
78
+
79
+
80
+ def test_owner_email_is_normalised_lowercase():
81
+ d = _doc(); d["owner_email"] = " SE.Name@ThoughtSpot.com "
82
+ assert parse_settings_payload(d).owner_email == "se.name@thoughtspot.com"
83
+
84
+
85
+ # ---------------------------------------------------------------- refusals
86
+
87
+ @pytest.mark.parametrize("version", ["2.0", "0.9", "1.1", "1", "", None, "one"])
88
+ def test_a_schema_version_this_server_does_not_read_is_refused(version):
89
+ d = _doc(); d["schema_version"] = version
90
+ with pytest.raises(SettingsPayloadError, match="schema_version"):
91
+ parse_settings_payload(d)
92
+
93
+
94
+ def test_unknown_keys_are_refused_not_ignored():
95
+ d = _doc(); d["buidl"] = {}
96
+ with pytest.raises(SettingsPayloadError, match="unknown top-level key"):
97
+ parse_settings_payload(d)
98
+ with pytest.raises(SettingsPayloadError, match="build: unknown key"):
99
+ parse_settings_payload(_doc(colum_naming_style="snake_case"))
100
+
101
+
102
+ def test_dollar_keys_are_comments():
103
+ d = _doc(); d["$comment"] = "free text"; d["build"]["$note"] = "ok"
104
+ parse_settings_payload(d)
105
+
106
+
107
+ @pytest.mark.parametrize("key,bad", [
108
+ ("model", "gpt-5.5"),
109
+ ("data_size", "Large"),
110
+ ("column_naming_style", "kebab-case"),
111
+ ("geo_scope", "Europe"),
112
+ ])
113
+ def test_a_value_outside_its_allowed_set_is_refused_with_the_choices(key, bad):
114
+ with pytest.raises(SettingsPayloadError) as e:
115
+ parse_settings_payload(_doc(**{key: bad}))
116
+ assert key in str(e.value) and "is not one of" in str(e.value)
117
+
118
+
119
+ @pytest.mark.parametrize("bad", ["10000", 0, -5, True, 1.5])
120
+ def test_row_counts_must_be_positive_integers(bad):
121
+ with pytest.raises(SettingsPayloadError, match="fact_table_rows"):
122
+ parse_settings_payload(_doc(fact_table_rows=bad))
123
+
124
+
125
+ def test_wrong_shapes_are_refused():
126
+ with pytest.raises(SettingsPayloadError, match="settings must be an object"):
127
+ parse_settings_payload("not a dict") # type: ignore[arg-type]
128
+ d = _doc(); d["build"] = ["list"]
129
+ with pytest.raises(SettingsPayloadError, match="build: must be an object"):
130
+ parse_settings_payload(d)
131
+ with pytest.raises(SettingsPayloadError, match="build.tag_name: must be a string"):
132
+ parse_settings_payload(_doc(tag_name=7))
133
+ with pytest.raises(SettingsPayloadError, match="build.share_with"):
134
+ parse_settings_payload(_doc(share_with=[1, 2]))
135
+
136
+
137
+ def test_admin_section_is_validated_and_log_level_is_optional():
138
+ d = _doc(); d["admin"] = {"log_level": "loud"}
139
+ with pytest.raises(SettingsPayloadError, match="admin.log_level"):
140
+ parse_settings_payload(d)
141
+ d["admin"] = {"snowflake": {"account": "X", "region": "us"}}
142
+ with pytest.raises(SettingsPayloadError, match="snowflake: unknown key"):
143
+ parse_settings_payload(d)
144
+ d["admin"] = {"snowflake": {"account": " X "}}
145
+ s = parse_settings_payload(d)
146
+ assert s.admin_snowflake == {"account": "X"} and s.admin_log_level == ""
147
+
148
+
149
+ def test_ui_defaults_are_accepted_but_not_part_of_build_settings():
150
+ d = _doc(); d["ui_defaults"] = {"validation_mode": "On", "vertical": "Retail"}
151
+ parse_settings_payload(d)
152
+ d["ui_defaults"] = {"validatoin_mode": "On"}
153
+ with pytest.raises(SettingsPayloadError, match="ui_defaults: unknown key"):
154
+ parse_settings_payload(d)
155
+
156
+
157
+ # ------------------------------------------------------------------- apply
158
+
159
+ def test_apply_writes_exactly_the_keys_the_pipeline_reads():
160
+ """These names are what chat_interface / thoughtspot_deployer read today
161
+ (fact_table_size, column_naming_style, ...). Renaming one here without
162
+ renaming the reader would silently drop the setting."""
163
+ s = parse_settings_payload(_doc(
164
+ model=UI_MODEL_CHOICES[-1], data_size="Small", column_naming_style="snake_case",
165
+ object_naming_prefix="ACME", liveboard_name="Acme Board", tag_name="demo",
166
+ share_with=["a@x.com", "b@x.com"], geo_scope="International",
167
+ existing_model_guid="abc-123"))
168
+ target = {"thoughtspot_url": "keep", "model": "old"}
169
+ applied = apply_build_settings(target, s, share_with_fallback="ops@x.com")
170
+ assert applied == {
171
+ "model": UI_MODEL_CHOICES[-1],
172
+ "fact_table_size": "1000", "dim_table_size": "50",
173
+ "column_naming_style": "snake_case",
174
+ "object_naming_prefix": "ACME",
175
+ "liveboard_name": "Acme Board",
176
+ "tag_name": "demo",
177
+ "share_with": "a@x.com,b@x.com",
178
+ "geo_scope": "International",
179
+ "use_existing_model": True, "existing_model_guid": "abc-123",
180
+ "validation_mode": "Off",
181
+ }
182
+ assert target["thoughtspot_url"] == "keep", "apply must not touch keys it does not own"
183
+ for k, v in applied.items():
184
+ assert target[k] == v
185
+
186
+
187
+ def test_apply_uses_the_server_fallback_only_when_nobody_is_named():
188
+ s = parse_settings_payload(_doc())
189
+ assert apply_build_settings({}, s, share_with_fallback="ops@x.com")["share_with"] == "ops@x.com"
190
+ s = parse_settings_payload(_doc(share_with=["se@x.com"]))
191
+ assert apply_build_settings({}, s, share_with_fallback="ops@x.com")["share_with"] == "se@x.com"
192
+
193
+
194
+ def test_row_counts_are_strings_on_the_controller():
195
+ """The pipeline does int(self.settings['fact_table_size']) on a string, as the
196
+ App tab stores it. Keep the type the reader expects."""
197
+ s = parse_settings_payload(_doc(fact_table_rows=3333, dim_table_rows=77))
198
+ applied = apply_build_settings({}, s)
199
+ assert applied["fact_table_size"] == "3333" and applied["dim_table_size"] == "77"