victor34593993 commited on
Commit
6edcbff
·
verified ·
1 Parent(s): db8a51a

multi-key failover + grounding + isolation

Browse files
app/config.py CHANGED
@@ -15,8 +15,9 @@ class Settings(BaseSettings):
15
  database_url: str = "sqlite+aiosqlite://"
16
  testing: bool = False
17
 
18
- # LLM: Groq (primary)
19
  groq_api_key: str = ""
 
20
  groq_base_url: str = "https://api.groq.com/openai/v1"
21
  # gpt-oss-120b: stable on Groq + reliable tool calling (Llama 3.3 70B
22
  # intermittently returns tool_use_failed on Groq).
@@ -67,7 +68,7 @@ class Settings(BaseSettings):
67
  smtp_password: str = ""
68
  smtp_from: str = ""
69
 
70
- @field_validator("llm_provider_order", "allowed_origins", mode="before")
71
  @classmethod
72
  def _split_csv(cls, v: object) -> object:
73
  if isinstance(v, str):
 
15
  database_url: str = "sqlite+aiosqlite://"
16
  testing: bool = False
17
 
18
+ # LLM: Groq (primary). Multiple keys for redundancy: GROQ_API_KEYS=k1,k2,k3
19
  groq_api_key: str = ""
20
+ groq_api_keys: Annotated[list[str], NoDecode] = []
21
  groq_base_url: str = "https://api.groq.com/openai/v1"
22
  # gpt-oss-120b: stable on Groq + reliable tool calling (Llama 3.3 70B
23
  # intermittently returns tool_use_failed on Groq).
 
68
  smtp_password: str = ""
69
  smtp_from: str = ""
70
 
71
+ @field_validator("llm_provider_order", "allowed_origins", "groq_api_keys", mode="before")
72
  @classmethod
73
  def _split_csv(cls, v: object) -> object:
74
  if isinstance(v, str):
app/llm/base.py CHANGED
@@ -50,6 +50,8 @@ class ChatResult:
50
  content: str | None
51
  tool_calls: list[ToolCall] = field(default_factory=list)
52
  finish_reason: str | None = None
 
 
53
 
54
 
55
  class LLMProvider(Protocol):
@@ -81,10 +83,17 @@ def parse_openai_choice(data: dict[str, Any]) -> ChatResult:
81
  calls.append(
82
  ToolCall(id=c.get("id", ""), name=fn.get("name", ""), arguments=args or {})
83
  )
 
 
 
 
 
 
84
  return ChatResult(
85
  content=msg.get("content"),
86
  tool_calls=calls,
87
  finish_reason=choice.get("finish_reason"),
 
88
  )
89
 
90
 
 
50
  content: str | None
51
  tool_calls: list[ToolCall] = field(default_factory=list)
52
  finish_reason: str | None = None
53
+ usage: dict[str, int] = field(default_factory=dict) # prompt/completion/total tokens
54
+ provider: str | None = None
55
 
56
 
57
  class LLMProvider(Protocol):
 
83
  calls.append(
84
  ToolCall(id=c.get("id", ""), name=fn.get("name", ""), arguments=args or {})
85
  )
86
+ raw_usage = data.get("usage") or {}
87
+ usage = {
88
+ "prompt_tokens": int(raw_usage.get("prompt_tokens") or 0),
89
+ "completion_tokens": int(raw_usage.get("completion_tokens") or 0),
90
+ "total_tokens": int(raw_usage.get("total_tokens") or 0),
91
+ }
92
  return ChatResult(
93
  content=msg.get("content"),
94
  tool_calls=calls,
95
  finish_reason=choice.get("finish_reason"),
96
+ usage=usage,
97
  )
98
 
99
 
app/llm/router.py CHANGED
@@ -45,12 +45,14 @@ class LLMRouter:
45
  if not model:
46
  continue
47
  try:
48
- return await provider.chat(
49
  messages=messages,
50
  tools=tools,
51
  model=model,
52
  temperature=self.temperature if temperature is None else temperature,
53
  )
 
 
54
  except ProviderError as exc:
55
  log.warning("LLM provider %s failed (%s); failing over", name, exc)
56
  last_exc = exc
@@ -59,28 +61,37 @@ class LLMRouter:
59
 
60
 
61
  def build_router_from_settings(settings: Any) -> LLMRouter:
62
- """Construct an LLMRouter from Settings, including only configured providers."""
 
 
 
 
63
  from app.llm.cloudflare import CloudflareProvider
64
  from app.llm.groq import GroqProvider
65
 
66
  providers: dict[str, LLMProvider] = {}
67
  models: dict[str, dict[str, str]] = {}
 
68
 
69
- if settings.groq_api_key:
70
- providers["groq"] = GroqProvider(
71
- api_key=settings.groq_api_key, base_url=settings.groq_base_url
72
- )
73
- models["groq"] = {"small": settings.model_small, "large": settings.model_large}
 
 
 
 
 
 
 
74
 
75
  if settings.cloudflare_account_id and settings.cloudflare_api_token:
76
  providers["cloudflare"] = CloudflareProvider(
77
  account_id=settings.cloudflare_account_id,
78
  api_token=settings.cloudflare_api_token,
79
  )
80
- models["cloudflare"] = {
81
- "small": settings.cf_model_small,
82
- "large": settings.cf_model_large,
83
- }
84
 
85
- order = [p for p in settings.llm_provider_order if p in providers]
86
  return LLMRouter(providers=providers, order=order, models=models)
 
45
  if not model:
46
  continue
47
  try:
48
+ result = await provider.chat(
49
  messages=messages,
50
  tools=tools,
51
  model=model,
52
  temperature=self.temperature if temperature is None else temperature,
53
  )
54
+ result.provider = name
55
+ return result
56
  except ProviderError as exc:
57
  log.warning("LLM provider %s failed (%s); failing over", name, exc)
58
  last_exc = exc
 
61
 
62
 
63
  def build_router_from_settings(settings: Any) -> LLMRouter:
64
+ """Build an LLMRouter with one entry PER Groq key (redundancy) + Cloudflare.
65
+
66
+ The router tries each in order and fails over on any error, so as long as a
67
+ single key/provider is healthy the bot answers. Add keys via GROQ_API_KEYS.
68
+ """
69
  from app.llm.cloudflare import CloudflareProvider
70
  from app.llm.groq import GroqProvider
71
 
72
  providers: dict[str, LLMProvider] = {}
73
  models: dict[str, dict[str, str]] = {}
74
+ order: list[str] = []
75
 
76
+ # Collect Groq keys (single + list), de-duplicated, preserving order.
77
+ groq_keys: list[str] = []
78
+ for key in [settings.groq_api_key, *settings.groq_api_keys]:
79
+ key = (key or "").strip()
80
+ if key and key not in groq_keys:
81
+ groq_keys.append(key)
82
+
83
+ for i, key in enumerate(groq_keys):
84
+ name = f"groq{i}"
85
+ providers[name] = GroqProvider(api_key=key, base_url=settings.groq_base_url)
86
+ models[name] = {"small": settings.model_small, "large": settings.model_large}
87
+ order.append(name)
88
 
89
  if settings.cloudflare_account_id and settings.cloudflare_api_token:
90
  providers["cloudflare"] = CloudflareProvider(
91
  account_id=settings.cloudflare_account_id,
92
  api_token=settings.cloudflare_api_token,
93
  )
94
+ models["cloudflare"] = {"small": settings.cf_model_small, "large": settings.cf_model_large}
95
+ order.append("cloudflare")
 
 
96
 
 
97
  return LLMRouter(providers=providers, order=order, models=models)
app/orchestrator.py CHANGED
@@ -3,6 +3,7 @@
3
  from __future__ import annotations
4
 
5
  import json
 
6
  from typing import Any
7
 
8
  from sqlalchemy import select
@@ -14,6 +15,8 @@ from app.schemas import ChatResponse
14
  from app.tools import registry
15
  from app.tools.registry import ToolContext
16
 
 
 
17
  HISTORY_LIMIT = 10
18
  MAX_TOOL_ITERS = 4
19
  FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
@@ -60,19 +63,26 @@ async def run_turn(
60
 
61
  used_tools: list[str] = []
62
  reply = FALLBACK_REPLY
 
 
63
 
64
  async def _chat(tools):
65
  """Resilient call: if tool-calling fails (e.g. Groq tool_use_failed) or a
66
  provider errors, degrade to a no-tools answer so we never 500."""
 
 
67
  try:
68
- return await router.chat(messages=messages, tools=tools, tier="large")
69
  except ProviderError:
70
- if not tools:
71
- return None
72
- try:
73
- return await router.chat(messages=messages, tools=[], tier="large")
74
- except ProviderError:
75
- return None
 
 
 
76
 
77
  for _ in range(MAX_TOOL_ITERS):
78
  result = await _chat(registry.specs())
@@ -108,6 +118,11 @@ async def run_turn(
108
  ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
109
  await ctx.db.flush()
110
 
 
 
 
 
 
111
  return ChatResponse(
112
  session_id=ctx.session.id, reply=reply, lang=ctx.session.lang, used_tools=used_tools
113
  )
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import logging
7
  from typing import Any
8
 
9
  from sqlalchemy import select
 
15
  from app.tools import registry
16
  from app.tools.registry import ToolContext
17
 
18
+ log = logging.getLogger(__name__)
19
+
20
  HISTORY_LIMIT = 10
21
  MAX_TOOL_ITERS = 4
22
  FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
 
63
 
64
  used_tools: list[str] = []
65
  reply = FALLBACK_REPLY
66
+ total_tokens = 0
67
+ provider_used: str | None = None
68
 
69
  async def _chat(tools):
70
  """Resilient call: if tool-calling fails (e.g. Groq tool_use_failed) or a
71
  provider errors, degrade to a no-tools answer so we never 500."""
72
+ nonlocal total_tokens, provider_used
73
+ result = None
74
  try:
75
+ result = await router.chat(messages=messages, tools=tools, tier="large")
76
  except ProviderError:
77
+ if tools:
78
+ try:
79
+ result = await router.chat(messages=messages, tools=[], tier="large")
80
+ except ProviderError:
81
+ result = None
82
+ if result is not None:
83
+ total_tokens += result.usage.get("total_tokens", 0)
84
+ provider_used = result.provider or provider_used
85
+ return result
86
 
87
  for _ in range(MAX_TOOL_ITERS):
88
  result = await _chat(registry.specs())
 
118
  ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
119
  await ctx.db.flush()
120
 
121
+ log.info(
122
+ "chat turn tenant=%s provider=%s tools=%s tokens=%s",
123
+ ctx.tenant_id, provider_used, used_tools, total_tokens,
124
+ )
125
+
126
  return ChatResponse(
127
  session_id=ctx.session.id, reply=reply, lang=ctx.session.lang, used_tools=used_tools
128
  )
app/prompts.py CHANGED
@@ -3,36 +3,47 @@
3
 
4
  from __future__ import annotations
5
 
6
- SYSTEM_TEMPLATE = """Eres {brand_name}, el asistente virtual de atención al cliente de la tienda.
7
 
8
  ## Idioma
9
- - Responde SIEMPRE en el mismo idioma en el que te escribe el cliente (detéctalo del último mensaje).
10
- - Si el cliente escribe en portugués, usa portugués de Portugal (pt-PT).
11
 
12
- ## Herramientas (úsalas SIEMPRE para datos reales)
13
- - `search_knowledge`: información de la tienda y productos (fichas, PDFs, web). Úsala para dudas de información, especificaciones, políticas, envíos, etc.
14
- - `search_products`: catálogo en vivo (precios y disponibilidad).
 
15
  - `lookup_order`: estado y seguimiento de un pedido.
16
-
17
- ## REGLA DE ORO — no inventes NADA
18
- - Responde ÚNICAMENTE con información que aparezca en los resultados de las herramientas.
19
- - NO inventes ni modifiques especificaciones, medidas, temperaturas, materiales, precios, plazos ni nombres de productos. Copia los datos TAL CUAL aparecen (p.ej. si pone "-10 ºC a +60 ºC", no digas otra cosa).
20
- - Si un dato concreto NO está en los resultados, dilo con honestidad ("no tengo ese dato a mano") y ofrece consultarlo o pasar la consulta al equipo. NUNCA rellenes huecos con conocimiento general.
21
- - Si no encuentras nada relevante, no te inventes un catálogo: pide más detalle o deriva al equipo.
22
-
23
- ## Pedidos (privacidad + detección flexible)
24
- - Para localizar un pedido necesitas el **email** del cliente y **UN segundo dato** que lo identifique. Acepta cualquiera de estos como segundo dato: **número de pedido**, **código postal** del envío, o **nombre y apellidos**.
25
- - Pide el email y ese segundo dato de forma natural; NO exijas el número de pedido si el cliente te da el código postal o el nombre. Pasa a `lookup_order` lo que tengas (email + lo que haya dado).
 
 
 
 
 
 
 
 
 
 
 
 
26
  - Revela ÚNICAMENTE el estado del envío y el seguimiento (transportista, número, enlace, fecha estimada). NUNCA reveles la dirección completa ni datos de pago.
27
- - Si la verificación falla, di de forma genérica que no has podido localizar el pedido con esos datos y pide que los revise. NO digas qué campo es incorrecto.
28
- - Tras varios intentos fallidos queda bloqueado temporalmente; pide que lo intente más tarde.
29
 
30
  ## Honestidad y escalado
31
- - Si no sabes algo o no puedes resolverlo, dilo con honestidad y ofrece pasar la consulta al equipo humano: pide el email del cliente y usa `escalate_to_human`.
32
 
33
  ## Tono y formato
34
- - Cercano, profesional y útil. Respuestas claras y al grano.
35
- - El chat es estrecho: NO uses tablas. Usa frases breves o listas con guiones "-". Resalta lo clave con **negrita**.
36
  """
37
 
38
 
 
3
 
4
  from __future__ import annotations
5
 
6
+ SYSTEM_TEMPLATE = """Eres {brand_name}, asistente virtual de atención al cliente de esta tienda online. Tu trabajo es ayudar a los clientes de forma precisa, cercana y útil, SIEMPRE basándote en datos reales de la tienda.
7
 
8
  ## Idioma
9
+ - Responde SIEMPRE en el mismo idioma del último mensaje del cliente (español, portugués de Portugal pt-PT, inglés, etc.). Si cambia de idioma, cámbialo tú también.
 
10
 
11
+ ## Cómo trabajas (lee bien y usa las herramientas SIEMPRE)
12
+ Tienes estas herramientas y DEBES usarlas para cualquier dato; nunca respondas de memoria:
13
+ - `search_knowledge`: base de conocimiento de la tienda (catálogo, fichas técnicas, PDFs, páginas web, políticas). Úsala para CUALQUIER duda de información: qué venden, especificaciones, materiales, medidas, usos, compatibilidad, instrucciones, garantía, envíos, devoluciones, pagos, datos de la empresa, recomendaciones, comparativas, etc.
14
+ - `search_products`: catálogo en vivo (precio y disponibilidad reales).
15
  - `lookup_order`: estado y seguimiento de un pedido.
16
+ - `escalate_to_human`: pasar la consulta al equipo humano.
17
+
18
+ Reglas de uso:
19
+ 1. Ante CUALQUIER pregunta de información o producto, llama PRIMERO a `search_knowledge` (y a `search_products` si preguntan precio o disponibilidad). Si la pregunta toca varios temas, haz varias búsquedas con términos distintos hasta tener la información.
20
+ 2. LEE con atención TODO lo que devuelven las herramientas antes de responder y usa todos los detalles relevantes.
21
+ 3. Reformula la búsqueda si la primera no trae lo que necesitas (prueba sinónimos o palabras clave del producto).
22
+
23
+ ## REGLA DE ORO exactitud, cero invención
24
+ - Responde ÚNICAMENTE con lo que aparezca en los resultados de las herramientas. Copia los datos TAL CUAL (cifras, medidas, temperaturas, materiales, precios, nombres de producto). Si la ficha dice "-10 ºC a +60 ºC", di exactamente eso.
25
+ - Si un dato concreto NO está en los resultados, dilo con honestidad ("no tengo ese dato exacto a mano") y ofrece consultarlo con el equipo. NUNCA rellenes huecos con conocimiento general ni te inventes productos, precios o especificaciones.
26
+ - Para recomendaciones o comparativas, básate SOLO en los productos/datos recuperados. Si falta información, pregunta o deriva al equipo.
27
+
28
+ ## Tipos de pregunta (sirve para cualquiera)
29
+ - Pregunta concreta (un dato): da el dato exacto del documento.
30
+ - Pregunta amplia o vaga ("¿qué vendéis?", "ayuda"): busca y da un resumen claro de las categorías/productos principales reales, y ofrece profundizar.
31
+ - Precio/stock: usa `search_products` y da precio y disponibilidad reales (con enlace si lo hay).
32
+ - Varias preguntas a la vez: responde a cada parte.
33
+ - Fuera de tema (no es de la tienda): redirige con amabilidad hacia cómo puedes ayudar con la tienda y sus productos.
34
+
35
+ ## Pedidos (privacidad + verificación flexible)
36
+ - Para localizar un pedido necesitas el **email** del cliente y **UN segundo dato** identificativo. Acepta cualquiera: **número de pedido**, **código postal** del envío, o **nombre y apellidos**. NO exijas el número de pedido si te dan el código postal o el nombre.
37
+ - Pasa a `lookup_order` el email y el segundo dato que te haya dado.
38
  - Revela ÚNICAMENTE el estado del envío y el seguimiento (transportista, número, enlace, fecha estimada). NUNCA reveles la dirección completa ni datos de pago.
39
+ - Si no se localiza, dilo de forma genérica ("no he podido localizar un pedido con esos datos, revísalos por favor") sin decir qué campo falla. Tras varios intentos queda bloqueado un rato.
 
40
 
41
  ## Honestidad y escalado
42
+ - Si no puedes resolver algo, honesto y ofrece pasar la consulta al equipo: pide el email del cliente y usa `escalate_to_human`.
43
 
44
  ## Tono y formato
45
+ - Cercano, profesional y resolutivo.
46
+ - El chat es estrecho: NO uses tablas. Usa frases breves o listas con guiones "-", y resalta lo clave con **negrita**. Respuestas claras y al grano.
47
  """
48
 
49
 
app/tools/knowledge_tool.py CHANGED
@@ -14,7 +14,7 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
14
  query = (args.get("query") or "").strip()
15
  if not query:
16
  return {"context": "", "sources": [], "note": "empty query"}
17
- results = await index.search(ctx.db, query, k=4, tenant_id=ctx.tenant_id)
18
  if not results:
19
  return {"context": "", "sources": [], "note": "no relevant information found"}
20
  blocks = []
 
14
  query = (args.get("query") or "").strip()
15
  if not query:
16
  return {"context": "", "sources": [], "note": "empty query"}
17
+ results = await index.search(ctx.db, query, k=6, tenant_id=ctx.tenant_id)
18
  if not results:
19
  return {"context": "", "sources": [], "note": "no relevant information found"}
20
  blocks = []
app/tools/order_tool.py CHANGED
@@ -43,7 +43,8 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
43
  }
44
 
45
  limiter = get_order_limiter()
46
- if not limiter.allow(email.lower(), ctx.now):
 
47
  return {"status": "locked"}
48
 
49
  try:
@@ -66,5 +67,5 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
66
  if not result.ok or result.matched is None:
67
  return {"status": "not_found"}
68
 
69
- limiter.reset(email.lower())
70
  return {"status": "verified", "tracking": parse_tracking(result.matched)}
 
43
  }
44
 
45
  limiter = get_order_limiter()
46
+ rl_key = f"{ctx.tenant_id}:{email.lower()}" # per-tenant so stores don't affect each other
47
+ if not limiter.allow(rl_key, ctx.now):
48
  return {"status": "locked"}
49
 
50
  try:
 
67
  if not result.ok or result.matched is None:
68
  return {"status": "not_found"}
69
 
70
+ limiter.reset(rl_key)
71
  return {"status": "verified", "tracking": parse_tracking(result.matched)}
tests/llm/test_groq.py CHANGED
@@ -61,6 +61,22 @@ async def test_groq_parses_tool_calls():
61
  assert r.tool_calls[0].arguments == {"email": "a@b.c"}
62
 
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  @respx.mock
65
  async def test_groq_429_raises_ratelimit():
66
  respx.post(URL).mock(return_value=httpx.Response(429, text="slow down"))
 
61
  assert r.tool_calls[0].arguments == {"email": "a@b.c"}
62
 
63
 
64
+ @respx.mock
65
+ async def test_groq_captures_token_usage():
66
+ respx.post(URL).mock(
67
+ return_value=httpx.Response(
68
+ 200,
69
+ json={
70
+ "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}],
71
+ "usage": {"prompt_tokens": 120, "completion_tokens": 30, "total_tokens": 150},
72
+ },
73
+ )
74
+ )
75
+ r = await _provider().chat(messages=[{"role": "user", "content": "x"}], tools=[], model="m")
76
+ assert r.usage["total_tokens"] == 150
77
+ assert r.usage["prompt_tokens"] == 120
78
+
79
+
80
  @respx.mock
81
  async def test_groq_429_raises_ratelimit():
82
  respx.post(URL).mock(return_value=httpx.Response(429, text="slow down"))
tests/llm/test_router.py CHANGED
@@ -53,6 +53,34 @@ async def test_router_failover_on_ratelimit():
53
  assert cf.last_model == "cs"
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  async def test_router_raises_when_all_fail():
57
  groq = FakeProvider(raise_exc=ProviderError("g"))
58
  cf = FakeProvider(raise_exc=ProviderError("c"))
 
53
  assert cf.last_model == "cs"
54
 
55
 
56
+ def test_build_router_multiple_groq_keys_and_cloudflare():
57
+ from types import SimpleNamespace
58
+
59
+ from app.llm.router import build_router_from_settings
60
+
61
+ s = SimpleNamespace(
62
+ groq_api_key="k1",
63
+ groq_api_keys=["k2", "k3", "k1"], # k1 deduped
64
+ groq_base_url="https://api.groq.com/openai/v1",
65
+ model_small="s",
66
+ model_large="l",
67
+ cloudflare_account_id="acct",
68
+ cloudflare_api_token="tok",
69
+ cf_model_small="cs",
70
+ cf_model_large="cl",
71
+ )
72
+ router = build_router_from_settings(s)
73
+ assert router.order == ["groq0", "groq1", "groq2", "cloudflare"] # 3 keys + CF
74
+ assert len(router.providers) == 4
75
+
76
+
77
+ async def test_router_tags_provider_name():
78
+ groq = FakeProvider(result=ChatResult(content="ok", tool_calls=[], finish_reason="stop"))
79
+ router = LLMRouter(providers={"groq0": groq}, order=["groq0"], models={"groq0": {"large": "l"}})
80
+ r = await router.chat(messages=[{"role": "user", "content": "x"}], tools=[], tier="large")
81
+ assert r.provider == "groq0"
82
+
83
+
84
  async def test_router_raises_when_all_fail():
85
  groq = FakeProvider(raise_exc=ProviderError("g"))
86
  cf = FakeProvider(raise_exc=ProviderError("c"))
tests/test_multitenant.py CHANGED
@@ -81,6 +81,42 @@ async def test_create_tenant_encrypts_secret(db_session):
81
  assert tenant_shopify_client(plain) is None
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  async def test_get_tenant_by_slug(db_session):
85
  db_session.add(Tenant(slug="toorx", brand_name="T"))
86
  await db_session.flush()
 
81
  assert tenant_shopify_client(plain) is None
82
 
83
 
84
+ async def test_order_lockout_is_per_tenant(db_session):
85
+ """A spammed email in tenant A must not lock that email in tenant B."""
86
+ from app.models import ChatSession, Tenant
87
+ from app.tools import order_tool
88
+ from app.tools.registry import ToolContext
89
+
90
+ a = Tenant(slug="ta")
91
+ b = Tenant(slug="tb")
92
+ db_session.add_all([a, b])
93
+ await db_session.flush()
94
+
95
+ class FakeShopify:
96
+ async def execute(self, query, variables=None):
97
+ return {"orders": {"edges": []}} # no match -> failed attempts
98
+
99
+ from app.ratelimit import get_order_limiter
100
+
101
+ get_order_limiter().clear()
102
+
103
+ async def session_for(tid):
104
+ s = ChatSession(shop="x", tenant_id=tid)
105
+ db_session.add(s)
106
+ await db_session.flush()
107
+ return s
108
+
109
+ # Exhaust attempts for email in tenant A
110
+ for _ in range(8):
111
+ ctx_a = ToolContext(db=db_session, session=await session_for(a.id), tenant_id=a.id, shopify=FakeShopify())
112
+ await order_tool.run({"email": "spam@x.com", "order_number": "9999"}, ctx_a)
113
+
114
+ # Same email in tenant B is NOT locked (independent counter)
115
+ ctx_b = ToolContext(db=db_session, session=await session_for(b.id), tenant_id=b.id, shopify=FakeShopify())
116
+ out_b = await order_tool.run({"email": "spam@x.com", "order_number": "9999"}, ctx_b)
117
+ assert out_b["status"] == "not_found" # not "locked"
118
+
119
+
120
  async def test_get_tenant_by_slug(db_session):
121
  db_session.add(Tenant(slug="toorx", brand_name="T"))
122
  await db_session.flush()
tests/test_prompts.py CHANGED
@@ -10,7 +10,7 @@ def test_system_prompt_includes_rules_and_brand():
10
  assert "mismo idioma" in p
11
  assert "pt-PT" in p
12
  # anti-hallucination grounding rule
13
- assert "no inventes" in p.lower()
14
  # flexible order verification (email + order number / postal code / name)
15
  assert "email" in p and "número de pedido" in p and "código postal" in p
16
  assert "NUNCA" in p # no full address / payment
 
10
  assert "mismo idioma" in p
11
  assert "pt-PT" in p
12
  # anti-hallucination grounding rule
13
+ assert "inventes" in p.lower()
14
  # flexible order verification (email + order number / postal code / name)
15
  assert "email" in p and "número de pedido" in p and "código postal" in p
16
  assert "NUNCA" in p # no full address / payment