deep knowledge + guards
Browse files- app/lang.py +43 -8
- app/orchestrator.py +4 -1
- app/rag/extract.py +84 -8
- app/rag/index.py +3 -1
- app/shopify/products.py +37 -9
- app/tools/knowledge_tool.py +1 -1
- app/tools/products_tool.py +24 -6
- tests/rag/test_extract.py +70 -0
- tests/shopify/test_products.py +42 -0
- tests/test_lang.py +15 -2
- tests/tools/test_tools.py +32 -0
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
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
return False
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
|
|
|
| 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:
|
| 13 |
edges {
|
| 14 |
node {
|
| 15 |
title
|
| 16 |
handle
|
| 17 |
onlineStoreUrl
|
| 18 |
description
|
|
|
|
|
|
|
| 19 |
featuredImage { url }
|
| 20 |
-
variants(first:
|
| 21 |
-
edges { node { id title price availableForSale } }
|
| 22 |
}
|
| 23 |
}
|
| 24 |
}
|
|
|
|
| 25 |
}
|
| 26 |
}
|
| 27 |
"""
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
|
|
|
|
| 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(
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 (
|
| 33 |
-
#
|
| 34 |
-
#
|
| 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"][:
|
| 43 |
if v["available"]
|
| 44 |
],
|
| 45 |
}
|
| 46 |
-
for p in products[:
|
| 47 |
]
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 115 |
-
assert needs_language_repair("Sí, tenemos
|
|
|
|
|
|
|
| 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 |
|