victor34593993 commited on
Commit
b81fe79
·
verified ·
1 Parent(s): 593d015

deep knowledge + guards

Browse files
app/lang.py CHANGED
@@ -201,19 +201,54 @@ _LATIN_LETTERS = re.compile(r"[A-Za-zÀ-ÿ]")
201
 
202
 
203
  def needs_language_repair(reply: str, pinned_name: str | None) -> bool:
204
- """True when a non-Latin language was pinned but the reply drifted to a
205
- mostly-Latin script (e.g. Korean pinned, answer came back in Spanish)."""
 
 
 
 
206
  if not reply or not pinned_name:
207
  return False
208
  code = _NAME_TO_CODE.get(pinned_name)
209
  script = _SCRIPT_BY_CODE.get(code or "")
210
- if script is None: # Latin or unmapped → skip (can't verify reliably by script)
 
 
 
 
 
 
 
 
 
211
  return False
212
- target = len(script.findall(reply))
213
- latin = len(_LATIN_LETTERS.findall(reply))
214
- # A correct non-Latin answer is dominated by its script; product names and
215
- # links add a little Latin. Drift = Latin letters outnumber target script.
216
- return target < latin
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
 
219
  def detect_language(text: str) -> str | None:
 
201
 
202
 
203
  def needs_language_repair(reply: str, pinned_name: str | None) -> bool:
204
+ """True when the reply drifted off the pinned language.
205
+
206
+ Non-Latin languages are verified by script (cheap, reliable). Latin-script
207
+ languages are verified by re-detecting the (long) reply and comparing — this
208
+ catches e.g. an English question answered in Spanish.
209
+ """
210
  if not reply or not pinned_name:
211
  return False
212
  code = _NAME_TO_CODE.get(pinned_name)
213
  script = _SCRIPT_BY_CODE.get(code or "")
214
+ if script is not None:
215
+ target = len(script.findall(reply))
216
+ latin = len(_LATIN_LETTERS.findall(reply))
217
+ # A correct non-Latin answer is dominated by its script; product names
218
+ # and links add a little Latin. Drift = Latin letters outnumber script.
219
+ return target < latin
220
+ # Latin-script pinned language: re-detect the reply and compare. Only act on
221
+ # a confident, different result (detect_language returns None when unsure),
222
+ # so we never loop on ambiguous text.
223
+ if len(reply) < 20:
224
  return False
225
+ detected = detect_language(reply)
226
+ return detected is not None and detected != pinned_name
227
+
228
+
229
+ # Strip emoji/pictographs the model may add despite the no-emoji rule. The
230
+ # product must never show emojis (hard requirement), so we enforce it
231
+ # deterministically on output instead of trusting the model.
232
+ _EMOJI = re.compile(
233
+ "["
234
+ "🀀-\U0001FAFF" # emoji, pictographs, symbols, supplemental
235
+ "✀-➿" # dingbats
236
+ "─-⯿" # misc symbols, arrows-block emoji, stars (☀ ⭐ ✅ ⌚ …)
237
+ "\U0001F1E6-\U0001F1FF" # regional indicators (flags)
238
+ "︀-️‍⃣" # variation selectors, ZWJ, keycap
239
+ "]+",
240
+ flags=re.UNICODE,
241
+ )
242
+
243
+
244
+ def strip_emojis(text: str) -> str:
245
+ if not text:
246
+ return text
247
+ cleaned = _EMOJI.sub("", text)
248
+ # tidy up double spaces / space-before-punct left by removed emojis
249
+ cleaned = re.sub(r"[ \t]{2,}", " ", cleaned)
250
+ cleaned = re.sub(r" ([,.;:!?])", r"\1", cleaned)
251
+ return cleaned.strip()
252
 
253
 
254
  def detect_language(text: str) -> str | None:
app/orchestrator.py CHANGED
@@ -131,7 +131,7 @@ async def run_turn(
131
  # Language guard: if a non-Latin language was pinned but the model drifted
132
  # (e.g. answered Korean question in Spanish), repair with one translation
133
  # pass. Guarantees the output language regardless of which provider answered.
134
- from app.lang import needs_language_repair
135
 
136
  if reply != FALLBACK_REPLY and needs_language_repair(reply, language):
137
  repair_msgs = [
@@ -154,6 +154,9 @@ async def run_turn(
154
  except ProviderError:
155
  pass # keep the original reply rather than fail the turn
156
 
 
 
 
157
  ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
158
 
159
  from app import analytics
 
131
  # Language guard: if a non-Latin language was pinned but the model drifted
132
  # (e.g. answered Korean question in Spanish), repair with one translation
133
  # pass. Guarantees the output language regardless of which provider answered.
134
+ from app.lang import needs_language_repair, strip_emojis
135
 
136
  if reply != FALLBACK_REPLY and needs_language_repair(reply, language):
137
  repair_msgs = [
 
154
  except ProviderError:
155
  pass # keep the original reply rather than fail the turn
156
 
157
+ # Hard no-emoji guarantee: strip any emoji the model added despite the rule.
158
+ reply = strip_emojis(reply) or reply
159
+
160
  ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
161
 
162
  from app import analytics
app/rag/extract.py CHANGED
@@ -1,31 +1,46 @@
1
- """Extract plain text from uploaded files and from URLs."""
2
 
3
  from __future__ import annotations
4
 
5
  import io
 
 
6
  from pathlib import Path
 
7
 
8
  import httpx
9
  from selectolax.parser import HTMLParser
10
 
 
11
 
12
- def extract_html(html: bytes | str) -> str:
13
- """Strip tags/scripts/styles and return visible text."""
 
 
 
 
 
14
  tree = HTMLParser(html if isinstance(html, str) else html.decode("utf-8", errors="replace"))
 
 
15
  for tag in tree.css("script, style, noscript"):
16
  tag.decompose()
17
  body = tree.body or tree.root
18
- if body is None:
19
- return ""
20
- text = body.text(separator=" ", strip=True)
21
- return " ".join(text.split())
 
 
 
22
 
23
 
24
  async def extract_url(url: str, *, client: httpx.AsyncClient | None = None) -> str:
 
25
  owns = client is None
26
  client = client or httpx.AsyncClient(timeout=20.0, follow_redirects=True)
27
  try:
28
- resp = await client.get(url, headers={"User-Agent": "shopify-support-bot/0.1"})
29
  resp.raise_for_status()
30
  return extract_html(resp.content)
31
  finally:
@@ -33,6 +48,67 @@ async def extract_url(url: str, *, client: httpx.AsyncClient | None = None) -> s
33
  await client.aclose()
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def _extract_pdf(data: bytes) -> str:
37
  from pypdf import PdfReader
38
 
 
1
+ """Extract plain text from uploaded files and from URLs (with site crawling)."""
2
 
3
  from __future__ import annotations
4
 
5
  import io
6
+ import logging
7
+ import re
8
  from pathlib import Path
9
+ from urllib.parse import urldefrag, urljoin, urlparse
10
 
11
  import httpx
12
  from selectolax.parser import HTMLParser
13
 
14
+ log = logging.getLogger(__name__)
15
 
16
+ _UA = {"User-Agent": "shopify-support-bot/0.1"}
17
+ # Bound the crawl so indexing a site stays fast and free.
18
+ CRAWL_MAX_PAGES = 25
19
+
20
+
21
+ def _text_and_links(html: bytes | str) -> tuple[str, list[str]]:
22
+ """Return (visible_text, hrefs) from an HTML document."""
23
  tree = HTMLParser(html if isinstance(html, str) else html.decode("utf-8", errors="replace"))
24
+ links = [a.attributes.get("href") for a in tree.css("a")]
25
+ links = [h for h in links if h]
26
  for tag in tree.css("script, style, noscript"):
27
  tag.decompose()
28
  body = tree.body or tree.root
29
+ text = " ".join(body.text(separator=" ", strip=True).split()) if body is not None else ""
30
+ return text, links
31
+
32
+
33
+ def extract_html(html: bytes | str) -> str:
34
+ """Strip tags/scripts/styles and return visible text."""
35
+ return _text_and_links(html)[0]
36
 
37
 
38
  async def extract_url(url: str, *, client: httpx.AsyncClient | None = None) -> str:
39
+ """Fetch a single page's visible text."""
40
  owns = client is None
41
  client = client or httpx.AsyncClient(timeout=20.0, follow_redirects=True)
42
  try:
43
+ resp = await client.get(url, headers=_UA)
44
  resp.raise_for_status()
45
  return extract_html(resp.content)
46
  finally:
 
48
  await client.aclose()
49
 
50
 
51
+ async def _sitemap_urls(client: httpx.AsyncClient, base: str, host: str) -> list[str]:
52
+ try:
53
+ resp = await client.get(urljoin(base, "/sitemap.xml"), headers=_UA)
54
+ if resp.status_code != 200:
55
+ return []
56
+ locs = re.findall(r"<loc>\s*([^<\s]+)\s*</loc>", resp.text)
57
+ return [u for u in locs if urlparse(u).netloc == host]
58
+ except Exception: # noqa: BLE001 - sitemap is best-effort
59
+ return []
60
+
61
+
62
+ async def crawl_url(
63
+ start_url: str, *, max_pages: int = CRAWL_MAX_PAGES, client: httpx.AsyncClient | None = None
64
+ ) -> str:
65
+ """Crawl same-domain pages from ``start_url`` and return the combined text.
66
+
67
+ Follows internal links (and sitemap.xml) breadth-first up to ``max_pages``
68
+ so the whole site is indexed, not just the single landing page. Stays on the
69
+ starting host and only parses HTML responses.
70
+ """
71
+ owns = client is None
72
+ client = client or httpx.AsyncClient(timeout=20.0, follow_redirects=True)
73
+ host = urlparse(start_url).netloc
74
+ seen: set[str] = set()
75
+ queue: list[str] = [urldefrag(start_url)[0]]
76
+ texts: list[str] = []
77
+ try:
78
+ for u in await _sitemap_urls(client, start_url, host):
79
+ u = urldefrag(u)[0]
80
+ if u not in queue:
81
+ queue.append(u)
82
+ while queue and len(seen) < max_pages:
83
+ url = queue.pop(0)
84
+ if url in seen:
85
+ continue
86
+ seen.add(url)
87
+ try:
88
+ resp = await client.get(url, headers=_UA)
89
+ resp.raise_for_status()
90
+ if "html" not in resp.headers.get("content-type", "").lower():
91
+ continue
92
+ text, links = _text_and_links(resp.content)
93
+ except Exception as exc: # noqa: BLE001 - skip a bad page, keep crawling
94
+ log.debug("crawl skip %s: %s", url, exc)
95
+ continue
96
+ if text:
97
+ texts.append(text)
98
+ if len(seen) + len(queue) >= max_pages:
99
+ continue
100
+ for href in links:
101
+ nxt = urldefrag(urljoin(url, href))[0]
102
+ p = urlparse(nxt)
103
+ if p.scheme in ("http", "https") and p.netloc == host:
104
+ if nxt not in seen and nxt not in queue:
105
+ queue.append(nxt)
106
+ finally:
107
+ if owns:
108
+ await client.aclose()
109
+ return "\n\n".join(texts)
110
+
111
+
112
  def _extract_pdf(data: bytes) -> str:
113
  from pypdf import PdfReader
114
 
app/rag/index.py CHANGED
@@ -18,7 +18,9 @@ log = logging.getLogger(__name__)
18
 
19
  async def _source_text(source: KnowledgeSource) -> str:
20
  if source.kind == "url":
21
- return await extract.extract_url(source.location)
 
 
22
  return extract.extract_file(source.location)
23
 
24
 
 
18
 
19
  async def _source_text(source: KnowledgeSource) -> str:
20
  if source.kind == "url":
21
+ # Crawl the whole site (internal links + sitemap), not just one page,
22
+ # so nothing on the merchant's site is left out of the knowledge base.
23
+ return await extract.crawl_url(source.location)
24
  return extract.extract_file(source.location)
25
 
26
 
app/shopify/products.py CHANGED
@@ -8,26 +8,33 @@ from typing import Any
8
  from app.shopify.client import ShopifyGraphQLClient
9
 
10
  PRODUCTS_QUERY = """
11
- query ProductSearch($q: String!) {
12
- products(first: 6, query: $q) {
13
  edges {
14
  node {
15
  title
16
  handle
17
  onlineStoreUrl
18
  description
 
 
19
  featuredImage { url }
20
- variants(first: 25) {
21
- edges { node { id title price availableForSale } }
22
  }
23
  }
24
  }
 
25
  }
26
  }
27
  """
28
 
 
 
 
29
 
30
- def _truncate(text: str | None, n: int = 300) -> str:
 
31
  text = (text or "").strip()
32
  return text if len(text) <= n else text[:n].rstrip() + "…"
33
 
@@ -61,6 +68,7 @@ def _parse_product(node: dict[str, Any]) -> dict[str, Any]:
61
  }
62
  )
63
  image = (node.get("featuredImage") or {}).get("url")
 
64
  return {
65
  "title": node.get("title", ""),
66
  "price": min(prices) if prices else None,
@@ -68,11 +76,31 @@ def _parse_product(node: dict[str, Any]) -> dict[str, Any]:
68
  "url": node.get("onlineStoreUrl"),
69
  "image": image,
70
  "description": _truncate(node.get("description")),
 
 
71
  "variants": variants,
72
  }
73
 
74
 
75
- async def search_products(client: ShopifyGraphQLClient, query: str) -> list[dict[str, Any]]:
76
- data = await client.execute(PRODUCTS_QUERY, {"q": query})
77
- edges = data.get("products", {}).get("edges", [])
78
- return [_parse_product(e["node"]) for e in edges]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  from app.shopify.client import ShopifyGraphQLClient
9
 
10
  PRODUCTS_QUERY = """
11
+ query ProductSearch($q: String!, $after: String) {
12
+ products(first: 50, query: $q, after: $after) {
13
  edges {
14
  node {
15
  title
16
  handle
17
  onlineStoreUrl
18
  description
19
+ productType
20
+ tags
21
  featuredImage { url }
22
+ variants(first: 50) {
23
+ edges { node { id title price availableForSale sku } }
24
  }
25
  }
26
  }
27
+ pageInfo { hasNextPage endCursor }
28
  }
29
  }
30
  """
31
 
32
+ # Walk through at most this many products (paginated). Covers full catalogs for
33
+ # typical stores while bounding cost; queries page until this cap is reached.
34
+ MAX_PRODUCTS = 250
35
 
36
+
37
+ def _truncate(text: str | None, n: int = 1500) -> str:
38
  text = (text or "").strip()
39
  return text if len(text) <= n else text[:n].rstrip() + "…"
40
 
 
68
  }
69
  )
70
  image = (node.get("featuredImage") or {}).get("url")
71
+ tags = node.get("tags") or []
72
  return {
73
  "title": node.get("title", ""),
74
  "price": min(prices) if prices else None,
 
76
  "url": node.get("onlineStoreUrl"),
77
  "image": image,
78
  "description": _truncate(node.get("description")),
79
+ "product_type": node.get("productType") or "",
80
+ "tags": tags if isinstance(tags, list) else [],
81
  "variants": variants,
82
  }
83
 
84
 
85
+ async def search_products(
86
+ client: ShopifyGraphQLClient, query: str, *, max_products: int = MAX_PRODUCTS
87
+ ) -> list[dict[str, Any]]:
88
+ """Search the catalog, paginating until exhausted or ``max_products`` hit.
89
+
90
+ Shopify caps a page at a handful of results; without pagination a store with
91
+ more than a page of matches would be invisible past the first page.
92
+ """
93
+ out: list[dict[str, Any]] = []
94
+ after: str | None = None
95
+ while len(out) < max_products:
96
+ data = await client.execute(PRODUCTS_QUERY, {"q": query, "after": after})
97
+ products = data.get("products", {})
98
+ for e in products.get("edges", []):
99
+ out.append(_parse_product(e["node"]))
100
+ page = products.get("pageInfo", {})
101
+ if not page.get("hasNextPage"):
102
+ break
103
+ after = page.get("endCursor")
104
+ if not after:
105
+ break
106
+ return out[:max_products]
app/tools/knowledge_tool.py CHANGED
@@ -14,7 +14,7 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
14
  query = (args.get("query") or "").strip()
15
  if not query:
16
  return {"context": "", "sources": [], "note": "empty query"}
17
- results = await index.search(ctx.db, query, k=6, tenant_id=ctx.tenant_id)
18
  if not results:
19
  # Log the gap so the merchant sees what the bot couldn't answer and can
20
  # add it to their knowledge. Tells the model to NOT invent an answer.
 
14
  query = (args.get("query") or "").strip()
15
  if not query:
16
  return {"context": "", "sources": [], "note": "empty query"}
17
+ results = await index.search(ctx.db, query, k=8, tenant_id=ctx.tenant_id)
18
  if not results:
19
  # Log the gap so the merchant sees what the bot couldn't answer and can
20
  # add it to their knowledge. Tells the model to NOT invent an answer.
app/tools/products_tool.py CHANGED
@@ -17,6 +17,14 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
17
  if not query:
18
  return {"status": "ok", "products": []}
19
  products = await search_products(ctx.shopify, query)
 
 
 
 
 
 
 
 
20
  # Surface as visual cards to the widget (de-duplicated by title).
21
  seen = {c.get("title") for c in ctx.cards}
22
  for p in products:
@@ -29,20 +37,30 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
29
  p["add_url"] = f"https://{ctx.shop}/cart/{variant['variant_id']}:1"
30
  ctx.cards.append(p)
31
  seen.add(p["title"])
32
- # Compact view for the model (the rich cards already went to the widget):
33
- # cap to 5 products + 10 variants, drop descriptions keeps requests small
34
- # so we stay under Groq's per-minute token limit.
35
  compact = [
36
  {
37
  "title": p["title"],
38
  "price": p["price"],
39
  "available": p["available"],
 
 
 
40
  "variants": [
41
  {"variant_id": v["variant_id"], "title": v["title"], "price": v["price"]}
42
- for v in p["variants"][:10]
43
  if v["available"]
44
  ],
45
  }
46
- for p in products[:5]
47
  ]
48
- return {"status": "ok", "products": compact}
 
 
 
 
 
 
 
 
17
  if not query:
18
  return {"status": "ok", "products": []}
19
  products = await search_products(ctx.shopify, query)
20
+ fallback = False
21
+ # Keyword search misses when the query language differs from the catalog
22
+ # (e.g. "garden hose" vs a Spanish catalog) or matches nothing. Rather than
23
+ # tell the customer "no products" when products exist, fall back to the full
24
+ # catalog so the model can match semantically across languages.
25
+ if not products:
26
+ products = await search_products(ctx.shopify, "", max_products=50)
27
+ fallback = bool(products)
28
  # Surface as visual cards to the widget (de-duplicated by title).
29
  seen = {c.get("title") for c in ctx.cards}
30
  for p in products:
 
37
  p["add_url"] = f"https://{ctx.shop}/cart/{variant['variant_id']}:1"
38
  ctx.cards.append(p)
39
  seen.add(p["title"])
40
+ # Compact view for the model (rich cards already went to the widget). Include
41
+ # a short description + type/tags so the model can match the customer's need
42
+ # across languages; cap products/variants to stay within token limits.
43
  compact = [
44
  {
45
  "title": p["title"],
46
  "price": p["price"],
47
  "available": p["available"],
48
+ "type": p.get("product_type") or None,
49
+ "tags": (p.get("tags") or [])[:6] or None,
50
+ "description": (p.get("description") or "")[:200] or None,
51
  "variants": [
52
  {"variant_id": v["variant_id"], "title": v["title"], "price": v["price"]}
53
+ for v in p["variants"][:12]
54
  if v["available"]
55
  ],
56
  }
57
+ for p in products[:10]
58
  ]
59
+ out: dict[str, Any] = {"status": "ok", "products": compact}
60
+ if fallback:
61
+ out["note"] = (
62
+ "La búsqueda por palabra clave no coincidió; estos son productos del "
63
+ "catálogo. Elige los que encajen con lo que pide el cliente (puede "
64
+ "estar en otro idioma); si ninguno encaja, dilo con sinceridad."
65
+ )
66
+ return out
tests/rag/test_extract.py CHANGED
@@ -86,3 +86,73 @@ def test_extract_unsupported_raises(tmp_path):
86
 
87
  with pytest.raises(ValueError):
88
  extract_file(p)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
  with pytest.raises(ValueError):
88
  extract_file(p)
89
+
90
+
91
+ class _Resp:
92
+ def __init__(self, *, content=b"", text="", status_code=200, content_type="text/html"):
93
+ self.content = content
94
+ self.text = text
95
+ self.status_code = status_code
96
+ self.headers = {"content-type": content_type}
97
+
98
+ def raise_for_status(self):
99
+ if self.status_code >= 400:
100
+ raise RuntimeError(f"http {self.status_code}")
101
+
102
+
103
+ class _FakeClient:
104
+ """Maps URLs to responses; unknown URLs return 404."""
105
+
106
+ def __init__(self, pages):
107
+ self.pages = pages
108
+ self.requested = []
109
+
110
+ async def get(self, url, headers=None):
111
+ self.requested.append(url)
112
+ return self.pages.get(url, _Resp(status_code=404))
113
+
114
+
115
+ async def test_crawl_follows_internal_links_and_sitemap():
116
+ from app.rag.extract import crawl_url
117
+
118
+ base = "https://shop.example"
119
+ pages = {
120
+ f"{base}/": _Resp(content=(
121
+ f'<html><body><h1>Inicio</h1>'
122
+ f'<a href="/faq">FAQ</a> <a href="{base}/envios">Envios</a>'
123
+ f'<a href="https://otro.com/x">externo</a></body></html>'
124
+ ).encode()),
125
+ f"{base}/faq": _Resp(content=b"<html><body>Garantia de dos anios</body></html>"),
126
+ f"{base}/envios": _Resp(content=b"<html><body>Envio gratis desde 199</body></html>"),
127
+ # discovered only via sitemap, not linked from any page
128
+ f"{base}/oculta": _Resp(content=b"<html><body>Pagina secreta del sitemap</body></html>"),
129
+ f"{base}/sitemap.xml": _Resp(
130
+ text=f"<urlset><url><loc>{base}/oculta</loc></url></urlset>",
131
+ content_type="application/xml",
132
+ ),
133
+ }
134
+ client = _FakeClient(pages)
135
+ text = await crawl_url(base + "/", client=client)
136
+ # every internal page is indexed (landing + linked + sitemap-only)
137
+ assert "Garantia de dos anios" in text
138
+ assert "Envio gratis desde 199" in text
139
+ assert "Pagina secreta del sitemap" in text
140
+ # external domain is never fetched
141
+ assert "https://otro.com/x" not in client.requested
142
+
143
+
144
+ async def test_crawl_respects_max_pages():
145
+ from app.rag.extract import crawl_url
146
+
147
+ base = "https://big.example"
148
+ pages = {f"{base}/sitemap.xml": _Resp(status_code=404)}
149
+ # landing links to 10 pages; cap at 3
150
+ links = " ".join(f'<a href="/p{i}">p{i}</a>' for i in range(10))
151
+ pages[f"{base}/"] = _Resp(content=f"<html><body>{links}</body></html>".encode())
152
+ for i in range(10):
153
+ pages[f"{base}/p{i}"] = _Resp(content=f"<html><body>page {i}</body></html>".encode())
154
+ client = _FakeClient(pages)
155
+ await crawl_url(base + "/", max_pages=3, client=client)
156
+ # sitemap probe + at most 3 page fetches
157
+ page_fetches = [u for u in client.requested if "sitemap" not in u]
158
+ assert len(page_fetches) <= 3
tests/shopify/test_products.py CHANGED
@@ -47,3 +47,45 @@ async def test_search_products_maps_nodes_with_variants_and_image():
47
 
48
  async def test_search_products_empty():
49
  assert await search_products(FakeClient({"products": {"edges": []}}), "nada") == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  async def test_search_products_empty():
49
  assert await search_products(FakeClient({"products": {"edges": []}}), "nada") == []
50
+
51
+
52
+ class PagingClient:
53
+ """Returns successive pages, honouring the cursor pagination contract."""
54
+
55
+ def __init__(self, pages):
56
+ self.pages = pages
57
+ self.calls = 0
58
+
59
+ async def execute(self, query, variables=None):
60
+ page = self.pages[min(self.calls, len(self.pages) - 1)]
61
+ self.calls += 1
62
+ return page
63
+
64
+
65
+ def _node(title):
66
+ return {"node": {"title": title, "handle": title, "onlineStoreUrl": None,
67
+ "description": "", "variants": {"edges": []}}}
68
+
69
+
70
+ async def test_search_products_paginates_until_exhausted():
71
+ pages = [
72
+ {"products": {"edges": [_node("A"), _node("B")],
73
+ "pageInfo": {"hasNextPage": True, "endCursor": "c1"}}},
74
+ {"products": {"edges": [_node("C")],
75
+ "pageInfo": {"hasNextPage": False, "endCursor": None}}},
76
+ ]
77
+ client = PagingClient(pages)
78
+ products = await search_products(client, "x")
79
+ assert [p["title"] for p in products] == ["A", "B", "C"] # both pages walked
80
+ assert client.calls == 2
81
+
82
+
83
+ async def test_long_description_not_truncated_to_300():
84
+ desc = "x" * 900 # was capped at 300; full descriptions must reach the model
85
+ data = {"products": {"edges": [{"node": {
86
+ "title": "T", "handle": "t", "onlineStoreUrl": None,
87
+ "description": desc, "variants": {"edges": []},
88
+ }}]}}
89
+ products = await search_products(FakeClient(data), "t")
90
+ assert products[0]["description"] == desc
91
+ assert "…" not in products[0]["description"]
tests/test_lang.py CHANGED
@@ -111,11 +111,24 @@ def test_language_guard_flags_drift():
111
  # correct: answer in the pinned script → no repair (Latin product names allowed)
112
  assert needs_language_repair("네, 정원용 호스가 있습니다. Gobeflat Medium 모델입니다.", ko) is False
113
  assert needs_language_repair("我们有花园用的水管。PVC 平面水管。", zh) is False
114
- # Latin-script pinned languages are not script-verifiable never force repair
115
- assert needs_language_repair("Sí, tenemos productos.", "français") is False
 
 
116
  assert needs_language_repair("anything", None) is False
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
119
  def test_prompt_forbids_emojis():
120
  from app.prompts import build_system_prompt
121
 
 
111
  # correct: answer in the pinned script → no repair (Latin product names allowed)
112
  assert needs_language_repair("네, 정원용 호스가 있습니다. Gobeflat Medium 모델입니다.", ko) is False
113
  assert needs_language_repair("我们有花园用的水管。PVC 平面水管。", zh) is False
114
+ # Latin drift is also caught: a French-pinned reply that came back in Spanish
115
+ assert needs_language_repair("Sí, tenemos varias mangueras de jardín disponibles para ti.", "français") is True
116
+ # ...but a genuinely French reply pinned to French is left alone
117
+ assert needs_language_repair("Bonjour, nous avons plusieurs tuyaux d'arrosage disponibles.", "français") is False
118
  assert needs_language_repair("anything", None) is False
119
 
120
 
121
+ def test_strip_emojis_removes_pictographs_keeps_text():
122
+ from app.lang import strip_emojis
123
+
124
+ assert strip_emojis("¡Hola! 😊 ¿En qué te ayudo?") == "¡Hola! ¿En qué te ayudo?"
125
+ assert strip_emojis("Tenemos mangueras 🌿✅ disponibles") == "Tenemos mangueras disponibles"
126
+ # non-Latin text must survive untouched (emoji ranges don't overlap scripts)
127
+ assert strip_emojis("정원용 호스 😀 있습니다") == "정원용 호스 있습니다"
128
+ assert strip_emojis("我们有水管 🚀") == "我们有水管"
129
+ assert strip_emojis("normal text") == "normal text"
130
+
131
+
132
  def test_prompt_forbids_emojis():
133
  from app.prompts import build_system_prompt
134
 
tests/tools/test_tools.py CHANGED
@@ -247,6 +247,38 @@ async def test_order_tool_rate_limited_across_sessions(db_session, monkeypatch):
247
  assert last["status"] == "locked" # 4th attempt across fresh sessions is throttled
248
 
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  async def test_escalate_tool_stores_request(db_session):
251
  from sqlalchemy import select
252
 
 
247
  assert last["status"] == "locked" # 4th attempt across fresh sessions is throttled
248
 
249
 
250
+ class QueryAwareShopify:
251
+ """Empty for keyword queries, returns the catalog for the broad ('') query."""
252
+
253
+ def __init__(self, catalog):
254
+ self.catalog = catalog
255
+
256
+ async def execute(self, query, variables=None):
257
+ q = (variables or {}).get("q", "")
258
+ edges = self.catalog if q == "" else []
259
+ return {"products": {"edges": edges, "pageInfo": {"hasNextPage": False}}}
260
+
261
+
262
+ async def test_products_tool_falls_back_to_catalog_when_keyword_misses(db_session):
263
+ catalog = [{"node": {
264
+ "title": "Manguera Plana Gobeflat", "handle": "gf", "onlineStoreUrl": None,
265
+ "description": "Manguera para riego y achique", "productType": "Mangueras",
266
+ "tags": ["jardin"], "variants": {"edges": [
267
+ {"node": {"id": "gid://shopify/ProductVariant/9", "title": "10m",
268
+ "price": "16.99", "availableForSale": True}}]}}}]
269
+ ctx = ToolContext(
270
+ db=db_session, session=await _session(db_session),
271
+ shop="s.myshopify.com", shopify=QueryAwareShopify(catalog),
272
+ )
273
+ # customer asked in another language; keyword "garden hose" matches nothing
274
+ out = await registry.dispatch("search_products", {"query": "garden hose"}, ctx)
275
+ assert out["status"] == "ok"
276
+ assert out["products"], "fallback must surface catalog products, not empty"
277
+ assert out["products"][0]["title"] == "Manguera Plana Gobeflat"
278
+ assert "note" in out # tells the model these came from the catalog fallback
279
+ assert ctx.cards # visual cards still surfaced
280
+
281
+
282
  async def test_escalate_tool_stores_request(db_session):
283
  from sqlalchemy import select
284