safety: confirm gate for write actions
Browse files- app/confirm.py +53 -0
- app/orchestrator.py +1 -0
- app/prompts.py +1 -1
- app/tools/actions_tool.py +24 -0
- app/tools/registry.py +1 -0
- tests/test_actions.py +32 -1
- tests/test_confirm.py +48 -0
app/confirm.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Detect an explicit affirmative confirmation before irreversible order actions.
|
| 2 |
+
|
| 3 |
+
Deliberately conservative: a false negative just makes the bot ask again (safe),
|
| 4 |
+
while a false positive could fire a non-reversible action (cancel/return/address)
|
| 5 |
+
the customer never approved. So we only accept clear yes/confirm words, and we
|
| 6 |
+
tokenize on word boundaries — "incorrecto" must NEVER match "correcto".
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
import unicodedata
|
| 13 |
+
|
| 14 |
+
# Single-word affirmatives (accent-stripped, lowercase), matched as whole tokens.
|
| 15 |
+
_AFFIRM_WORDS = {
|
| 16 |
+
# español
|
| 17 |
+
"si", "sip", "sii", "siii", "siiii", "claro", "vale", "ok", "oka", "okay",
|
| 18 |
+
"okey", "correcto", "confirmo", "confirmado", "confirmar", "adelante",
|
| 19 |
+
"hazlo", "hazla", "procede", "proceder", "dale", "perfecto", "afirmativo",
|
| 20 |
+
"exacto", "venga",
|
| 21 |
+
# português
|
| 22 |
+
"sim", "certo", "isso", "avanca", "avancar", "podes", "pode",
|
| 23 |
+
# english
|
| 24 |
+
"yes", "yeah", "yep", "yup", "confirm", "confirmed", "sure", "proceed",
|
| 25 |
+
"correct",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# Multi-word affirmatives, matched as substrings on the normalized text.
|
| 29 |
+
_AFFIRM_PHRASES = (
|
| 30 |
+
"de acuerdo", "eso es", "esta bien", "estoy de acuerdo", "go ahead",
|
| 31 |
+
"do it", "hazlo ya", "por favor hazlo", "que si", "adelante con",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _normalize(text: str) -> str:
|
| 36 |
+
text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode()
|
| 37 |
+
return text.lower().strip()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def is_affirmative(text: str) -> bool:
|
| 41 |
+
"""True only when the message is a clear yes/confirm.
|
| 42 |
+
|
| 43 |
+
Examples:
|
| 44 |
+
"sí" / "vale, hazlo" / "confirmo" -> True
|
| 45 |
+
"incorrecto" / "no" / "quiero devolver" / "" -> False
|
| 46 |
+
"""
|
| 47 |
+
norm = _normalize(text)
|
| 48 |
+
if not norm:
|
| 49 |
+
return False
|
| 50 |
+
if any(phrase in norm for phrase in _AFFIRM_PHRASES):
|
| 51 |
+
return True
|
| 52 |
+
tokens = set(re.findall(r"[a-z]+", norm))
|
| 53 |
+
return bool(tokens & _AFFIRM_WORDS)
|
app/orchestrator.py
CHANGED
|
@@ -49,6 +49,7 @@ async def run_turn(
|
|
| 49 |
brand_name: str = "Asistente",
|
| 50 |
) -> ChatResponse:
|
| 51 |
prior = await _load_history(ctx)
|
|
|
|
| 52 |
|
| 53 |
from app.lang import detect_language
|
| 54 |
|
|
|
|
| 49 |
brand_name: str = "Asistente",
|
| 50 |
) -> ChatResponse:
|
| 51 |
prior = await _load_history(ctx)
|
| 52 |
+
ctx.user_message = user_message # for the deterministic write-action confirm gate
|
| 53 |
|
| 54 |
from app.lang import detect_language
|
| 55 |
|
app/prompts.py
CHANGED
|
@@ -24,7 +24,7 @@ VENDER: recomienda por necesidad/presupuesto buscando con search_products y expl
|
|
| 24 |
|
| 25 |
PEDIDOS (privacidad): para localizar un pedido pide email + UN segundo dato (nº de pedido, código postal o nombre). Pásalos a lookup_order. Revela SOLO estado de envío y seguimiento (transportista, nº, enlace, fecha); NUNCA dirección completa ni pago. Si no localiza, dilo de forma genérica (sin decir qué campo falla).
|
| 26 |
|
| 27 |
-
ACCIONES sobre el pedido (
|
| 28 |
|
| 29 |
ESCALADO: si no puedes resolver, o si el cliente se frustra/enfada, discúlpate y ofrece pasar con una persona (pide email, usa escalate_to_human).
|
| 30 |
"""
|
|
|
|
| 24 |
|
| 25 |
PEDIDOS (privacidad): para localizar un pedido pide email + UN segundo dato (nº de pedido, código postal o nombre). Pásalos a lookup_order. Revela SOLO estado de envío y seguimiento (transportista, nº, enlace, fecha); NUNCA dirección completa ni pago. Si no localiza, dilo de forma genérica (sin decir qué campo falla).
|
| 26 |
|
| 27 |
+
ACCIONES sobre el pedido (cancel_order/change_shipping_address/start_return): solo sobre un pedido YA verificado con lookup_order. CONFIRMACIÓN OBLIGATORIA: antes de ejecutar, pregunta y ESPERA un "sí" CLARO del cliente. Un motivo de devolución, un "no", un "incorrecto" o cualquier respuesta ambigua NO es confirmación: vuelve a preguntar, no ejecutes. Nunca metas el motivo como si fuera el "sí". País en código ISO (ES, PT). Si una herramienta devuelve `needs_confirmation`, pide el "sí" y no la repitas hasta tenerlo. Si devuelve `not_allowed` o `error`, discúlpate y deriva con escalate_to_human. Los REEMBOLSOS no los haces tú: inicia la devolución y deriva al equipo.
|
| 28 |
|
| 29 |
ESCALADO: si no puedes resolver, o si el cliente se frustra/enfada, discúlpate y ofrece pasar con una persona (pide email, usa escalate_to_human).
|
| 30 |
"""
|
app/tools/actions_tool.py
CHANGED
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
| 8 |
from typing import TYPE_CHECKING, Any
|
| 9 |
|
| 10 |
from app import analytics
|
|
|
|
| 11 |
from app.shopify import actions
|
| 12 |
|
| 13 |
if TYPE_CHECKING:
|
|
@@ -25,6 +26,23 @@ def _need_verify() -> dict[str, Any]:
|
|
| 25 |
return {"status": "need_verification", "hint": "verifica el pedido con lookup_order primero"}
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
async def reorder(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
| 29 |
if ctx.shopify is None or not ctx.shop:
|
| 30 |
return {"status": "unavailable"}
|
|
@@ -49,6 +67,8 @@ async def cancel_order(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]
|
|
| 49 |
gid = _verified_order(ctx)
|
| 50 |
if not gid:
|
| 51 |
return _need_verify()
|
|
|
|
|
|
|
| 52 |
result = await actions.cancel_order(ctx.shopify, gid, reason=args.get("reason"))
|
| 53 |
if result.get("ok"):
|
| 54 |
analytics.record(ctx.db, ctx.tenant_id, "order_cancelled", session_id=ctx.session.id)
|
|
@@ -64,6 +84,8 @@ async def change_address(args: dict[str, Any], ctx: ToolContext) -> dict[str, An
|
|
| 64 |
gid = _verified_order(ctx)
|
| 65 |
if not gid:
|
| 66 |
return _need_verify()
|
|
|
|
|
|
|
| 67 |
address = {
|
| 68 |
k: v
|
| 69 |
for k, v in {
|
|
@@ -96,6 +118,8 @@ async def start_return(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]
|
|
| 96 |
gid = _verified_order(ctx)
|
| 97 |
if not gid:
|
| 98 |
return _need_verify()
|
|
|
|
|
|
|
| 99 |
result = await actions.start_return(ctx.shopify, gid, reason=args.get("reason"))
|
| 100 |
if result.get("ok"):
|
| 101 |
analytics.record(ctx.db, ctx.tenant_id, "return_started", session_id=ctx.session.id)
|
|
|
|
| 8 |
from typing import TYPE_CHECKING, Any
|
| 9 |
|
| 10 |
from app import analytics
|
| 11 |
+
from app.confirm import is_affirmative
|
| 12 |
from app.shopify import actions
|
| 13 |
|
| 14 |
if TYPE_CHECKING:
|
|
|
|
| 26 |
return {"status": "need_verification", "hint": "verifica el pedido con lookup_order primero"}
|
| 27 |
|
| 28 |
|
| 29 |
+
def _need_confirm(ctx: ToolContext) -> dict[str, Any] | None:
|
| 30 |
+
"""Deterministic safety gate for irreversible actions: only proceed if the
|
| 31 |
+
customer's latest message is a clear affirmative. Returns a block result
|
| 32 |
+
otherwise so the model has to ask for an explicit 'sí' first. This does NOT
|
| 33 |
+
depend on the prompt (which can drift across providers)."""
|
| 34 |
+
if is_affirmative(ctx.user_message):
|
| 35 |
+
return None
|
| 36 |
+
return {
|
| 37 |
+
"status": "needs_confirmation",
|
| 38 |
+
"hint": (
|
| 39 |
+
"El cliente AÚN no ha confirmado. Pídele que confirme de forma "
|
| 40 |
+
"EXPLÍCITA con un 'sí' antes de ejecutar. Un motivo o un 'no' NO "
|
| 41 |
+
"es confirmación. No vuelvas a llamar a esta herramienta hasta el 'sí'."
|
| 42 |
+
),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
async def reorder(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
| 47 |
if ctx.shopify is None or not ctx.shop:
|
| 48 |
return {"status": "unavailable"}
|
|
|
|
| 67 |
gid = _verified_order(ctx)
|
| 68 |
if not gid:
|
| 69 |
return _need_verify()
|
| 70 |
+
if (block := _need_confirm(ctx)) is not None:
|
| 71 |
+
return block
|
| 72 |
result = await actions.cancel_order(ctx.shopify, gid, reason=args.get("reason"))
|
| 73 |
if result.get("ok"):
|
| 74 |
analytics.record(ctx.db, ctx.tenant_id, "order_cancelled", session_id=ctx.session.id)
|
|
|
|
| 84 |
gid = _verified_order(ctx)
|
| 85 |
if not gid:
|
| 86 |
return _need_verify()
|
| 87 |
+
if (block := _need_confirm(ctx)) is not None:
|
| 88 |
+
return block
|
| 89 |
address = {
|
| 90 |
k: v
|
| 91 |
for k, v in {
|
|
|
|
| 118 |
gid = _verified_order(ctx)
|
| 119 |
if not gid:
|
| 120 |
return _need_verify()
|
| 121 |
+
if (block := _need_confirm(ctx)) is not None:
|
| 122 |
+
return block
|
| 123 |
result = await actions.start_return(ctx.shopify, gid, reason=args.get("reason"))
|
| 124 |
if result.get("ok"):
|
| 125 |
analytics.record(ctx.db, ctx.tenant_id, "return_started", session_id=ctx.session.id)
|
app/tools/registry.py
CHANGED
|
@@ -36,6 +36,7 @@ class ToolContext:
|
|
| 36 |
escalation_sender: Callable[[str, str, str], Awaitable[bool]] | None = None
|
| 37 |
now: datetime = field(default_factory=lambda: datetime.now(UTC))
|
| 38 |
cards: list[dict[str, Any]] = field(default_factory=list) # product cards for the widget
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
SPECS: list[ToolSpec] = [
|
|
|
|
| 36 |
escalation_sender: Callable[[str, str, str], Awaitable[bool]] | None = None
|
| 37 |
now: datetime = field(default_factory=lambda: datetime.now(UTC))
|
| 38 |
cards: list[dict[str, Any]] = field(default_factory=list) # product cards for the widget
|
| 39 |
+
user_message: str = "" # message that triggered this turn (for the confirm gate)
|
| 40 |
|
| 41 |
|
| 42 |
SPECS: list[ToolSpec] = [
|
tests/test_actions.py
CHANGED
|
@@ -39,7 +39,7 @@ class FakeShopify:
|
|
| 39 |
return {}
|
| 40 |
|
| 41 |
|
| 42 |
-
async def _verified_ctx(db, **kw):
|
| 43 |
t = Tenant(slug="t", **kw)
|
| 44 |
db.add(t)
|
| 45 |
await db.flush()
|
|
@@ -51,6 +51,7 @@ async def _verified_ctx(db, **kw):
|
|
| 51 |
shopify=FakeShopify(), allow_cancel=kw.get("allow_cancel", False),
|
| 52 |
allow_address_change=kw.get("allow_address_change", False),
|
| 53 |
allow_returns=kw.get("allow_returns", False),
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
|
@@ -97,3 +98,33 @@ async def test_start_return_succeeds_when_allowed(db_session):
|
|
| 97 |
ctx = await _verified_ctx(db_session, allow_returns=True)
|
| 98 |
out = await actions_tool.start_return({"reason": "llegó defectuoso"}, ctx)
|
| 99 |
assert out["status"] == "return_requested"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return {}
|
| 40 |
|
| 41 |
|
| 42 |
+
async def _verified_ctx(db, *, user_message="sí, confirmo", **kw):
|
| 43 |
t = Tenant(slug="t", **kw)
|
| 44 |
db.add(t)
|
| 45 |
await db.flush()
|
|
|
|
| 51 |
shopify=FakeShopify(), allow_cancel=kw.get("allow_cancel", False),
|
| 52 |
allow_address_change=kw.get("allow_address_change", False),
|
| 53 |
allow_returns=kw.get("allow_returns", False),
|
| 54 |
+
user_message=user_message, # customer's latest message (the confirm gate reads it)
|
| 55 |
)
|
| 56 |
|
| 57 |
|
|
|
|
| 98 |
ctx = await _verified_ctx(db_session, allow_returns=True)
|
| 99 |
out = await actions_tool.start_return({"reason": "llegó defectuoso"}, ctx)
|
| 100 |
assert out["status"] == "return_requested"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# --- confirmation gate: irreversible actions need an explicit "sí" -----------
|
| 104 |
+
|
| 105 |
+
async def test_return_blocked_without_confirmation(db_session):
|
| 106 |
+
# The real incident: user typed "INCORRECTO" (meaning "no"); it must NOT fire.
|
| 107 |
+
ctx = await _verified_ctx(db_session, allow_returns=True, user_message="INCORRECTO")
|
| 108 |
+
out = await actions_tool.start_return({"reason": "INCORRECTO"}, ctx)
|
| 109 |
+
assert out["status"] == "needs_confirmation"
|
| 110 |
+
assert not ctx.shopify.calls # never reached Shopify
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
async def test_cancel_blocked_without_confirmation(db_session):
|
| 114 |
+
ctx = await _verified_ctx(db_session, allow_cancel=True, user_message="quiero cancelar")
|
| 115 |
+
out = await actions_tool.cancel_order({}, ctx)
|
| 116 |
+
assert out["status"] == "needs_confirmation"
|
| 117 |
+
assert not ctx.shopify.calls
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
async def test_change_address_blocked_without_confirmation(db_session):
|
| 121 |
+
ctx = await _verified_ctx(db_session, allow_address_change=True, user_message="no")
|
| 122 |
+
out = await actions_tool.change_address({"address1": "C/1", "city": "Madrid", "zip": "28013"}, ctx)
|
| 123 |
+
assert out["status"] == "needs_confirmation"
|
| 124 |
+
assert not ctx.shopify.calls
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def test_cancel_proceeds_after_yes(db_session):
|
| 128 |
+
ctx = await _verified_ctx(db_session, allow_cancel=True, user_message="sí, cancélalo")
|
| 129 |
+
out = await actions_tool.cancel_order({}, ctx)
|
| 130 |
+
assert out["status"] == "cancelled"
|
tests/test_confirm.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from app.confirm import is_affirmative
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.mark.parametrize(
|
| 9 |
+
"text",
|
| 10 |
+
[
|
| 11 |
+
"sí",
|
| 12 |
+
"si",
|
| 13 |
+
"Sí, confirmo",
|
| 14 |
+
"vale, hazlo",
|
| 15 |
+
"ok",
|
| 16 |
+
"okey",
|
| 17 |
+
"dale",
|
| 18 |
+
"adelante",
|
| 19 |
+
"perfecto, procede",
|
| 20 |
+
"correcto",
|
| 21 |
+
"de acuerdo",
|
| 22 |
+
"sim", # portugués
|
| 23 |
+
"yes", # inglés
|
| 24 |
+
"sí, cancélalo por favor",
|
| 25 |
+
],
|
| 26 |
+
)
|
| 27 |
+
def test_affirmatives(text):
|
| 28 |
+
assert is_affirmative(text) is True
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.mark.parametrize(
|
| 32 |
+
"text",
|
| 33 |
+
[
|
| 34 |
+
"INCORRECTO", # the real incident — must NOT count as yes
|
| 35 |
+
"incorrecto",
|
| 36 |
+
"no",
|
| 37 |
+
"no, mejor no",
|
| 38 |
+
"para",
|
| 39 |
+
"quiero devolver",
|
| 40 |
+
"el producto llegó roto",
|
| 41 |
+
"talla incorrecta",
|
| 42 |
+
"",
|
| 43 |
+
" ",
|
| 44 |
+
"mmm no sé",
|
| 45 |
+
],
|
| 46 |
+
)
|
| 47 |
+
def test_non_affirmatives(text):
|
| 48 |
+
assert is_affirmative(text) is False
|