victor34593993 commited on
Commit
1f85e29
·
verified ·
1 Parent(s): 8ff26a0

safety: confirm gate for write actions

Browse files
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 (reorder/cancel_order/change_shipping_address/start_return): solo sobre un pedido YA verificado con lookup_order; CONFIRMA con el cliente antes de ejecutar. País en código ISO (ES, PT). 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
  """
 
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