Feature 4: true visual product search (CLIP)
Browse files- app/imgembed.py +77 -0
- app/models.py +8 -0
- app/orchestrator.py +28 -12
- app/products/index.py +27 -0
- app/products/sync.py +53 -0
- app/routes/chat.py +19 -8
- app/tools/products_tool.py +45 -9
- migrations/versions/0030_image_embeddings.py +36 -0
- tests/products/test_visual.py +87 -0
- tests/test_orchestrator.py +43 -0
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:
|
| 292 |
-
#
|
| 293 |
-
#
|
| 294 |
-
#
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
"
|
| 388 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
| 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) +
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|