Spaces:
Running
feat(mcp): settings document on build_demo_from_brief replaces per-run Supabase settings reads
Browse filesA 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 +36 -10
- demoprep_app/settings/__init__.py +6 -0
- demoprep_app/settings/payload.py +260 -0
- docs/agent_q_flags.md +5 -1
- docs/demoprep_settings_inventory.md +245 -0
- docs/demoprep_settings_payload.example.json +49 -0
- docs/settings_app_agent_brief.md +136 -0
- mcp_server.py +110 -29
- sprint_2026_04.md +54 -2
- tests/test_custom_mcp_demo_pack.py +4 -1
- tests/test_mcp_settings_document.py +224 -0
- tests/test_payload_capture_is_complete.py +2 -1
- tests/test_payload_reaches_the_database.py +2 -1
- tests/test_settings_payload.py +199 -0
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
+
"""
|
|
@@ -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
|
|
@@ -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-
|
| 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.
|
|
@@ -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 |
|
|
@@ -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 |
+
}
|
|
@@ -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.
|
|
@@ -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 = ""
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 958 |
|
| 959 |
-
# (ii)
|
| 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 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
"
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -1613,5 +1613,57 @@ Source-level edits deferred to v2.
|
|
| 1613 |
|
| 1614 |
---
|
| 1615 |
|
| 1616 |
-
|
| 1617 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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*
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
| 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)))
|
|
@@ -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
|
|
@@ -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())
|
|
@@ -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())
|
|
@@ -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"
|