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

Feature 4: true visual product search (CLIP)

Browse files
app/imgembed.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CLIP image embeddings for VISUAL product search.
2
+
3
+ A shopper's uploaded photo and each product's primary photo are embedded into the
4
+ same 512-dim CLIP space (fastembed ONNX, CPU, no torch), so we can rank the catalog
5
+ by visual similarity to the photo — "find me products that look like this".
6
+
7
+ Everything is best-effort: any failure (no model, bad bytes, download error) returns
8
+ None so the caller silently degrades to the text-description search. The model is
9
+ loaded lazily in a worker thread and cached for the process lifetime.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import logging
16
+
17
+ import anyio
18
+ import httpx
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ IMAGE_MODEL = "Qdrant/clip-ViT-B-32-vision"
23
+ IMAGE_EMBED_DIM = 512
24
+ _DOWNLOAD_TIMEOUT = 15.0
25
+ _MAX_IMAGE_BYTES = 12 * 1024 * 1024 # ignore anything bigger (defensive)
26
+
27
+ _model = None
28
+
29
+
30
+ def _get_model():
31
+ global _model
32
+ if _model is None:
33
+ from fastembed import ImageEmbedding
34
+
35
+ _model = ImageEmbedding(model_name=IMAGE_MODEL, cache_dir=".fastembed_cache")
36
+ return _model
37
+
38
+
39
+ def _embed_sync(data: bytes) -> list[float] | None:
40
+ from PIL import Image
41
+
42
+ try:
43
+ img = Image.open(io.BytesIO(data)).convert("RGB")
44
+ except Exception: # noqa: BLE001 - unreadable/corrupt image
45
+ log.warning("imgembed: could not decode image bytes")
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
+
53
+ async def embed_image(data: bytes) -> list[float] | None:
54
+ """512-dim CLIP vector for raw image bytes, or None on any failure."""
55
+ if not data or len(data) > _MAX_IMAGE_BYTES:
56
+ return None
57
+ try:
58
+ return await anyio.to_thread.run_sync(_embed_sync, data)
59
+ except Exception: # noqa: BLE001 - embedding is best-effort by design
60
+ log.warning("imgembed: embed_image failed", exc_info=True)
61
+ return None
62
+
63
+
64
+ async def embed_image_url(url: str) -> list[float] | None:
65
+ """Download an image URL (bounded) and embed it. None on any failure."""
66
+ url = (url or "").strip()
67
+ if not url.startswith("http"):
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
77
+ return await embed_image(data)
app/models.py CHANGED
@@ -233,6 +233,11 @@ class ProductChunk(Base):
233
  updated_at: Mapped[str] = mapped_column(String(40), default="")
234
  text: Mapped[str] = mapped_column(Text)
235
  embedding: Mapped[list[float]] = mapped_column(EmbeddingType)
 
 
 
 
 
236
  meta: Mapped[dict] = mapped_column(JSON, default=dict)
237
 
238
 
@@ -396,6 +401,9 @@ class Attachment(Base):
396
  filename: Mapped[str] = mapped_column(String(255), default="")
397
  mime: Mapped[str] = mapped_column(String(60), default="")
398
  text: Mapped[str] = mapped_column(Text, default="")
 
 
 
399
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
400
 
401
 
 
233
  updated_at: Mapped[str] = mapped_column(String(40), default="")
234
  text: Mapped[str] = mapped_column(Text)
235
  embedding: Mapped[list[float]] = mapped_column(EmbeddingType)
236
+ # CLIP image embedding (512-dim) of the product's primary photo, for visual
237
+ # search (a shopper's uploaded photo -> visually similar products). Plain JSON
238
+ # (different dim from the 384-dim text vector); Python cosine, tenant-scoped.
239
+ # Nullable: filled in lazily by the background sync, best-effort.
240
+ image_embedding: Mapped[list | None] = mapped_column(JSON, nullable=True)
241
  meta: Mapped[dict] = mapped_column(JSON, default=dict)
242
 
243
 
 
401
  filename: Mapped[str] = mapped_column(String(255), default="")
402
  mime: Mapped[str] = mapped_column(String(60), default="")
403
  text: Mapped[str] = mapped_column(Text, default="")
404
+ # CLIP image embedding (512-dim) of the uploaded photo — lets the next product
405
+ # search find visually similar items. Plain JSON, nullable (only for images).
406
+ image_embedding: Mapped[list | None] = mapped_column(JSON, nullable=True)
407
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
408
 
409
 
app/orchestrator.py CHANGED
@@ -224,6 +224,7 @@ async def run_turn(
224
  instructions: str = "",
225
  on_stage: Callable[[str], None] | None = None,
226
  intent_text: str | None = None,
 
227
  ) -> ChatResponse:
228
  # intent_text: the customer's OWN words, without any attachment block the
229
  # route prepended. The deterministic intent gates must match only what the
@@ -288,11 +289,15 @@ async def run_turn(
288
  "pasar con una persona. NUNCA mandes al cliente a la web del fabricante "
289
  "ni a un tercero. PROHIBIDO inventar."
290
  )})
291
- # Photo-to-product chain: an attachment rode in with THIS message AND the
292
- # customer is product-seeking ("¿tenéis algo como esto?") -> force the
293
- # catalog search with the image's attributes. Marker presence ALONE never
294
- # forces it: document questions are answered from the attached text itself.
295
- if ATTACHMENT_MARKER in user_message and _PHOTO_PRODUCT_INTENT.search(gate_text):
 
 
 
 
296
  messages.append({"role": "system", "content": PHOTO_GROUNDING_NOTE})
297
  for m in prior:
298
  if m.role in ("user", "assistant"):
@@ -379,14 +384,25 @@ async def run_turn(
379
  # Plain-text reply. ENFORCE grounding in code: a real question answered
380
  # with NO tool ever called is a memory/hallucination answer (or a redirect)
381
  # — make the model search first, once, before we accept it.
382
- if _needs_grounding(gate_text) and not used_tools and not forced_search:
383
  forced_search = True
384
- messages.append({"role": "user", "content": (
385
- "(Sistema) No respondas todavía. DEBES llamar primero a "
386
- "search_knowledge (y a search_products si la pregunta es de "
387
- "productos/precio/stock) y responder SOLO con lo que devuelvan. "
388
- "No respondas de memoria ni mandes al cliente fuera de la tienda."
389
- )})
 
 
 
 
 
 
 
 
 
 
 
390
  continue
391
  reply = result.content or FALLBACK_REPLY
392
  break
 
224
  instructions: str = "",
225
  on_stage: Callable[[str], None] | None = None,
226
  intent_text: str | None = None,
227
+ image_uploaded: bool = False,
228
  ) -> ChatResponse:
229
  # intent_text: the customer's OWN words, without any attachment block the
230
  # route prepended. The deterministic intent gates must match only what the
 
289
  "pasar con una persona. NUNCA mandes al cliente a la web del fabricante "
290
  "ni a un tercero. PROHIBIDO inventar."
291
  )})
292
+ # Photo-to-product chain: force a (visually-aware) catalog search when the visitor
293
+ # uploaded a PHOTO and either (a) said something product-seeking, or (b) sent the
294
+ # photo with no substantive question of its own — a bare photo IS "do you have
295
+ # this?". search_products then blends visual similarity (the uploaded photo's CLIP
296
+ # vector) with text. A PDF or a real non-product question is NOT forced.
297
+ photo_search = (
298
+ ATTACHMENT_MARKER in user_message and _PHOTO_PRODUCT_INTENT.search(gate_text)
299
+ ) or (image_uploaded and not _needs_grounding(gate_text))
300
+ if photo_search:
301
  messages.append({"role": "system", "content": PHOTO_GROUNDING_NOTE})
302
  for m in prior:
303
  if m.role in ("user", "assistant"):
 
384
  # Plain-text reply. ENFORCE grounding in code: a real question answered
385
  # with NO tool ever called is a memory/hallucination answer (or a redirect)
386
  # — make the model search first, once, before we accept it.
387
+ if (_needs_grounding(gate_text) or photo_search) and not used_tools and not forced_search:
388
  forced_search = True
389
+ if photo_search:
390
+ # A photo rode in: the grounded answer comes from the catalog, matched
391
+ # by the image (visual + its described attributes), never from memory.
392
+ messages.append({"role": "user", "content": (
393
+ "(Sistema) No respondas todavía. DEBES llamar primero a "
394
+ "search_products usando como consulta los atributos de la imagen "
395
+ "(tipo de objeto, categoría, color, material, medidas, marca) y "
396
+ "responder SOLO con los productos que devuelva. Si no hay nada "
397
+ "parecido, dilo con sinceridad. No inventes productos."
398
+ )})
399
+ else:
400
+ messages.append({"role": "user", "content": (
401
+ "(Sistema) No respondas todavía. DEBES llamar primero a "
402
+ "search_knowledge (y a search_products si la pregunta es de "
403
+ "productos/precio/stock) y responder SOLO con lo que devuelvan. "
404
+ "No respondas de memoria ni mandes al cliente fuera de la tienda."
405
+ )})
406
  continue
407
  reply = result.content or FALLBACK_REPLY
408
  break
app/products/index.py CHANGED
@@ -38,3 +38,30 @@ async def search(
38
  scored = [(c, embeddings.cosine(qvec, c.embedding)) for c in chunks]
39
  scored.sort(key=lambda pair: pair[1], reverse=True)
40
  return scored[:k]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  scored = [(c, embeddings.cosine(qvec, c.embedding)) for c in chunks]
39
  scored.sort(key=lambda pair: pair[1], reverse=True)
40
  return scored[:k]
41
+
42
+
43
+ async def visual_search(
44
+ session: AsyncSession,
45
+ image_vec: list[float],
46
+ k: int = 8,
47
+ *,
48
+ tenant_id: int | None = None,
49
+ ) -> list[tuple[ProductChunk, float]]:
50
+ """Rank this tenant's products by VISUAL similarity (CLIP cosine) to an uploaded
51
+ photo. Only products whose primary image has been embedded participate; returns up
52
+ to k (ProductChunk, score) pairs, most similar first. Always tenant-scoped, so one
53
+ store's catalog can never surface in another's. Python cosine full-scan over the
54
+ tenant's image vectors — bounded by catalog size, and only runs on a photo upload."""
55
+ if not image_vec:
56
+ return []
57
+ stmt = select(ProductChunk).where(ProductChunk.image_embedding.is_not(None))
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]
app/products/sync.py CHANGED
@@ -24,6 +24,50 @@ log = logging.getLogger(__name__)
24
  # so it can cover the whole catalog — decoupled from the per-request retrieval
25
  # caps. This avoids a silent ceiling where products past #250 are never indexed.
26
  CORPUS_MAX_PRODUCTS = 5000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def _embed_text(p: dict) -> str:
@@ -114,6 +158,14 @@ async def sync_tenant(
114
 
115
  await db.flush()
116
 
 
 
 
 
 
 
 
 
117
  # Also refresh the store's shop policies (returns/shipping/privacy/terms) so
118
  # the bot ALWAYS knows them — the #1 service gap was "no tengo la política de
119
  # devoluciones". Best-effort: never let a policy hiccup fail the catalog sync.
@@ -128,6 +180,7 @@ async def sync_tenant(
128
  "total": len(products),
129
  "reembedded": len(pending),
130
  "removed": len(removed),
 
131
  "policies": policies.get("indexed", 0),
132
  "policies_reason": policies.get("reason") or policies.get("status", ""),
133
  }
 
24
  # so it can cover the whole catalog — decoupled from the per-request retrieval
25
  # caps. This avoids a silent ceiling where products past #250 are never indexed.
26
  CORPUS_MAX_PRODUCTS = 5000
27
+ # Embedding a product photo means downloading it + a CLIP forward pass, so cap how
28
+ # many we (re)embed per sync run; the daily tick fills the rest over a few runs.
29
+ IMAGE_EMBED_CAP = 60
30
+
31
+
32
+ async def sync_tenant_images(
33
+ db: AsyncSession, tenant: Tenant, *, cap: int = IMAGE_EMBED_CAP
34
+ ) -> int:
35
+ """Best-effort: embed the primary photo of products that don't have a current image
36
+ vector yet (or whose photo URL changed), for visual search. Bounded per run; the
37
+ CLIP image binary is fetched, embedded, and discarded. Returns how many were
38
+ embedded. Never raises — visual search degrades to text when a vector is missing."""
39
+ from app import imgembed
40
+
41
+ chunks = (
42
+ await db.execute(
43
+ select(ProductChunk).where(ProductChunk.tenant_id == tenant.id)
44
+ )
45
+ ).scalars().all()
46
+ todo = []
47
+ for c in chunks:
48
+ img = (c.meta or {}).get("image")
49
+ if not img:
50
+ continue
51
+ # (re)embed when missing, or when the photo URL differs from what we embedded
52
+ if c.image_embedding is not None and (c.meta or {}).get("image_emb_src") == img:
53
+ continue
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
67
+ done += 1
68
+ if done:
69
+ await db.flush()
70
+ return done
71
 
72
 
73
  def _embed_text(p: dict) -> str:
 
158
 
159
  await db.flush()
160
 
161
+ # Visual search: embed product photos (bounded, best-effort). Never let an image
162
+ # hiccup fail the catalog sync — the text corpus above is what matters most.
163
+ images_embedded = 0
164
+ try:
165
+ images_embedded = await sync_tenant_images(db, tenant)
166
+ except Exception: # noqa: BLE001 - image embedding is additive, never fatal
167
+ log.warning("image embedding pass failed for tenant %s", tenant.id, exc_info=True)
168
+
169
  # Also refresh the store's shop policies (returns/shipping/privacy/terms) so
170
  # the bot ALWAYS knows them — the #1 service gap was "no tengo la política de
171
  # devoluciones". Best-effort: never let a policy hiccup fail the catalog sync.
 
180
  "total": len(products),
181
  "reembedded": len(pending),
182
  "removed": len(removed),
183
+ "images_embedded": images_embedded,
184
  "policies": policies.get("indexed", 0),
185
  "policies_reason": policies.get("reason") or policies.get("status", ""),
186
  }
app/routes/chat.py CHANGED
@@ -221,8 +221,9 @@ async def _make_context(
221
 
222
  async def _attachment_block(
223
  db: AsyncSession, tenant_id: int, session_id: str, attachment_ids: list[str]
224
- ) -> str:
225
- """Context block for the attachments referenced by THIS turn.
 
226
 
227
  Strict isolation invariant (the product's #1 requirement): an id only
228
  matches when it belongs to the SAME tenant AND the SAME chat session.
@@ -230,7 +231,7 @@ async def _attachment_block(
230
  is SILENTLY dropped: never an error, so existence is never revealed.
231
  """
232
  if not attachment_ids:
233
- return ""
234
  rows = (
235
  await db.execute(
236
  select(Attachment)
@@ -243,7 +244,8 @@ async def _attachment_block(
243
  )
244
  ).scalars().all()
245
  parts = [f"[El cliente ha adjuntado {a.filename}]:\n{a.text}\n\n" for a in rows]
246
- return "".join(parts)[:ATTACHMENT_BLOCK_CAP]
 
247
 
248
 
249
  async def _run_chat(
@@ -268,12 +270,12 @@ async def _run_chat(
268
  # Attachments ride INSIDE the user message (also persisted that way, so
269
  # the conversation history replays coherently). They are never embedded
270
  # nor written to the knowledge base.
271
- block = await _attachment_block(db, tenant.id, ctx.session.id, attachment_ids or [])
272
  # intent_text=message: the deterministic grounding gates must read ONLY the
273
  # customer's own words, never the attachment text riding in the block.
274
  response = await run_turn(router_, ctx, block + message, brand_name=tenant.brand_name,
275
  instructions=tenant.custom_instructions, on_stage=on_stage,
276
- intent_text=message)
277
  await purge_old_sessions(db, settings.session_retention_days,
278
  conversation_hours=settings.conversation_retention_hours)
279
  await db.commit()
@@ -379,8 +381,16 @@ async def _upload_impl(
379
  text = (text or "").strip()[:ATTACHMENT_TEXT_CAP]
380
  if not text:
381
  raise HTTPException(status_code=422, detail=MSG_PDF_NO_TEXT)
382
- else:
383
- described = await vision.describe_image(data, mime)
 
 
 
 
 
 
 
 
384
  text = (described or "").strip()[:ATTACHMENT_TEXT_CAP] or VISION_FALLBACK_TEXT
385
 
386
  safe_name = SAFE_NAME.sub("_", file.filename or "archivo")[:255]
@@ -389,6 +399,7 @@ async def _upload_impl(
389
  att = Attachment(
390
  tenant_id=tenant.id, session_id=session.id,
391
  filename=safe_name, mime=mime, text=text,
 
392
  )
393
  db.add(att)
394
  await db.flush()
 
221
 
222
  async def _attachment_block(
223
  db: AsyncSession, tenant_id: int, session_id: str, attachment_ids: list[str]
224
+ ) -> tuple[str, bool]:
225
+ """Context block for the attachments referenced by THIS turn, plus whether any of
226
+ them is a PHOTO (so the orchestrator can force a visual product search).
227
 
228
  Strict isolation invariant (the product's #1 requirement): an id only
229
  matches when it belongs to the SAME tenant AND the SAME chat session.
 
231
  is SILENTLY dropped: never an error, so existence is never revealed.
232
  """
233
  if not attachment_ids:
234
+ return "", False
235
  rows = (
236
  await db.execute(
237
  select(Attachment)
 
244
  )
245
  ).scalars().all()
246
  parts = [f"[El cliente ha adjuntado {a.filename}]:\n{a.text}\n\n" for a in rows]
247
+ has_image = any((a.mime or "").startswith("image/") for a in rows)
248
+ return "".join(parts)[:ATTACHMENT_BLOCK_CAP], has_image
249
 
250
 
251
  async def _run_chat(
 
270
  # Attachments ride INSIDE the user message (also persisted that way, so
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,
277
  instructions=tenant.custom_instructions, on_stage=on_stage,
278
+ intent_text=message, image_uploaded=has_image)
279
  await purge_old_sessions(db, settings.session_retention_days,
280
  conversation_hours=settings.conversation_retention_hours)
281
  await db.commit()
 
381
  text = (text or "").strip()[:ATTACHMENT_TEXT_CAP]
382
  if not text:
383
  raise HTTPException(status_code=422, detail=MSG_PDF_NO_TEXT)
384
+ image_embedding = None
385
+ if kind != "pdf":
386
+ # Vision description (for the LLM's context) AND a CLIP embedding (for visual
387
+ # product search) in parallel — both from the same bytes, which are then
388
+ # discarded. Visual embedding is best-effort: None just falls back to text.
389
+ from app import imgembed
390
+
391
+ described, image_embedding = await asyncio.gather(
392
+ vision.describe_image(data, mime), imgembed.embed_image(data)
393
+ )
394
  text = (described or "").strip()[:ATTACHMENT_TEXT_CAP] or VISION_FALLBACK_TEXT
395
 
396
  safe_name = SAFE_NAME.sub("_", file.filename or "archivo")[:255]
 
399
  att = Attachment(
400
  tenant_id=tenant.id, session_id=session.id,
401
  filename=safe_name, mime=mime, text=text,
402
+ image_embedding=image_embedding,
403
  )
404
  db.add(att)
405
  await db.flush()
app/tools/products_tool.py CHANGED
@@ -17,8 +17,10 @@ import asyncio
17
  import logging
18
  from typing import TYPE_CHECKING, Any
19
 
 
 
20
  from app import embeddings
21
- from app.models import ProductChunk
22
  from app.products import index as product_index
23
  from app.products.sync import _embed_text
24
  from app.shopify.products import fetch_products_by_ids, search_products
@@ -30,18 +32,43 @@ log = logging.getLogger(__name__)
30
 
31
  MAX_RESULTS = 8 # never flood the model; surface the few most relevant
32
  COLD_CATALOG_CAP = 50 # in-request semantic rank cap before the corpus is built
 
33
 
34
 
35
- def _rrf(*ranked_lists: list[str], k: int = 60) -> list[str]:
36
- """Reciprocal Rank Fusion: blend several ranked id lists into one order."""
 
 
 
37
  scores: dict[str, float] = {}
38
- for lst in ranked_lists:
 
39
  for rank, pid in enumerate(lst):
40
  if pid:
41
- scores[pid] = scores.get(pid, 0.0) + 1.0 / (k + rank + 1)
42
  return sorted(scores, key=lambda pid: scores[pid], reverse=True)
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  async def _persist_cold(
46
  ctx: ToolContext, catalog: list[dict[str, Any]], vecs: list[list[float]]
47
  ) -> None:
@@ -143,23 +170,32 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
143
  lim = 4
144
  lim = max(1, min(lim, MAX_RESULTS))
145
 
146
- # Two retrieval arms in parallel: Shopify keyword + semantic corpus.
 
 
147
  kw_task = asyncio.create_task(search_products(ctx.shopify, query))
148
  sem_task = asyncio.create_task(
149
  product_index.search(ctx.db, query, k=20, tenant_id=ctx.tenant_id)
150
  )
151
- kw_products, sem_results = await asyncio.gather(kw_task, sem_task)
 
 
 
152
 
153
  kw_ids = [str(p.get("product_id") or "") for p in kw_products]
154
  sem_ids = [c.shopify_product_id for c, _ in sem_results]
 
155
 
156
  products: list[dict[str, Any]]
157
- if not sem_results and not kw_products:
158
  # Corpus not built AND keyword missed → in-request semantic rank so we
159
  # never wrongly say "no products" (the old arbitrary-slice bug).
160
  products = await _cold_semantic_rank(ctx, query)
161
  else:
162
- fused = _rrf(kw_ids, sem_ids)[:MAX_RESULTS]
 
 
 
163
  kw_by_id = {str(p.get("product_id") or ""): p for p in kw_products}
164
  sem_only = [pid for pid in fused if pid and pid not in kw_by_id]
165
  # Hydrate live price/stock for the fused shortlist in one batched call.
 
17
  import logging
18
  from typing import TYPE_CHECKING, Any
19
 
20
+ from sqlalchemy import select
21
+
22
  from app import embeddings
23
+ from app.models import Attachment, ProductChunk
24
  from app.products import index as product_index
25
  from app.products.sync import _embed_text
26
  from app.shopify.products import fetch_products_by_ids, search_products
 
32
 
33
  MAX_RESULTS = 8 # never flood the model; surface the few most relevant
34
  COLD_CATALOG_CAP = 50 # in-request semantic rank cap before the corpus is built
35
+ VISUAL_WEIGHT = 2.0 # boost the photo-similarity arm: an uploaded photo IS the intent
36
 
37
 
38
+ def _rrf(
39
+ *ranked_lists: list[str], k: int = 60, weights: list[float] | None = None
40
+ ) -> list[str]:
41
+ """Reciprocal Rank Fusion: blend several ranked id lists into one order. Optional
42
+ per-list weights let one arm (e.g. visual similarity) count more."""
43
  scores: dict[str, float] = {}
44
+ for i, lst in enumerate(ranked_lists):
45
+ w = weights[i] if weights else 1.0
46
  for rank, pid in enumerate(lst):
47
  if pid:
48
+ scores[pid] = scores.get(pid, 0.0) + w / (k + rank + 1)
49
  return sorted(scores, key=lambda pid: scores[pid], reverse=True)
50
 
51
 
52
+ async def _session_image_vec(ctx: ToolContext) -> list[float] | None:
53
+ """The CLIP vector of the most recent photo the visitor uploaded THIS session, if
54
+ any — so search blends in visually-similar products. Strictly session-scoped."""
55
+ if ctx.tenant_id is None or ctx.session is None:
56
+ return None
57
+ row = (
58
+ await ctx.db.execute(
59
+ select(Attachment.image_embedding)
60
+ .where(
61
+ Attachment.session_id == ctx.session.id,
62
+ Attachment.tenant_id == ctx.tenant_id,
63
+ Attachment.image_embedding.is_not(None),
64
+ )
65
+ .order_by(Attachment.created_at.desc(), Attachment.id.desc())
66
+ .limit(1)
67
+ )
68
+ ).first()
69
+ return list(row[0]) if row and row[0] else None
70
+
71
+
72
  async def _persist_cold(
73
  ctx: ToolContext, catalog: list[dict[str, Any]], vecs: list[list[float]]
74
  ) -> None:
 
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]
187
+ vis_ids = [c.shopify_product_id for c, _ in vis_results]
188
 
189
  products: list[dict[str, Any]]
190
+ if not sem_results and not kw_products and not vis_results:
191
  # Corpus not built AND keyword missed → in-request semantic rank so we
192
  # never wrongly say "no products" (the old arbitrary-slice bug).
193
  products = await _cold_semantic_rank(ctx, query)
194
  else:
195
+ # Visual matches count extra: when there's a photo, looking-alike IS the answer.
196
+ fused = _rrf(
197
+ kw_ids, sem_ids, vis_ids, weights=[1.0, 1.0, VISUAL_WEIGHT]
198
+ )[:MAX_RESULTS]
199
  kw_by_id = {str(p.get("product_id") or ""): p for p in kw_products}
200
  sem_only = [pid for pid in fused if pid and pid not in kw_by_id]
201
  # Hydrate live price/stock for the fused shortlist in one batched call.
migrations/versions/0030_image_embeddings.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visual product search: CLIP image embeddings.
2
+
3
+ Adds nullable `image_embedding` JSON columns to product_chunks (the product's
4
+ primary-photo vector, filled lazily by the background catalog sync) and to
5
+ attachments (a shopper's uploaded-photo vector), so an uploaded photo can be
6
+ matched against the catalog by visual similarity.
7
+
8
+ Postgres-only, additive + idempotent. No-op elsewhere (tests/dev use create_all).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from alembic import op
14
+
15
+ revision = "0030_image_embeddings"
16
+ down_revision = "0029_abandoned_carts"
17
+ branch_labels = None
18
+ depends_on = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ if op.get_bind().dialect.name != "postgresql":
23
+ return
24
+ op.execute(
25
+ "ALTER TABLE product_chunks ADD COLUMN IF NOT EXISTS image_embedding JSONB"
26
+ )
27
+ op.execute(
28
+ "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS image_embedding JSONB"
29
+ )
30
+
31
+
32
+ def downgrade() -> None:
33
+ if op.get_bind().dialect.name != "postgresql":
34
+ return
35
+ op.execute("ALTER TABLE product_chunks DROP COLUMN IF EXISTS image_embedding")
36
+ op.execute("ALTER TABLE attachments DROP COLUMN IF EXISTS image_embedding")
tests/products/test_visual.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+
4
+ from app import imgembed
5
+ from app.models import Attachment, ChatSession, ProductChunk
6
+ from app.products import index as product_index
7
+ from app.tools import registry
8
+ from app.tools.registry import ToolContext
9
+
10
+
11
+ def _vec(*xs): # tiny normalized-ish vectors for cosine
12
+ return [float(x) for x in xs]
13
+
14
+
15
+ async def test_visual_search_ranks_and_is_tenant_scoped(db_session):
16
+ # tenant 1: a "red" and a "blue" product (toy 3-dim image vectors)
17
+ db_session.add(ProductChunk(tenant_id=1, shopify_product_id="red", text="r",
18
+ embedding=_vec(0, 0, 0), image_embedding=_vec(1, 0, 0)))
19
+ db_session.add(ProductChunk(tenant_id=1, shopify_product_id="blue", text="b",
20
+ embedding=_vec(0, 0, 0), image_embedding=_vec(0, 0, 1)))
21
+ # tenant 2: a near-identical "red" — must NEVER surface for tenant 1
22
+ db_session.add(ProductChunk(tenant_id=2, shopify_product_id="red2", text="r2",
23
+ embedding=_vec(0, 0, 0), image_embedding=_vec(1, 0, 0)))
24
+ # a product with NO image vector is ignored
25
+ db_session.add(ProductChunk(tenant_id=1, shopify_product_id="noimg", text="n",
26
+ embedding=_vec(0, 0, 0), image_embedding=None))
27
+ await db_session.flush()
28
+
29
+ out = await product_index.visual_search(db_session, _vec(0.9, 0.1, 0), k=5, tenant_id=1)
30
+ ids = [c.shopify_product_id for c, _ in out]
31
+ assert ids[0] == "red" # most visually similar first
32
+ assert "red2" not in ids # tenant isolation
33
+ assert "noimg" not in ids # no image vector -> excluded
34
+ assert "blue" in ids and ids.index("blue") > ids.index("red")
35
+
36
+
37
+ async def test_visual_search_empty_vector_returns_nothing(db_session):
38
+ assert await product_index.visual_search(db_session, [], tenant_id=1) == []
39
+
40
+
41
+ class _FakeShopify:
42
+ """Returns no keyword hits + hydrates the visual-matched product id live."""
43
+
44
+ async def execute(self, query, variables=None):
45
+ return {"products": {"edges": []}}
46
+
47
+
48
+ async def test_products_tool_blends_uploaded_photo(db_session, monkeypatch):
49
+ """End-to-end: a photo uploaded this session makes search_products surface the
50
+ visually-similar product even when the keyword/semantic text arms miss it."""
51
+ sess = ChatSession(shop="x", tenant_id=1)
52
+ db_session.add(sess)
53
+ await db_session.flush() # populate sess.id before referencing it
54
+ db_session.add(ProductChunk(tenant_id=1, shopify_product_id="P9", title="Mystery Item",
55
+ text="zzz", embedding=_vec(0, 0, 0), image_embedding=_vec(1, 0, 0)))
56
+ db_session.add(Attachment(tenant_id=1, session_id=sess.id, mime="image/jpeg",
57
+ text="una foto", image_embedding=_vec(0.95, 0.05, 0)))
58
+ await db_session.flush()
59
+
60
+ # keyword arm returns nothing; hydrate returns the live product for the visual id
61
+ import app.tools.products_tool as pt
62
+
63
+ async def fake_kw(client, query, max_products=50):
64
+ return []
65
+
66
+ async def fake_hydrate(client, ids):
67
+ return [{"product_id": "P9", "title": "Mystery Item", "price": "10", "available": True,
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
84
+ assert await imgembed.embed_image(b"") is None
85
+ assert await imgembed.embed_image(b"not-an-image") is None
86
+ assert await imgembed.embed_image_url("ftp://nope") is None
87
+ assert await imgembed.embed_image_url("") is None
tests/test_orchestrator.py CHANGED
@@ -450,3 +450,46 @@ async def test_no_cart_email_attach_when_recovery_off(db_session):
450
  rows = (await db_session.execute(
451
  select(AbandonedCart).where(AbandonedCart.session_id == sess.id))).scalars().all()
452
  assert rows == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  rows = (await db_session.execute(
451
  select(AbandonedCart).where(AbandonedCart.session_id == sess.id))).scalars().all()
452
  assert rows == []
453
+
454
+
455
+ class _CapturingRouter:
456
+ def __init__(self, results):
457
+ self._results = list(results)
458
+ self.seen = []
459
+
460
+ async def chat(self, messages, tools, tier="large", temperature=None, order=None,
461
+ tool_choice=None, max_tokens=None):
462
+ self.seen.append([m.get("content", "") for m in messages])
463
+ return self._results.pop(0)
464
+
465
+
466
+ async def test_bare_photo_forces_product_search(db_session):
467
+ """A photo uploaded with NO substantive text ('do you have this?') deterministically
468
+ forces a search_products call even if the model would have just chatted."""
469
+ sess = await _session(db_session)
470
+ router = _CapturingRouter([
471
+ ChatResult(content="Bonita foto.", tool_calls=[], finish_reason="stop"), # model dodges
472
+ ChatResult(content="Esto es lo que tenemos.", tool_calls=[], finish_reason="stop"),
473
+ ])
474
+ ctx = ToolContext(db=db_session, session=sess, tenant_id=1)
475
+ await run_turn(
476
+ router, ctx, ATTACHMENT_MARKER + " foto.jpg]:\nuna zapatilla roja de running",
477
+ intent_text="", image_uploaded=True,
478
+ )
479
+ flat = " ".join(c for call in router.seen for c in call)
480
+ assert "search_products" in flat # the deterministic force kicked in
481
+
482
+
483
+ async def test_pdf_or_plain_text_does_not_force_product_search(db_session):
484
+ """No image uploaded + a normal question must NOT trigger the photo->product force."""
485
+ sess = await _session(db_session)
486
+ router = _CapturingRouter([
487
+ ChatResult(content="Te ayudo con eso.", tool_calls=[], finish_reason="stop"),
488
+ ChatResult(content="Aquí tienes.", tool_calls=[], finish_reason="stop"),
489
+ ])
490
+ ctx = ToolContext(db=db_session, session=sess, tenant_id=1)
491
+ await run_turn(router, ctx, "¿cuánto tarda el envío?", intent_text="¿cuánto tarda el envío?",
492
+ image_uploaded=False)
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