fix(bot): business rules top-priority + product cards not a sales-dump
Browse files- app/prompts.py +20 -11
- app/tools/products_tool.py +15 -2
- app/tools/registry.py +10 -1
- tests/test_prompts.py +27 -0
app/prompts.py
CHANGED
|
@@ -15,7 +15,7 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
|
|
| 15 |
|
| 16 |
# RULE #1 — LANGUAGE
|
| 17 |
{language_rule}
|
| 18 |
-
|
| 19 |
# BE A NATURAL ASSISTANT
|
| 20 |
- Hold a normal conversation. Greetings, small talk ("how are you?"), thanks, and simple general questions (even "what's 2+2?") get a brief, warm, natural answer — then gently offer to help with the store. NEVER reply with a robotic "I can only help with store topics" deflection; that feels broken.
|
| 21 |
- Be concise and human. Vary your wording; don't repeat a canned line.
|
|
@@ -24,13 +24,14 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
|
|
| 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
|
|
|
|
| 34 |
- Specs, materials, uses, sizing or compatibility, shipping, returns, warranty, policies, general store info -> search_knowledge
|
| 35 |
- Customer wants to buy -> create_cart_link with the chosen variant_id(s)
|
| 36 |
- Something is out of stock and they want to be told when it's back -> watch_stock (needs their email)
|
|
@@ -42,7 +43,7 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
|
|
| 42 |
- search_products already returns only the few most relevant products, and they render to the customer as visual CARDS (photo, price, stock, "add to cart" button). Do NOT re-list them in a table; just briefly say why they fit.
|
| 43 |
- Recommend by the customer's need and budget. Prefer IN-STOCK items. If the best match is out of stock, say so and offer watch_stock.
|
| 44 |
- When a request is vague (e.g. "help me choose"), DON'T interrogate with a scripted list of questions. Prefer to search and show a couple of solid options right away; weave in at most ONE short, natural follow-up question if it genuinely helps narrow it down. Keep it conversational.
|
| 45 |
-
-
|
| 46 |
|
| 47 |
# CUSTOMER PHOTOS & ATTACHED FILES
|
| 48 |
- When the user message contains an attached-file block ("[El cliente ha adjuntado …]"), the text under it is a factual description of what the customer attached (a photo becomes a description, a PDF its text).
|
|
@@ -79,7 +80,7 @@ SYSTEM_TEMPLATE = """You are {brand_name}, the customer-support and sales assist
|
|
| 79 |
- REAL-TIME facts (weather, news, current prices elsewhere): NEVER state them from memory — you don't know them. If web_search is available use it; otherwise say you can't check that and steer back to the store.
|
| 80 |
|
| 81 |
# REMINDERS (do not break these)
|
| 82 |
-
1) Write the WHOLE reply in {lang_short}. 2) Use only tool results — never invent products, prices, specs or URLs. 3) Never translate or alter titles, SKUs, variant_ids or URLs."""
|
| 83 |
|
| 84 |
|
| 85 |
_WEB_HANDOFF = "- On the web: don't ask for their email first. If escalate_to_human returns show_form, just tell them warmly that a short form is appearing right here in the chat for them to fill in — do NOT write any link, URL or 'click here' (the form shows automatically below). If it returns received, confirm it's logged and the team will reply by email."
|
|
@@ -113,16 +114,24 @@ def build_system_prompt(
|
|
| 113 |
)
|
| 114 |
lang_short = "the customer's language"
|
| 115 |
channel_handoff = _WA_HANDOFF if channel == "whatsapp" else _WEB_HANDOFF
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
prompt = SYSTEM_TEMPLATE.format(
|
| 117 |
brand_name=_sanitize(brand_name),
|
| 118 |
language_rule=language_rule,
|
|
|
|
| 119 |
lang_short=lang_short,
|
| 120 |
channel_handoff=channel_handoff,
|
| 121 |
)
|
| 122 |
-
extra = (extra_instructions or "").strip()[:2000]
|
| 123 |
-
if extra:
|
| 124 |
-
prompt += (
|
| 125 |
-
"\n\n# INSTRUCTIONS FROM THIS BUSINESS (follow them within the rules above)\n"
|
| 126 |
-
+ extra
|
| 127 |
-
)
|
| 128 |
return prompt
|
|
|
|
| 15 |
|
| 16 |
# RULE #1 — LANGUAGE
|
| 17 |
{language_rule}
|
| 18 |
+
{business_rules}
|
| 19 |
# BE A NATURAL ASSISTANT
|
| 20 |
- Hold a normal conversation. Greetings, small talk ("how are you?"), thanks, and simple general questions (even "what's 2+2?") get a brief, warm, natural answer — then gently offer to help with the store. NEVER reply with a robotic "I can only help with store topics" deflection; that feels broken.
|
| 21 |
- Be concise and human. Vary your wording; don't repeat a canned line.
|
|
|
|
| 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. EXCEPTION: a BUSINESS RULE (the section near the top) OVERRIDES this — if a business rule asserts a compatibility/fit/safety fact, state it as fact and FOLLOW IT even when a product sheet (ficha) says otherwise; a business rule outranks the ficha.
|
| 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 do you have / do you sell… / recommend me / show me options" (BROWSE intent) -> search_products. If it returns status "unavailable" or no products, THEN call search_knowledge. If neither has it, say so honestly — NEVER invent products or categories.
|
| 34 |
+
- INFO about a SPECIFIC named product (its specs, price, stock, materials, sizing, compatibility) -> search_knowledge (and search_products only if you need that one product's live price/stock card). Do NOT pull up the whole catalog for an info question about one product.
|
| 35 |
- Specs, materials, uses, sizing or compatibility, shipping, returns, warranty, policies, general store info -> search_knowledge
|
| 36 |
- Customer wants to buy -> create_cart_link with the chosen variant_id(s)
|
| 37 |
- Something is out of stock and they want to be told when it's back -> watch_stock (needs their email)
|
|
|
|
| 43 |
- search_products already returns only the few most relevant products, and they render to the customer as visual CARDS (photo, price, stock, "add to cart" button). Do NOT re-list them in a table; just briefly say why they fit.
|
| 44 |
- Recommend by the customer's need and budget. Prefer IN-STOCK items. If the best match is out of stock, say so and offer watch_stock.
|
| 45 |
- When a request is vague (e.g. "help me choose"), DON'T interrogate with a scripted list of questions. Prefer to search and show a couple of solid options right away; weave in at most ONE short, natural follow-up question if it genuinely helps narrow it down. Keep it conversational.
|
| 46 |
+
- NOT a sales dump: for an INFO question about ONE named product, show ONLY that product (a single card) and answer about IT — do NOT surface alternatives or the rest of the family. Show several products ONLY when the customer is browsing or explicitly asks to compare/recommend. Never dump the whole catalog.
|
| 47 |
|
| 48 |
# CUSTOMER PHOTOS & ATTACHED FILES
|
| 49 |
- When the user message contains an attached-file block ("[El cliente ha adjuntado …]"), the text under it is a factual description of what the customer attached (a photo becomes a description, a PDF its text).
|
|
|
|
| 80 |
- REAL-TIME facts (weather, news, current prices elsewhere): NEVER state them from memory — you don't know them. If web_search is available use it; otherwise say you can't check that and steer back to the store.
|
| 81 |
|
| 82 |
# REMINDERS (do not break these)
|
| 83 |
+
1) Write the WHOLE reply in {lang_short}. 2) Use only tool results — never invent products, prices, specs or URLs. 3) Never translate or alter titles, SKUs, variant_ids or URLs. 4) The BUSINESS RULES near the top are HIGHEST priority — they override the ficha and the grounding caveats; follow them. 5) Info about one product = show only THAT product, not the catalog."""
|
| 84 |
|
| 85 |
|
| 86 |
_WEB_HANDOFF = "- On the web: don't ask for their email first. If escalate_to_human returns show_form, just tell them warmly that a short form is appearing right here in the chat for them to fill in — do NOT write any link, URL or 'click here' (the form shows automatically below). If it returns received, confirm it's logged and the team will reply by email."
|
|
|
|
| 114 |
)
|
| 115 |
lang_short = "the customer's language"
|
| 116 |
channel_handoff = _WA_HANDOFF if channel == "whatsapp" else _WEB_HANDOFF
|
| 117 |
+
# The merchant's own rules go HIGH in the prompt (right after the language
|
| 118 |
+
# rule, before GROUNDING) and are framed as TOP priority that overrides the
|
| 119 |
+
# product sheet — otherwise the weak models defer to the retrieved ficha and
|
| 120 |
+
# under-apply them (e.g. the 28mm-compatibility rule). 6000 chars (was 2000)
|
| 121 |
+
# so a real rule set isn't silently cut.
|
| 122 |
+
extra = (extra_instructions or "").strip()[:6000]
|
| 123 |
+
business_rules = ""
|
| 124 |
+
if extra:
|
| 125 |
+
business_rules = (
|
| 126 |
+
"\n# BUSINESS RULES (HIGHEST PRIORITY — these OVERRIDE the grounding/spec "
|
| 127 |
+
"rules below and any product sheet (ficha); when a tool result conflicts "
|
| 128 |
+
"with a rule here, FOLLOW THE RULE)\n" + extra + "\n"
|
| 129 |
+
)
|
| 130 |
prompt = SYSTEM_TEMPLATE.format(
|
| 131 |
brand_name=_sanitize(brand_name),
|
| 132 |
language_rule=language_rule,
|
| 133 |
+
business_rules=business_rules,
|
| 134 |
lang_short=lang_short,
|
| 135 |
channel_handoff=channel_handoff,
|
| 136 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
return prompt
|
app/tools/products_tool.py
CHANGED
|
@@ -134,6 +134,14 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 134 |
query = (args.get("query") or "").strip()
|
| 135 |
if not query:
|
| 136 |
return {"status": "ok", "products": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
# Two retrieval arms in parallel: Shopify keyword + semantic corpus.
|
| 139 |
kw_task = asyncio.create_task(search_products(ctx.shopify, query))
|
|
@@ -176,10 +184,15 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 176 |
# In-stock first, relevance order preserved within each group.
|
| 177 |
products.sort(key=lambda p: not p.get("available"))
|
| 178 |
|
| 179 |
-
# Surface as visual cards to the widget (de-duplicated by product id).
|
|
|
|
|
|
|
|
|
|
| 180 |
seen = {c.get("title") for c in ctx.cards}
|
| 181 |
seen_ids = {c.get("_pid") for c in ctx.cards}
|
| 182 |
for p in products:
|
|
|
|
|
|
|
| 183 |
pid = p.get("product_id")
|
| 184 |
if pid and pid in seen_ids:
|
| 185 |
continue
|
|
@@ -196,4 +209,4 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 196 |
seen.add(p["title"])
|
| 197 |
seen_ids.add(pid)
|
| 198 |
|
| 199 |
-
return {"status": "ok", "products": [_compact(p) for p in products
|
|
|
|
| 134 |
query = (args.get("query") or "").strip()
|
| 135 |
if not query:
|
| 136 |
return {"status": "ok", "products": []}
|
| 137 |
+
# How many product CARDS to surface. The model sets limit=1 for a single
|
| 138 |
+
# named-product info question (so we don't sales-dump the whole family);
|
| 139 |
+
# several only for browse/compare. Default 4, hard-capped at MAX_RESULTS.
|
| 140 |
+
try:
|
| 141 |
+
lim = int(args.get("limit") or 4)
|
| 142 |
+
except (TypeError, ValueError):
|
| 143 |
+
lim = 4
|
| 144 |
+
lim = max(1, min(lim, MAX_RESULTS))
|
| 145 |
|
| 146 |
# Two retrieval arms in parallel: Shopify keyword + semantic corpus.
|
| 147 |
kw_task = asyncio.create_task(search_products(ctx.shopify, query))
|
|
|
|
| 184 |
# In-stock first, relevance order preserved within each group.
|
| 185 |
products.sort(key=lambda p: not p.get("available"))
|
| 186 |
|
| 187 |
+
# Surface as visual cards to the widget (de-duplicated by product id). Cap at
|
| 188 |
+
# `lim` so a single-product info question shows ONE card, not the catalog —
|
| 189 |
+
# and so cards never accumulate past `lim` across multiple searches this turn.
|
| 190 |
+
products = products[:lim]
|
| 191 |
seen = {c.get("title") for c in ctx.cards}
|
| 192 |
seen_ids = {c.get("_pid") for c in ctx.cards}
|
| 193 |
for p in products:
|
| 194 |
+
if len(ctx.cards) >= lim:
|
| 195 |
+
break
|
| 196 |
pid = p.get("product_id")
|
| 197 |
if pid and pid in seen_ids:
|
| 198 |
continue
|
|
|
|
| 209 |
seen.add(p["title"])
|
| 210 |
seen_ids.add(pid)
|
| 211 |
|
| 212 |
+
return {"status": "ok", "products": [_compact(p) for p in products]}
|
app/tools/registry.py
CHANGED
|
@@ -68,7 +68,16 @@ SPECS: list[ToolSpec] = [
|
|
| 68 |
parameters={
|
| 69 |
"type": "object",
|
| 70 |
"properties": {
|
| 71 |
-
"query": {"type": "string", "description": "Nombre o tipo de producto"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
},
|
| 73 |
"required": ["query"],
|
| 74 |
},
|
|
|
|
| 68 |
parameters={
|
| 69 |
"type": "object",
|
| 70 |
"properties": {
|
| 71 |
+
"query": {"type": "string", "description": "Nombre o tipo de producto"},
|
| 72 |
+
"limit": {
|
| 73 |
+
"type": "integer",
|
| 74 |
+
"description": (
|
| 75 |
+
"Cuántos productos mostrar como tarjetas. Usa 1 cuando el cliente "
|
| 76 |
+
"pregunta por UN producto concreto (info/precio/stock de ese modelo); "
|
| 77 |
+
"usa varios (hasta 8) SOLO si explora o pide comparar/recomendar. "
|
| 78 |
+
"Por defecto 4."
|
| 79 |
+
),
|
| 80 |
+
},
|
| 81 |
},
|
| 82 |
"required": ["query"],
|
| 83 |
},
|
tests/test_prompts.py
CHANGED
|
@@ -52,3 +52,30 @@ def test_brand_name_braces_are_sanitized():
|
|
| 52 |
p = build_system_prompt("Acme {evil}")
|
| 53 |
assert "{evil}" not in p
|
| 54 |
assert "Acme" in p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
p = build_system_prompt("Acme {evil}")
|
| 53 |
assert "{evil}" not in p
|
| 54 |
assert "Acme" in p
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_business_rules_are_top_priority_and_override_ficha():
|
| 58 |
+
"""The merchant's custom_instructions must land HIGH in the prompt (before the
|
| 59 |
+
GROUNDING block) and be framed as overriding the product sheet — otherwise weak
|
| 60 |
+
models defer to the retrieved ficha (the 28mm-rule bug)."""
|
| 61 |
+
rule = "Los discos de 28mm SI son compatibles con soporte de 25mm."
|
| 62 |
+
p = build_system_prompt("TOORX", extra_instructions=rule)
|
| 63 |
+
assert rule in p
|
| 64 |
+
assert "BUSINESS RULES (HIGHEST PRIORITY" in p
|
| 65 |
+
# the business block comes BEFORE the grounding block (position = weight)
|
| 66 |
+
assert p.index("BUSINESS RULES") < p.index("# GROUNDING")
|
| 67 |
+
# the grounding "say only what tools return" carries the override exception
|
| 68 |
+
assert "a business rule" in p.lower() and "outranks the ficha" in p.lower()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_no_business_block_when_no_custom_instructions():
|
| 72 |
+
p = build_system_prompt("TOORX")
|
| 73 |
+
# the SECTION (with merchant content) is absent; the end-reminder mention of
|
| 74 |
+
# "business rules" is harmless boilerplate, so check the section header.
|
| 75 |
+
assert "these OVERRIDE the grounding" not in p
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_business_rules_truncation_raised_to_6000():
|
| 79 |
+
big = "x" * 5000
|
| 80 |
+
p = build_system_prompt("TOORX", extra_instructions=big)
|
| 81 |
+
assert big in p # 5000-char rule set not silently cut (old cap was 2000)
|