no-invent + per-tenant chips
Browse files- app/models.py +3 -0
- app/orchestrator.py +20 -14
- app/portal_ui/index.html +6 -1
- app/prompts.py +3 -2
- app/routes/portal.py +2 -0
- app/routes/widget.py +7 -3
- app/static/widget.js +5 -2
- app/tenancy.py +1 -0
- migrations/versions/0013_tenant_starters.py +29 -0
- tests/routes/test_portal.py +14 -0
- tests/test_phase_bc.py +1 -1
app/models.py
CHANGED
|
@@ -92,6 +92,9 @@ class Tenant(Base):
|
|
| 92 |
# What the web embed shows: "chat" (AI panel), "whatsapp" (direct wa.me
|
| 93 |
# button) or "both" (consumer chooses). Per-tenant.
|
| 94 |
widget_mode: Mapped[str] = mapped_column(String(16), default="chat")
|
|
|
|
|
|
|
|
|
|
| 95 |
# Where this store's "talk to a human" leads are emailed (per-client inbox).
|
| 96 |
support_email: Mapped[str] = mapped_column(String(320), default="")
|
| 97 |
# Secret that lets this store log into its OWN client portal (sees only its
|
|
|
|
| 92 |
# What the web embed shows: "chat" (AI panel), "whatsapp" (direct wa.me
|
| 93 |
# button) or "both" (consumer chooses). Per-tenant.
|
| 94 |
widget_mode: Mapped[str] = mapped_column(String(16), default="chat")
|
| 95 |
+
# Quick-reply starter chips shown in the widget — per tenant, since a store
|
| 96 |
+
# selling services needs different ones than one selling products.
|
| 97 |
+
starters: Mapped[list] = mapped_column(JSON, default=list)
|
| 98 |
# Where this store's "talk to a human" leads are emailed (per-client inbox).
|
| 99 |
support_email: Mapped[str] = mapped_column(String(320), default="")
|
| 100 |
# Secret that lets this store log into its OWN client portal (sees only its
|
app/orchestrator.py
CHANGED
|
@@ -20,11 +20,16 @@ log = logging.getLogger(__name__)
|
|
| 20 |
|
| 21 |
HISTORY_LIMIT = 10
|
| 22 |
MAX_TOOL_ITERS = 6
|
| 23 |
-
#
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
r"
|
| 27 |
-
r"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
re.IGNORECASE,
|
| 29 |
)
|
| 30 |
MAX_REPLY_TOKENS = 1000 # bound output; high enough for verbose scripts (ar/th/hi)
|
|
@@ -72,16 +77,17 @@ async def run_turn(
|
|
| 72 |
language = detect_language(user_message)
|
| 73 |
system_prompt = build_system_prompt(brand_name, language, channel=ctx.channel)
|
| 74 |
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
| 75 |
-
# Deterministic anti-hallucination:
|
| 76 |
-
#
|
| 77 |
-
# grounded answer by requiring a tool
|
| 78 |
-
if
|
| 79 |
messages.append({"role": "system", "content": (
|
| 80 |
-
"Para esta pregunta sobre productos/
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
|
|
|
| 85 |
)})
|
| 86 |
for m in prior:
|
| 87 |
if m.role in ("user", "assistant"):
|
|
|
|
| 20 |
|
| 21 |
HISTORY_LIMIT = 10
|
| 22 |
MAX_TOOL_ITERS = 6
|
| 23 |
+
# Questions about the business/identity/catalog/recommendation where weak models
|
| 24 |
+
# tend to INVENT a fake store. We force a grounded (tool-based) answer for these.
|
| 25 |
+
_GROUND_INTENT = re.compile(
|
| 26 |
+
r"(qu[eé]\s+(vend|produc|ofrec|servici|ten[eé]is|hac[eé]is|hay|art[ií]culo)|"
|
| 27 |
+
r"de\s+qu[eé]\s+(va|trata|sois)|qu[eé]\s+es\s+(esto|esta|este|la\s+(p[aá]gina|web|empresa|tienda))|"
|
| 28 |
+
r"a\s+qu[eé]\s+(os\s+|te\s+)?dedic|qui[eé]n(es)?\s+sois|sobre\s+(vosotros|nosotros|la\s+empresa)|"
|
| 29 |
+
r"ay[uú]dame\s+a\s+elegir|recomi[eé]nd|qu[eé]\s+me\s+recomiend|qu[eé]\s+puedo\s+comprar|"
|
| 30 |
+
r"cat[aá]log|catalog|productos?\b|servicios?\b|"
|
| 31 |
+
r"what\s+(do\s+you\s+(sell|do|offer)|is\s+this|are\s+you|services|products)|"
|
| 32 |
+
r"who\s+are\s+you|about\s+(you|us)|help\s+me\s+choose|do\s+you\s+sell)",
|
| 33 |
re.IGNORECASE,
|
| 34 |
)
|
| 35 |
MAX_REPLY_TOKENS = 1000 # bound output; high enough for verbose scripts (ar/th/hi)
|
|
|
|
| 77 |
language = detect_language(user_message)
|
| 78 |
system_prompt = build_system_prompt(brand_name, language, channel=ctx.channel)
|
| 79 |
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
|
| 80 |
+
# Deterministic anti-hallucination: asked what the business is/sells/offers
|
| 81 |
+
# or to recommend, weak providers invent a fake store from memory. Force a
|
| 82 |
+
# grounded answer by requiring a tool lookup first.
|
| 83 |
+
if _GROUND_INTENT.search(user_message):
|
| 84 |
messages.append({"role": "system", "content": (
|
| 85 |
+
"Para esta pregunta sobre la empresa/tienda/productos/servicios DEBES "
|
| 86 |
+
"llamar primero a search_knowledge (y a search_products si hay catálogo) "
|
| 87 |
+
"ANTES de responder. Describe el negocio, lo que vende o lo que ofrece "
|
| 88 |
+
"SOLO con lo que devuelvan las herramientas. Si no hay información, dilo "
|
| 89 |
+
"con sinceridad y ofrece ayuda. PROHIBIDO inventar el nombre del negocio, "
|
| 90 |
+
"qué es, qué vende, categorías o productos."
|
| 91 |
)})
|
| 92 |
for m in prior:
|
| 93 |
if m.role in ("user", "assistant"):
|
app/portal_ui/index.html
CHANGED
|
@@ -114,6 +114,9 @@
|
|
| 114 |
<option value="whatsapp">Solo un botón de WhatsApp</option>
|
| 115 |
<option value="both">Las dos cosas (el visitante elige)</option>
|
| 116 |
</select>
|
|
|
|
|
|
|
|
|
|
| 117 |
<div class="row" style="margin-top:14px;"><button onclick="saveBusiness()">Guardar</button><span id="biz-state" class="ok"></span></div>
|
| 118 |
</div>
|
| 119 |
</section>
|
|
@@ -225,6 +228,7 @@
|
|
| 225 |
$("b-avatar").value = c.avatar_url || "";
|
| 226 |
$("b-email").value = c.support_email || "";
|
| 227 |
$("b-mode").value = c.widget_mode || "chat";
|
|
|
|
| 228 |
syncColorPick();
|
| 229 |
$("sh-shop").value = c.shopify_shop || "";
|
| 230 |
$("sh-id").value = c.shopify_client_id || "";
|
|
@@ -266,10 +270,11 @@
|
|
| 266 |
else { var e = await r.json().catch(function(){return {};}); if(stateId) $(stateId).textContent = "Error: " + (e.detail || "no se pudo guardar"); }
|
| 267 |
}
|
| 268 |
function saveBusiness(){
|
|
|
|
| 269 |
putConfig({ assistant_name: $("b-assistant").value.trim(), brand_name: $("b-brand").value.trim(),
|
| 270 |
brand_color: $("b-color").value.trim(), welcome: $("b-welcome").value.trim(),
|
| 271 |
avatar_url: $("b-avatar").value.trim(), support_email: $("b-email").value.trim(),
|
| 272 |
-
widget_mode: $("b-mode").value }, "biz-state");
|
| 273 |
}
|
| 274 |
function saveShopify(){
|
| 275 |
var body = { shopify_shop: $("sh-shop").value.trim(), shopify_client_id: $("sh-id").value.trim() };
|
|
|
|
| 114 |
<option value="whatsapp">Solo un botón de WhatsApp</option>
|
| 115 |
<option value="both">Las dos cosas (el visitante elige)</option>
|
| 116 |
</select>
|
| 117 |
+
<label>Botones rápidos del chat (máx. 4, separados por coma)</label>
|
| 118 |
+
<input id="b-starters" placeholder="¿Qué ofrecéis?, Pedir presupuesto, Hablar con una persona" />
|
| 119 |
+
<p class="muted">Son los atajos que verá el visitante al abrir el chat. Pon los que encajen con TU negocio (productos, servicios, etc.). Si lo dejas vacío, se ponen unos genéricos.</p>
|
| 120 |
<div class="row" style="margin-top:14px;"><button onclick="saveBusiness()">Guardar</button><span id="biz-state" class="ok"></span></div>
|
| 121 |
</div>
|
| 122 |
</section>
|
|
|
|
| 228 |
$("b-avatar").value = c.avatar_url || "";
|
| 229 |
$("b-email").value = c.support_email || "";
|
| 230 |
$("b-mode").value = c.widget_mode || "chat";
|
| 231 |
+
$("b-starters").value = (c.starters || []).join(", ");
|
| 232 |
syncColorPick();
|
| 233 |
$("sh-shop").value = c.shopify_shop || "";
|
| 234 |
$("sh-id").value = c.shopify_client_id || "";
|
|
|
|
| 270 |
else { var e = await r.json().catch(function(){return {};}); if(stateId) $(stateId).textContent = "Error: " + (e.detail || "no se pudo guardar"); }
|
| 271 |
}
|
| 272 |
function saveBusiness(){
|
| 273 |
+
var starters = $("b-starters").value.split(",").map(function(s){return s.trim();}).filter(Boolean).slice(0,4);
|
| 274 |
putConfig({ assistant_name: $("b-assistant").value.trim(), brand_name: $("b-brand").value.trim(),
|
| 275 |
brand_color: $("b-color").value.trim(), welcome: $("b-welcome").value.trim(),
|
| 276 |
avatar_url: $("b-avatar").value.trim(), support_email: $("b-email").value.trim(),
|
| 277 |
+
widget_mode: $("b-mode").value, starters: starters }, "biz-state");
|
| 278 |
}
|
| 279 |
function saveShopify(){
|
| 280 |
var body = { shopify_shop: $("sh-shop").value.trim(), shopify_client_id: $("sh-id").value.trim() };
|
app/prompts.py
CHANGED
|
@@ -22,8 +22,9 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
|
|
| 22 |
- You CAN use everyday general knowledge for small talk and trivial questions. The ONLY hard limit is the grounding rule below: never invent THIS STORE's products, prices, stock, specs, policies or order data — those come from tools.
|
| 23 |
|
| 24 |
# GROUNDING (most important)
|
| 25 |
-
- For anything about this store — products, prices, stock, specs, shipping, returns, policies, orders — answer ONLY with facts returned by your tools. Never use memory or general knowledge for those.
|
| 26 |
-
-
|
|
|
|
| 27 |
- Don't repeat the same search. As soon as you have the info, answer.
|
| 28 |
|
| 29 |
# TOOL ROUTING (choose by intent — do not guess)
|
|
|
|
| 22 |
- You CAN use everyday general knowledge for small talk and trivial questions. The ONLY hard limit is the grounding rule below: never invent THIS STORE's products, prices, stock, specs, policies or order data — those come from tools.
|
| 23 |
|
| 24 |
# GROUNDING (most important)
|
| 25 |
+
- For anything about this store — what the business IS, what it sells or does, products, prices, stock, specs, shipping, returns, policies, orders — answer ONLY with facts returned by your tools. Never use memory or general knowledge for those.
|
| 26 |
+
- NEVER invent the store's name, what it sells, or product/service categories. If asked "what is this / what do you sell / help me choose" and you have no tool result, do NOT guess a business — search first; if there's still nothing, say honestly you don't have that info yet and offer to connect a person. Making up a business or catalog is a serious error.
|
| 27 |
+
- If a tool returns nothing useful, OR your tools are unavailable, say so plainly (in the customer's language) and offer to connect them with a person.
|
| 28 |
- Don't repeat the same search. As soon as you have the info, answer.
|
| 29 |
|
| 30 |
# TOOL ROUTING (choose by intent — do not guess)
|
app/routes/portal.py
CHANGED
|
@@ -38,6 +38,7 @@ class PortalConfigIn(BaseModel):
|
|
| 38 |
assistant_name: str | None = None
|
| 39 |
avatar_url: str | None = None
|
| 40 |
widget_mode: str | None = None
|
|
|
|
| 41 |
support_email: str | None = None
|
| 42 |
shopify_shop: ShopDomain = None
|
| 43 |
shopify_client_id: str | None = None
|
|
@@ -86,6 +87,7 @@ def _config(tenant: Tenant) -> dict:
|
|
| 86 |
"assistant_name": tenant.assistant_name,
|
| 87 |
"avatar_url": tenant.avatar_url,
|
| 88 |
"widget_mode": tenant.widget_mode or "chat",
|
|
|
|
| 89 |
"support_email": tenant.support_email,
|
| 90 |
"shopify_shop": tenant.shopify_shop,
|
| 91 |
"shopify_client_id": tenant.shopify_client_id,
|
|
|
|
| 38 |
assistant_name: str | None = None
|
| 39 |
avatar_url: str | None = None
|
| 40 |
widget_mode: str | None = None
|
| 41 |
+
starters: list[str] | None = None
|
| 42 |
support_email: str | None = None
|
| 43 |
shopify_shop: ShopDomain = None
|
| 44 |
shopify_client_id: str | None = None
|
|
|
|
| 87 |
"assistant_name": tenant.assistant_name,
|
| 88 |
"avatar_url": tenant.avatar_url,
|
| 89 |
"widget_mode": tenant.widget_mode or "chat",
|
| 90 |
+
"starters": tenant.starters or [],
|
| 91 |
"support_email": tenant.support_email,
|
| 92 |
"shopify_shop": tenant.shopify_shop,
|
| 93 |
"shopify_client_id": tenant.shopify_client_id,
|
app/routes/widget.py
CHANGED
|
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|
| 12 |
import re
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
-
from fastapi import APIRouter, Depends
|
| 16 |
from fastapi.responses import FileResponse, HTMLResponse
|
| 17 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
|
|
@@ -100,8 +100,10 @@ async def preview(
|
|
| 100 |
|
| 101 |
@router.get("/widget-config")
|
| 102 |
async def widget_config(
|
| 103 |
-
t: str = DEFAULT_SLUG, db: AsyncSession = Depends(get_session)
|
| 104 |
-
) -> dict
|
|
|
|
|
|
|
| 105 |
tenant = await get_tenant_by_slug(db, t)
|
| 106 |
if tenant is None:
|
| 107 |
return {
|
|
@@ -112,6 +114,7 @@ async def widget_config(
|
|
| 112 |
"avatar_url": "",
|
| 113 |
"whatsapp_number": "",
|
| 114 |
"widget_mode": "chat",
|
|
|
|
| 115 |
}
|
| 116 |
return {
|
| 117 |
"brand_name": tenant.brand_name,
|
|
@@ -121,4 +124,5 @@ async def widget_config(
|
|
| 121 |
"avatar_url": tenant.avatar_url,
|
| 122 |
"whatsapp_number": tenant.whatsapp_number,
|
| 123 |
"widget_mode": tenant.widget_mode or "chat",
|
|
|
|
| 124 |
}
|
|
|
|
| 12 |
import re
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
+
from fastapi import APIRouter, Depends, Response
|
| 16 |
from fastapi.responses import FileResponse, HTMLResponse
|
| 17 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
|
|
|
|
| 100 |
|
| 101 |
@router.get("/widget-config")
|
| 102 |
async def widget_config(
|
| 103 |
+
response: Response, t: str = DEFAULT_SLUG, db: AsyncSession = Depends(get_session)
|
| 104 |
+
) -> dict:
|
| 105 |
+
# Never cache: a store's branding/mode/starter changes must show on next load.
|
| 106 |
+
response.headers["Cache-Control"] = "no-store, max-age=0"
|
| 107 |
tenant = await get_tenant_by_slug(db, t)
|
| 108 |
if tenant is None:
|
| 109 |
return {
|
|
|
|
| 114 |
"avatar_url": "",
|
| 115 |
"whatsapp_number": "",
|
| 116 |
"widget_mode": "chat",
|
| 117 |
+
"starters": [],
|
| 118 |
}
|
| 119 |
return {
|
| 120 |
"brand_name": tenant.brand_name,
|
|
|
|
| 124 |
"avatar_url": tenant.avatar_url,
|
| 125 |
"whatsapp_number": tenant.whatsapp_number,
|
| 126 |
"widget_mode": tenant.widget_mode or "chat",
|
| 127 |
+
"starters": tenant.starters or [],
|
| 128 |
}
|
app/static/widget.js
CHANGED
|
@@ -211,8 +211,11 @@
|
|
| 211 |
function userMsg(t) { row("user").textContent = t; msgs.scrollTop = msgs.scrollHeight; }
|
| 212 |
function typing() { var b = row("bot"); b.classList.add("ssb-typing"); b.innerHTML = "<span></span><span></span><span></span>"; msgs.scrollTop = msgs.scrollHeight; return b; }
|
| 213 |
|
| 214 |
-
// Suggested starter chips
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
| 216 |
function renderStarters() {
|
| 217 |
var wrap = document.createElement("div"); wrap.className = "ssb-chips";
|
| 218 |
STARTERS.forEach(function (s) {
|
|
|
|
| 211 |
function userMsg(t) { row("user").textContent = t; msgs.scrollTop = msgs.scrollHeight; }
|
| 212 |
function typing() { var b = row("bot"); b.classList.add("ssb-typing"); b.innerHTML = "<span></span><span></span><span></span>"; msgs.scrollTop = msgs.scrollHeight; return b; }
|
| 213 |
|
| 214 |
+
// Suggested starter chips — per tenant (a services store needs different
|
| 215 |
+
// ones than a products store). Falls back to neutral, non-ecommerce chips.
|
| 216 |
+
var STARTERS = (cfg.starters && cfg.starters.length)
|
| 217 |
+
? cfg.starters.slice(0, 4)
|
| 218 |
+
: ["¿Qué ofrecéis?", "Quiero más información", "Hablar con una persona"];
|
| 219 |
function renderStarters() {
|
| 220 |
var wrap = document.createElement("div"); wrap.className = "ssb-chips";
|
| 221 |
STARTERS.forEach(function (s) {
|
app/tenancy.py
CHANGED
|
@@ -58,6 +58,7 @@ _TENANT_FIELDS = {
|
|
| 58 |
"whatsapp_number",
|
| 59 |
"support_email",
|
| 60 |
"widget_mode",
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
|
|
|
| 58 |
"whatsapp_number",
|
| 59 |
"support_email",
|
| 60 |
"widget_mode",
|
| 61 |
+
"starters",
|
| 62 |
}
|
| 63 |
|
| 64 |
|
migrations/versions/0013_tenant_starters.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-tenant widget starter chips
|
| 2 |
+
|
| 3 |
+
Revision ID: 0013_tenant_starters
|
| 4 |
+
Revises: 0012_source_urls
|
| 5 |
+
Create Date: 2026-06-10
|
| 6 |
+
|
| 7 |
+
Additive, Postgres-guarded. Tests use create_all.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from alembic import op
|
| 13 |
+
|
| 14 |
+
revision = "0013_tenant_starters"
|
| 15 |
+
down_revision = "0012_source_urls"
|
| 16 |
+
branch_labels = None
|
| 17 |
+
depends_on = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def upgrade() -> None:
|
| 21 |
+
if op.get_bind().dialect.name != "postgresql":
|
| 22 |
+
return
|
| 23 |
+
op.execute("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS starters JSONB NOT NULL DEFAULT '[]'::jsonb")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def downgrade() -> None:
|
| 27 |
+
if op.get_bind().dialect.name != "postgresql":
|
| 28 |
+
return
|
| 29 |
+
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS starters")
|
tests/routes/test_portal.py
CHANGED
|
@@ -150,6 +150,20 @@ async def test_portal_client_configures_own_business_and_shopify(app_client, db_
|
|
| 150 |
assert g["brand_name"] == "Mi Marca" and g["support_email"] == "dueno@x.com"
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
async def test_portal_config_is_isolated_per_tenant(app_client, db_session):
|
| 154 |
_app, client = app_client
|
| 155 |
a, a_tok = await _tenant_with_token(db_session, "ca")
|
|
|
|
| 150 |
assert g["brand_name"] == "Mi Marca" and g["support_email"] == "dueno@x.com"
|
| 151 |
|
| 152 |
|
| 153 |
+
async def test_portal_sets_starters_and_widget_config_serves_them(app_client, db_session):
|
| 154 |
+
_app, client = app_client
|
| 155 |
+
a, tok = await _tenant_with_token(db_session, "chips")
|
| 156 |
+
await db_session.commit()
|
| 157 |
+
hdr = {"Authorization": "Bearer " + tok}
|
| 158 |
+
r = await client.put("/portal/api/config", headers=hdr,
|
| 159 |
+
json={"starters": ["Pedir presupuesto", "Ver servicios", "Hablar con alguien"]})
|
| 160 |
+
assert r.status_code == 200
|
| 161 |
+
assert r.json()["starters"] == ["Pedir presupuesto", "Ver servicios", "Hablar con alguien"]
|
| 162 |
+
# the widget config endpoint serves the tenant's own chips
|
| 163 |
+
wc = (await client.get("/widget-config?t=chips")).json()
|
| 164 |
+
assert wc["starters"] == ["Pedir presupuesto", "Ver servicios", "Hablar con alguien"]
|
| 165 |
+
|
| 166 |
+
|
| 167 |
async def test_portal_config_is_isolated_per_tenant(app_client, db_session):
|
| 168 |
_app, client = app_client
|
| 169 |
a, a_tok = await _tenant_with_token(db_session, "ca")
|
tests/test_phase_bc.py
CHANGED
|
@@ -36,5 +36,5 @@ def test_widget_has_proactive_and_starters():
|
|
| 36 |
js = (ROOT / "app" / "static" / "widget.js").read_text()
|
| 37 |
assert "ssb-bubble" in js # proactive nudge
|
| 38 |
assert "renderStarters" in js # guided starter chips
|
| 39 |
-
assert "
|
| 40 |
assert "sendText" in js # chips reuse the send path
|
|
|
|
| 36 |
js = (ROOT / "app" / "static" / "widget.js").read_text()
|
| 37 |
assert "ssb-bubble" in js # proactive nudge
|
| 38 |
assert "renderStarters" in js # guided starter chips
|
| 39 |
+
assert "cfg.starters" in js # chips are per-tenant (not hardcoded)
|
| 40 |
assert "sendText" in js # chips reuse the send path
|