visual search: harden against adversarial review (7 findings)
Browse files- app/imgembed.py +14 -4
- app/orchestrator.py +7 -1
- app/products/index.py +14 -7
- app/products/sync.py +15 -6
- app/routes/chat.py +1 -0
- app/tools/products_tool.py +11 -9
- app/tools/registry.py +1 -0
- extension/.shopify/deploy-bundle.br +0 -0
- extension/.shopify/deploy-bundle/288ab506-3769-fe13-0858-10bbb247f01cb0ff7c2a/blocks/chat.liquid +11 -37
- extension/.shopify/deploy-bundle/manifest.json +12 -0
- tests/products/test_visual.py +12 -1
- tests/test_orchestrator.py +25 -0
app/imgembed.py
CHANGED
|
@@ -46,7 +46,9 @@ def _embed_sync(data: bytes) -> list[float] | None:
|
|
| 46 |
return None
|
| 47 |
model = _get_model()
|
| 48 |
for vec in model.embed([img]): # one image in, one vector out
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
return None
|
| 51 |
|
| 52 |
|
|
@@ -68,9 +70,17 @@ async def embed_image_url(url: str) -> list[float] | None:
|
|
| 68 |
return None
|
| 69 |
try:
|
| 70 |
async with httpx.AsyncClient(timeout=_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
except Exception: # noqa: BLE001 - network is best-effort
|
| 75 |
log.warning("imgembed: download failed for %s", url[:120])
|
| 76 |
return None
|
|
|
|
| 46 |
return None
|
| 47 |
model = _get_model()
|
| 48 |
for vec in model.embed([img]): # one image in, one vector out
|
| 49 |
+
# Round to 6 decimals: keeps cosine effectively identical but roughly halves the
|
| 50 |
+
# JSON footprint of the stored vector (the shared Neon DB is a scarce resource).
|
| 51 |
+
return [round(float(x), 6) for x in vec]
|
| 52 |
return None
|
| 53 |
|
| 54 |
|
|
|
|
| 70 |
return None
|
| 71 |
try:
|
| 72 |
async with httpx.AsyncClient(timeout=_DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
|
| 73 |
+
# Stream + abort early so a giant/booby-trapped image URL can't balloon memory
|
| 74 |
+
# (the cap was only checked AFTER the full body had already been buffered).
|
| 75 |
+
async with client.stream("GET", url) as r:
|
| 76 |
+
r.raise_for_status()
|
| 77 |
+
buf = bytearray()
|
| 78 |
+
async for chunk in r.aiter_bytes():
|
| 79 |
+
buf.extend(chunk)
|
| 80 |
+
if len(buf) > _MAX_IMAGE_BYTES:
|
| 81 |
+
log.warning("imgembed: image too large, aborting %s", url[:120])
|
| 82 |
+
return None
|
| 83 |
+
data = bytes(buf)
|
| 84 |
except Exception: # noqa: BLE001 - network is best-effort
|
| 85 |
log.warning("imgembed: download failed for %s", url[:120])
|
| 86 |
return None
|
app/orchestrator.py
CHANGED
|
@@ -364,7 +364,13 @@ async def run_turn(
|
|
| 364 |
for tc in result.tool_calls:
|
| 365 |
used_tools.append(tc.name)
|
| 366 |
_emit_stage(on_stage, "tool:" + tc.name)
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
payload = json.dumps(tool_result, ensure_ascii=False, default=str)
|
| 369 |
if len(payload) > MAX_TOOL_RESULT_CHARS: # protect small-window models
|
| 370 |
# keep it VALID JSON (a byte-slice would corrupt the object)
|
|
|
|
| 364 |
for tc in result.tool_calls:
|
| 365 |
used_tools.append(tc.name)
|
| 366 |
_emit_stage(on_stage, "tool:" + tc.name)
|
| 367 |
+
# Error boundary: a tool raising must degrade to a model-readable error,
|
| 368 |
+
# never 500 the whole chat turn. The model then apologizes / offers help.
|
| 369 |
+
try:
|
| 370 |
+
tool_result = await registry.dispatch(tc.name, tc.arguments, ctx)
|
| 371 |
+
except Exception: # noqa: BLE001 - one tool failure never kills the turn
|
| 372 |
+
log.warning("tool %s raised; degrading turn", tc.name, exc_info=True)
|
| 373 |
+
tool_result = {"error": "tool failed", "status": "error"}
|
| 374 |
payload = json.dumps(tool_result, ensure_ascii=False, default=str)
|
| 375 |
if len(payload) > MAX_TOOL_RESULT_CHARS: # protect small-window models
|
| 376 |
# keep it VALID JSON (a byte-slice would corrupt the object)
|
app/products/index.py
CHANGED
|
@@ -7,6 +7,7 @@ so one store's catalog can never surface in another store's bot.
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
from sqlalchemy import select, text
|
| 11 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
|
|
@@ -58,10 +59,16 @@ async def visual_search(
|
|
| 58 |
if tenant_id is not None:
|
| 59 |
stmt = stmt.where(ProductChunk.tenant_id == tenant_id)
|
| 60 |
chunks = (await session.execute(stmt)).scalars().all()
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import anyio
|
| 11 |
from sqlalchemy import select, text
|
| 12 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
|
|
|
|
| 59 |
if tenant_id is not None:
|
| 60 |
stmt = stmt.where(ProductChunk.tenant_id == tenant_id)
|
| 61 |
chunks = (await session.execute(stmt)).scalars().all()
|
| 62 |
+
|
| 63 |
+
# Pure-Python cosine over the whole image corpus can be heavy — run it OFF the event
|
| 64 |
+
# loop so a photo search never blocks other requests on the single worker.
|
| 65 |
+
def _score() -> list[tuple[ProductChunk, float]]:
|
| 66 |
+
scored = [
|
| 67 |
+
(c, embeddings.cosine(image_vec, c.image_embedding))
|
| 68 |
+
for c in chunks
|
| 69 |
+
if c.image_embedding
|
| 70 |
+
]
|
| 71 |
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
| 72 |
+
return scored[:k]
|
| 73 |
+
|
| 74 |
+
return await anyio.to_thread.run_sync(_score)
|
app/products/sync.py
CHANGED
|
@@ -8,6 +8,7 @@ live at answer time. Runs OFF the request path (scheduler), never during a chat.
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 11 |
import logging
|
| 12 |
|
| 13 |
from sqlalchemy import select
|
|
@@ -54,13 +55,21 @@ async def sync_tenant_images(
|
|
| 54 |
todo.append((c, img))
|
| 55 |
if len(todo) >= cap:
|
| 56 |
break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
done = 0
|
| 58 |
-
for c, img in todo:
|
| 59 |
-
try:
|
| 60 |
-
vec = await imgembed.embed_image_url(img)
|
| 61 |
-
except Exception: # noqa: BLE001 - one bad image never blocks the rest
|
| 62 |
-
log.warning("image embed failed for product %s", c.shopify_product_id, exc_info=True)
|
| 63 |
-
vec = None
|
| 64 |
if vec:
|
| 65 |
c.image_embedding = vec
|
| 66 |
c.meta = {**(c.meta or {}), "image_emb_src": img} # reassign so JSON change is tracked
|
|
|
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
| 11 |
+
import asyncio
|
| 12 |
import logging
|
| 13 |
|
| 14 |
from sqlalchemy import select
|
|
|
|
| 55 |
todo.append((c, img))
|
| 56 |
if len(todo) >= cap:
|
| 57 |
break
|
| 58 |
+
|
| 59 |
+
# Download + embed a few at a time (overlaps the network I/O), then assign results
|
| 60 |
+
# sequentially — never mutate ORM rows from concurrent tasks on the shared session.
|
| 61 |
+
sem = asyncio.Semaphore(4)
|
| 62 |
+
|
| 63 |
+
async def _embed(c, img):
|
| 64 |
+
async with sem:
|
| 65 |
+
try:
|
| 66 |
+
return c, img, await imgembed.embed_image_url(img)
|
| 67 |
+
except Exception: # noqa: BLE001 - one bad image never blocks the rest
|
| 68 |
+
log.warning("image embed failed for product %s", c.shopify_product_id, exc_info=True)
|
| 69 |
+
return c, img, None
|
| 70 |
+
|
| 71 |
done = 0
|
| 72 |
+
for c, img, vec in await asyncio.gather(*(_embed(c, img) for c, img in todo)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
if vec:
|
| 74 |
c.image_embedding = vec
|
| 75 |
c.meta = {**(c.meta or {}), "image_emb_src": img} # reassign so JSON change is tracked
|
app/routes/chat.py
CHANGED
|
@@ -271,6 +271,7 @@ async def _run_chat(
|
|
| 271 |
# the conversation history replays coherently). They are never embedded
|
| 272 |
# nor written to the knowledge base.
|
| 273 |
block, has_image = await _attachment_block(db, tenant.id, ctx.session.id, attachment_ids or [])
|
|
|
|
| 274 |
# intent_text=message: the deterministic grounding gates must read ONLY the
|
| 275 |
# customer's own words, never the attachment text riding in the block.
|
| 276 |
response = await run_turn(router_, ctx, block + message, brand_name=tenant.brand_name,
|
|
|
|
| 271 |
# the conversation history replays coherently). They are never embedded
|
| 272 |
# nor written to the knowledge base.
|
| 273 |
block, has_image = await _attachment_block(db, tenant.id, ctx.session.id, attachment_ids or [])
|
| 274 |
+
ctx.image_uploaded = has_image # visual product search only on the turn the photo rode in
|
| 275 |
# intent_text=message: the deterministic grounding gates must read ONLY the
|
| 276 |
# customer's own words, never the attachment text riding in the block.
|
| 277 |
response = await run_turn(router_, ctx, block + message, brand_name=tenant.brand_name,
|
app/tools/products_tool.py
CHANGED
|
@@ -170,17 +170,19 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
|
| 170 |
lim = 4
|
| 171 |
lim = max(1, min(lim, MAX_RESULTS))
|
| 172 |
|
| 173 |
-
#
|
| 174 |
-
#
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
| 176 |
kw_task = asyncio.create_task(search_products(ctx.shopify, query))
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
| 179 |
)
|
| 180 |
-
|
| 181 |
-
product_index.visual_search(ctx.db, image_vec or [], k=20, tenant_id=ctx.tenant_id)
|
| 182 |
-
)
|
| 183 |
-
kw_products, sem_results, vis_results = await asyncio.gather(kw_task, sem_task, vis_task)
|
| 184 |
|
| 185 |
kw_ids = [str(p.get("product_id") or "") for p in kw_products]
|
| 186 |
sem_ids = [c.shopify_product_id for c, _ in sem_results]
|
|
|
|
| 170 |
lim = 4
|
| 171 |
lim = max(1, min(lim, MAX_RESULTS))
|
| 172 |
|
| 173 |
+
# The Shopify keyword arm is HTTP (independent of the DB session) so it overlaps the
|
| 174 |
+
# DB work. The semantic + visual arms BOTH use ctx.db — and a single AsyncSession is
|
| 175 |
+
# NOT safe for concurrent use — so they run sequentially on it, never gathered.
|
| 176 |
+
# Visual matching only fires on the turn the photo actually rode in (ctx.image_uploaded),
|
| 177 |
+
# so a stale photo never contaminates later text-only searches in the session.
|
| 178 |
+
image_vec = await _session_image_vec(ctx) if getattr(ctx, "image_uploaded", False) else None
|
| 179 |
kw_task = asyncio.create_task(search_products(ctx.shopify, query))
|
| 180 |
+
sem_results = await product_index.search(ctx.db, query, k=20, tenant_id=ctx.tenant_id)
|
| 181 |
+
vis_results = (
|
| 182 |
+
await product_index.visual_search(ctx.db, image_vec, k=20, tenant_id=ctx.tenant_id)
|
| 183 |
+
if image_vec else []
|
| 184 |
)
|
| 185 |
+
kw_products = await kw_task
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
kw_ids = [str(p.get("product_id") or "") for p in kw_products]
|
| 188 |
sem_ids = [c.shopify_product_id for c, _ in sem_results]
|
app/tools/registry.py
CHANGED
|
@@ -47,6 +47,7 @@ class ToolContext:
|
|
| 47 |
web_urls: list[str] = field(default_factory=list) # URLs found this turn (link-guard pass)
|
| 48 |
customer: Any | None = None # Customer profile (returning-customer memory), if known
|
| 49 |
customer_note: str = "" # system note injected when this is a RETURNING customer
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
SPECS: list[ToolSpec] = [
|
|
|
|
| 47 |
web_urls: list[str] = field(default_factory=list) # URLs found this turn (link-guard pass)
|
| 48 |
customer: Any | None = None # Customer profile (returning-customer memory), if known
|
| 49 |
customer_note: str = "" # system note injected when this is a RETURNING customer
|
| 50 |
+
image_uploaded: bool = False # a photo rode in THIS turn -> enable visual product search
|
| 51 |
|
| 52 |
|
| 53 |
SPECS: list[ToolSpec] = [
|
extension/.shopify/deploy-bundle.br
CHANGED
|
Binary files a/extension/.shopify/deploy-bundle.br and b/extension/.shopify/deploy-bundle.br differ
|
|
|
extension/.shopify/deploy-bundle/288ab506-3769-fe13-0858-10bbb247f01cb0ff7c2a/blocks/chat.liquid
CHANGED
|
@@ -1,46 +1,20 @@
|
|
| 1 |
{% comment %}
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
></div>
|
| 13 |
-
<script src="{{ 'widget.js' | asset_url }}" defer></script>
|
| 14 |
|
| 15 |
{% schema %}
|
| 16 |
{
|
| 17 |
"name": "Chatbot de soporte",
|
| 18 |
"target": "body",
|
| 19 |
-
"settings": [
|
| 20 |
-
{
|
| 21 |
-
"type": "text",
|
| 22 |
-
"id": "backend_path",
|
| 23 |
-
"label": "Ruta del backend (App Proxy)",
|
| 24 |
-
"default": "/apps/chat"
|
| 25 |
-
},
|
| 26 |
-
{
|
| 27 |
-
"type": "text",
|
| 28 |
-
"id": "brand_name",
|
| 29 |
-
"label": "Nombre del asistente",
|
| 30 |
-
"default": "Asistente"
|
| 31 |
-
},
|
| 32 |
-
{
|
| 33 |
-
"type": "color",
|
| 34 |
-
"id": "brand_color",
|
| 35 |
-
"label": "Color principal",
|
| 36 |
-
"default": "#e8491d"
|
| 37 |
-
},
|
| 38 |
-
{
|
| 39 |
-
"type": "textarea",
|
| 40 |
-
"id": "welcome",
|
| 41 |
-
"label": "Mensaje de bienvenida",
|
| 42 |
-
"default": "¡Hola! ¿En qué puedo ayudarte?"
|
| 43 |
-
}
|
| 44 |
-
]
|
| 45 |
}
|
| 46 |
{% endschema %}
|
|
|
|
| 1 |
{% comment %}
|
| 2 |
+
Atendyo — AI support chat (app embed). The assistant loads from the Atendyo app
|
| 3 |
+
and talks to your store through the SIGNED Shopify App Proxy (/apps/chat): it
|
| 4 |
+
reads your catalog, order status and policies, accepts file uploads, supports
|
| 5 |
+
voice, and can hand off to a human — no links to paste.
|
| 6 |
|
| 7 |
+
Configure it (name, colour, welcome, instructions) from the Atendyo app panel.
|
| 8 |
+
Enable it here under Online Store > Themes > Customize > App embeds, then Save.
|
| 9 |
+
{% endcomment %}
|
| 10 |
+
<script
|
| 11 |
+
src="https://victor34593993-flexigo-support-bot.hf.space/widget.js?proxy=1"
|
| 12 |
+
defer></script>
|
|
|
|
|
|
|
| 13 |
|
| 14 |
{% schema %}
|
| 15 |
{
|
| 16 |
"name": "Chatbot de soporte",
|
| 17 |
"target": "body",
|
| 18 |
+
"settings": []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
{% endschema %}
|
extension/.shopify/deploy-bundle/manifest.json
CHANGED
|
@@ -105,6 +105,18 @@
|
|
| 105 |
"api_version": "2026-01",
|
| 106 |
"uri": "https://victor34593993-flexigo-support-bot.hf.space/shopify/webhooks/app_subscriptions_update"
|
| 107 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
]
|
| 110 |
}
|
|
|
|
| 105 |
"api_version": "2026-01",
|
| 106 |
"uri": "https://victor34593993-flexigo-support-bot.hf.space/shopify/webhooks/app_subscriptions_update"
|
| 107 |
}
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"type": "webhook_subscription",
|
| 111 |
+
"handle": "c65863898f02bd781a28ed9757c3b563284a6ab2",
|
| 112 |
+
"uid": "app/uninstalled::undefined::https://victor34593993-flexigo-support-bot.hf.space/shopify/webhooks/app_uninstalled",
|
| 113 |
+
"assets": "app/uninstalled::undefined::https://victor34593993-flexigo-support-bot.hf.space/shopify/webhooks/app_uninstalled",
|
| 114 |
+
"target": "",
|
| 115 |
+
"config": {
|
| 116 |
+
"topic": "app/uninstalled",
|
| 117 |
+
"api_version": "2026-01",
|
| 118 |
+
"uri": "https://victor34593993-flexigo-support-bot.hf.space/shopify/webhooks/app_uninstalled"
|
| 119 |
+
}
|
| 120 |
}
|
| 121 |
]
|
| 122 |
}
|
tests/products/test_visual.py
CHANGED
|
@@ -68,16 +68,27 @@ async def test_products_tool_blends_uploaded_photo(db_session, monkeypatch):
|
|
| 68 |
"image": "http://img", "url": "http://u", "variants": [], "tags": [],
|
| 69 |
"description": "", "product_type": ""}]
|
| 70 |
|
|
|
|
|
|
|
|
|
|
| 71 |
monkeypatch.setattr(pt, "search_products", fake_kw)
|
| 72 |
monkeypatch.setattr(pt, "fetch_products_by_ids", fake_hydrate)
|
|
|
|
| 73 |
|
|
|
|
| 74 |
ctx = ToolContext(db=db_session, session=sess, tenant_id=1, shopify=_FakeShopify(),
|
| 75 |
-
shop="x.myshopify.com")
|
| 76 |
out = await registry.dispatch("search_products", {"query": "algo así"}, ctx)
|
| 77 |
titles = [p["title"] for p in out["products"]]
|
| 78 |
assert "Mystery Item" in titles # surfaced purely by VISUAL similarity
|
| 79 |
assert any(c["title"] == "Mystery Item" for c in ctx.cards)
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
async def test_imgembed_rejects_bad_input():
|
| 83 |
# empty / non-image bytes -> None, without loading the CLIP model
|
|
|
|
| 68 |
"image": "http://img", "url": "http://u", "variants": [], "tags": [],
|
| 69 |
"description": "", "product_type": ""}]
|
| 70 |
|
| 71 |
+
async def empty_text_search(session, query, k=8, *, tenant_id=None):
|
| 72 |
+
return [] # isolate the VISUAL arm: the text/semantic arm matches nothing
|
| 73 |
+
|
| 74 |
monkeypatch.setattr(pt, "search_products", fake_kw)
|
| 75 |
monkeypatch.setattr(pt, "fetch_products_by_ids", fake_hydrate)
|
| 76 |
+
monkeypatch.setattr(product_index, "search", empty_text_search)
|
| 77 |
|
| 78 |
+
# image_uploaded=True -> the photo rode in THIS turn, so the visual arm fires
|
| 79 |
ctx = ToolContext(db=db_session, session=sess, tenant_id=1, shopify=_FakeShopify(),
|
| 80 |
+
shop="x.myshopify.com", image_uploaded=True)
|
| 81 |
out = await registry.dispatch("search_products", {"query": "algo así"}, ctx)
|
| 82 |
titles = [p["title"] for p in out["products"]]
|
| 83 |
assert "Mystery Item" in titles # surfaced purely by VISUAL similarity
|
| 84 |
assert any(c["title"] == "Mystery Item" for c in ctx.cards)
|
| 85 |
|
| 86 |
+
# ...but a LATER text-only turn (no photo this turn) must NOT re-use the stale photo
|
| 87 |
+
ctx2 = ToolContext(db=db_session, session=sess, tenant_id=1, shopify=_FakeShopify(),
|
| 88 |
+
shop="x.myshopify.com", image_uploaded=False)
|
| 89 |
+
out2 = await registry.dispatch("search_products", {"query": "otra cosa"}, ctx2)
|
| 90 |
+
assert "Mystery Item" not in [p["title"] for p in out2["products"]]
|
| 91 |
+
|
| 92 |
|
| 93 |
async def test_imgembed_rejects_bad_input():
|
| 94 |
# empty / non-image bytes -> None, without loading the CLIP model
|
tests/test_orchestrator.py
CHANGED
|
@@ -493,3 +493,28 @@ async def test_pdf_or_plain_text_does_not_force_product_search(db_session):
|
|
| 493 |
flat = " ".join(c for call in router.seen for c in call)
|
| 494 |
# it may force search_knowledge (grounding), but NOT the image->search_products note
|
| 495 |
assert "atributos de la imagen" not in flat
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
flat = " ".join(c for call in router.seen for c in call)
|
| 494 |
# it may force search_knowledge (grounding), but NOT the image->search_products note
|
| 495 |
assert "atributos de la imagen" not in flat
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
async def test_tool_exception_degrades_not_500(db_session):
|
| 499 |
+
"""A tool raising must NOT crash the turn — it degrades to a model-readable error."""
|
| 500 |
+
router = FakeRouter([
|
| 501 |
+
ChatResult(content=None, tool_calls=[
|
| 502 |
+
ToolCall(id="t1", name="search_products", arguments={"query": "x"})],
|
| 503 |
+
finish_reason="tool_calls"),
|
| 504 |
+
ChatResult(content="Lo siento, ahora no puedo consultarlo.", tool_calls=[], finish_reason="stop"),
|
| 505 |
+
])
|
| 506 |
+
ctx = ToolContext(db=db_session, session=await _session(db_session), tenant_id=1)
|
| 507 |
+
|
| 508 |
+
import app.tools.registry as reg
|
| 509 |
+
|
| 510 |
+
async def boom(name, args, c):
|
| 511 |
+
raise RuntimeError("tool blew up")
|
| 512 |
+
|
| 513 |
+
# monkeypatch dispatch to raise
|
| 514 |
+
orig = reg.dispatch
|
| 515 |
+
reg.dispatch = boom
|
| 516 |
+
try:
|
| 517 |
+
resp = await run_turn(router, ctx, "enséñame algo")
|
| 518 |
+
finally:
|
| 519 |
+
reg.dispatch = orig
|
| 520 |
+
assert resp.reply # a real reply, no exception propagated
|