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 +15 -5
- app/orchestrator.py +47 -30
- app/portal_ui/index.html +37 -24
- app/prompts.py +9 -7
- tests/test_lang.py +29 -0
- tests/test_orchestrator.py +96 -4
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
|
| 150 |
messages.append({"role": "system", "content": (
|
| 151 |
-
"Para
|
| 152 |
-
"
|
| 153 |
-
"
|
| 154 |
-
"
|
| 155 |
-
"
|
| 156 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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>
|
| 270 |
-
<p class="muted">Te lo enseñamos
|
| 271 |
-
<div id="
|
| 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 === "
|
|
|
|
| 314 |
}
|
| 315 |
|
| 316 |
// ── WhatsApp 1-click connect (Embedded Signup, self-service) ──────────
|
|
@@ -546,24 +552,28 @@
|
|
| 546 |
box.appendChild(d);
|
| 547 |
});
|
| 548 |
}
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
try {
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 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 |
-
"
|
| 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
|
| 26 |
-
-
|
| 27 |
-
-
|
| 28 |
-
-
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 171 |
-
assert not
|
| 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
|