victor34593993 commited on
Commit
d183415
·
verified ·
1 Parent(s): 4778987

AI quality: open grounding (always consult the brain) + leveled-up prompt (no product-conflation / no-invent / no-repeat / human-last) + Spanish-detection fix + credentials video in Tienda Shopify tab + honest copy

Browse files
app/lang.py CHANGED
@@ -100,7 +100,10 @@ _CA_WORDS = re.compile(
100
  )
101
 
102
 
103
- def _iberian(text: str) -> str | None:
 
 
 
104
  scores = {
105
  "es": len(_ES_CHARS.findall(text)) + len(_ES_WORDS.findall(text)),
106
  "pt": len(_PT_CHARS.findall(text)) + len(_PT_WORDS.findall(text)),
@@ -108,11 +111,11 @@ def _iberian(text: str) -> str | None:
108
  }
109
  best = max(scores, key=lambda k: scores[k])
110
  if scores[best] == 0:
111
- return None
112
  # Require a clear winner (avoid coin-flips on weak signals).
113
  if list(scores.values()).count(scores[best]) > 1:
114
- return None
115
- return _NAMES[best]
116
 
117
 
118
  # Unambiguous greeting/short words → language. Lets us pin even very short
@@ -328,11 +331,18 @@ def detect_language(text: str) -> str | None:
328
  code, prob = _langdetect()
329
 
330
  # Latin script — be careful (this is where langdetect misfires).
331
- ib = _iberian(text)
332
  # A strong Iberian signal (¿ ¡ ñ / ã õ) is authoritative: it beats a
333
  # langdetect mislabel (e.g. "¿qué productos vendéis?" scored as French).
334
  if ib and (_ES_CHARS.search(text) or _PT_CHARS.search(text)):
335
  return ib
 
 
 
 
 
 
 
336
  # Otherwise an English-looking sentence wins over a weak stray Iberian marker
337
  # (e.g. "where is mi product please" — the lone "mi" must not flip it to es).
338
  if _looks_english(text):
 
100
  )
101
 
102
 
103
+ def _iberian(text: str) -> tuple[str | None, int]:
104
+ """Return (language_name, score). score = number of Iberian marker hits for
105
+ the winning language (0 when no clear winner) — the caller uses the strength
106
+ to decide whether it outweighs a weak English-looking signal."""
107
  scores = {
108
  "es": len(_ES_CHARS.findall(text)) + len(_ES_WORDS.findall(text)),
109
  "pt": len(_PT_CHARS.findall(text)) + len(_PT_WORDS.findall(text)),
 
111
  }
112
  best = max(scores, key=lambda k: scores[k])
113
  if scores[best] == 0:
114
+ return None, 0
115
  # Require a clear winner (avoid coin-flips on weak signals).
116
  if list(scores.values()).count(scores[best]) > 1:
117
+ return None, 0
118
+ return _NAMES[best], scores[best]
119
 
120
 
121
  # Unambiguous greeting/short words → language. Lets us pin even very short
 
331
  code, prob = _langdetect()
332
 
333
  # Latin script — be careful (this is where langdetect misfires).
334
+ ib, ib_score = _iberian(text)
335
  # A strong Iberian signal (¿ ¡ ñ / ã õ) is authoritative: it beats a
336
  # langdetect mislabel (e.g. "¿qué productos vendéis?" scored as French).
337
  if ib and (_ES_CHARS.search(text) or _PT_CHARS.search(text)):
338
  return ib
339
+ # A strong Iberian WORD score (2+ Spanish/Portuguese words) is also
340
+ # authoritative even WITHOUT ¿¡ñ/ãõ: plain-accent Spanish like "voy a comprar
341
+ # ... quiero ... los plazos de entrega ... envios" is unmistakably Spanish and
342
+ # must beat _looks_english firing on the Romance preposition "a" (the real
343
+ # production bug: a Spanish question got answered in English).
344
+ if ib and ib_score >= 2:
345
+ return ib
346
  # Otherwise an English-looking sentence wins over a weak stray Iberian marker
347
  # (e.g. "where is mi product please" — the lone "mi" must not flip it to es).
348
  if _looks_english(text):
app/orchestrator.py CHANGED
@@ -21,29 +21,6 @@ log = logging.getLogger(__name__)
21
 
22
  HISTORY_LIMIT = 10
23
  MAX_TOOL_ITERS = 6
24
- # Questions about the business/identity/catalog/recommendation where weak models
25
- # tend to INVENT a fake store. We force a grounded (tool-based) answer for these.
26
- _GROUND_INTENT = re.compile(
27
- r"(qu[eé]\s+(vend|produc|ofrec|servici|ten[eé]is|hac[eé]is|hay|art[ií]culo)|"
28
- 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))|"
29
- r"a\s+qu[eé]\s+(os\s+|te\s+)?dedic|qui[eé]n(es)?\s+sois|sobre\s+(vosotros|nosotros|la\s+empresa)|"
30
- r"ay[uú]dame\s+a\s+elegir|recomi[eé]nd|qu[eé]\s+me\s+recomiend|qu[eé]\s+puedo\s+comprar|"
31
- r"cat[aá]log|catalog|productos?\b|servicios?\b|whatsapp|"
32
- r"cu[aá]nto\s+(cuesta|vale|cobr|es\s+el\s+precio)|precios?\b|tarifas?\b|"
33
- r"(quiero|c[oó]mo)\s+(lo\s+)?compr|comprarlo|contratar|"
34
- r"what\s+(do\s+you\s+(sell|do|offer)|is\s+this|are\s+you|services|products)|"
35
- r"who\s+are\s+you|about\s+(you|us)|help\s+me\s+choose|do\s+you\s+sell|"
36
- r"how\s+much|price|cost\b|"
37
- # similarity / availability ("do you have this / something like this") —
38
- # typical right after the customer uploads a product photo
39
- r"algo\s+(como|parecido\s+a|similar\s+a)\s+est[oae]|algo\s+as[ií]\b|"
40
- r"\bparecid[oa]s?\b|\bsimilar(es)?\b|"
41
- r"ten[eé]is\s+(esto|este|esta|algo)\b|(lo|la)\s+(ten[eé]is|vend[eé]is)\b|"
42
- r"do\s+you\s+have\s+(this|something|anything|one)\b|"
43
- r"something\s+like\s+(this|that)\b|similar\s+to\s+(this|that)\b|"
44
- r"like\s+this\s+one\b)",
45
- re.IGNORECASE,
46
- )
47
  # Marker prepended by app/routes/chat.py (_attachment_block) for every file the
48
  # visitor attached to THIS turn. Its presence means an image description / PDF
49
  # text rides inside the user message.
@@ -74,6 +51,40 @@ PHOTO_GROUNDING_NOTE = (
74
  "sinceridad y ofrece alternativas o hablar con una persona. PROHIBIDO "
75
  "responder de memoria o inventar productos."
76
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  MAX_REPLY_TOKENS = 1000 # bound output; high enough for verbose scripts (ar/th/hi)
78
  MAX_TOOL_RESULT_CHARS = 8000 # cap any single tool result before sending it back
79
  FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
@@ -146,14 +157,20 @@ async def run_turn(
146
  # Deterministic anti-hallucination: asked what the business is/sells/offers
147
  # or to recommend, weak providers invent a fake store from memory. Force a
148
  # grounded answer by requiring a tool lookup first.
149
- if _GROUND_INTENT.search(gate_text):
150
  messages.append({"role": "system", "content": (
151
- "Para esta pregunta sobre la empresa/tienda/productos/servicios DEBES "
152
- "llamar primero a search_knowledge (y a search_products si hay catálogo) "
153
- "ANTES de responder. Describe el negocio, lo que vende o lo que ofrece "
154
- "SOLO con lo que devuelvan las herramientas. Si no hay información, dilo "
155
- "con sinceridad y ofrece ayuda. PROHIBIDO inventar el nombre del negocio, "
156
- "qué es, qué vende, categorías o productos."
 
 
 
 
 
 
157
  )})
158
  # Photo-to-product chain: an attachment rode in with THIS message AND the
159
  # customer is product-seeking ("¿tenéis algo como esto?") -> force the
 
21
 
22
  HISTORY_LIMIT = 10
23
  MAX_TOOL_ITERS = 6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # Marker prepended by app/routes/chat.py (_attachment_block) for every file the
25
  # visitor attached to THIS turn. Its presence means an image description / PDF
26
  # text rides inside the user message.
 
51
  "sinceridad y ofrece alternativas o hablar con una persona. PROHIBIDO "
52
  "responder de memoria o inventar productos."
53
  )
54
+ # OPEN grounding policy: the bot is NOT tied to a fixed list of question types —
55
+ # it consults the store's knowledge/products for ANY real question, whatever the
56
+ # wording (shipping, delivery times, dimensions, compatibility, specs, a specific
57
+ # model, prices...). So we force a tool lookup by DEFAULT and only skip it for
58
+ # (a) pure greetings/thanks with nothing substantive left, and (b) questions about
59
+ # an attached document (answered from the attached text, not a catalog search).
60
+ _PLEASANTRY = re.compile(
61
+ r"\b(hola|buenas|buenos|d[ií]as|tardes|noches|hey|hello|hi|qu[eé]|tal|c[oó]mo|"
62
+ r"est[aá]s|est[aá]is|va|andas|gracias|muchas|mil|ok|okay|okey|vale|perfecto|"
63
+ r"genial|estupendo|adi[oó]s|hasta|luego|pronto|chao|bye|thanks|thank|you|good|"
64
+ r"morning|afternoon|evening|how|are|por|todo|de|nada|saludos?|un|abrazo|y|t[uú])\b",
65
+ re.IGNORECASE,
66
+ )
67
+ _DOC_QUESTION = re.compile(
68
+ r"\b(documento|pdf|archivo|adjunto|fichero|document|attachment|attached)\b",
69
+ re.IGNORECASE,
70
+ )
71
+
72
+
73
+ def _needs_grounding(text: str) -> bool:
74
+ """True when the bot must search the store's tools FIRST and answer ONLY from
75
+ their results. Default True — consult the brain for every real question;
76
+ only pure small talk and attached-document questions return False."""
77
+ t = (text or "").strip()
78
+ if not t:
79
+ return False
80
+ if _DOC_QUESTION.search(t):
81
+ return False
82
+ # strip greetings/thanks/closers + punctuation; if nothing substantive is left
83
+ # it's small talk ("hola, buenos días", "gracias por todo", "ok perfecto").
84
+ leftover = re.sub(r"[^0-9A-Za-zÀ-ÿ]+", " ", _PLEASANTRY.sub(" ", t)).split()
85
+ return len(leftover) >= 1
86
+
87
+
88
  MAX_REPLY_TOKENS = 1000 # bound output; high enough for verbose scripts (ar/th/hi)
89
  MAX_TOOL_RESULT_CHARS = 8000 # cap any single tool result before sending it back
90
  FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
 
157
  # Deterministic anti-hallucination: asked what the business is/sells/offers
158
  # or to recommend, weak providers invent a fake store from memory. Force a
159
  # grounded answer by requiring a tool lookup first.
160
+ if _needs_grounding(gate_text):
161
  messages.append({"role": "system", "content": (
162
+ "Para responder a esto consulta SIEMPRE primero tus herramientas "
163
+ "(search_knowledge y, si aplica, search_products) y responde SOLO con lo "
164
+ "que devuelvan, en cualquier tema de esta tienda: qué vende, productos, "
165
+ "precios, stock, envíos y plazos de entrega, medidas, especificaciones, "
166
+ "compatibilidad, devoluciones, pedidos. Si el cliente nombra un producto "
167
+ "o modelo concreto, busca ESE producto y no reutilices datos de otro ni "
168
+ "de mensajes anteriores. Responde SOLO con lo que digan las herramientas: "
169
+ "no añadas garantías, avisos de seguridad, homologaciones, 'no "
170
+ "recomendado' ni nada que no aparezca en el resultado. Si tras buscar no "
171
+ "hay información, dilo con sinceridad; SOLO como último recurso ofrece "
172
+ "pasar con una persona. NUNCA mandes al cliente a la web del fabricante "
173
+ "ni a un tercero. PROHIBIDO inventar."
174
  )})
175
  # Photo-to-product chain: an attachment rode in with THIS message AND the
176
  # customer is product-seeking ("¿tenéis algo como esto?") -> force the
app/portal_ui/index.html CHANGED
@@ -116,7 +116,7 @@
116
  </div>
117
  <div class="card">
118
  <strong>Preguntas que el bot no supo responder</strong>
119
- <p class="muted">Añade esa información en "Conocimiento" y el bot dejará de fallar en eso. Así mejora solo.</p>
120
  <div id="unresolved"></div>
121
  </div>
122
  </section>
@@ -189,6 +189,11 @@
189
  <input id="sh-secret" type="password" placeholder="••••••••" />
190
  <div class="row" style="margin-top:14px;"><button onclick="saveShopify()">Guardar y conectar</button><span id="sh-state" class="ok"></span></div>
191
  </div>
 
 
 
 
 
192
  </section>
193
 
194
  <!-- CONOCIMIENTO -->
@@ -266,9 +271,9 @@
266
  <p class="muted" style="margin-top:12px;">Probar cómo se ve antes de instalar: <a id="preview-link" target="_blank">abrir vista previa</a></p>
267
  </div>
268
  <div class="card">
269
- <strong>Vídeos de ayuda</strong>
270
- <p class="muted">Te lo enseñamos en vídeo, paso a paso.</p>
271
- <div id="videos"></div>
272
  </div>
273
  </section>
274
 
@@ -310,7 +315,8 @@
310
  if(name === "conocimiento") loadSources();
311
  if(name === "whatsapp") loadWaConfig();
312
  if(name === "conversaciones") loadConversations();
313
- if(name === "instalar") loadVideos();
 
314
  }
315
 
316
  // ── WhatsApp 1-click connect (Embedded Signup, self-service) ──────────
@@ -546,24 +552,28 @@
546
  box.appendChild(d);
547
  });
548
  }
549
- async function loadVideos(){
550
- var box = $("videos"); if(!box) return; box.innerHTML = "";
551
- var vids = [];
552
- try { vids = await (await api("/portal/api/videos")).json(); } catch(e){ return; }
553
- var en = (typeof plang === "function" && plang() === "en");
554
- vids.forEach(function(v){
555
- var wrap = document.createElement("div"); wrap.style.margin = "10px 0";
556
- var t = document.createElement("strong"); t.textContent = en ? v.title_en : v.title_es; wrap.appendChild(t);
557
- if(v.available){
558
- var vid = document.createElement("video"); vid.controls = true; vid.src = v.url;
559
- vid.style.cssText = "display:block;width:100%;max-width:520px;margin-top:6px;border-radius:10px;";
560
- wrap.appendChild(vid);
561
- } else {
562
- var p = document.createElement("p"); p.className = "muted"; p.textContent = tr("Lo añadiremos muy pronto.");
563
- wrap.appendChild(p);
564
- }
565
- box.appendChild(wrap);
566
- });
 
 
 
 
567
  }
568
  async function loadConversations(){
569
  var box = $("conv-list"); box.textContent = tr("Cargando…");
@@ -623,7 +633,7 @@ var PEN = {
623
  "Cuando un cliente deja sus datos en el chat para hablar con una persona, aparecen aquí (y te llegan por email si lo configuras en \"Mi negocio\").":"When a customer leaves their details in the chat to talk to a person, they appear here (and reach your email if set in \"My business\").",
624
  "Sin solicitudes todavía.":"No requests yet.",
625
  "Preguntas que el bot no supo responder":"Questions the bot could not answer",
626
- "Añade esa información en \"Conocimiento\" y el bot dejará de fallar en eso. Así mejora solo.":"Add that information under \"Knowledge\" and the bot will stop missing it. It improves on its own.",
627
  "Nada por ahora — el bot está respondiendo todo.":"Nothing so far — the bot is answering everything.",
628
  "Tu negocio":"Your business",
629
  "Cómo se ve y se llama tu asistente, y dónde quieres recibir los avisos.":"How your assistant looks and is named, and where you want your alerts.",
@@ -660,6 +670,9 @@ var PEN = {
660
  "Aún no has añadido nada.":"Nothing added yet.","Quitar":"Remove","¿Quitar esta fuente?":"Remove this source?",
661
  "Re-analizar todo":"Re-analyze everything","Actualizar catálogo Shopify":"Refresh Shopify catalog",
662
  "Vídeos de ayuda":"Help videos","Te lo enseñamos en vídeo, paso a paso.":"We show you on video, step by step.","Lo añadiremos muy pronto.":"We'll add it very soon.",
 
 
 
663
  "Analizando la web…":"Analyzing the website…","Añadido.":"Added.","No se pudo añadir.":"Could not add it.",
664
  "Subiendo y analizando…":"Uploading and analyzing…","Subido.":"Uploaded.","No se pudo subir (revisa el tipo de archivo).":"Could not upload (check the file type).",
665
  "Re-analizando…":"Re-analyzing…","Listo.":"Done.","Actualizando catálogo…":"Refreshing catalog…","Conecta Shopify primero.":"Connect Shopify first.",
 
116
  </div>
117
  <div class="card">
118
  <strong>Preguntas que el bot no supo responder</strong>
119
+ <p class="muted">Aquí ves lo que el bot no supo contestar. Pulsa "Enseñar la respuesta" (o añade la info en "Conocimiento") y dejará de fallar en eso. No aprende solo: aprende con lo que tú le enseñas.</p>
120
  <div id="unresolved"></div>
121
  </div>
122
  </section>
 
189
  <input id="sh-secret" type="password" placeholder="••••••••" />
190
  <div class="row" style="margin-top:14px;"><button onclick="saveShopify()">Guardar y conectar</button><span id="sh-state" class="ok"></span></div>
191
  </div>
192
+ <div class="card">
193
+ <strong>Vídeo: cómo sacar las credenciales de Shopify</strong>
194
+ <p class="muted">Te lo enseñamos paso a paso.</p>
195
+ <div id="video-credenciales"></div>
196
+ </div>
197
  </section>
198
 
199
  <!-- CONOCIMIENTO -->
 
271
  <p class="muted" style="margin-top:12px;">Probar cómo se ve antes de instalar: <a id="preview-link" target="_blank">abrir vista previa</a></p>
272
  </div>
273
  <div class="card">
274
+ <strong>Vídeo: cómo poner el código en Shopify</strong>
275
+ <p class="muted">Te lo enseñamos paso a paso.</p>
276
+ <div id="video-codigo"></div>
277
  </div>
278
  </section>
279
 
 
315
  if(name === "conocimiento") loadSources();
316
  if(name === "whatsapp") loadWaConfig();
317
  if(name === "conversaciones") loadConversations();
318
+ if(name === "shopify") renderVideo("video-credenciales", "shopify-credenciales");
319
+ if(name === "instalar") renderVideo("video-codigo", "shopify-codigo");
320
  }
321
 
322
  // ── WhatsApp 1-click connect (Embedded Signup, self-service) ──────────
 
552
  box.appendChild(d);
553
  });
554
  }
555
+ var _VIDEO_CACHE = null;
556
+ async function _allVideos(){
557
+ if(_VIDEO_CACHE) return _VIDEO_CACHE;
558
+ try { _VIDEO_CACHE = await (await api("/portal/api/videos")).json(); } catch(e){ _VIDEO_CACHE = []; }
559
+ return _VIDEO_CACHE;
560
+ }
561
+ // Render ONE video into a specific container: the credentials video lives in
562
+ // the "Tienda Shopify" tab (next to the connect form) and the code-line video
563
+ // in "Instalar" — each where the merchant actually needs it.
564
+ async function renderVideo(containerId, videoId){
565
+ var box = $(containerId); if(!box) return;
566
+ var v = (await _allVideos()).find(function(x){ return x.id === videoId; });
567
+ box.innerHTML = "";
568
+ if(!v) return;
569
+ if(v.available){
570
+ var vid = document.createElement("video"); vid.controls = true; vid.preload = "metadata"; vid.src = v.url;
571
+ vid.style.cssText = "display:block;width:100%;max-width:560px;margin-top:6px;border-radius:10px;";
572
+ box.appendChild(vid);
573
+ } else {
574
+ var p = document.createElement("p"); p.className = "muted"; p.textContent = tr("Lo añadiremos muy pronto.");
575
+ box.appendChild(p);
576
+ }
577
  }
578
  async function loadConversations(){
579
  var box = $("conv-list"); box.textContent = tr("Cargando…");
 
633
  "Cuando un cliente deja sus datos en el chat para hablar con una persona, aparecen aquí (y te llegan por email si lo configuras en \"Mi negocio\").":"When a customer leaves their details in the chat to talk to a person, they appear here (and reach your email if set in \"My business\").",
634
  "Sin solicitudes todavía.":"No requests yet.",
635
  "Preguntas que el bot no supo responder":"Questions the bot could not answer",
636
+ "Aquí ves lo que el bot no supo contestar. Pulsa \"Enseñar la respuesta\" (o añade la info en \"Conocimiento\") y dejará de fallar en eso. No aprende solo: aprende con lo que tú le enseñas.":"Here you see what the bot couldn't answer. Click \"Teach the answer\" (or add the info under \"Knowledge\") and it will stop missing it. It doesn't learn by itself: it learns from what you teach it.",
637
  "Nada por ahora — el bot está respondiendo todo.":"Nothing so far — the bot is answering everything.",
638
  "Tu negocio":"Your business",
639
  "Cómo se ve y se llama tu asistente, y dónde quieres recibir los avisos.":"How your assistant looks and is named, and where you want your alerts.",
 
670
  "Aún no has añadido nada.":"Nothing added yet.","Quitar":"Remove","¿Quitar esta fuente?":"Remove this source?",
671
  "Re-analizar todo":"Re-analyze everything","Actualizar catálogo Shopify":"Refresh Shopify catalog",
672
  "Vídeos de ayuda":"Help videos","Te lo enseñamos en vídeo, paso a paso.":"We show you on video, step by step.","Lo añadiremos muy pronto.":"We'll add it very soon.",
673
+ "Vídeo: cómo sacar las credenciales de Shopify":"Video: how to get your Shopify credentials",
674
+ "Vídeo: cómo poner el código en Shopify":"Video: how to add the code in Shopify",
675
+ "Te lo enseñamos paso a paso.":"We show you step by step.",
676
  "Analizando la web…":"Analyzing the website…","Añadido.":"Added.","No se pudo añadir.":"Could not add it.",
677
  "Subiendo y analizando…":"Uploading and analyzing…","Subido.":"Uploaded.","No se pudo subir (revisa el tipo de archivo).":"Could not upload (check the file type).",
678
  "Re-analizando…":"Re-analyzing…","Listo.":"Done.","Actualizando catálogo…":"Refreshing catalog…","Conecta Shopify primero.":"Connect Shopify first.",
app/prompts.py CHANGED
@@ -21,11 +21,13 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
21
  - Be concise and human. Vary your wording; don't repeat a canned line.
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)
31
  - Which products / price / stock / "do you sell…" / recommendations -> search_products. If it returns status "unavailable" or no products, THEN call search_knowledge to answer from the store's info. If neither has it, say so honestly — NEVER invent products or categories.
@@ -57,8 +59,8 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
57
  - MANDATORY CONFIRMATION: ask and WAIT for a clear "yes" before executing. A return reason, a "no", an "incorrect", or any ambiguous reply is NOT a yes — ask again, do not execute. Country in ISO code (ES, PT).
58
  - If a tool returns needs_confirmation, ask for the yes and don't repeat the tool until you have it. If it returns not_allowed or error, apologize and use escalate_to_human. You never issue refunds yourself: start the return and hand off to the team.
59
 
60
- # ESCALATION
61
- - Use escalate_to_human ONLY when (a) the customer explicitly asks for a person/human/agent, or (b) after using your tools you cannot resolve their specific case. NEVER on a greeting or a first message — greet and ask how you can help instead.
62
  {channel_handoff}
63
 
64
  # FORMAT & TONE
 
21
  - Be concise and human. Vary your wording; don't repeat a canned line.
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 — your TOOLS ARE YOUR BRAIN)
25
+ - For ANY question about this store — what it sells, products, prices, stock, specs, dimensions, compatibility, materials, shipping and delivery times, returns, warranty, policies, orders — SEARCH first (search_knowledge / search_products) and answer ONLY with what the tools return. Do this for EVERY such question, whatever its wording, including follow-ups. NEVER answer these from memory, general knowledge, or earlier messages. You are open-ended: don't wait for a specific phrasing — if it could be about this store, look it up.
26
+ - ONE product at a time: when the customer names a DIFFERENT product or model than the one already discussed, you MUST search again for THAT exact product before answering. NEVER reuse another product's dimensions, specs, compatibility or price each model is independent, and earlier answers are NOT a source.
27
+ - Say ONLY what the tool result actually states. NEVER add warranty terms, safety warnings, certifications, "not recommended", "not covered", legal or risk judgments that are not in the source. If the data doesn't mention it, don't claim it — give what you DO know and, only if useful, offer to confirm with the team.
28
+ - NEVER invent the store's name or catalog. Making up a business, product, spec or policy is a serious error.
29
+ - If, after searching, you genuinely lack the info: say so honestly and briefly. Do NOT send the customer away — never tell them to visit a manufacturer's/brand's website or to contact "their customer service" or any third party. You are THIS store's assistant. Offering to pass them to a person is the LAST resort, only when you truly cannot help — never your first move.
30
+ - Don't repeat yourself. If the customer pushes back or adds a detail, engage with their SPECIFIC point (search again for more detail if it helps, acknowledge what's actually true) and move the conversation forward — never restate the same paragraph; that feels broken.
31
 
32
  # TOOL ROUTING (choose by intent — do not guess)
33
  - Which products / price / stock / "do you sell…" / recommendations -> search_products. If it returns status "unavailable" or no products, THEN call search_knowledge to answer from the store's info. If neither has it, say so honestly — NEVER invent products or categories.
 
59
  - MANDATORY CONFIRMATION: ask and WAIT for a clear "yes" before executing. A return reason, a "no", an "incorrect", or any ambiguous reply is NOT a yes — ask again, do not execute. Country in ISO code (ES, PT).
60
  - If a tool returns needs_confirmation, ask for the yes and don't repeat the tool until you have it. If it returns not_allowed or error, apologize and use escalate_to_human. You never issue refunds yourself: start the return and hand off to the team.
61
 
62
+ # ESCALATION (last resort)
63
+ - escalate_to_human is the LAST resort, never the first move: ALWAYS try to answer with your tools first. Use it ONLY when (a) the customer explicitly asks for a person/human/agent, or (b) you genuinely cannot resolve their case AFTER searching. NEVER on a greeting or a first message — greet and ask how you can help instead.
64
  {channel_handoff}
65
 
66
  # FORMAT & TONE
tests/test_lang.py CHANGED
@@ -67,6 +67,23 @@ def test_english_with_stray_spanish_word_stays_english():
67
  assert detect_language("hello, can you help me find a product") == "English"
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @pytest.mark.parametrize(
71
  "text",
72
  [
@@ -181,3 +198,15 @@ def test_prompt_forbids_emojis():
181
  from app.prompts import build_system_prompt
182
 
183
  assert "emoji" in build_system_prompt("X").lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  assert detect_language("hello, can you help me find a product") == "English"
68
 
69
 
70
+ @pytest.mark.parametrize(
71
+ "text",
72
+ [
73
+ # the real production bug: plain Spanish WITHOUT ¿¡ñ accents was pinned
74
+ # English because the Spanish preposition "a" tripped _looks_english.
75
+ "Hola, voy a comprar la Toorx msx70 y quiero saber los plazos de entrega "
76
+ "para envios a la peninsula.",
77
+ # same defect without a leading greeting (exercises the score path)
78
+ "quiero comprar la bici y saber los plazos de envios a la peninsula",
79
+ "necesito ayuda para elegir y comprar productos para mi pedido",
80
+ ],
81
+ )
82
+ def test_plain_spanish_without_accents_is_spanish(text):
83
+ # regression: a strong Spanish word-score must beat the "a"/"to" English bias
84
+ assert detect_language(text) == "español"
85
+
86
+
87
  @pytest.mark.parametrize(
88
  "text",
89
  [
 
198
  from app.prompts import build_system_prompt
199
 
200
  assert "emoji" in build_system_prompt("X").lower()
201
+
202
+
203
+ def test_prompt_forbids_redirecting_customer_to_third_parties():
204
+ """Regression: the bot once told a Spanish customer to 'visit Toorx's website
205
+ / contact their customer service' instead of helping. The prompt must forbid
206
+ sending the customer to a manufacturer/third party and keep them with the
207
+ store's own team."""
208
+ from app.prompts import build_system_prompt
209
+
210
+ p = build_system_prompt("Tienda X").lower()
211
+ assert "third party" in p and "manufacturer" in p
212
+ assert "escalate_to_human" in p
tests/test_orchestrator.py CHANGED
@@ -5,8 +5,8 @@ import pytest
5
  from app.llm.base import ChatResult, ProviderError, ToolCall
6
  from app.models import ChatMessage, ChatSession
7
  from app.orchestrator import (
8
- _GROUND_INTENT,
9
  _PHOTO_PRODUCT_INTENT,
 
10
  ATTACHMENT_MARKER,
11
  PHOTO_GROUNDING_NOTE,
12
  run_turn,
@@ -154,7 +154,25 @@ async def test_total_provider_failure_returns_fallback_not_500(db_session):
154
  ],
155
  )
156
  def test_ground_intent_fires_on_similarity_phrases(msg):
157
- assert _GROUND_INTENT.search(msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
 
160
  @pytest.mark.parametrize(
@@ -162,13 +180,15 @@ def test_ground_intent_fires_on_similarity_phrases(msg):
162
  [
163
  "hola, buenos días",
164
  "gracias por todo",
 
 
165
  "¿qué pone en el documento?",
166
  "resume el pdf adjunto",
167
  "what does the document say?",
168
  ],
169
  )
170
- def test_ground_intent_silent_on_smalltalk_and_document_questions(msg):
171
- assert not _GROUND_INTENT.search(msg)
172
 
173
 
174
  @pytest.mark.parametrize(
@@ -296,3 +316,75 @@ async def test_tool_budget_exhausted_forces_final_answer(db_session):
296
  resp = await run_turn(router, ctx, "bucle")
297
  assert resp.reply == "Respuesta final."
298
  assert router.calls == 5 # 4 tool iters + 1 forced final
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  from app.llm.base import ChatResult, ProviderError, ToolCall
6
  from app.models import ChatMessage, ChatSession
7
  from app.orchestrator import (
 
8
  _PHOTO_PRODUCT_INTENT,
9
+ _needs_grounding,
10
  ATTACHMENT_MARKER,
11
  PHOTO_GROUNDING_NOTE,
12
  run_turn,
 
154
  ],
155
  )
156
  def test_ground_intent_fires_on_similarity_phrases(msg):
157
+ assert _needs_grounding(msg)
158
+
159
+
160
+ @pytest.mark.parametrize(
161
+ "msg",
162
+ [
163
+ # OPEN grounding: ANY real question grounds, whatever its wording — not a
164
+ # fixed intent list. (Regression: these used to free-wheel from memory.)
165
+ "si pido una srx-100 cuanto tarda en llegarme",
166
+ "necesito saber las medidas de la asx-90",
167
+ "es compatible con discos de 28mm?",
168
+ "mis discos de 28 valen en la asx-2000?",
169
+ "cuanto tarda el envio a canarias",
170
+ "que material es",
171
+ "how long does shipping take?",
172
+ ],
173
+ )
174
+ def test_grounding_fires_on_any_real_question(msg):
175
+ assert _needs_grounding(msg)
176
 
177
 
178
  @pytest.mark.parametrize(
 
180
  [
181
  "hola, buenos días",
182
  "gracias por todo",
183
+ "ok perfecto",
184
+ "¿qué tal?",
185
  "¿qué pone en el documento?",
186
  "resume el pdf adjunto",
187
  "what does the document say?",
188
  ],
189
  )
190
+ def test_grounding_silent_on_smalltalk_and_document_questions(msg):
191
+ assert not _needs_grounding(msg)
192
 
193
 
194
  @pytest.mark.parametrize(
 
316
  resp = await run_turn(router, ctx, "bucle")
317
  assert resp.reply == "Respuesta final."
318
  assert router.calls == 5 # 4 tool iters + 1 forced final
319
+
320
+
321
+ # --- verification: the OPEN grounding actually reaches the model -------------
322
+
323
+ class CapturingRouter:
324
+ """Records the messages sent to the LLM on the first call, then answers."""
325
+
326
+ def __init__(self, reply="ok"):
327
+ self.reply = reply
328
+ self.seen = None
329
+
330
+ async def chat(self, messages, tools, tier="large", temperature=None, order=None, tool_choice=None, max_tokens=None):
331
+ if self.seen is None:
332
+ self.seen = list(messages)
333
+ return ChatResult(content=self.reply, tool_calls=[], finish_reason="stop")
334
+
335
+ def system_text(self):
336
+ return "\n".join(m.get("content", "") for m in (self.seen or []) if m.get("role") == "system").lower()
337
+
338
+
339
+ @pytest.mark.parametrize(
340
+ "q",
341
+ [
342
+ "si pido una srx-100 cuanto tarda en llegarme", # delivery time
343
+ "necesito las medidas de la asx-90", # dimensions
344
+ "es compatible con discos de 28mm?", # compatibility
345
+ "mis discos de 28 valen en la asx-2000?", # a DIFFERENT model
346
+ "que material es la barra", # specs
347
+ ],
348
+ )
349
+ async def test_real_question_forces_grounding_note(db_session, q):
350
+ """Every real store question must reach the model WITH the grounding note that
351
+ forces a tool search first and forbids inventing / reusing other products /
352
+ redirecting to third parties — whatever the wording."""
353
+ r = CapturingRouter(reply="...")
354
+ ctx = ToolContext(db=db_session, session=await _session(db_session))
355
+ await run_turn(r, ctx, q, brand_name="Tienda")
356
+ sys = r.system_text()
357
+ assert "consulta siempre primero tus herramientas" in sys # search-first
358
+ assert "no reutilices datos de otro" in sys # no product conflation
359
+ assert "tercero" in sys and "prohibido inventar" in sys # no redirect / no invent
360
+
361
+
362
+ async def test_smalltalk_does_not_force_grounding_note(db_session):
363
+ r = CapturingRouter(reply="¡Hola! ¿En qué te ayudo?")
364
+ ctx = ToolContext(db=db_session, session=await _session(db_session))
365
+ await run_turn(r, ctx, "hola, buenos días", brand_name="Tienda")
366
+ assert "consulta siempre primero tus herramientas" not in r.system_text()
367
+
368
+
369
+ async def test_delivery_question_runs_knowledge_search_and_answers(db_session, monkeypatch):
370
+ """The exact production failure: a delivery-time question now triggers a
371
+ knowledge search and answers from it (instead of free-wheeling 'no info')."""
372
+ from app.models import KnowledgeChunk
373
+ from app.rag import index
374
+
375
+ async def fake_search(session, query, k=4, *, tenant_id=None):
376
+ chunk = KnowledgeChunk(
377
+ source_id=999, tenant_id=tenant_id, ordinal=0,
378
+ text="Envíos a la península en 24-48h.", embedding=[], meta={"source_name": "envios"},
379
+ )
380
+ return [(chunk, 0.92)]
381
+
382
+ monkeypatch.setattr(index, "search", fake_search)
383
+ router = FakeRouter([
384
+ ChatResult(content=None, tool_calls=[ToolCall(id="t1", name="search_knowledge", arguments={"query": "plazo de entrega peninsula"})], finish_reason="tool_calls"),
385
+ ChatResult(content="Llega en 24-48h a la península.", tool_calls=[], finish_reason="stop"),
386
+ ])
387
+ ctx = ToolContext(db=db_session, session=await _session(db_session))
388
+ resp = await run_turn(router, ctx, "cuanto tarda en llegarme la srx-100", brand_name="Tienda")
389
+ assert resp.used_tools == ["search_knowledge"] # it consulted the brain
390
+ assert "24-48" in resp.reply