victor34593993 commited on
Commit
f6b8341
·
verified ·
1 Parent(s): e2ad187

visual search: harden against adversarial review (7 findings)

Browse files
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
- return [float(x) for x in vec]
 
 
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
- r = await client.get(url)
72
- r.raise_for_status()
73
- data = r.content
 
 
 
 
 
 
 
 
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
- tool_result = await registry.dispatch(tc.name, tc.arguments, ctx)
 
 
 
 
 
 
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
- scored = [
62
- (c, embeddings.cosine(image_vec, c.image_embedding))
63
- for c in chunks
64
- if c.image_embedding
65
- ]
66
- scored.sort(key=lambda pair: pair[1], reverse=True)
67
- return scored[:k]
 
 
 
 
 
 
 
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
- # Retrieval arms in parallel: Shopify keyword + semantic corpus + (if the visitor
174
- # uploaded a photo this session) VISUAL similarity over the catalog's image vectors.
175
- image_vec = await _session_image_vec(ctx)
 
 
 
176
  kw_task = asyncio.create_task(search_products(ctx.shopify, query))
177
- sem_task = asyncio.create_task(
178
- product_index.search(ctx.db, query, k=20, tenant_id=ctx.tenant_id)
 
 
179
  )
180
- vis_task = asyncio.create_task(
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
- App embed block: injects the support chat widget on every storefront page.
3
- Enable it in Online Store > Themes > Customize > App embeds.
4
- {% endcomment %}
 
5
 
6
- <div
7
- id="ssb-chat-root"
8
- data-backend="{{ block.settings.backend_path }}"
9
- data-brand="{{ block.settings.brand_name | escape }}"
10
- data-color="{{ block.settings.brand_color }}"
11
- data-welcome="{{ block.settings.welcome | escape }}"
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