victor34593993 commited on
Commit
94d8cac
·
verified ·
1 Parent(s): 43c1587

no-invent + per-tenant chips

Browse files
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
- # Catalog-intent: "what do you sell / what products / catálogo / qué ofrecéis…"
24
- _CATALOG_INTENT = re.compile(
25
- r"\b(qu[eé]\s+(vend|produc|ofrec|ten[eé]is|hay|art[ií]culo)|"
26
- r"what\s+(do\s+you\s+sell|products|can\s+i\s+buy)|cat[]log|catalog|"
27
- r"productos?\b|qu[eé]\s+puedo\s+comprar|do\s+you\s+sell)",
 
 
 
 
 
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: when the customer asks what the store
76
- # sells/offers, weak providers tend to invent a catalog from memory. Force a
77
- # grounded answer by requiring a tool call first.
78
- if _CATALOG_INTENT.search(user_message):
79
  messages.append({"role": "system", "content": (
80
- "Para esta pregunta sobre productos/catálogo DEBES llamar primero a "
81
- "search_products y, si no hay catálogo en vivo o no devuelve nada, a "
82
- "search_knowledge. Responde SOLO con lo que devuelvan. Si ninguna "
83
- "tiene la respuesta, dilo con sinceridad. PROHIBIDO describir productos "
84
- "o categorías de memoria."
 
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[]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
- - 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. NEVER invent a product, price, spec or a catalog — inventing is a serious error.
 
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[str, str]:
 
 
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, incl. a guided recommender entry point.
215
- var STARTERS = ["Ayúdame a elegir", "¿Dónde está mi pedido?", "Ver productos"];
 
 
 
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 "Ayúdame a elegir" in js # recommender entry point
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