deploy flexigo support bot
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +15 -0
- .python-version +1 -0
- Dockerfile +27 -0
- Procfile +1 -0
- README.md +116 -5
- alembic.ini +149 -0
- app/__init__.py +0 -0
- app/admin_ui/index.html +188 -0
- app/config.py +83 -0
- app/crypto.py +28 -0
- app/db.py +68 -0
- app/deps.py +42 -0
- app/embeddings.py +62 -0
- app/escalation.py +26 -0
- app/llm/__init__.py +0 -0
- app/llm/base.py +153 -0
- app/llm/cloudflare.py +11 -0
- app/llm/groq.py +15 -0
- app/llm/router.py +86 -0
- app/mailer.py +46 -0
- app/main.py +72 -0
- app/models.py +154 -0
- app/orchestrator.py +97 -0
- app/prompts.py +30 -0
- app/rag/__init__.py +0 -0
- app/rag/chunk.py +29 -0
- app/rag/extract.py +82 -0
- app/rag/index.py +103 -0
- app/ratelimit.py +48 -0
- app/retention.py +23 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +166 -0
- app/routes/chat.py +161 -0
- app/routes/health.py +12 -0
- app/routes/widget.py +49 -0
- app/schemas.py +96 -0
- app/shopify/__init__.py +0 -0
- app/shopify/client.py +95 -0
- app/shopify/orders.py +120 -0
- app/shopify/products.py +54 -0
- app/shopify/proxy.py +33 -0
- app/shopify/token.py +86 -0
- app/static/widget.js +104 -0
- app/store.py +59 -0
- app/tenancy.py +118 -0
- app/tools/__init__.py +0 -0
- app/tools/escalate_tool.py +34 -0
- app/tools/knowledge_tool.py +27 -0
- app/tools/order_tool.py +55 -0
- app/tools/products_tool.py +20 -0
.dockerignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
__pycache__
|
| 4 |
+
*.pyc
|
| 5 |
+
.pytest_cache
|
| 6 |
+
.ruff_cache
|
| 7 |
+
.fastembed_cache
|
| 8 |
+
.hf_cache
|
| 9 |
+
tests
|
| 10 |
+
uploads
|
| 11 |
+
*.db
|
| 12 |
+
*.sqlite3
|
| 13 |
+
docs
|
| 14 |
+
.env
|
| 15 |
+
.env.*
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker image for Hugging Face Spaces (Docker SDK) — free, no card, 16GB RAM.
|
| 2 |
+
# Listens on 7860 (HF default). DB is external (Neon Postgres) via DATABASE_URL.
|
| 3 |
+
FROM python:3.12-slim
|
| 4 |
+
|
| 5 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 6 |
+
PIP_NO_CACHE_DIR=1 \
|
| 7 |
+
UV_LINK_MODE=copy \
|
| 8 |
+
HF_HOME=/app/.cache/hf \
|
| 9 |
+
PORT=7860
|
| 10 |
+
|
| 11 |
+
# HF Spaces run containers as UID 1000.
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
RUN pip install --no-cache-dir uv
|
| 14 |
+
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
COPY --chown=user:user pyproject.toml uv.lock ./
|
| 17 |
+
RUN uv sync --frozen --no-dev && chown -R user:user /app
|
| 18 |
+
|
| 19 |
+
COPY --chown=user:user . .
|
| 20 |
+
RUN chown -R user:user /app
|
| 21 |
+
|
| 22 |
+
USER user
|
| 23 |
+
# Pre-download the embedding model into the image so cold starts are fast.
|
| 24 |
+
RUN uv run python -c "from app.embeddings import _get_model; _get_model()"
|
| 25 |
+
|
| 26 |
+
EXPOSE 7860
|
| 27 |
+
CMD ["sh", "-c", "uv run alembic upgrade head && uv run uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: uv run alembic upgrade head && uv run uvicorn app.main:app --host 0.0.0.0 --port $PORT
|
README.md
CHANGED
|
@@ -1,10 +1,121 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: FlexiGo Support Bot
|
| 3 |
+
emoji: 💬
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Shopify Support Chatbot
|
| 12 |
+
|
| 13 |
+
Chatbot de soporte embebible para tiendas **Shopify**. Responde dudas de
|
| 14 |
+
**información** (catálogo en vivo + base de conocimiento de PDFs/URLs) y de
|
| 15 |
+
**pedidos** (estado y nº de seguimiento, con verificación de identidad), en el
|
| 16 |
+
**idioma del cliente**. Cerebro LLM **gratis** (Groq con failover a Cloudflare
|
| 17 |
+
Workers AI), embeddings **locales**. Pensado para una tienda y reutilizable.
|
| 18 |
+
|
| 19 |
+
> Diseño: [`docs/superpowers/specs/2026-06-09-shopify-support-chatbot-design.md`](docs/superpowers/specs/2026-06-09-shopify-support-chatbot-design.md)
|
| 20 |
+
> Plan: [`docs/superpowers/plans/2026-06-09-shopify-support-chatbot.md`](docs/superpowers/plans/2026-06-09-shopify-support-chatbot.md)
|
| 21 |
+
|
| 22 |
+
## Arquitectura
|
| 23 |
+
|
| 24 |
+
```
|
| 25 |
+
Widget (Theme App Extension) ──/apps/chat (App Proxy, sin CORS, HMAC)──► FastAPI
|
| 26 |
+
│
|
| 27 |
+
┌────────────────────────────────────────────────────────────────────┐ │
|
| 28 |
+
│ Orquestador (tool-calling) │ │
|
| 29 |
+
│ • search_knowledge → RAG (pgvector + embeddings locales) │ │
|
| 30 |
+
│ • search_products → Shopify GraphQL Admin API │ │
|
| 31 |
+
│ • lookup_order → Shopify GraphQL + verificación de identidad │ │
|
| 32 |
+
│ • escalate_to_human → email al equipo │ │
|
| 33 |
+
│ LLM: Groq (primario) → Cloudflare Workers AI (failover) │ │
|
| 34 |
+
└────────────────────────────────────────────────────────────────────┘ │
|
| 35 |
+
Postgres + pgvector ◄──────────────────────┘
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Stack
|
| 39 |
+
Python 3.12 · FastAPI · SQLAlchemy 2 (async) · Postgres + pgvector · Alembic ·
|
| 40 |
+
fastembed (ONNX, multilingüe) · httpx · pytest. Gestor: **uv**.
|
| 41 |
+
|
| 42 |
+
## Desarrollo local
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
uv sync # crea el venv (Python 3.12) e instala dependencias
|
| 46 |
+
cp .env.example .env # rellena las claves (ver abajo)
|
| 47 |
+
uv run uvicorn app.main:app --reload
|
| 48 |
+
# Tests:
|
| 49 |
+
uv run pytest # suite completa (incluye el test lento del embedder)
|
| 50 |
+
uv run pytest -m "not slow" # rápido (sin cargar el modelo de embeddings)
|
| 51 |
+
uv run ruff check . # lint
|
| 52 |
+
```
|
| 53 |
+
Sin Postgres local puedes usar sqlite para una prueba rápida:
|
| 54 |
+
`DATABASE_URL=sqlite+aiosqlite:///./dev.db` (pgvector solo en Postgres; en sqlite
|
| 55 |
+
la búsqueda RAG usa coseno en Python).
|
| 56 |
+
|
| 57 |
+
## Claves LLM (gratis)
|
| 58 |
+
|
| 59 |
+
- **Groq** (primario): crea una API key en <https://console.groq.com> (gratis, sin
|
| 60 |
+
tarjeta). Uso comercial permitido y **no entrena con tus datos**. → `GROQ_API_KEY`.
|
| 61 |
+
- **Cloudflare Workers AI** (failover): Account ID + API token de Workers AI.
|
| 62 |
+
Gratis (10.000 neuronas/día), **no entrena con tus datos**. →
|
| 63 |
+
`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`.
|
| 64 |
+
|
| 65 |
+
Mismos modelos Llama 3.3 70B / 3.1 8B en ambos, así que el failover es transparente.
|
| 66 |
+
**Camino de upgrade** si la tienda crece: Groq de pago, o Cloudflare con facturación,
|
| 67 |
+
o Gemini de pago — basta cambiar variables de entorno (`LLM_PROVIDER_ORDER` + claves).
|
| 68 |
+
|
| 69 |
+
Embeddings: modelo multilingüe local (`fastembed`), 0 €, sin enviar datos a terceros.
|
| 70 |
+
|
| 71 |
+
## 🚀 Despliegue (multi-tenant: el cliente no toca nada)
|
| 72 |
+
|
| 73 |
+
**Modelo:** NOSOTROS alojamos **un solo backend** que sirve a **todos los clientes**
|
| 74 |
+
(cada uno es un *tenant*). El cliente solo recibe **una línea** para pegar (o se la
|
| 75 |
+
pegamos). Cero infraestructura, cero claves, cero Railway para el cliente.
|
| 76 |
+
|
| 77 |
+
### Una vez (nosotros) — desplegar el backend
|
| 78 |
+
1. Desplegar este repo en Railway (usa [`railway.json`](railway.json): migraciones +
|
| 79 |
+
uvicorn + healthcheck `/healthz`, automático).
|
| 80 |
+
2. Volumen en `/app/data` (SQLite + caché del modelo).
|
| 81 |
+
3. Variables: `GROQ_API_KEY` (nuestra), `ADMIN_TOKEN` (nuestra), `SECRET_KEY` (cifra
|
| 82 |
+
los secretos de los tenants), `DATABASE_URL=sqlite+aiosqlite:////app/data/bot.db`.
|
| 83 |
+
(Opcional `CLOUDFLARE_*` para el failover.)
|
| 84 |
+
|
| 85 |
+
### Por cada cliente (alta de 5 min, en `/admin/`)
|
| 86 |
+
1. Entrar en `https://NUESTRO_BACKEND/admin/` con el `ADMIN_TOKEN`.
|
| 87 |
+
2. **Crear la tienda** (slug, p.ej. `toorx`), poner nombre/color/bienvenida.
|
| 88 |
+
3. Subir sus **PDFs/URLs** (base de conocimiento, aislada por tenant).
|
| 89 |
+
4. (Opcional, para pedidos) pegar las credenciales de su **custom app de Shopify**
|
| 90 |
+
(`shop`, `client_id`, `client_secret` — se guarda **cifrado**). Sin esto = bot solo-info.
|
| 91 |
+
5. Copiar la **línea de incrustación** que muestra el panel:
|
| 92 |
+
|
| 93 |
+
```html
|
| 94 |
+
<script src="https://NUESTRO_BACKEND/widget.js?t=toorx" defer></script>
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### El cliente — pega 1 línea
|
| 98 |
+
En su tema de Shopify (**Online Store → Themes → Edit code → `theme.liquid`**, antes
|
| 99 |
+
de `</body>`), pega esa línea. **Fin.** El widget se sirve solo, detecta su backend y
|
| 100 |
+
su tienda por el `?t=`, lee su marca de `/widget-config` y habla con `/chat` (CORS).
|
| 101 |
+
|
| 102 |
+
> Aislamiento: la base de conocimiento, sesiones y pedidos de cada tenant están
|
| 103 |
+
> separados por `tenant_id`; una tienda nunca ve los datos de otra.
|
| 104 |
+
|
| 105 |
+
### (Opcional) Modo seguro con App Proxy
|
| 106 |
+
Si un cliente quiere firmas HMAC + `logged_in_customer_id` en vez del `<script>`:
|
| 107 |
+
desplegar `extension/` con `shopify app deploy`, configurar el App Proxy
|
| 108 |
+
(`subpath=chat`, `prefix=apps`, URL → `/apps/chat`) y activar el bloque en *App embeds*.
|
| 109 |
+
`/apps/chat` resuelve el tenant por el `shop` firmado.
|
| 110 |
+
|
| 111 |
+
### (Opcional) Postgres + pgvector a escala
|
| 112 |
+
Cambia `DATABASE_URL` a Postgres; la migración crea la extensión `vector` y usa
|
| 113 |
+
búsqueda vectorial nativa. (En SQLite la búsqueda RAG usa coseno en Python.)
|
| 114 |
+
|
| 115 |
+
## Privacidad / GDPR
|
| 116 |
+
- LLMs sin entrenamiento con datos (Groq + Cloudflare) → seguro para datos de pedidos.
|
| 117 |
+
- Verificación por niveles: email + nº de pedido; solo se revela estado de envío +
|
| 118 |
+
seguimiento (nunca dirección completa ni pago). Errores genéricos + bloqueo por
|
| 119 |
+
intentos.
|
| 120 |
+
- Retención: las sesiones/mensajes se purgan automáticamente a los
|
| 121 |
+
`SESSION_RETENTION_DAYS` días.
|
alembic.ini
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# A generic, single database configuration.
|
| 2 |
+
|
| 3 |
+
[alembic]
|
| 4 |
+
# path to migration scripts.
|
| 5 |
+
# this is typically a path given in POSIX (e.g. forward slashes)
|
| 6 |
+
# format, relative to the token %(here)s which refers to the location of this
|
| 7 |
+
# ini file
|
| 8 |
+
script_location = %(here)s/migrations
|
| 9 |
+
|
| 10 |
+
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
| 11 |
+
# Uncomment the line below if you want the files to be prepended with date and time
|
| 12 |
+
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
| 13 |
+
# for all available tokens
|
| 14 |
+
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
| 15 |
+
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
| 16 |
+
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
| 17 |
+
|
| 18 |
+
# sys.path path, will be prepended to sys.path if present.
|
| 19 |
+
# defaults to the current working directory. for multiple paths, the path separator
|
| 20 |
+
# is defined by "path_separator" below.
|
| 21 |
+
prepend_sys_path = .
|
| 22 |
+
|
| 23 |
+
# timezone to use when rendering the date within the migration file
|
| 24 |
+
# as well as the filename.
|
| 25 |
+
# If specified, requires the tzdata library which can be installed by adding
|
| 26 |
+
# `alembic[tz]` to the pip requirements.
|
| 27 |
+
# string value is passed to ZoneInfo()
|
| 28 |
+
# leave blank for localtime
|
| 29 |
+
# timezone =
|
| 30 |
+
|
| 31 |
+
# max length of characters to apply to the "slug" field
|
| 32 |
+
# truncate_slug_length = 40
|
| 33 |
+
|
| 34 |
+
# set to 'true' to run the environment during
|
| 35 |
+
# the 'revision' command, regardless of autogenerate
|
| 36 |
+
# revision_environment = false
|
| 37 |
+
|
| 38 |
+
# set to 'true' to allow .pyc and .pyo files without
|
| 39 |
+
# a source .py file to be detected as revisions in the
|
| 40 |
+
# versions/ directory
|
| 41 |
+
# sourceless = false
|
| 42 |
+
|
| 43 |
+
# version location specification; This defaults
|
| 44 |
+
# to <script_location>/versions. When using multiple version
|
| 45 |
+
# directories, initial revisions must be specified with --version-path.
|
| 46 |
+
# The path separator used here should be the separator specified by "path_separator"
|
| 47 |
+
# below.
|
| 48 |
+
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
| 49 |
+
|
| 50 |
+
# path_separator; This indicates what character is used to split lists of file
|
| 51 |
+
# paths, including version_locations and prepend_sys_path within configparser
|
| 52 |
+
# files such as alembic.ini.
|
| 53 |
+
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
| 54 |
+
# to provide os-dependent path splitting.
|
| 55 |
+
#
|
| 56 |
+
# Note that in order to support legacy alembic.ini files, this default does NOT
|
| 57 |
+
# take place if path_separator is not present in alembic.ini. If this
|
| 58 |
+
# option is omitted entirely, fallback logic is as follows:
|
| 59 |
+
#
|
| 60 |
+
# 1. Parsing of the version_locations option falls back to using the legacy
|
| 61 |
+
# "version_path_separator" key, which if absent then falls back to the legacy
|
| 62 |
+
# behavior of splitting on spaces and/or commas.
|
| 63 |
+
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
| 64 |
+
# behavior of splitting on spaces, commas, or colons.
|
| 65 |
+
#
|
| 66 |
+
# Valid values for path_separator are:
|
| 67 |
+
#
|
| 68 |
+
# path_separator = :
|
| 69 |
+
# path_separator = ;
|
| 70 |
+
# path_separator = space
|
| 71 |
+
# path_separator = newline
|
| 72 |
+
#
|
| 73 |
+
# Use os.pathsep. Default configuration used for new projects.
|
| 74 |
+
path_separator = os
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# set to 'true' to search source files recursively
|
| 78 |
+
# in each "version_locations" directory
|
| 79 |
+
# new in Alembic version 1.10
|
| 80 |
+
# recursive_version_locations = false
|
| 81 |
+
|
| 82 |
+
# the output encoding used when revision files
|
| 83 |
+
# are written from script.py.mako
|
| 84 |
+
# output_encoding = utf-8
|
| 85 |
+
|
| 86 |
+
# database URL. This is consumed by the user-maintained env.py script only.
|
| 87 |
+
# other means of configuring database URLs may be customized within the env.py
|
| 88 |
+
# file.
|
| 89 |
+
sqlalchemy.url = driver://user:pass@localhost/dbname
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
[post_write_hooks]
|
| 93 |
+
# post_write_hooks defines scripts or Python functions that are run
|
| 94 |
+
# on newly generated revision scripts. See the documentation for further
|
| 95 |
+
# detail and examples
|
| 96 |
+
|
| 97 |
+
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
| 98 |
+
# hooks = black
|
| 99 |
+
# black.type = console_scripts
|
| 100 |
+
# black.entrypoint = black
|
| 101 |
+
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
| 102 |
+
|
| 103 |
+
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
| 104 |
+
# hooks = ruff
|
| 105 |
+
# ruff.type = module
|
| 106 |
+
# ruff.module = ruff
|
| 107 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 108 |
+
|
| 109 |
+
# Alternatively, use the exec runner to execute a binary found on your PATH
|
| 110 |
+
# hooks = ruff
|
| 111 |
+
# ruff.type = exec
|
| 112 |
+
# ruff.executable = ruff
|
| 113 |
+
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
| 114 |
+
|
| 115 |
+
# Logging configuration. This is also consumed by the user-maintained
|
| 116 |
+
# env.py script only.
|
| 117 |
+
[loggers]
|
| 118 |
+
keys = root,sqlalchemy,alembic
|
| 119 |
+
|
| 120 |
+
[handlers]
|
| 121 |
+
keys = console
|
| 122 |
+
|
| 123 |
+
[formatters]
|
| 124 |
+
keys = generic
|
| 125 |
+
|
| 126 |
+
[logger_root]
|
| 127 |
+
level = WARNING
|
| 128 |
+
handlers = console
|
| 129 |
+
qualname =
|
| 130 |
+
|
| 131 |
+
[logger_sqlalchemy]
|
| 132 |
+
level = WARNING
|
| 133 |
+
handlers =
|
| 134 |
+
qualname = sqlalchemy.engine
|
| 135 |
+
|
| 136 |
+
[logger_alembic]
|
| 137 |
+
level = INFO
|
| 138 |
+
handlers =
|
| 139 |
+
qualname = alembic
|
| 140 |
+
|
| 141 |
+
[handler_console]
|
| 142 |
+
class = StreamHandler
|
| 143 |
+
args = (sys.stderr,)
|
| 144 |
+
level = NOTSET
|
| 145 |
+
formatter = generic
|
| 146 |
+
|
| 147 |
+
[formatter_generic]
|
| 148 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 149 |
+
datefmt = %H:%M:%S
|
app/__init__.py
ADDED
|
File without changes
|
app/admin_ui/index.html
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="es">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Admin · Chatbot de soporte</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root { --fg:#1a1a1a; --muted:#666; --accent:#e8491d; --bg:#fafafa; --line:#e3e3e3; }
|
| 9 |
+
* { box-sizing: border-box; }
|
| 10 |
+
body { font-family: system-ui, sans-serif; margin: 0; color: var(--fg); background: var(--bg); }
|
| 11 |
+
header { background: var(--accent); color: #fff; padding: 16px 24px; }
|
| 12 |
+
header h1 { margin: 0; font-size: 18px; }
|
| 13 |
+
main { max-width: 920px; margin: 0 auto; padding: 24px; }
|
| 14 |
+
.card { background:#fff; border:1px solid var(--line); border-radius:10px; padding:18px; margin-bottom:18px; }
|
| 15 |
+
h2 { font-size:15px; margin:0 0 12px; }
|
| 16 |
+
label { display:block; font-size:13px; color:var(--muted); margin:8px 0 4px; }
|
| 17 |
+
input, select { width:100%; padding:9px 10px; border:1px solid var(--line); border-radius:8px; font-size:14px; }
|
| 18 |
+
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:9px 14px; font-size:14px; cursor:pointer; }
|
| 19 |
+
button.secondary { background:#444; }
|
| 20 |
+
table { width:100%; border-collapse:collapse; font-size:13px; }
|
| 21 |
+
th, td { text-align:left; padding:8px; border-bottom:1px solid var(--line); }
|
| 22 |
+
.status-indexed { color:#1a7f37; } .status-error { color:#b00; } .status-pending { color:#a60; }
|
| 23 |
+
.row { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
|
| 24 |
+
.row > div { flex:1; min-width:160px; }
|
| 25 |
+
.muted { color:var(--muted); font-size:12px; }
|
| 26 |
+
.embed { background:#f3f3f3; padding:10px; border-radius:8px; font-family:monospace; font-size:12px; word-break:break-all; }
|
| 27 |
+
.hidden { display:none; }
|
| 28 |
+
</style>
|
| 29 |
+
</head>
|
| 30 |
+
<body>
|
| 31 |
+
<header><h1>Chatbot de soporte · Administración (multi-tienda)</h1></header>
|
| 32 |
+
<main>
|
| 33 |
+
<div class="card">
|
| 34 |
+
<h2>Acceso</h2>
|
| 35 |
+
<label for="token">Admin token (Bearer)</label>
|
| 36 |
+
<input id="token" type="password" placeholder="pega aquí tu ADMIN_TOKEN" />
|
| 37 |
+
<p class="muted">Se guarda solo en memoria de esta pestaña.</p>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="card">
|
| 41 |
+
<h2>Tienda (tenant)</h2>
|
| 42 |
+
<div class="row">
|
| 43 |
+
<div>
|
| 44 |
+
<label for="tenant">Selecciona la tienda</label>
|
| 45 |
+
<select id="tenant" onchange="onTenant()"></select>
|
| 46 |
+
</div>
|
| 47 |
+
<button class="secondary" onclick="loadTenants()">Recargar</button>
|
| 48 |
+
</div>
|
| 49 |
+
<hr style="border:none;border-top:1px solid var(--line);margin:16px 0;" />
|
| 50 |
+
<h2>Crear tienda nueva</h2>
|
| 51 |
+
<div class="row">
|
| 52 |
+
<div><label for="new-slug">Identificador (slug)</label><input id="new-slug" placeholder="toorx" /></div>
|
| 53 |
+
<div><label for="new-name">Nombre</label><input id="new-name" placeholder="TOORX" /></div>
|
| 54 |
+
<button onclick="createTenant()">Crear</button>
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div id="panel" class="hidden">
|
| 59 |
+
<div class="card">
|
| 60 |
+
<h2>Incrustar en la web del cliente (1 línea)</h2>
|
| 61 |
+
<div class="embed" id="embed"></div>
|
| 62 |
+
<p class="muted">Pegar antes de <code></body></code> en el tema de Shopify.</p>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="card">
|
| 66 |
+
<h2>Fuentes de conocimiento</h2>
|
| 67 |
+
<div class="row">
|
| 68 |
+
<div><label for="url">Añadir por URL</label><input id="url" type="url" placeholder="https://tienda.com/faq" /></div>
|
| 69 |
+
<button onclick="addUrl()">Añadir URL</button>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="row" style="margin-top:12px;">
|
| 72 |
+
<div><label for="file">Subir fichero (PDF, Excel, Word, TXT)</label><input id="file" type="file" /></div>
|
| 73 |
+
<button onclick="addFile()">Subir</button>
|
| 74 |
+
<button class="secondary" onclick="reindex()">Reindexar</button>
|
| 75 |
+
</div>
|
| 76 |
+
<table style="margin-top:16px;">
|
| 77 |
+
<thead><tr><th>#</th><th>Nombre</th><th>Tipo</th><th>Estado</th><th></th></tr></thead>
|
| 78 |
+
<tbody id="sources"></tbody>
|
| 79 |
+
</table>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<div class="card">
|
| 83 |
+
<h2>Marca</h2>
|
| 84 |
+
<div class="row">
|
| 85 |
+
<div><label for="brand">Nombre del asistente</label><input id="brand" /></div>
|
| 86 |
+
<div><label for="color">Color</label><input id="color" /></div>
|
| 87 |
+
<button onclick="saveBrand()">Guardar marca</button>
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
<div class="card">
|
| 92 |
+
<h2>Pedidos (Shopify) — opcional</h2>
|
| 93 |
+
<div class="row">
|
| 94 |
+
<div><label>Shop</label><input id="sh-shop" placeholder="tienda.myshopify.com" /></div>
|
| 95 |
+
<div><label>Client ID</label><input id="sh-id" /></div>
|
| 96 |
+
</div>
|
| 97 |
+
<div class="row" style="margin-top:8px;">
|
| 98 |
+
<div><label>Client Secret</label><input id="sh-secret" type="password" placeholder="(se guarda cifrado)" /></div>
|
| 99 |
+
<button onclick="saveShopify()">Guardar Shopify</button>
|
| 100 |
+
</div>
|
| 101 |
+
<p class="muted" id="sh-state"></p>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
<p class="muted" id="msg"></p>
|
| 105 |
+
</main>
|
| 106 |
+
|
| 107 |
+
<script>
|
| 108 |
+
var ORIGIN = location.origin;
|
| 109 |
+
var $ = function (id) { return document.getElementById(id); };
|
| 110 |
+
function auth() { return { "Authorization": "Bearer " + $("token").value.trim() }; }
|
| 111 |
+
function flash(m) { $("msg").textContent = m; }
|
| 112 |
+
function slug() { return $("tenant").value; }
|
| 113 |
+
|
| 114 |
+
async function api(path, opts) {
|
| 115 |
+
opts = opts || {};
|
| 116 |
+
var res = await fetch("/admin" + path, { ...opts, headers: { ...(opts.headers || {}), ...auth() } });
|
| 117 |
+
if (res.status === 401) { flash("Token inválido."); throw new Error("unauthorized"); }
|
| 118 |
+
return res;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
async function loadTenants() {
|
| 122 |
+
try {
|
| 123 |
+
var rows = await (await api("/tenants")).json();
|
| 124 |
+
$("tenant").innerHTML = rows.map(function (t) {
|
| 125 |
+
return '<option value="' + t.slug + '">' + t.slug + (t.name ? " — " + t.name : "") + "</option>";
|
| 126 |
+
}).join("");
|
| 127 |
+
if (rows.length) onTenant();
|
| 128 |
+
} catch (e) {}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
async function createTenant() {
|
| 132 |
+
var s = $("new-slug").value.trim(); if (!s) return;
|
| 133 |
+
await api("/tenants", { method: "POST", headers: { "Content-Type": "application/json" },
|
| 134 |
+
body: JSON.stringify({ slug: s, name: $("new-name").value.trim() }) });
|
| 135 |
+
$("new-slug").value = ""; $("new-name").value = "";
|
| 136 |
+
flash("Tienda creada."); await loadTenants(); $("tenant").value = s; onTenant();
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function onTenant() {
|
| 140 |
+
var s = slug(); if (!s) return;
|
| 141 |
+
$("panel").classList.remove("hidden");
|
| 142 |
+
$("embed").textContent = '<script src="' + ORIGIN + '/widget.js?t=' + s + '" defer><' + '/script>';
|
| 143 |
+
var rows = await (await api("/tenants")).json();
|
| 144 |
+
var t = rows.find(function (x) { return x.slug === s; }) || {};
|
| 145 |
+
$("brand").value = t.brand_name || ""; $("color").value = t.brand_color || "";
|
| 146 |
+
$("sh-shop").value = t.shopify_shop || ""; $("sh-id").value = t.shopify_client_id || "";
|
| 147 |
+
$("sh-state").textContent = t.has_shopify_secret ? "Secret guardado ✓" : "Sin secret (bot solo-info)";
|
| 148 |
+
loadSources();
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
async function loadSources() {
|
| 152 |
+
var s = slug();
|
| 153 |
+
var rows = await (await api("/tenants/" + s + "/sources")).json();
|
| 154 |
+
$("sources").innerHTML = rows.map(function (r) {
|
| 155 |
+
return '<tr><td>' + r.id + '</td><td>' + r.name + '</td><td>' + r.kind + '</td>' +
|
| 156 |
+
'<td class="status-' + r.status + '">' + r.status + (r.error ? " · " + r.error : "") + '</td>' +
|
| 157 |
+
'<td><button class="secondary" onclick="del(' + r.id + ')">Borrar</button></td></tr>';
|
| 158 |
+
}).join("");
|
| 159 |
+
}
|
| 160 |
+
async function addUrl() {
|
| 161 |
+
var loc = $("url").value.trim(); if (!loc) return;
|
| 162 |
+
await api("/tenants/" + slug() + "/sources/url", { method: "POST", headers: { "Content-Type": "application/json" },
|
| 163 |
+
body: JSON.stringify({ kind: "url", location: loc }) });
|
| 164 |
+
$("url").value = ""; flash("URL añadida."); loadSources();
|
| 165 |
+
}
|
| 166 |
+
async function addFile() {
|
| 167 |
+
var f = $("file").files[0]; if (!f) return;
|
| 168 |
+
var fd = new FormData(); fd.append("file", f);
|
| 169 |
+
await api("/tenants/" + slug() + "/sources/file", { method: "POST", body: fd });
|
| 170 |
+
flash("Fichero subido."); loadSources();
|
| 171 |
+
}
|
| 172 |
+
async function del(id) { await api("/tenants/" + slug() + "/sources/" + id, { method: "DELETE" }); loadSources(); }
|
| 173 |
+
async function reindex() { await api("/tenants/" + slug() + "/reindex", { method: "POST" }); flash("Reindexando…"); loadSources(); }
|
| 174 |
+
async function saveBrand() {
|
| 175 |
+
await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" },
|
| 176 |
+
body: JSON.stringify({ brand_name: $("brand").value.trim(), brand_color: $("color").value.trim() }) });
|
| 177 |
+
flash("Marca guardada.");
|
| 178 |
+
}
|
| 179 |
+
async function saveShopify() {
|
| 180 |
+
var body = { shopify_shop: $("sh-shop").value.trim(), shopify_client_id: $("sh-id").value.trim() };
|
| 181 |
+
var sec = $("sh-secret").value.trim(); if (sec) body.shopify_client_secret = sec;
|
| 182 |
+
await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
| 183 |
+
$("sh-secret").value = ""; flash("Shopify guardado."); onTenant();
|
| 184 |
+
}
|
| 185 |
+
$("token").addEventListener("change", loadTenants);
|
| 186 |
+
</script>
|
| 187 |
+
</body>
|
| 188 |
+
</html>
|
app/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application settings, loaded from environment / .env."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Annotated
|
| 6 |
+
|
| 7 |
+
from pydantic import field_validator
|
| 8 |
+
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
|
| 13 |
+
|
| 14 |
+
# Core
|
| 15 |
+
database_url: str = "sqlite+aiosqlite://"
|
| 16 |
+
testing: bool = False
|
| 17 |
+
|
| 18 |
+
# LLM: Groq (primary)
|
| 19 |
+
groq_api_key: str = ""
|
| 20 |
+
groq_base_url: str = "https://api.groq.com/openai/v1"
|
| 21 |
+
model_large: str = "llama-3.3-70b-versatile"
|
| 22 |
+
model_small: str = "llama-3.1-8b-instant"
|
| 23 |
+
|
| 24 |
+
# LLM: Cloudflare Workers AI (failover)
|
| 25 |
+
cloudflare_account_id: str = ""
|
| 26 |
+
cloudflare_api_token: str = ""
|
| 27 |
+
cf_model_large: str = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
|
| 28 |
+
cf_model_small: str = "@cf/meta/llama-3.1-8b-instruct"
|
| 29 |
+
|
| 30 |
+
llm_provider_order: Annotated[list[str], NoDecode] = ["groq", "cloudflare"]
|
| 31 |
+
|
| 32 |
+
# Shopify
|
| 33 |
+
shopify_shop: str = ""
|
| 34 |
+
shopify_client_id: str = ""
|
| 35 |
+
shopify_client_secret: str = ""
|
| 36 |
+
shopify_api_version: str = "2026-01"
|
| 37 |
+
shopify_app_proxy_secret: str = ""
|
| 38 |
+
shopify_read_all_orders: bool = False
|
| 39 |
+
|
| 40 |
+
# Admin
|
| 41 |
+
admin_token: str = ""
|
| 42 |
+
# Key used to encrypt per-tenant secrets at rest. CHANGE in production.
|
| 43 |
+
secret_key: str = "dev-insecure-change-me"
|
| 44 |
+
|
| 45 |
+
# Branding (overridable at runtime via the admin /config endpoint)
|
| 46 |
+
brand_name: str = "Asistente"
|
| 47 |
+
|
| 48 |
+
# CORS allow-list for the simple-mode /chat + widget (public chat widget).
|
| 49 |
+
# Comma-separated origins, or "*" for any (default; no cookies are used).
|
| 50 |
+
allowed_origins: Annotated[list[str], NoDecode] = ["*"]
|
| 51 |
+
|
| 52 |
+
# Embeddings
|
| 53 |
+
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
| 54 |
+
|
| 55 |
+
# Verification / retention
|
| 56 |
+
order_verify_max_attempts: int = 5
|
| 57 |
+
order_verify_lockout_seconds: int = 900
|
| 58 |
+
session_retention_days: int = 30
|
| 59 |
+
|
| 60 |
+
# Escalation (SMTP)
|
| 61 |
+
support_email: str = ""
|
| 62 |
+
smtp_host: str = ""
|
| 63 |
+
smtp_port: int = 587
|
| 64 |
+
smtp_user: str = ""
|
| 65 |
+
smtp_password: str = ""
|
| 66 |
+
smtp_from: str = ""
|
| 67 |
+
|
| 68 |
+
@field_validator("llm_provider_order", "allowed_origins", mode="before")
|
| 69 |
+
@classmethod
|
| 70 |
+
def _split_csv(cls, v: object) -> object:
|
| 71 |
+
if isinstance(v, str):
|
| 72 |
+
return [item.strip() for item in v.split(",") if item.strip()]
|
| 73 |
+
return v
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
_settings: Settings | None = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_settings() -> Settings:
|
| 80 |
+
global _settings
|
| 81 |
+
if _settings is None:
|
| 82 |
+
_settings = Settings()
|
| 83 |
+
return _settings
|
app/crypto.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Symmetric encryption for secrets stored at rest (per-tenant Shopify secret)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import base64
|
| 6 |
+
import hashlib
|
| 7 |
+
|
| 8 |
+
from cryptography.fernet import Fernet
|
| 9 |
+
|
| 10 |
+
from app.config import get_settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _fernet() -> Fernet:
|
| 14 |
+
# Derive a stable 32-byte urlsafe key from the configured secret.
|
| 15 |
+
digest = hashlib.sha256(get_settings().secret_key.encode("utf-8")).digest()
|
| 16 |
+
return Fernet(base64.urlsafe_b64encode(digest))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def encrypt(plaintext: str) -> str:
|
| 20 |
+
if not plaintext:
|
| 21 |
+
return ""
|
| 22 |
+
return _fernet().encrypt(plaintext.encode("utf-8")).decode("utf-8")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def decrypt(token: str) -> str:
|
| 26 |
+
if not token:
|
| 27 |
+
return ""
|
| 28 |
+
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
|
app/db.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async database engine, session factory, and declarative base."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import AsyncIterator
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from sqlalchemy.ext.asyncio import (
|
| 9 |
+
AsyncEngine,
|
| 10 |
+
AsyncSession,
|
| 11 |
+
async_sessionmaker,
|
| 12 |
+
create_async_engine,
|
| 13 |
+
)
|
| 14 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Base(DeclarativeBase):
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
_engine: AsyncEngine | None = None
|
| 22 |
+
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def init_engine(url: str, **engine_kwargs: Any) -> AsyncEngine:
|
| 26 |
+
"""Create the global async engine + session factory.
|
| 27 |
+
|
| 28 |
+
Extra kwargs are forwarded to ``create_async_engine`` (used by tests to
|
| 29 |
+
pass a StaticPool so an in-memory sqlite DB is shared across sessions).
|
| 30 |
+
"""
|
| 31 |
+
global _engine, _sessionmaker
|
| 32 |
+
# For networked DBs (e.g. Neon Postgres that autosuspends), validate
|
| 33 |
+
# connections before use so a dropped connection is transparently replaced.
|
| 34 |
+
if "sqlite" not in url and "pool_pre_ping" not in engine_kwargs:
|
| 35 |
+
engine_kwargs["pool_pre_ping"] = True
|
| 36 |
+
_engine = create_async_engine(url, future=True, **engine_kwargs)
|
| 37 |
+
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False, class_=AsyncSession)
|
| 38 |
+
return _engine
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def is_initialized() -> bool:
|
| 42 |
+
return _engine is not None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_engine() -> AsyncEngine:
|
| 46 |
+
assert _engine is not None, "engine not initialized; call init_engine() first"
|
| 47 |
+
return _engine
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
| 51 |
+
assert _sessionmaker is not None, "engine not initialized; call init_engine() first"
|
| 52 |
+
return _sessionmaker
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
| 56 |
+
"""FastAPI dependency yielding a session (overridable in tests)."""
|
| 57 |
+
async with get_sessionmaker()() as session:
|
| 58 |
+
yield session
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def create_all() -> None:
|
| 62 |
+
"""Create all tables registered on Base.metadata (tests / sqlite dev)."""
|
| 63 |
+
async with get_engine().begin() as conn:
|
| 64 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def is_postgres(url: str) -> bool:
|
| 68 |
+
return url.startswith("postgresql") or url.startswith("postgres")
|
app/deps.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI dependencies: LLM router, Shopify client, admin auth (overridable)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hmac
|
| 6 |
+
|
| 7 |
+
from fastapi import Header, HTTPException
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
from app.llm.router import build_router_from_settings
|
| 11 |
+
from app.shopify.client import ShopifyGraphQLClient
|
| 12 |
+
from app.shopify.token import ShopifyTokenManager
|
| 13 |
+
|
| 14 |
+
_router = None
|
| 15 |
+
_shopify_built = False
|
| 16 |
+
_shopify_client: ShopifyGraphQLClient | None = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_router():
|
| 20 |
+
global _router
|
| 21 |
+
if _router is None:
|
| 22 |
+
_router = build_router_from_settings(get_settings())
|
| 23 |
+
return _router
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_shopify() -> ShopifyGraphQLClient | None:
|
| 27 |
+
global _shopify_built, _shopify_client
|
| 28 |
+
if not _shopify_built:
|
| 29 |
+
s = get_settings()
|
| 30 |
+
if s.shopify_shop and s.shopify_client_id and s.shopify_client_secret:
|
| 31 |
+
tm = ShopifyTokenManager(s.shopify_shop, s.shopify_client_id, s.shopify_client_secret)
|
| 32 |
+
_shopify_client = ShopifyGraphQLClient(s.shopify_shop, s.shopify_api_version, tm)
|
| 33 |
+
_shopify_built = True
|
| 34 |
+
return _shopify_client
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def require_admin(authorization: str = Header(default="")) -> bool:
|
| 38 |
+
token = authorization.removeprefix("Bearer ").strip()
|
| 39 |
+
s = get_settings()
|
| 40 |
+
if not s.admin_token or not hmac.compare_digest(token, s.admin_token):
|
| 41 |
+
raise HTTPException(status_code=401, detail="unauthorized")
|
| 42 |
+
return True
|
app/embeddings.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local multilingual embeddings via fastembed (ONNX, no torch).
|
| 2 |
+
|
| 3 |
+
Default model: sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
|
| 4 |
+
(384-dim, multilingual incl. ES/PT/EN). If an e5 model is configured, the
|
| 5 |
+
"query: " / "passage: " prefixes it expects are applied automatically.
|
| 6 |
+
Inference runs in a worker thread; the model is loaded lazily and cached.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
|
| 13 |
+
import anyio
|
| 14 |
+
|
| 15 |
+
from app.config import get_settings
|
| 16 |
+
|
| 17 |
+
_model = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _get_model():
|
| 21 |
+
global _model
|
| 22 |
+
if _model is None:
|
| 23 |
+
from fastembed import TextEmbedding
|
| 24 |
+
|
| 25 |
+
_model = TextEmbedding(
|
| 26 |
+
model_name=get_settings().embedding_model, cache_dir=".fastembed_cache"
|
| 27 |
+
)
|
| 28 |
+
return _model
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _embed_sync(texts: list[str]) -> list[list[float]]:
|
| 32 |
+
model = _get_model()
|
| 33 |
+
return [[float(x) for x in vec] for vec in model.embed(texts)]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _apply_prefix(kind: str, texts: list[str]) -> list[str]:
|
| 37 |
+
# e5 family expects "query: " / "passage: " prefixes; others must not.
|
| 38 |
+
if "e5" in get_settings().embedding_model.lower():
|
| 39 |
+
return [f"{kind}: {t}" for t in texts]
|
| 40 |
+
return texts
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def embed_texts(texts: list[str], *, kind: str = "passage") -> list[list[float]]:
|
| 44 |
+
if not texts:
|
| 45 |
+
return []
|
| 46 |
+
return await anyio.to_thread.run_sync(_embed_sync, _apply_prefix(kind, texts))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def embed_query(text: str) -> list[float]:
|
| 50 |
+
result = await embed_texts([text], kind="query")
|
| 51 |
+
return result[0]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def cosine(a: list[float], b: list[float]) -> float:
|
| 55 |
+
if not a or not b:
|
| 56 |
+
return 0.0
|
| 57 |
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
| 58 |
+
na = math.sqrt(sum(x * x for x in a))
|
| 59 |
+
nb = math.sqrt(sum(y * y for y in b))
|
| 60 |
+
if na == 0 or nb == 0:
|
| 61 |
+
return 0.0
|
| 62 |
+
return dot / (na * nb)
|
app/escalation.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Human escalation: forward an unanswered question + customer email to the store."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Awaitable, Callable
|
| 6 |
+
|
| 7 |
+
Sender = Callable[[str, str, str], Awaitable[bool]]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def create_escalation(
|
| 11 |
+
email: str,
|
| 12 |
+
question: str,
|
| 13 |
+
*,
|
| 14 |
+
session_id: str,
|
| 15 |
+
support_email: str,
|
| 16 |
+
sender: Sender,
|
| 17 |
+
) -> dict[str, object]:
|
| 18 |
+
subject = f"[Bot] Nueva consulta de soporte ({session_id})"
|
| 19 |
+
body = (
|
| 20 |
+
"Una consulta del chatbot necesita atención humana.\n\n"
|
| 21 |
+
f"Email del cliente: {email}\n"
|
| 22 |
+
f"Sesión: {session_id}\n\n"
|
| 23 |
+
f"Consulta:\n{question}\n"
|
| 24 |
+
)
|
| 25 |
+
sent = await sender(support_email, subject, body)
|
| 26 |
+
return {"sent": bool(sent), "support_email": support_email}
|
app/llm/__init__.py
ADDED
|
File without changes
|
app/llm/base.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM provider protocol + a shared OpenAI-compatible HTTP provider.
|
| 2 |
+
|
| 3 |
+
Groq and Cloudflare Workers AI both expose OpenAI-compatible
|
| 4 |
+
``/chat/completions`` endpoints, so they share one implementation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Any, Protocol
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ProviderError(Exception):
|
| 17 |
+
"""Any non-recoverable provider failure (triggers failover)."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class RateLimitError(ProviderError):
|
| 21 |
+
"""HTTP 429 from a provider (triggers failover)."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class ToolSpec:
|
| 26 |
+
name: str
|
| 27 |
+
description: str
|
| 28 |
+
parameters: dict[str, Any]
|
| 29 |
+
|
| 30 |
+
def to_openai(self) -> dict[str, Any]:
|
| 31 |
+
return {
|
| 32 |
+
"type": "function",
|
| 33 |
+
"function": {
|
| 34 |
+
"name": self.name,
|
| 35 |
+
"description": self.description,
|
| 36 |
+
"parameters": self.parameters,
|
| 37 |
+
},
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class ToolCall:
|
| 43 |
+
id: str
|
| 44 |
+
name: str
|
| 45 |
+
arguments: dict[str, Any]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class ChatResult:
|
| 50 |
+
content: str | None
|
| 51 |
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 52 |
+
finish_reason: str | None = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class LLMProvider(Protocol):
|
| 56 |
+
async def chat(
|
| 57 |
+
self,
|
| 58 |
+
messages: list[dict[str, Any]],
|
| 59 |
+
tools: list[ToolSpec],
|
| 60 |
+
model: str,
|
| 61 |
+
temperature: float = 0.3,
|
| 62 |
+
) -> ChatResult: ...
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def parse_openai_choice(data: dict[str, Any]) -> ChatResult:
|
| 66 |
+
"""Map an OpenAI-style chat completion response to ChatResult."""
|
| 67 |
+
choices = data.get("choices") if isinstance(data, dict) else None
|
| 68 |
+
if not choices:
|
| 69 |
+
raise ProviderError(f"malformed response: no choices ({str(data)[:200]})")
|
| 70 |
+
choice = choices[0]
|
| 71 |
+
msg = choice.get("message", {}) or {}
|
| 72 |
+
calls: list[ToolCall] = []
|
| 73 |
+
for c in msg.get("tool_calls") or []:
|
| 74 |
+
fn = c.get("function", {}) or {}
|
| 75 |
+
args = fn.get("arguments")
|
| 76 |
+
if isinstance(args, str):
|
| 77 |
+
try:
|
| 78 |
+
args = json.loads(args) if args.strip() else {}
|
| 79 |
+
except json.JSONDecodeError:
|
| 80 |
+
args = {}
|
| 81 |
+
calls.append(
|
| 82 |
+
ToolCall(id=c.get("id", ""), name=fn.get("name", ""), arguments=args or {})
|
| 83 |
+
)
|
| 84 |
+
return ChatResult(
|
| 85 |
+
content=msg.get("content"),
|
| 86 |
+
tool_calls=calls,
|
| 87 |
+
finish_reason=choice.get("finish_reason"),
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class OpenAICompatProvider:
|
| 92 |
+
"""Generic OpenAI-compatible chat-completions provider over httpx."""
|
| 93 |
+
|
| 94 |
+
def __init__(
|
| 95 |
+
self,
|
| 96 |
+
base_url: str,
|
| 97 |
+
api_key: str,
|
| 98 |
+
*,
|
| 99 |
+
extra_headers: dict[str, str] | None = None,
|
| 100 |
+
timeout: float = 30.0,
|
| 101 |
+
) -> None:
|
| 102 |
+
self.base_url = base_url.rstrip("/")
|
| 103 |
+
self._headers = {
|
| 104 |
+
"Authorization": f"Bearer {api_key}",
|
| 105 |
+
"Content-Type": "application/json",
|
| 106 |
+
}
|
| 107 |
+
if extra_headers:
|
| 108 |
+
self._headers.update(extra_headers)
|
| 109 |
+
self._timeout = timeout
|
| 110 |
+
self._client: httpx.AsyncClient | None = None
|
| 111 |
+
|
| 112 |
+
def _get_client(self) -> httpx.AsyncClient:
|
| 113 |
+
if self._client is None:
|
| 114 |
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
| 115 |
+
return self._client
|
| 116 |
+
|
| 117 |
+
async def aclose(self) -> None:
|
| 118 |
+
if self._client is not None:
|
| 119 |
+
await self._client.aclose()
|
| 120 |
+
self._client = None
|
| 121 |
+
|
| 122 |
+
async def chat(
|
| 123 |
+
self,
|
| 124 |
+
messages: list[dict[str, Any]],
|
| 125 |
+
tools: list[ToolSpec],
|
| 126 |
+
model: str,
|
| 127 |
+
temperature: float = 0.3,
|
| 128 |
+
) -> ChatResult:
|
| 129 |
+
payload: dict[str, Any] = {
|
| 130 |
+
"model": model,
|
| 131 |
+
"messages": messages,
|
| 132 |
+
"temperature": temperature,
|
| 133 |
+
}
|
| 134 |
+
if tools:
|
| 135 |
+
payload["tools"] = [t.to_openai() for t in tools]
|
| 136 |
+
payload["tool_choice"] = "auto"
|
| 137 |
+
try:
|
| 138 |
+
resp = await self._get_client().post(
|
| 139 |
+
f"{self.base_url}/chat/completions", json=payload, headers=self._headers
|
| 140 |
+
)
|
| 141 |
+
except httpx.HTTPError as exc:
|
| 142 |
+
raise ProviderError(f"network error: {exc}") from exc
|
| 143 |
+
|
| 144 |
+
if resp.status_code == 429:
|
| 145 |
+
raise RateLimitError(f"rate limited: {resp.text[:200]}")
|
| 146 |
+
if resp.status_code >= 400:
|
| 147 |
+
raise ProviderError(f"http {resp.status_code}: {resp.text[:200]}")
|
| 148 |
+
try:
|
| 149 |
+
return parse_openai_choice(resp.json())
|
| 150 |
+
except ProviderError:
|
| 151 |
+
raise
|
| 152 |
+
except Exception as exc: # noqa: BLE001 - any parse failure should fail over
|
| 153 |
+
raise ProviderError(f"unparseable response: {exc}") from exc
|
app/llm/cloudflare.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cloudflare Workers AI provider (OpenAI-compatible). Failover LLM."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from app.llm.base import OpenAICompatProvider
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class CloudflareProvider(OpenAICompatProvider):
|
| 9 |
+
def __init__(self, account_id: str, api_token: str, **kwargs: object) -> None:
|
| 10 |
+
base_url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1"
|
| 11 |
+
super().__init__(base_url, api_token, **kwargs) # type: ignore[arg-type]
|
app/llm/groq.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Groq provider (OpenAI-compatible). Primary LLM."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from app.llm.base import OpenAICompatProvider
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class GroqProvider(OpenAICompatProvider):
|
| 9 |
+
def __init__(
|
| 10 |
+
self,
|
| 11 |
+
api_key: str,
|
| 12 |
+
base_url: str = "https://api.groq.com/openai/v1",
|
| 13 |
+
**kwargs: object,
|
| 14 |
+
) -> None:
|
| 15 |
+
super().__init__(base_url, api_key, **kwargs) # type: ignore[arg-type]
|
app/llm/router.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLMRouter: picks a model per tier and fails over across providers in order."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from app.llm.base import ChatResult, LLMProvider, ProviderError, ToolSpec
|
| 9 |
+
|
| 10 |
+
log = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LLMRouter:
|
| 14 |
+
"""Route a chat call to the first healthy provider in ``order``.
|
| 15 |
+
|
| 16 |
+
``models`` maps provider name -> {"small": model_id, "large": model_id}.
|
| 17 |
+
On ProviderError/RateLimitError, fail over to the next provider.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
providers: dict[str, LLMProvider],
|
| 23 |
+
order: list[str],
|
| 24 |
+
models: dict[str, dict[str, str]],
|
| 25 |
+
temperature: float = 0.3,
|
| 26 |
+
) -> None:
|
| 27 |
+
self.providers = providers
|
| 28 |
+
self.order = order
|
| 29 |
+
self.models = models
|
| 30 |
+
self.temperature = temperature
|
| 31 |
+
|
| 32 |
+
async def chat(
|
| 33 |
+
self,
|
| 34 |
+
messages: list[dict[str, Any]],
|
| 35 |
+
tools: list[ToolSpec],
|
| 36 |
+
tier: str = "large",
|
| 37 |
+
temperature: float | None = None,
|
| 38 |
+
) -> ChatResult:
|
| 39 |
+
last_exc: Exception | None = None
|
| 40 |
+
for name in self.order:
|
| 41 |
+
provider = self.providers.get(name)
|
| 42 |
+
if provider is None:
|
| 43 |
+
continue
|
| 44 |
+
model = self.models.get(name, {}).get(tier)
|
| 45 |
+
if not model:
|
| 46 |
+
continue
|
| 47 |
+
try:
|
| 48 |
+
return await provider.chat(
|
| 49 |
+
messages=messages,
|
| 50 |
+
tools=tools,
|
| 51 |
+
model=model,
|
| 52 |
+
temperature=self.temperature if temperature is None else temperature,
|
| 53 |
+
)
|
| 54 |
+
except ProviderError as exc:
|
| 55 |
+
log.warning("LLM provider %s failed (%s); failing over", name, exc)
|
| 56 |
+
last_exc = exc
|
| 57 |
+
continue
|
| 58 |
+
raise last_exc or ProviderError("no LLM providers configured")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_router_from_settings(settings: Any) -> LLMRouter:
|
| 62 |
+
"""Construct an LLMRouter from Settings, including only configured providers."""
|
| 63 |
+
from app.llm.cloudflare import CloudflareProvider
|
| 64 |
+
from app.llm.groq import GroqProvider
|
| 65 |
+
|
| 66 |
+
providers: dict[str, LLMProvider] = {}
|
| 67 |
+
models: dict[str, dict[str, str]] = {}
|
| 68 |
+
|
| 69 |
+
if settings.groq_api_key:
|
| 70 |
+
providers["groq"] = GroqProvider(
|
| 71 |
+
api_key=settings.groq_api_key, base_url=settings.groq_base_url
|
| 72 |
+
)
|
| 73 |
+
models["groq"] = {"small": settings.model_small, "large": settings.model_large}
|
| 74 |
+
|
| 75 |
+
if settings.cloudflare_account_id and settings.cloudflare_api_token:
|
| 76 |
+
providers["cloudflare"] = CloudflareProvider(
|
| 77 |
+
account_id=settings.cloudflare_account_id,
|
| 78 |
+
api_token=settings.cloudflare_api_token,
|
| 79 |
+
)
|
| 80 |
+
models["cloudflare"] = {
|
| 81 |
+
"small": settings.cf_model_small,
|
| 82 |
+
"large": settings.cf_model_large,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
order = [p for p in settings.llm_provider_order if p in providers]
|
| 86 |
+
return LLMRouter(providers=providers, order=order, models=models)
|
app/mailer.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SMTP mailer for human-escalation notifications."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import smtplib
|
| 7 |
+
from email.message import EmailMessage
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import anyio
|
| 11 |
+
|
| 12 |
+
log = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SmtpMailer:
|
| 16 |
+
def __init__(self, settings: Any) -> None:
|
| 17 |
+
self.settings = settings
|
| 18 |
+
|
| 19 |
+
def _send_sync(self, to: str, subject: str, body: str) -> bool:
|
| 20 |
+
s = self.settings
|
| 21 |
+
if not s.smtp_host:
|
| 22 |
+
log.warning("SMTP not configured; escalation email not sent: %s", subject)
|
| 23 |
+
return False
|
| 24 |
+
msg = EmailMessage()
|
| 25 |
+
msg["From"] = s.smtp_from or s.smtp_user
|
| 26 |
+
msg["To"] = to
|
| 27 |
+
msg["Subject"] = subject
|
| 28 |
+
msg.set_content(body)
|
| 29 |
+
with smtplib.SMTP(s.smtp_host, s.smtp_port) as server:
|
| 30 |
+
server.starttls()
|
| 31 |
+
if s.smtp_user:
|
| 32 |
+
server.login(s.smtp_user, s.smtp_password)
|
| 33 |
+
server.send_message(msg)
|
| 34 |
+
return True
|
| 35 |
+
|
| 36 |
+
async def send(self, to: str, subject: str, body: str) -> bool:
|
| 37 |
+
return await anyio.to_thread.run_sync(self._send_sync, to, subject, body)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def default_sender(settings: Any):
|
| 41 |
+
mailer = SmtpMailer(settings)
|
| 42 |
+
|
| 43 |
+
async def _sender(to: str, subject: str, body: str) -> bool:
|
| 44 |
+
return await mailer.send(to, subject, body)
|
| 45 |
+
|
| 46 |
+
return _sender
|
app/main.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application factory."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from contextlib import asynccontextmanager
|
| 7 |
+
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
+
|
| 11 |
+
from app import db as dbmod
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
from app.routes import admin, chat, health, widget
|
| 14 |
+
|
| 15 |
+
log = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
settings = get_settings()
|
| 21 |
+
if not dbmod.is_initialized():
|
| 22 |
+
dbmod.init_engine(settings.database_url)
|
| 23 |
+
|
| 24 |
+
if not settings.testing:
|
| 25 |
+
# Dev convenience for sqlite; Postgres uses Alembic migrations.
|
| 26 |
+
if dbmod.get_engine().dialect.name == "sqlite":
|
| 27 |
+
await dbmod.create_all()
|
| 28 |
+
# Warm the embedding model so the first chat isn't slow.
|
| 29 |
+
try:
|
| 30 |
+
from app.embeddings import embed_query
|
| 31 |
+
|
| 32 |
+
await embed_query("warmup")
|
| 33 |
+
except Exception: # noqa: BLE001 - warmup is best-effort
|
| 34 |
+
log.warning("embedding warmup failed", exc_info=True)
|
| 35 |
+
# Ensure a 'default' tenant exists + GDPR retention sweep at boot.
|
| 36 |
+
try:
|
| 37 |
+
from app.retention import purge_old_sessions
|
| 38 |
+
from app.tenancy import ensure_default_tenant
|
| 39 |
+
|
| 40 |
+
async with dbmod.get_sessionmaker()() as session:
|
| 41 |
+
await ensure_default_tenant(session)
|
| 42 |
+
await purge_old_sessions(session, settings.session_retention_days)
|
| 43 |
+
await session.commit()
|
| 44 |
+
except Exception: # noqa: BLE001 - best-effort
|
| 45 |
+
log.warning("startup sweep failed", exc_info=True)
|
| 46 |
+
yield
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def create_app() -> FastAPI:
|
| 50 |
+
settings = get_settings()
|
| 51 |
+
if not dbmod.is_initialized():
|
| 52 |
+
dbmod.init_engine(settings.database_url)
|
| 53 |
+
|
| 54 |
+
if not settings.testing and not settings.admin_token:
|
| 55 |
+
log.warning("ADMIN_TOKEN is empty — the admin panel/API will reject all requests")
|
| 56 |
+
|
| 57 |
+
app = FastAPI(title="Shopify Support Bot", version="0.1.0", lifespan=lifespan)
|
| 58 |
+
app.add_middleware(
|
| 59 |
+
CORSMiddleware,
|
| 60 |
+
allow_origins=settings.allowed_origins,
|
| 61 |
+
allow_credentials=False,
|
| 62 |
+
allow_methods=["*"],
|
| 63 |
+
allow_headers=["*"],
|
| 64 |
+
)
|
| 65 |
+
app.include_router(health.router)
|
| 66 |
+
app.include_router(widget.router)
|
| 67 |
+
app.include_router(chat.router)
|
| 68 |
+
app.include_router(admin.router)
|
| 69 |
+
return app
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
app = create_app()
|
app/models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ORM models. The embedding column uses pgvector on Postgres and JSON
|
| 2 |
+
elsewhere (so unit tests run on sqlite)."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import UTC, datetime
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import (
|
| 10 |
+
JSON,
|
| 11 |
+
DateTime,
|
| 12 |
+
ForeignKey,
|
| 13 |
+
Integer,
|
| 14 |
+
String,
|
| 15 |
+
Text,
|
| 16 |
+
types,
|
| 17 |
+
)
|
| 18 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 19 |
+
|
| 20 |
+
from app.db import Base
|
| 21 |
+
|
| 22 |
+
EMBEDDING_DIM = 384
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _utcnow() -> datetime:
|
| 26 |
+
return datetime.now(UTC)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class EmbeddingType(types.TypeDecorator):
|
| 30 |
+
"""Vector(384) on Postgres, JSON list[float] on other dialects."""
|
| 31 |
+
|
| 32 |
+
impl = JSON
|
| 33 |
+
cache_ok = True
|
| 34 |
+
|
| 35 |
+
def load_dialect_impl(self, dialect):
|
| 36 |
+
if dialect.name == "postgresql":
|
| 37 |
+
from pgvector.sqlalchemy import Vector
|
| 38 |
+
|
| 39 |
+
return dialect.type_descriptor(Vector(EMBEDDING_DIM))
|
| 40 |
+
return dialect.type_descriptor(JSON())
|
| 41 |
+
|
| 42 |
+
def process_bind_param(self, value, dialect):
|
| 43 |
+
if value is None:
|
| 44 |
+
return None
|
| 45 |
+
return list(value)
|
| 46 |
+
|
| 47 |
+
def process_result_value(self, value, dialect):
|
| 48 |
+
if value is None:
|
| 49 |
+
return None
|
| 50 |
+
return list(value)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Config(Base):
|
| 54 |
+
__tablename__ = "config"
|
| 55 |
+
|
| 56 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 57 |
+
key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
| 58 |
+
value: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class Tenant(Base):
|
| 62 |
+
"""A merchant served by the shared multi-tenant backend."""
|
| 63 |
+
|
| 64 |
+
__tablename__ = "tenants"
|
| 65 |
+
|
| 66 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 67 |
+
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
| 68 |
+
name: Mapped[str] = mapped_column(String(255), default="")
|
| 69 |
+
brand_name: Mapped[str] = mapped_column(String(255), default="Asistente")
|
| 70 |
+
brand_color: Mapped[str] = mapped_column(String(16), default="#e8491d")
|
| 71 |
+
welcome: Mapped[str] = mapped_column(Text, default="¡Hola! ¿En qué puedo ayudarte?")
|
| 72 |
+
# Shopify (optional — only for order tracking / live catalog). Secret encrypted.
|
| 73 |
+
shopify_shop: Mapped[str] = mapped_column(String(255), default="")
|
| 74 |
+
shopify_client_id: Mapped[str] = mapped_column(String(255), default="")
|
| 75 |
+
shopify_client_secret_enc: Mapped[str] = mapped_column(Text, default="")
|
| 76 |
+
shopify_api_version: Mapped[str] = mapped_column(String(20), default="2026-01")
|
| 77 |
+
read_all_orders: Mapped[bool] = mapped_column(default=False)
|
| 78 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class KnowledgeSource(Base):
|
| 82 |
+
__tablename__ = "knowledge_sources"
|
| 83 |
+
|
| 84 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 85 |
+
tenant_id: Mapped[int | None] = mapped_column(
|
| 86 |
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True
|
| 87 |
+
)
|
| 88 |
+
kind: Mapped[str] = mapped_column(String(10)) # "file" | "url"
|
| 89 |
+
name: Mapped[str] = mapped_column(String(500))
|
| 90 |
+
location: Mapped[str] = mapped_column(String(2000)) # file path or URL
|
| 91 |
+
status: Mapped[str] = mapped_column(String(20), default="pending") # pending|indexed|error
|
| 92 |
+
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 93 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 94 |
+
|
| 95 |
+
chunks: Mapped[list[KnowledgeChunk]] = relationship(
|
| 96 |
+
back_populates="source", cascade="all, delete-orphan"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class KnowledgeChunk(Base):
|
| 101 |
+
__tablename__ = "knowledge_chunks"
|
| 102 |
+
|
| 103 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 104 |
+
source_id: Mapped[int] = mapped_column(
|
| 105 |
+
ForeignKey("knowledge_sources.id", ondelete="CASCADE"), index=True
|
| 106 |
+
)
|
| 107 |
+
tenant_id: Mapped[int | None] = mapped_column(
|
| 108 |
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True
|
| 109 |
+
)
|
| 110 |
+
ordinal: Mapped[int] = mapped_column(Integer, default=0)
|
| 111 |
+
text: Mapped[str] = mapped_column(Text)
|
| 112 |
+
embedding: Mapped[list[float]] = mapped_column(EmbeddingType)
|
| 113 |
+
meta: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 114 |
+
|
| 115 |
+
source: Mapped[KnowledgeSource] = relationship(back_populates="chunks")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class ChatSession(Base):
|
| 119 |
+
__tablename__ = "chat_sessions"
|
| 120 |
+
|
| 121 |
+
id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: uuid.uuid4().hex)
|
| 122 |
+
tenant_id: Mapped[int | None] = mapped_column(
|
| 123 |
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True
|
| 124 |
+
)
|
| 125 |
+
shop: Mapped[str] = mapped_column(String(255), default="")
|
| 126 |
+
lang: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
| 127 |
+
verified: Mapped[bool] = mapped_column(default=False)
|
| 128 |
+
verify_attempts: Mapped[int] = mapped_column(Integer, default=0)
|
| 129 |
+
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 130 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 131 |
+
last_seen: Mapped[datetime] = mapped_column(
|
| 132 |
+
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
messages: Mapped[list[ChatMessage]] = relationship(
|
| 136 |
+
back_populates="session",
|
| 137 |
+
cascade="all, delete-orphan",
|
| 138 |
+
order_by="ChatMessage.id",
|
| 139 |
+
lazy="selectin",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class ChatMessage(Base):
|
| 144 |
+
__tablename__ = "chat_messages"
|
| 145 |
+
|
| 146 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 147 |
+
session_id: Mapped[str] = mapped_column(
|
| 148 |
+
ForeignKey("chat_sessions.id", ondelete="CASCADE"), index=True
|
| 149 |
+
)
|
| 150 |
+
role: Mapped[str] = mapped_column(String(20)) # user|assistant|tool|system
|
| 151 |
+
content: Mapped[str] = mapped_column(Text)
|
| 152 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
| 153 |
+
|
| 154 |
+
session: Mapped[ChatSession] = relationship(back_populates="messages")
|
app/orchestrator.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat orchestrator: builds the prompt, runs the LLM tool loop, persists turn."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
|
| 10 |
+
from app.llm.base import ToolCall
|
| 11 |
+
from app.models import ChatMessage
|
| 12 |
+
from app.prompts import build_system_prompt
|
| 13 |
+
from app.schemas import ChatResponse
|
| 14 |
+
from app.tools import registry
|
| 15 |
+
from app.tools.registry import ToolContext
|
| 16 |
+
|
| 17 |
+
HISTORY_LIMIT = 10
|
| 18 |
+
MAX_TOOL_ITERS = 4
|
| 19 |
+
FALLBACK_REPLY = "Lo siento, ahora mismo no he podido completar la consulta. ¿Puedes reformularla?"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _toolcall_to_openai(tc: ToolCall) -> dict[str, Any]:
|
| 23 |
+
return {
|
| 24 |
+
"id": tc.id,
|
| 25 |
+
"type": "function",
|
| 26 |
+
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments, ensure_ascii=False)},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def _load_history(ctx: ToolContext) -> list[ChatMessage]:
|
| 31 |
+
rows = (
|
| 32 |
+
await ctx.db.execute(
|
| 33 |
+
select(ChatMessage)
|
| 34 |
+
.where(ChatMessage.session_id == ctx.session.id)
|
| 35 |
+
.order_by(ChatMessage.id)
|
| 36 |
+
)
|
| 37 |
+
).scalars().all()
|
| 38 |
+
return list(rows)[-HISTORY_LIMIT:]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def run_turn(
|
| 42 |
+
router: Any,
|
| 43 |
+
ctx: ToolContext,
|
| 44 |
+
user_message: str,
|
| 45 |
+
*,
|
| 46 |
+
brand_name: str = "Asistente",
|
| 47 |
+
) -> ChatResponse:
|
| 48 |
+
prior = await _load_history(ctx)
|
| 49 |
+
|
| 50 |
+
messages: list[dict[str, Any]] = [
|
| 51 |
+
{"role": "system", "content": build_system_prompt(brand_name)}
|
| 52 |
+
]
|
| 53 |
+
for m in prior:
|
| 54 |
+
if m.role in ("user", "assistant"):
|
| 55 |
+
messages.append({"role": m.role, "content": m.content})
|
| 56 |
+
messages.append({"role": "user", "content": user_message})
|
| 57 |
+
|
| 58 |
+
ctx.db.add(ChatMessage(session_id=ctx.session.id, role="user", content=user_message))
|
| 59 |
+
await ctx.db.flush()
|
| 60 |
+
|
| 61 |
+
used_tools: list[str] = []
|
| 62 |
+
reply = FALLBACK_REPLY
|
| 63 |
+
|
| 64 |
+
for _ in range(MAX_TOOL_ITERS):
|
| 65 |
+
result = await router.chat(messages=messages, tools=registry.specs(), tier="large")
|
| 66 |
+
if result.tool_calls:
|
| 67 |
+
messages.append(
|
| 68 |
+
{
|
| 69 |
+
"role": "assistant",
|
| 70 |
+
"content": result.content or "",
|
| 71 |
+
"tool_calls": [_toolcall_to_openai(tc) for tc in result.tool_calls],
|
| 72 |
+
}
|
| 73 |
+
)
|
| 74 |
+
for tc in result.tool_calls:
|
| 75 |
+
used_tools.append(tc.name)
|
| 76 |
+
tool_result = await registry.dispatch(tc.name, tc.arguments, ctx)
|
| 77 |
+
messages.append(
|
| 78 |
+
{
|
| 79 |
+
"role": "tool",
|
| 80 |
+
"tool_call_id": tc.id,
|
| 81 |
+
"content": json.dumps(tool_result, ensure_ascii=False, default=str),
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
continue
|
| 85 |
+
reply = result.content or FALLBACK_REPLY
|
| 86 |
+
break
|
| 87 |
+
else:
|
| 88 |
+
# Tool budget exhausted: force a final answer with no tools.
|
| 89 |
+
final = await router.chat(messages=messages, tools=[], tier="large")
|
| 90 |
+
reply = final.content or FALLBACK_REPLY
|
| 91 |
+
|
| 92 |
+
ctx.db.add(ChatMessage(session_id=ctx.session.id, role="assistant", content=reply))
|
| 93 |
+
await ctx.db.flush()
|
| 94 |
+
|
| 95 |
+
return ChatResponse(
|
| 96 |
+
session_id=ctx.session.id, reply=reply, lang=ctx.session.lang, used_tools=used_tools
|
| 97 |
+
)
|
app/prompts.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompt builder for the support assistant."""
|
| 2 |
+
# ruff: noqa: E501 - this module is a natural-language prompt template
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
SYSTEM_TEMPLATE = """Eres {brand_name}, el asistente virtual de atención al cliente de la tienda.
|
| 7 |
+
|
| 8 |
+
Reglas de idioma:
|
| 9 |
+
- Responde SIEMPRE en el mismo idioma en el que te escribe el cliente (detéctalo del último mensaje).
|
| 10 |
+
- Si el cliente escribe en portugués, usa portugués de Portugal (pt-PT).
|
| 11 |
+
|
| 12 |
+
Qué puedes hacer (usa las herramientas, no inventes datos):
|
| 13 |
+
- Información de la tienda y productos: usa `search_knowledge` (base de conocimiento con fichas, PDFs y enlaces) y `search_products` (catálogo en vivo: precios y disponibilidad).
|
| 14 |
+
- Estado de pedidos y seguimiento: usa `lookup_order`.
|
| 15 |
+
|
| 16 |
+
Política de pedidos (privacidad):
|
| 17 |
+
- Para consultar un pedido necesitas el email del cliente y el número de pedido. Pídelos si faltan.
|
| 18 |
+
- Revela ÚNICAMENTE el estado del envío y la información de seguimiento (transportista, número, enlace, fecha estimada). NUNCA reveles la dirección completa ni datos de pago.
|
| 19 |
+
- Si la verificación falla, di de forma genérica que no has podido verificar los datos. NO digas qué campo es incorrecto.
|
| 20 |
+
- Tras varios intentos fallidos la consulta queda bloqueada temporalmente; pide al cliente que lo intente más tarde.
|
| 21 |
+
|
| 22 |
+
Honestidad y escalado:
|
| 23 |
+
- Si no sabes algo o no puedes resolverlo, dilo con honestidad. Ofrece pasar la consulta al equipo humano: pide el email del cliente y usa `escalate_to_human`.
|
| 24 |
+
|
| 25 |
+
Tono: cercano, claro y útil. Respuestas breves.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build_system_prompt(brand_name: str = "Asistente") -> str:
|
| 30 |
+
return SYSTEM_TEMPLATE.format(brand_name=brand_name or "Asistente")
|
app/rag/__init__.py
ADDED
|
File without changes
|
app/rag/chunk.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic sliding-window text chunking."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def chunk_text(text: str, size: int = 1000, overlap: int = 200) -> list[str]:
|
| 7 |
+
"""Split text into overlapping windows.
|
| 8 |
+
|
| 9 |
+
Each chunk is at most ``size`` chars; consecutive chunks share ``overlap``
|
| 10 |
+
chars. Whitespace-only input yields no chunks.
|
| 11 |
+
"""
|
| 12 |
+
if size <= 0:
|
| 13 |
+
raise ValueError("size must be positive")
|
| 14 |
+
if overlap < 0 or overlap >= size:
|
| 15 |
+
raise ValueError("overlap must be in [0, size)")
|
| 16 |
+
|
| 17 |
+
text = text.strip()
|
| 18 |
+
if not text:
|
| 19 |
+
return []
|
| 20 |
+
|
| 21 |
+
chunks: list[str] = []
|
| 22 |
+
step = size - overlap
|
| 23 |
+
start = 0
|
| 24 |
+
while start < len(text):
|
| 25 |
+
piece = text[start : start + size].strip()
|
| 26 |
+
if piece:
|
| 27 |
+
chunks.append(piece)
|
| 28 |
+
start += step
|
| 29 |
+
return chunks
|
app/rag/extract.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 32 |
+
if owns:
|
| 33 |
+
await client.aclose()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _extract_pdf(data: bytes) -> str:
|
| 37 |
+
from pypdf import PdfReader
|
| 38 |
+
|
| 39 |
+
reader = PdfReader(io.BytesIO(data))
|
| 40 |
+
parts = [page.extract_text() or "" for page in reader.pages]
|
| 41 |
+
return "\n".join(parts).strip()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _extract_xlsx(data: bytes) -> str:
|
| 45 |
+
from openpyxl import load_workbook
|
| 46 |
+
|
| 47 |
+
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
| 48 |
+
lines: list[str] = []
|
| 49 |
+
for ws in wb.worksheets:
|
| 50 |
+
for row in ws.iter_rows(values_only=True):
|
| 51 |
+
cells = [str(c) for c in row if c is not None and str(c).strip()]
|
| 52 |
+
if cells:
|
| 53 |
+
lines.append(" ".join(cells))
|
| 54 |
+
wb.close()
|
| 55 |
+
return "\n".join(lines).strip()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _extract_docx(data: bytes) -> str:
|
| 59 |
+
from docx import Document
|
| 60 |
+
|
| 61 |
+
doc = Document(io.BytesIO(data))
|
| 62 |
+
return "\n".join(p.text for p in doc.paragraphs if p.text.strip()).strip()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def extract_bytes(data: bytes, suffix: str) -> str:
|
| 66 |
+
suffix = suffix.lower().lstrip(".")
|
| 67 |
+
if suffix == "pdf":
|
| 68 |
+
return _extract_pdf(data)
|
| 69 |
+
if suffix in ("xlsx", "xlsm"):
|
| 70 |
+
return _extract_xlsx(data)
|
| 71 |
+
if suffix == "docx":
|
| 72 |
+
return _extract_docx(data)
|
| 73 |
+
if suffix in ("txt", "md", "csv"):
|
| 74 |
+
return data.decode("utf-8", errors="replace").strip()
|
| 75 |
+
if suffix in ("html", "htm"):
|
| 76 |
+
return extract_html(data)
|
| 77 |
+
raise ValueError(f"unsupported file type: .{suffix}")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def extract_file(path: str | Path) -> str:
|
| 81 |
+
path = Path(path)
|
| 82 |
+
return extract_bytes(path.read_bytes(), path.suffix)
|
app/rag/index.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Index knowledge sources into chunks+embeddings and search them."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import delete, select, text
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app import embeddings
|
| 11 |
+
from app.db import get_engine
|
| 12 |
+
from app.models import KnowledgeChunk, KnowledgeSource
|
| 13 |
+
from app.rag import extract
|
| 14 |
+
from app.rag.chunk import chunk_text
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger(__name__)
|
| 17 |
+
|
| 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 |
+
|
| 25 |
+
async def index_source(session: AsyncSession, source: KnowledgeSource) -> KnowledgeSource:
|
| 26 |
+
"""Extract -> chunk -> embed -> store. Sets source.status."""
|
| 27 |
+
# clear any prior chunks for idempotent reindex
|
| 28 |
+
await session.execute(
|
| 29 |
+
delete(KnowledgeChunk).where(KnowledgeChunk.source_id == source.id)
|
| 30 |
+
)
|
| 31 |
+
try:
|
| 32 |
+
raw = await _source_text(source)
|
| 33 |
+
chunks = chunk_text(raw)
|
| 34 |
+
if not chunks:
|
| 35 |
+
source.status = "error"
|
| 36 |
+
source.error = "no extractable text"
|
| 37 |
+
await session.flush()
|
| 38 |
+
return source
|
| 39 |
+
vectors = await embeddings.embed_texts(chunks)
|
| 40 |
+
for ordinal, (chunk, vector) in enumerate(zip(chunks, vectors, strict=True)):
|
| 41 |
+
session.add(
|
| 42 |
+
KnowledgeChunk(
|
| 43 |
+
source_id=source.id,
|
| 44 |
+
tenant_id=source.tenant_id,
|
| 45 |
+
ordinal=ordinal,
|
| 46 |
+
text=chunk,
|
| 47 |
+
embedding=vector,
|
| 48 |
+
meta={"source_name": source.name},
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
source.status = "indexed"
|
| 52 |
+
source.error = None
|
| 53 |
+
except Exception as exc: # noqa: BLE001 - surface any extraction/embed failure on the source
|
| 54 |
+
log.exception("indexing source %s failed", source.id)
|
| 55 |
+
source.status = "error"
|
| 56 |
+
source.error = str(exc)[:500]
|
| 57 |
+
await session.flush()
|
| 58 |
+
return source
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def delete_source(session: AsyncSession, source_id: int) -> None:
|
| 62 |
+
await session.execute(
|
| 63 |
+
delete(KnowledgeChunk).where(KnowledgeChunk.source_id == source_id)
|
| 64 |
+
)
|
| 65 |
+
await session.execute(
|
| 66 |
+
delete(KnowledgeSource).where(KnowledgeSource.id == source_id)
|
| 67 |
+
)
|
| 68 |
+
await session.flush()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def reindex_all(session: AsyncSession, *, tenant_id: int | None = None) -> None:
|
| 72 |
+
stmt = select(KnowledgeSource)
|
| 73 |
+
if tenant_id is not None:
|
| 74 |
+
stmt = stmt.where(KnowledgeSource.tenant_id == tenant_id)
|
| 75 |
+
sources = (await session.execute(stmt)).scalars().all()
|
| 76 |
+
for source in sources:
|
| 77 |
+
await index_source(session, source)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def search(
|
| 81 |
+
session: AsyncSession, query: str, k: int = 4, *, tenant_id: int | None = None
|
| 82 |
+
) -> list[tuple[KnowledgeChunk, float]]:
|
| 83 |
+
"""Return up to k (chunk, cosine_score) pairs, most similar first.
|
| 84 |
+
|
| 85 |
+
When tenant_id is given, only that tenant's chunks are searched (isolation).
|
| 86 |
+
"""
|
| 87 |
+
qvec = await embeddings.embed_query(query)
|
| 88 |
+
if get_engine().dialect.name == "postgresql":
|
| 89 |
+
vec_literal = "[" + ",".join(repr(float(x)) for x in qvec) + "]"
|
| 90 |
+
stmt = select(KnowledgeChunk)
|
| 91 |
+
if tenant_id is not None:
|
| 92 |
+
stmt = stmt.where(KnowledgeChunk.tenant_id == tenant_id)
|
| 93 |
+
stmt = stmt.order_by(text("embedding <=> (:qv)::vector")).limit(k)
|
| 94 |
+
rows = (await session.execute(stmt, {"qv": vec_literal})).scalars().all()
|
| 95 |
+
return [(c, embeddings.cosine(qvec, c.embedding)) for c in rows]
|
| 96 |
+
|
| 97 |
+
stmt = select(KnowledgeChunk)
|
| 98 |
+
if tenant_id is not None:
|
| 99 |
+
stmt = stmt.where(KnowledgeChunk.tenant_id == tenant_id)
|
| 100 |
+
chunks = (await session.execute(stmt)).scalars().all()
|
| 101 |
+
scored = [(c, embeddings.cosine(qvec, c.embedding)) for c in chunks]
|
| 102 |
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
| 103 |
+
return scored[:k]
|
app/ratelimit.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-process fixed-window rate limiter.
|
| 2 |
+
|
| 3 |
+
Used to throttle order-verification attempts per email so the per-session
|
| 4 |
+
lockout can't be bypassed by rotating the client-supplied session_id.
|
| 5 |
+
Single-instance only (state is in-memory); fine for one Railway service.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from datetime import UTC, datetime
|
| 12 |
+
|
| 13 |
+
from app.config import get_settings
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RateLimiter:
|
| 17 |
+
def __init__(self, max_hits: int, window_seconds: int) -> None:
|
| 18 |
+
self.max_hits = max_hits
|
| 19 |
+
self.window = window_seconds
|
| 20 |
+
self._hits: dict[str, list[float]] = defaultdict(list)
|
| 21 |
+
|
| 22 |
+
def allow(self, key: str, now: datetime | None = None) -> bool:
|
| 23 |
+
ts = (now or datetime.now(UTC)).timestamp()
|
| 24 |
+
cutoff = ts - self.window
|
| 25 |
+
hits = [h for h in self._hits[key] if h > cutoff]
|
| 26 |
+
if len(hits) >= self.max_hits:
|
| 27 |
+
self._hits[key] = hits
|
| 28 |
+
return False
|
| 29 |
+
hits.append(ts)
|
| 30 |
+
self._hits[key] = hits
|
| 31 |
+
return True
|
| 32 |
+
|
| 33 |
+
def reset(self, key: str) -> None:
|
| 34 |
+
self._hits.pop(key, None)
|
| 35 |
+
|
| 36 |
+
def clear(self) -> None:
|
| 37 |
+
self._hits.clear()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
_order_limiter: RateLimiter | None = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_order_limiter() -> RateLimiter:
|
| 44 |
+
global _order_limiter
|
| 45 |
+
if _order_limiter is None:
|
| 46 |
+
s = get_settings()
|
| 47 |
+
_order_limiter = RateLimiter(s.order_verify_max_attempts, s.order_verify_lockout_seconds)
|
| 48 |
+
return _order_limiter
|
app/retention.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GDPR retention: purge chat sessions/messages older than N days."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import UTC, datetime, timedelta
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import delete, select
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.models import ChatMessage, ChatSession
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def purge_old_sessions(db: AsyncSession, days: int) -> int:
|
| 14 |
+
cutoff = datetime.now(UTC) - timedelta(days=days)
|
| 15 |
+
ids = (
|
| 16 |
+
await db.execute(select(ChatSession.id).where(ChatSession.last_seen < cutoff))
|
| 17 |
+
).scalars().all()
|
| 18 |
+
if not ids:
|
| 19 |
+
return 0
|
| 20 |
+
await db.execute(delete(ChatMessage).where(ChatMessage.session_id.in_(ids)))
|
| 21 |
+
await db.execute(delete(ChatSession).where(ChatSession.id.in_(ids)))
|
| 22 |
+
await db.flush()
|
| 23 |
+
return len(ids)
|
app/routes/__init__.py
ADDED
|
File without changes
|
app/routes/admin.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin API + UI (multi-tenant): manage tenants + their knowledge sources.
|
| 2 |
+
|
| 3 |
+
Super-admin (Bearer ADMIN_TOKEN) manages all tenants. Everything except the
|
| 4 |
+
HTML page requires auth.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 13 |
+
from fastapi.responses import HTMLResponse
|
| 14 |
+
from sqlalchemy import select
|
| 15 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 16 |
+
|
| 17 |
+
from app.db import get_session
|
| 18 |
+
from app.deps import require_admin
|
| 19 |
+
from app.models import KnowledgeSource, Tenant
|
| 20 |
+
from app.rag import index
|
| 21 |
+
from app.schemas import SourceIn, SourceOut, TenantIn, TenantOut, TenantUpdate
|
| 22 |
+
from app.tenancy import create_tenant, get_tenant_by_slug, list_tenants, update_tenant
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/admin")
|
| 25 |
+
|
| 26 |
+
_UI_PATH = Path(__file__).resolve().parent.parent / "admin_ui" / "index.html"
|
| 27 |
+
_SAFE = re.compile(r"[^A-Za-z0-9._-]")
|
| 28 |
+
_ALLOWED_EXTS = {".pdf", ".xlsx", ".xlsm", ".docx", ".txt", ".md", ".csv", ".html", ".htm"}
|
| 29 |
+
_MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/", response_class=HTMLResponse)
|
| 33 |
+
async def admin_ui() -> HTMLResponse:
|
| 34 |
+
return HTMLResponse(_UI_PATH.read_text(encoding="utf-8"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def _require_tenant(db: AsyncSession, slug: str) -> Tenant:
|
| 38 |
+
tenant = await get_tenant_by_slug(db, slug)
|
| 39 |
+
if tenant is None:
|
| 40 |
+
raise HTTPException(status_code=404, detail="unknown tenant")
|
| 41 |
+
return tenant
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ── Tenants ─────────────────────────────────────────────────────────────
|
| 45 |
+
@router.get("/tenants", dependencies=[Depends(require_admin)])
|
| 46 |
+
async def get_tenants(db: AsyncSession = Depends(get_session)) -> list[TenantOut]:
|
| 47 |
+
return [TenantOut.from_tenant(t) for t in await list_tenants(db)]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/tenants", status_code=201, dependencies=[Depends(require_admin)])
|
| 51 |
+
async def post_tenant(body: TenantIn, db: AsyncSession = Depends(get_session)) -> TenantOut:
|
| 52 |
+
if await get_tenant_by_slug(db, body.slug) is not None:
|
| 53 |
+
raise HTTPException(status_code=409, detail="slug already exists")
|
| 54 |
+
fields = body.model_dump(exclude={"slug", "shopify_client_secret"}, exclude_none=True)
|
| 55 |
+
tenant = await create_tenant(
|
| 56 |
+
db, body.slug, shopify_client_secret=body.shopify_client_secret, **fields
|
| 57 |
+
)
|
| 58 |
+
await db.commit()
|
| 59 |
+
return TenantOut.from_tenant(tenant)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.put("/tenants/{slug}", dependencies=[Depends(require_admin)])
|
| 63 |
+
async def put_tenant(
|
| 64 |
+
slug: str, body: TenantUpdate, db: AsyncSession = Depends(get_session)
|
| 65 |
+
) -> TenantOut:
|
| 66 |
+
tenant = await _require_tenant(db, slug)
|
| 67 |
+
fields = body.model_dump(exclude={"shopify_client_secret"}, exclude_none=True)
|
| 68 |
+
await update_tenant(
|
| 69 |
+
db, tenant, shopify_client_secret=body.shopify_client_secret, **fields
|
| 70 |
+
)
|
| 71 |
+
await db.commit()
|
| 72 |
+
return TenantOut.from_tenant(tenant)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ── Knowledge sources (per tenant) ──────────────────────────────────────
|
| 76 |
+
@router.get("/tenants/{slug}/sources", dependencies=[Depends(require_admin)])
|
| 77 |
+
async def list_sources(
|
| 78 |
+
slug: str, db: AsyncSession = Depends(get_session)
|
| 79 |
+
) -> list[SourceOut]:
|
| 80 |
+
tenant = await _require_tenant(db, slug)
|
| 81 |
+
rows = (
|
| 82 |
+
await db.execute(
|
| 83 |
+
select(KnowledgeSource)
|
| 84 |
+
.where(KnowledgeSource.tenant_id == tenant.id)
|
| 85 |
+
.order_by(KnowledgeSource.id)
|
| 86 |
+
)
|
| 87 |
+
).scalars().all()
|
| 88 |
+
return [SourceOut.model_validate(r, from_attributes=True) for r in rows]
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/tenants/{slug}/sources/url", status_code=201, dependencies=[Depends(require_admin)])
|
| 92 |
+
async def add_url_source(
|
| 93 |
+
slug: str, body: SourceIn, db: AsyncSession = Depends(get_session)
|
| 94 |
+
) -> SourceOut:
|
| 95 |
+
tenant = await _require_tenant(db, slug)
|
| 96 |
+
if not body.location:
|
| 97 |
+
raise HTTPException(status_code=422, detail="location required for url sources")
|
| 98 |
+
src = KnowledgeSource(
|
| 99 |
+
kind="url", name=body.location, location=body.location, tenant_id=tenant.id
|
| 100 |
+
)
|
| 101 |
+
db.add(src)
|
| 102 |
+
await db.flush()
|
| 103 |
+
await index.index_source(db, src)
|
| 104 |
+
await db.commit()
|
| 105 |
+
return SourceOut.model_validate(src, from_attributes=True)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.post("/tenants/{slug}/sources/file", status_code=201, dependencies=[Depends(require_admin)])
|
| 109 |
+
async def add_file_source(
|
| 110 |
+
slug: str,
|
| 111 |
+
file: UploadFile = File(...),
|
| 112 |
+
db: AsyncSession = Depends(get_session),
|
| 113 |
+
) -> SourceOut:
|
| 114 |
+
tenant = await _require_tenant(db, slug)
|
| 115 |
+
safe_name = _SAFE.sub("_", file.filename or "upload")
|
| 116 |
+
if safe_name in (".", "..", ""):
|
| 117 |
+
raise HTTPException(status_code=422, detail="invalid filename")
|
| 118 |
+
if Path(safe_name).suffix.lower() not in _ALLOWED_EXTS:
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=415, detail=f"unsupported file type; allowed: {sorted(_ALLOWED_EXTS)}"
|
| 121 |
+
)
|
| 122 |
+
data = await file.read()
|
| 123 |
+
if len(data) > _MAX_UPLOAD_BYTES:
|
| 124 |
+
raise HTTPException(status_code=413, detail="file too large (max 25 MB)")
|
| 125 |
+
|
| 126 |
+
upload_dir = Path("uploads") / tenant.slug
|
| 127 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 128 |
+
dest = upload_dir / safe_name
|
| 129 |
+
dest.write_bytes(data)
|
| 130 |
+
|
| 131 |
+
src = KnowledgeSource(
|
| 132 |
+
kind="file", name=safe_name, location=str(dest), tenant_id=tenant.id
|
| 133 |
+
)
|
| 134 |
+
db.add(src)
|
| 135 |
+
await db.flush()
|
| 136 |
+
await index.index_source(db, src)
|
| 137 |
+
await db.commit()
|
| 138 |
+
return SourceOut.model_validate(src, from_attributes=True)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@router.delete("/tenants/{slug}/sources/{source_id}", dependencies=[Depends(require_admin)])
|
| 142 |
+
async def delete_source(
|
| 143 |
+
slug: str, source_id: int, db: AsyncSession = Depends(get_session)
|
| 144 |
+
) -> dict[str, bool]:
|
| 145 |
+
tenant = await _require_tenant(db, slug)
|
| 146 |
+
src = (
|
| 147 |
+
await db.execute(
|
| 148 |
+
select(KnowledgeSource).where(
|
| 149 |
+
KnowledgeSource.id == source_id,
|
| 150 |
+
KnowledgeSource.tenant_id == tenant.id,
|
| 151 |
+
)
|
| 152 |
+
)
|
| 153 |
+
).scalar_one_or_none()
|
| 154 |
+
if src is None:
|
| 155 |
+
raise HTTPException(status_code=404, detail="source not found for tenant")
|
| 156 |
+
await index.delete_source(db, source_id)
|
| 157 |
+
await db.commit()
|
| 158 |
+
return {"deleted": True}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@router.post("/tenants/{slug}/reindex", dependencies=[Depends(require_admin)])
|
| 162 |
+
async def reindex(slug: str, db: AsyncSession = Depends(get_session)) -> dict[str, str]:
|
| 163 |
+
tenant = await _require_tenant(db, slug)
|
| 164 |
+
await index.reindex_all(db, tenant_id=tenant.id)
|
| 165 |
+
await db.commit()
|
| 166 |
+
return {"status": "reindexed"}
|
app/routes/chat.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat endpoints (multi-tenant).
|
| 2 |
+
|
| 3 |
+
- POST /chat?t=<slug> — simple mode: public, CORS-enabled, no signature. The
|
| 4 |
+
backend-served widget uses this (one <script> to embed).
|
| 5 |
+
- POST /apps/chat — secure mode: behind the Shopify App Proxy (HMAC); tenant
|
| 6 |
+
resolved from the signed `shop` param (or ?t).
|
| 7 |
+
|
| 8 |
+
Both share one handler. Order disclosure always requires email+order#
|
| 9 |
+
verification + per-email rate limiting regardless of entry point.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from datetime import UTC, datetime
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 17 |
+
from sqlalchemy import select
|
| 18 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 19 |
+
|
| 20 |
+
from app.config import Settings, get_settings
|
| 21 |
+
from app.db import get_session
|
| 22 |
+
from app.deps import get_router
|
| 23 |
+
from app.models import Tenant
|
| 24 |
+
from app.orchestrator import run_turn
|
| 25 |
+
from app.retention import purge_old_sessions
|
| 26 |
+
from app.schemas import ChatRequest, ChatResponse
|
| 27 |
+
from app.shopify.proxy import verify_proxy_signature
|
| 28 |
+
from app.store import get_or_create_session
|
| 29 |
+
from app.tenancy import DEFAULT_SLUG, get_tenant_by_slug, tenant_shopify_client
|
| 30 |
+
from app.tools.registry import ToolContext
|
| 31 |
+
from app.verification import mark_trusted
|
| 32 |
+
|
| 33 |
+
router = APIRouter()
|
| 34 |
+
|
| 35 |
+
PROXY_TIMESTAMP_TOLERANCE = 300 # seconds; reject replayed/stale signed requests
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _collect_params(request: Request) -> dict[str, object]:
|
| 39 |
+
"""Preserve repeated query keys as lists (required for correct HMAC)."""
|
| 40 |
+
params: dict[str, object] = {}
|
| 41 |
+
for key, value in request.query_params.multi_items():
|
| 42 |
+
if key in params:
|
| 43 |
+
existing = params[key]
|
| 44 |
+
if isinstance(existing, list):
|
| 45 |
+
existing.append(value)
|
| 46 |
+
else:
|
| 47 |
+
params[key] = [existing, value]
|
| 48 |
+
else:
|
| 49 |
+
params[key] = value
|
| 50 |
+
return params
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _timestamp_fresh(params: dict[str, object], now: datetime) -> bool:
|
| 54 |
+
raw = params.get("timestamp")
|
| 55 |
+
if not isinstance(raw, str) or not raw.isdigit():
|
| 56 |
+
return False
|
| 57 |
+
return abs(now.timestamp() - int(raw)) <= PROXY_TIMESTAMP_TOLERANCE
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def _resolve_tenant(db: AsyncSession, slug: str | None, *, shop: str = "") -> Tenant:
|
| 61 |
+
if slug:
|
| 62 |
+
tenant = await get_tenant_by_slug(db, slug)
|
| 63 |
+
if tenant is not None:
|
| 64 |
+
return tenant
|
| 65 |
+
if shop:
|
| 66 |
+
tenant = (
|
| 67 |
+
await db.execute(select(Tenant).where(Tenant.shopify_shop == shop))
|
| 68 |
+
).scalar_one_or_none()
|
| 69 |
+
if tenant is not None:
|
| 70 |
+
return tenant
|
| 71 |
+
tenant = await get_tenant_by_slug(db, DEFAULT_SLUG)
|
| 72 |
+
if tenant is None:
|
| 73 |
+
raise HTTPException(status_code=404, detail="unknown tenant")
|
| 74 |
+
return tenant
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def _run_chat(
|
| 78 |
+
*,
|
| 79 |
+
db: AsyncSession,
|
| 80 |
+
router_,
|
| 81 |
+
settings: Settings,
|
| 82 |
+
tenant: Tenant,
|
| 83 |
+
message: str,
|
| 84 |
+
session_id: str | None,
|
| 85 |
+
trusted: bool,
|
| 86 |
+
now: datetime,
|
| 87 |
+
) -> ChatResponse:
|
| 88 |
+
session = await get_or_create_session(
|
| 89 |
+
db, session_id, shop=tenant.shopify_shop, tenant_id=tenant.id
|
| 90 |
+
)
|
| 91 |
+
if trusted:
|
| 92 |
+
mark_trusted(session)
|
| 93 |
+
ctx = ToolContext(
|
| 94 |
+
db=db,
|
| 95 |
+
session=session,
|
| 96 |
+
shopify=tenant_shopify_client(tenant),
|
| 97 |
+
tenant_id=tenant.id,
|
| 98 |
+
support_email=settings.support_email,
|
| 99 |
+
now=now,
|
| 100 |
+
)
|
| 101 |
+
response = await run_turn(router_, ctx, message, brand_name=tenant.brand_name)
|
| 102 |
+
await purge_old_sessions(db, settings.session_retention_days)
|
| 103 |
+
await db.commit()
|
| 104 |
+
return response
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@router.post("/chat", response_model=ChatResponse)
|
| 108 |
+
async def chat_simple(
|
| 109 |
+
body: ChatRequest,
|
| 110 |
+
request: Request,
|
| 111 |
+
db: AsyncSession = Depends(get_session),
|
| 112 |
+
router_=Depends(get_router),
|
| 113 |
+
settings: Settings = Depends(get_settings),
|
| 114 |
+
) -> ChatResponse:
|
| 115 |
+
"""Simple-mode chat: public + CORS. No proxy signature required."""
|
| 116 |
+
tenant = await _resolve_tenant(db, request.query_params.get("t"))
|
| 117 |
+
return await _run_chat(
|
| 118 |
+
db=db,
|
| 119 |
+
router_=router_,
|
| 120 |
+
settings=settings,
|
| 121 |
+
tenant=tenant,
|
| 122 |
+
message=body.message,
|
| 123 |
+
session_id=body.session_id,
|
| 124 |
+
trusted=False,
|
| 125 |
+
now=datetime.now(UTC),
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/apps/chat", response_model=ChatResponse)
|
| 130 |
+
async def chat_proxied(
|
| 131 |
+
body: ChatRequest,
|
| 132 |
+
request: Request,
|
| 133 |
+
db: AsyncSession = Depends(get_session),
|
| 134 |
+
router_=Depends(get_router),
|
| 135 |
+
settings: Settings = Depends(get_settings),
|
| 136 |
+
) -> ChatResponse:
|
| 137 |
+
"""Secure-mode chat: behind the Shopify App Proxy (HMAC + timestamp)."""
|
| 138 |
+
now = datetime.now(UTC)
|
| 139 |
+
params = _collect_params(request)
|
| 140 |
+
if not verify_proxy_signature(params, settings.shopify_app_proxy_secret):
|
| 141 |
+
raise HTTPException(status_code=401, detail="invalid proxy signature")
|
| 142 |
+
if not _timestamp_fresh(params, now):
|
| 143 |
+
raise HTTPException(status_code=401, detail="stale or missing timestamp")
|
| 144 |
+
|
| 145 |
+
shop = params.get("shop", "")
|
| 146 |
+
t = params.get("t")
|
| 147 |
+
tenant = await _resolve_tenant(
|
| 148 |
+
db,
|
| 149 |
+
t if isinstance(t, str) else None,
|
| 150 |
+
shop=shop if isinstance(shop, str) else "",
|
| 151 |
+
)
|
| 152 |
+
return await _run_chat(
|
| 153 |
+
db=db,
|
| 154 |
+
router_=router_,
|
| 155 |
+
settings=settings,
|
| 156 |
+
tenant=tenant,
|
| 157 |
+
message=body.message,
|
| 158 |
+
session_id=body.session_id,
|
| 159 |
+
trusted=bool(params.get("logged_in_customer_id")),
|
| 160 |
+
now=now,
|
| 161 |
+
)
|
app/routes/health.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health check."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.get("/healthz")
|
| 11 |
+
async def healthz() -> dict[str, str]:
|
| 12 |
+
return {"status": "ok"}
|
app/routes/widget.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serve the embeddable chat widget (simple mode, multi-tenant).
|
| 2 |
+
|
| 3 |
+
Embed in any Shopify theme with a single line (the ?t is the tenant slug):
|
| 4 |
+
<script src="https://YOUR_BACKEND/widget.js?t=toorx" defer></script>
|
| 5 |
+
The widget auto-detects its backend + tenant from its own <script> src, reads
|
| 6 |
+
branding from /widget-config?t=, and posts to /chat?t=. No App Proxy / CLI needed.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Depends
|
| 14 |
+
from fastapi.responses import FileResponse
|
| 15 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 16 |
+
|
| 17 |
+
from app.db import get_session
|
| 18 |
+
from app.tenancy import DEFAULT_SLUG, get_tenant_by_slug
|
| 19 |
+
|
| 20 |
+
router = APIRouter()
|
| 21 |
+
|
| 22 |
+
_WIDGET_JS = Path(__file__).resolve().parent.parent / "static" / "widget.js"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/widget.js")
|
| 26 |
+
async def widget_js() -> FileResponse:
|
| 27 |
+
return FileResponse(
|
| 28 |
+
_WIDGET_JS,
|
| 29 |
+
media_type="application/javascript",
|
| 30 |
+
headers={"Cache-Control": "public, max-age=300"},
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/widget-config")
|
| 35 |
+
async def widget_config(
|
| 36 |
+
t: str = DEFAULT_SLUG, db: AsyncSession = Depends(get_session)
|
| 37 |
+
) -> dict[str, str]:
|
| 38 |
+
tenant = await get_tenant_by_slug(db, t)
|
| 39 |
+
if tenant is None:
|
| 40 |
+
return {
|
| 41 |
+
"brand_name": "Asistente",
|
| 42 |
+
"brand_color": "#e8491d",
|
| 43 |
+
"welcome": "¡Hola! ¿En qué puedo ayudarte?",
|
| 44 |
+
}
|
| 45 |
+
return {
|
| 46 |
+
"brand_name": tenant.brand_name,
|
| 47 |
+
"brand_color": tenant.brand_color,
|
| 48 |
+
"welcome": tenant.welcome,
|
| 49 |
+
}
|
app/schemas.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response models for the HTTP API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ChatRequest(BaseModel):
|
| 11 |
+
message: str = Field(min_length=1, max_length=4000)
|
| 12 |
+
session_id: str | None = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ChatResponse(BaseModel):
|
| 16 |
+
session_id: str
|
| 17 |
+
reply: str
|
| 18 |
+
lang: str | None = None
|
| 19 |
+
used_tools: list[str] = Field(default_factory=list)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class SourceIn(BaseModel):
|
| 23 |
+
kind: str = Field(pattern="^(file|url)$")
|
| 24 |
+
location: str | None = None # URL for kind="url"; ignored for file uploads
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SourceOut(BaseModel):
|
| 28 |
+
id: int
|
| 29 |
+
kind: str
|
| 30 |
+
name: str
|
| 31 |
+
location: str
|
| 32 |
+
status: str
|
| 33 |
+
error: str | None = None
|
| 34 |
+
created_at: datetime
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ConfigIn(BaseModel):
|
| 38 |
+
brand_name: str | None = None
|
| 39 |
+
brand_color: str | None = None
|
| 40 |
+
welcome: str | None = None
|
| 41 |
+
read_all_orders: bool | None = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class TenantIn(BaseModel):
|
| 45 |
+
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{1,63}$")
|
| 46 |
+
name: str | None = None
|
| 47 |
+
brand_name: str | None = None
|
| 48 |
+
brand_color: str | None = None
|
| 49 |
+
welcome: str | None = None
|
| 50 |
+
shopify_shop: str | None = None
|
| 51 |
+
shopify_client_id: str | None = None
|
| 52 |
+
shopify_client_secret: str | None = None
|
| 53 |
+
shopify_api_version: str | None = None
|
| 54 |
+
read_all_orders: bool | None = None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TenantUpdate(BaseModel):
|
| 58 |
+
name: str | None = None
|
| 59 |
+
brand_name: str | None = None
|
| 60 |
+
brand_color: str | None = None
|
| 61 |
+
welcome: str | None = None
|
| 62 |
+
shopify_shop: str | None = None
|
| 63 |
+
shopify_client_id: str | None = None
|
| 64 |
+
shopify_client_secret: str | None = None
|
| 65 |
+
shopify_api_version: str | None = None
|
| 66 |
+
read_all_orders: bool | None = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TenantOut(BaseModel):
|
| 70 |
+
id: int
|
| 71 |
+
slug: str
|
| 72 |
+
name: str
|
| 73 |
+
brand_name: str
|
| 74 |
+
brand_color: str
|
| 75 |
+
welcome: str
|
| 76 |
+
shopify_shop: str
|
| 77 |
+
shopify_client_id: str
|
| 78 |
+
has_shopify_secret: bool
|
| 79 |
+
shopify_api_version: str
|
| 80 |
+
read_all_orders: bool
|
| 81 |
+
|
| 82 |
+
@classmethod
|
| 83 |
+
def from_tenant(cls, t) -> TenantOut:
|
| 84 |
+
return cls(
|
| 85 |
+
id=t.id,
|
| 86 |
+
slug=t.slug,
|
| 87 |
+
name=t.name,
|
| 88 |
+
brand_name=t.brand_name,
|
| 89 |
+
brand_color=t.brand_color,
|
| 90 |
+
welcome=t.welcome,
|
| 91 |
+
shopify_shop=t.shopify_shop,
|
| 92 |
+
shopify_client_id=t.shopify_client_id,
|
| 93 |
+
has_shopify_secret=bool(t.shopify_client_secret_enc),
|
| 94 |
+
shopify_api_version=t.shopify_api_version,
|
| 95 |
+
read_all_orders=t.read_all_orders,
|
| 96 |
+
)
|
app/shopify/__init__.py
ADDED
|
File without changes
|
app/shopify/client.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin async GraphQL Admin API client."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
from app.shopify.token import ShopifyTokenManager
|
| 12 |
+
|
| 13 |
+
log = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ShopifyError(Exception):
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ShopifyGraphQLClient:
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
shop: str,
|
| 24 |
+
api_version: str,
|
| 25 |
+
token_manager: ShopifyTokenManager,
|
| 26 |
+
*,
|
| 27 |
+
client: httpx.AsyncClient | None = None,
|
| 28 |
+
max_throttle_retries: int = 2,
|
| 29 |
+
) -> None:
|
| 30 |
+
self.shop = shop
|
| 31 |
+
self.api_version = api_version
|
| 32 |
+
self.token_manager = token_manager
|
| 33 |
+
self._client = client
|
| 34 |
+
self._max_throttle_retries = max_throttle_retries
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def _url(self) -> str:
|
| 38 |
+
return f"https://{self.shop}/admin/api/{self.api_version}/graphql.json"
|
| 39 |
+
|
| 40 |
+
def _get_client(self) -> httpx.AsyncClient:
|
| 41 |
+
if self._client is None:
|
| 42 |
+
self._client = httpx.AsyncClient(timeout=20.0)
|
| 43 |
+
return self._client
|
| 44 |
+
|
| 45 |
+
async def execute(
|
| 46 |
+
self, query: str, variables: dict[str, Any] | None = None
|
| 47 |
+
) -> dict[str, Any]:
|
| 48 |
+
payload = {"query": query, "variables": variables or {}}
|
| 49 |
+
auth_retried = False
|
| 50 |
+
throttle_tries = 0
|
| 51 |
+
|
| 52 |
+
while True:
|
| 53 |
+
token = await self.token_manager.get_token()
|
| 54 |
+
resp = await self._get_client().post(
|
| 55 |
+
self._url,
|
| 56 |
+
json=payload,
|
| 57 |
+
headers={
|
| 58 |
+
"X-Shopify-Access-Token": token,
|
| 59 |
+
"Content-Type": "application/json",
|
| 60 |
+
},
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
if resp.status_code == 401 and not auth_retried:
|
| 64 |
+
self.token_manager.invalidate()
|
| 65 |
+
auth_retried = True
|
| 66 |
+
continue
|
| 67 |
+
|
| 68 |
+
if resp.status_code == 429:
|
| 69 |
+
if throttle_tries < self._max_throttle_retries:
|
| 70 |
+
throttle_tries += 1
|
| 71 |
+
await asyncio.sleep(0.5 * throttle_tries)
|
| 72 |
+
continue
|
| 73 |
+
raise ShopifyError("throttled (429) after retries")
|
| 74 |
+
|
| 75 |
+
if resp.status_code >= 400:
|
| 76 |
+
raise ShopifyError(f"http {resp.status_code}: {resp.text[:300]}")
|
| 77 |
+
|
| 78 |
+
body = resp.json()
|
| 79 |
+
if body.get("errors"):
|
| 80 |
+
# Retry once on THROTTLED user errors
|
| 81 |
+
codes = {
|
| 82 |
+
(e.get("extensions") or {}).get("code") for e in body["errors"]
|
| 83 |
+
}
|
| 84 |
+
if "THROTTLED" in codes and throttle_tries < self._max_throttle_retries:
|
| 85 |
+
throttle_tries += 1
|
| 86 |
+
await asyncio.sleep(0.5 * throttle_tries)
|
| 87 |
+
continue
|
| 88 |
+
raise ShopifyError(f"graphql errors: {body['errors']}")
|
| 89 |
+
|
| 90 |
+
return body.get("data", {})
|
| 91 |
+
|
| 92 |
+
async def aclose(self) -> None:
|
| 93 |
+
if self._client is not None:
|
| 94 |
+
await self._client.aclose()
|
| 95 |
+
self._client = None
|
app/shopify/orders.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Order lookup + fulfillment/tracking parsing (GraphQL Admin API).
|
| 2 |
+
|
| 3 |
+
Note: with the plain ``read_orders`` scope only orders from the last 60 days
|
| 4 |
+
are visible. Older history needs the protected ``read_all_orders`` scope.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import re
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from app.shopify.client import ShopifyGraphQLClient
|
| 13 |
+
|
| 14 |
+
_EMAIL_RE = re.compile(r"^[^@\s\"]+@[^@\s\"]+\.[^@\s\"]+$")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_email_filter(email: str) -> str:
|
| 18 |
+
"""Build a Shopify search filter for an email, validated + escaped.
|
| 19 |
+
|
| 20 |
+
Prevents search-query ('q') injection via crafted email values.
|
| 21 |
+
"""
|
| 22 |
+
e = email.strip()
|
| 23 |
+
if not _EMAIL_RE.match(e):
|
| 24 |
+
raise ValueError("invalid email")
|
| 25 |
+
e = e.replace("\\", "\\\\").replace('"', '\\"')
|
| 26 |
+
return f'email:"{e}"'
|
| 27 |
+
|
| 28 |
+
ORDERS_BY_EMAIL_QUERY = """
|
| 29 |
+
query OrdersByEmail($q: String!) {
|
| 30 |
+
orders(first: 10, query: $q, sortKey: CREATED_AT, reverse: true) {
|
| 31 |
+
edges {
|
| 32 |
+
node {
|
| 33 |
+
id
|
| 34 |
+
name
|
| 35 |
+
createdAt
|
| 36 |
+
email
|
| 37 |
+
displayFinancialStatus
|
| 38 |
+
displayFulfillmentStatus
|
| 39 |
+
customer { firstName lastName email }
|
| 40 |
+
fulfillments(first: 10) {
|
| 41 |
+
displayStatus
|
| 42 |
+
estimatedDeliveryAt
|
| 43 |
+
inTransitAt
|
| 44 |
+
deliveredAt
|
| 45 |
+
trackingInfo { company number url }
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def normalize_order_number(value: str) -> str:
|
| 55 |
+
return value.strip().lstrip("#").strip().lower()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _parse_order(node: dict[str, Any]) -> dict[str, Any]:
|
| 59 |
+
return {
|
| 60 |
+
"id": node.get("id"),
|
| 61 |
+
"name": node.get("name", ""),
|
| 62 |
+
"created_at": node.get("createdAt"),
|
| 63 |
+
"email": node.get("email") or (node.get("customer") or {}).get("email") or "",
|
| 64 |
+
"financial_status": node.get("displayFinancialStatus"),
|
| 65 |
+
"fulfillment_status": node.get("displayFulfillmentStatus"),
|
| 66 |
+
"fulfillments": node.get("fulfillments", []),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def find_orders_by_email(
|
| 71 |
+
client: ShopifyGraphQLClient, email: str
|
| 72 |
+
) -> list[dict[str, Any]]:
|
| 73 |
+
data = await client.execute(ORDERS_BY_EMAIL_QUERY, {"q": build_email_filter(email)})
|
| 74 |
+
edges = data.get("orders", {}).get("edges", [])
|
| 75 |
+
return [_parse_order(e["node"]) for e in edges]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def find_order_by_email_and_number(
|
| 79 |
+
client: ShopifyGraphQLClient, email: str, order_number: str
|
| 80 |
+
) -> dict[str, Any] | None:
|
| 81 |
+
target = normalize_order_number(order_number)
|
| 82 |
+
for order in await find_orders_by_email(client, email):
|
| 83 |
+
if (
|
| 84 |
+
order["email"].lower() == email.strip().lower()
|
| 85 |
+
and normalize_order_number(order["name"]) == target
|
| 86 |
+
):
|
| 87 |
+
return order
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def parse_tracking(order: dict[str, Any]) -> dict[str, Any]:
|
| 92 |
+
"""Summarise shipment status + tracking for an order (no PII beyond tracking)."""
|
| 93 |
+
fulfillments = order.get("fulfillments", []) or []
|
| 94 |
+
tracking: list[dict[str, Any]] = []
|
| 95 |
+
status = None
|
| 96 |
+
estimated_delivery = None
|
| 97 |
+
delivered_at = None
|
| 98 |
+
in_transit_at = None
|
| 99 |
+
for f in fulfillments:
|
| 100 |
+
status = f.get("displayStatus") or status
|
| 101 |
+
estimated_delivery = f.get("estimatedDeliveryAt") or estimated_delivery
|
| 102 |
+
delivered_at = f.get("deliveredAt") or delivered_at
|
| 103 |
+
in_transit_at = f.get("inTransitAt") or in_transit_at
|
| 104 |
+
for t in f.get("trackingInfo", []) or []:
|
| 105 |
+
tracking.append(
|
| 106 |
+
{
|
| 107 |
+
"company": t.get("company"),
|
| 108 |
+
"number": t.get("number"),
|
| 109 |
+
"url": t.get("url"),
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
return {
|
| 113 |
+
"order_number": order.get("name"),
|
| 114 |
+
"fulfillment_status": order.get("fulfillment_status"),
|
| 115 |
+
"shipment_status": status,
|
| 116 |
+
"tracking": tracking,
|
| 117 |
+
"estimated_delivery": estimated_delivery,
|
| 118 |
+
"delivered_at": delivered_at,
|
| 119 |
+
"in_transit_at": in_transit_at,
|
| 120 |
+
}
|
app/shopify/products.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live product search against the Shopify catalog (GraphQL Admin API)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from app.shopify.client import ShopifyGraphQLClient
|
| 8 |
+
|
| 9 |
+
PRODUCTS_QUERY = """
|
| 10 |
+
query ProductSearch($q: String!) {
|
| 11 |
+
products(first: 5, query: $q) {
|
| 12 |
+
edges {
|
| 13 |
+
node {
|
| 14 |
+
title
|
| 15 |
+
handle
|
| 16 |
+
onlineStoreUrl
|
| 17 |
+
description
|
| 18 |
+
variants(first: 50) {
|
| 19 |
+
edges { node { price availableForSale } }
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _truncate(text: str | None, n: int = 400) -> str:
|
| 29 |
+
text = (text or "").strip()
|
| 30 |
+
return text if len(text) <= n else text[:n].rstrip() + "…"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _parse_product(node: dict[str, Any]) -> dict[str, Any]:
|
| 34 |
+
variant_nodes = [e["node"] for e in node.get("variants", {}).get("edges", [])]
|
| 35 |
+
prices = []
|
| 36 |
+
for v in variant_nodes:
|
| 37 |
+
try:
|
| 38 |
+
prices.append(float(v.get("price")))
|
| 39 |
+
except (TypeError, ValueError):
|
| 40 |
+
continue
|
| 41 |
+
available = any(v.get("availableForSale") for v in variant_nodes)
|
| 42 |
+
return {
|
| 43 |
+
"title": node.get("title", ""),
|
| 44 |
+
"price": min(prices) if prices else None,
|
| 45 |
+
"available": available,
|
| 46 |
+
"url": node.get("onlineStoreUrl"),
|
| 47 |
+
"description": _truncate(node.get("description")),
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def search_products(client: ShopifyGraphQLClient, query: str) -> list[dict[str, Any]]:
|
| 52 |
+
data = await client.execute(PRODUCTS_QUERY, {"q": query})
|
| 53 |
+
edges = data.get("products", {}).get("edges", [])
|
| 54 |
+
return [_parse_product(e["node"]) for e in edges]
|
app/shopify/proxy.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shopify App Proxy request signature verification.
|
| 2 |
+
|
| 3 |
+
Shopify signs proxied storefront requests: sort the query params (excluding
|
| 4 |
+
``signature``), format each as ``key=value`` (array values joined by commas),
|
| 5 |
+
concatenate WITHOUT separators, then HMAC-SHA256 with the app's shared secret.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
+
import hmac
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def compute_proxy_signature(params: dict[str, object], secret: str) -> str:
|
| 15 |
+
items = {k: v for k, v in params.items() if k != "signature"}
|
| 16 |
+
parts = []
|
| 17 |
+
for key in sorted(items):
|
| 18 |
+
value = items[key]
|
| 19 |
+
if isinstance(value, (list, tuple)):
|
| 20 |
+
value = ",".join(str(v) for v in value)
|
| 21 |
+
parts.append(f"{key}={value}")
|
| 22 |
+
message = "".join(parts)
|
| 23 |
+
return hmac.new(
|
| 24 |
+
secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
|
| 25 |
+
).hexdigest()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def verify_proxy_signature(params: dict[str, object], secret: str) -> bool:
|
| 29 |
+
provided = params.get("signature")
|
| 30 |
+
if not provided or not isinstance(provided, str):
|
| 31 |
+
return False
|
| 32 |
+
expected = compute_proxy_signature(params, secret)
|
| 33 |
+
return hmac.compare_digest(expected, provided)
|
app/shopify/token.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shopify Admin API access token via the client_credentials grant.
|
| 2 |
+
|
| 3 |
+
New (2026) custom apps no longer expose a static token; you exchange the
|
| 4 |
+
app's client_id/secret for a token that expires in ~24h, so it must be
|
| 5 |
+
refreshed programmatically.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
from collections.abc import Callable
|
| 12 |
+
from datetime import UTC, datetime, timedelta
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
REFRESH_SKEW = timedelta(seconds=60)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _now() -> datetime:
|
| 20 |
+
return datetime.now(UTC)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ShopifyTokenManager:
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
shop: str,
|
| 27 |
+
client_id: str,
|
| 28 |
+
client_secret: str,
|
| 29 |
+
*,
|
| 30 |
+
now: Callable[[], datetime] = _now,
|
| 31 |
+
client: httpx.AsyncClient | None = None,
|
| 32 |
+
) -> None:
|
| 33 |
+
self.shop = shop
|
| 34 |
+
self.client_id = client_id
|
| 35 |
+
self.client_secret = client_secret
|
| 36 |
+
self._now = now
|
| 37 |
+
self._client = client
|
| 38 |
+
self._token: str | None = None
|
| 39 |
+
self._expires_at: datetime | None = None
|
| 40 |
+
self._lock = asyncio.Lock()
|
| 41 |
+
|
| 42 |
+
@property
|
| 43 |
+
def _url(self) -> str:
|
| 44 |
+
return f"https://{self.shop}/admin/oauth/access_token"
|
| 45 |
+
|
| 46 |
+
def _get_client(self) -> httpx.AsyncClient:
|
| 47 |
+
if self._client is None:
|
| 48 |
+
self._client = httpx.AsyncClient(timeout=20.0)
|
| 49 |
+
return self._client
|
| 50 |
+
|
| 51 |
+
def invalidate(self) -> None:
|
| 52 |
+
self._token = None
|
| 53 |
+
self._expires_at = None
|
| 54 |
+
|
| 55 |
+
def _is_fresh(self) -> bool:
|
| 56 |
+
return (
|
| 57 |
+
self._token is not None
|
| 58 |
+
and self._expires_at is not None
|
| 59 |
+
and self._now() < self._expires_at - REFRESH_SKEW
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
async def get_token(self) -> str:
|
| 63 |
+
if self._is_fresh():
|
| 64 |
+
return self._token # type: ignore[return-value]
|
| 65 |
+
async with self._lock:
|
| 66 |
+
if self._is_fresh():
|
| 67 |
+
return self._token # type: ignore[return-value]
|
| 68 |
+
resp = await self._get_client().post(
|
| 69 |
+
self._url,
|
| 70 |
+
json={
|
| 71 |
+
"client_id": self.client_id,
|
| 72 |
+
"client_secret": self.client_secret,
|
| 73 |
+
"grant_type": "client_credentials",
|
| 74 |
+
},
|
| 75 |
+
)
|
| 76 |
+
resp.raise_for_status()
|
| 77 |
+
data = resp.json()
|
| 78 |
+
self._token = data["access_token"]
|
| 79 |
+
expires_in = int(data.get("expires_in", 86399))
|
| 80 |
+
self._expires_at = self._now() + timedelta(seconds=expires_in)
|
| 81 |
+
return self._token
|
| 82 |
+
|
| 83 |
+
async def aclose(self) -> None:
|
| 84 |
+
if self._client is not None:
|
| 85 |
+
await self._client.aclose()
|
| 86 |
+
self._client = None
|
app/static/widget.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Backend-served support chat widget (simple mode).
|
| 2 |
+
Embed: <script src="https://BACKEND/widget.js" defer></script>
|
| 3 |
+
Auto-detects its backend from its own origin and posts to BACKEND/chat. */
|
| 4 |
+
(function () {
|
| 5 |
+
"use strict";
|
| 6 |
+
|
| 7 |
+
// Resolve our backend origin from this script's own URL.
|
| 8 |
+
var me = document.currentScript;
|
| 9 |
+
if (!me) {
|
| 10 |
+
var scripts = document.getElementsByTagName("script");
|
| 11 |
+
for (var i = scripts.length - 1; i >= 0; i--) {
|
| 12 |
+
if (scripts[i].src && scripts[i].src.indexOf("/widget.js") !== -1) { me = scripts[i]; break; }
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
var SRC = me ? new URL(me.src) : null;
|
| 16 |
+
var BACKEND = SRC ? SRC.origin : "";
|
| 17 |
+
var TENANT = (SRC && SRC.searchParams.get("t")) || "default";
|
| 18 |
+
var QS = "?t=" + encodeURIComponent(TENANT);
|
| 19 |
+
var SESSION_KEY = "ssb_session_id_" + TENANT;
|
| 20 |
+
|
| 21 |
+
var cfg = { brand_name: "Asistente", brand_color: "#e8491d", welcome: "¡Hola! ¿En qué puedo ayudarte?" };
|
| 22 |
+
|
| 23 |
+
function getSessionId() { try { return localStorage.getItem(SESSION_KEY) || null; } catch (e) { return null; } }
|
| 24 |
+
function setSessionId(id) { try { if (id) localStorage.setItem(SESSION_KEY, id); } catch (e) {} }
|
| 25 |
+
|
| 26 |
+
function injectStyles() {
|
| 27 |
+
var c = cfg.brand_color;
|
| 28 |
+
var style = document.createElement("style");
|
| 29 |
+
style.textContent =
|
| 30 |
+
"#ssb-bubble{position:fixed;bottom:20px;right:20px;width:60px;height:60px;border-radius:50%;background:" + c +
|
| 31 |
+
";color:#fff;border:0;cursor:pointer;font-size:26px;box-shadow:0 4px 14px rgba(0,0,0,.25);z-index:99998}" +
|
| 32 |
+
"#ssb-panel{position:fixed;bottom:90px;right:20px;width:360px;max-width:92vw;height:520px;max-height:74vh;background:#fff;" +
|
| 33 |
+
"border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,.25);display:none;flex-direction:column;overflow:hidden;z-index:99999;font-family:system-ui,sans-serif}" +
|
| 34 |
+
"#ssb-panel.open{display:flex}" +
|
| 35 |
+
"#ssb-head{background:" + c + ";color:#fff;padding:14px 16px;font-weight:600}" +
|
| 36 |
+
"#ssb-msgs{flex:1;overflow-y:auto;padding:14px;background:#f7f7f7}" +
|
| 37 |
+
".ssb-msg{margin:6px 0;padding:9px 12px;border-radius:12px;max-width:80%;white-space:pre-wrap;line-height:1.35;font-size:14px}" +
|
| 38 |
+
".ssb-user{background:" + c + ";color:#fff;margin-left:auto;border-bottom-right-radius:3px}" +
|
| 39 |
+
".ssb-bot{background:#fff;color:#1a1a1a;border:1px solid #e3e3e3;border-bottom-left-radius:3px}" +
|
| 40 |
+
"#ssb-form{display:flex;border-top:1px solid #e3e3e3}" +
|
| 41 |
+
"#ssb-input{flex:1;border:0;padding:13px;font-size:14px;outline:none}" +
|
| 42 |
+
"#ssb-send{border:0;background:" + c + ";color:#fff;padding:0 18px;cursor:pointer;font-size:16px}";
|
| 43 |
+
document.head.appendChild(style);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function build() {
|
| 47 |
+
injectStyles();
|
| 48 |
+
var bubble = document.createElement("button");
|
| 49 |
+
bubble.id = "ssb-bubble"; bubble.setAttribute("aria-label", "Abrir chat"); bubble.textContent = "💬";
|
| 50 |
+
var panel = document.createElement("div");
|
| 51 |
+
panel.id = "ssb-panel";
|
| 52 |
+
panel.innerHTML =
|
| 53 |
+
'<div id="ssb-head"></div><div id="ssb-msgs"></div>' +
|
| 54 |
+
'<form id="ssb-form"><input id="ssb-input" type="text" autocomplete="off" placeholder="Escribe aquí…" />' +
|
| 55 |
+
'<button id="ssb-send" type="submit" aria-label="Enviar">➤</button></form>';
|
| 56 |
+
document.body.appendChild(bubble);
|
| 57 |
+
document.body.appendChild(panel);
|
| 58 |
+
panel.querySelector("#ssb-head").textContent = cfg.brand_name;
|
| 59 |
+
|
| 60 |
+
var msgs = panel.querySelector("#ssb-msgs");
|
| 61 |
+
var form = panel.querySelector("#ssb-form");
|
| 62 |
+
var input = panel.querySelector("#ssb-input");
|
| 63 |
+
var greeted = false;
|
| 64 |
+
|
| 65 |
+
function addMsg(text, who) {
|
| 66 |
+
var el = document.createElement("div");
|
| 67 |
+
el.className = "ssb-msg " + (who === "user" ? "ssb-user" : "ssb-bot");
|
| 68 |
+
el.textContent = text; msgs.appendChild(el); msgs.scrollTop = msgs.scrollHeight; return el;
|
| 69 |
+
}
|
| 70 |
+
bubble.addEventListener("click", function () {
|
| 71 |
+
panel.classList.toggle("open");
|
| 72 |
+
if (panel.classList.contains("open")) {
|
| 73 |
+
if (!greeted) { addMsg(cfg.welcome, "bot"); greeted = true; }
|
| 74 |
+
input.focus();
|
| 75 |
+
}
|
| 76 |
+
});
|
| 77 |
+
form.addEventListener("submit", function (e) {
|
| 78 |
+
e.preventDefault();
|
| 79 |
+
var text = input.value.trim(); if (!text) return;
|
| 80 |
+
addMsg(text, "user"); input.value = "";
|
| 81 |
+
var typing = addMsg("…", "bot");
|
| 82 |
+
fetch(BACKEND + "/chat" + QS, {
|
| 83 |
+
method: "POST", headers: { "Content-Type": "application/json" },
|
| 84 |
+
body: JSON.stringify({ message: text, session_id: getSessionId() })
|
| 85 |
+
})
|
| 86 |
+
.then(function (r) { return r.json(); })
|
| 87 |
+
.then(function (data) { setSessionId(data.session_id); typing.textContent = data.reply || "…"; msgs.scrollTop = msgs.scrollHeight; })
|
| 88 |
+
.catch(function () { typing.textContent = "Lo siento, ha habido un problema. Inténtalo de nuevo."; });
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function start() {
|
| 93 |
+
fetch(BACKEND + "/widget-config" + QS)
|
| 94 |
+
.then(function (r) { return r.json(); })
|
| 95 |
+
.then(function (data) { if (data) cfg = Object.assign(cfg, data); })
|
| 96 |
+
.catch(function () {})
|
| 97 |
+
.then(function () {
|
| 98 |
+
if (document.body) build();
|
| 99 |
+
else document.addEventListener("DOMContentLoaded", build);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
start();
|
| 104 |
+
})();
|
app/store.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small persistence helpers: sessions and runtime config (branding)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import UTC, datetime
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
|
| 11 |
+
from app.models import ChatSession, Config
|
| 12 |
+
|
| 13 |
+
BRANDING_KEY = "branding"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def get_or_create_session(
|
| 17 |
+
db: AsyncSession, session_id: str | None, shop: str, *, tenant_id: int | None = None
|
| 18 |
+
) -> ChatSession:
|
| 19 |
+
if session_id:
|
| 20 |
+
existing = await db.get(ChatSession, session_id)
|
| 21 |
+
# Scope by tenant: a session_id from one tenant can't be reused under another.
|
| 22 |
+
if existing is not None and (tenant_id is None or existing.tenant_id == tenant_id):
|
| 23 |
+
# Touch last_seen so active conversations aren't purged by retention
|
| 24 |
+
# (inserting child messages does not bump the parent's onupdate).
|
| 25 |
+
existing.last_seen = datetime.now(UTC)
|
| 26 |
+
return existing
|
| 27 |
+
session = ChatSession(shop=shop or "", tenant_id=tenant_id)
|
| 28 |
+
db.add(session)
|
| 29 |
+
await db.flush()
|
| 30 |
+
return session
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def get_config(db: AsyncSession, key: str) -> dict[str, Any] | None:
|
| 34 |
+
row = (
|
| 35 |
+
await db.execute(select(Config).where(Config.key == key))
|
| 36 |
+
).scalar_one_or_none()
|
| 37 |
+
return row.value if row else None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def upsert_config(db: AsyncSession, key: str, value: dict[str, Any]) -> dict[str, Any]:
|
| 41 |
+
row = (
|
| 42 |
+
await db.execute(select(Config).where(Config.key == key))
|
| 43 |
+
).scalar_one_or_none()
|
| 44 |
+
if row is None:
|
| 45 |
+
row = Config(key=key, value=value)
|
| 46 |
+
db.add(row)
|
| 47 |
+
else:
|
| 48 |
+
merged = dict(row.value or {})
|
| 49 |
+
merged.update(value)
|
| 50 |
+
row.value = merged
|
| 51 |
+
await db.flush()
|
| 52 |
+
return row.value
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def get_brand_name(db: AsyncSession, default: str) -> str:
|
| 56 |
+
branding = await get_config(db, BRANDING_KEY)
|
| 57 |
+
if branding and branding.get("brand_name"):
|
| 58 |
+
return branding["brand_name"]
|
| 59 |
+
return default
|
app/tenancy.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tenant management + per-tenant Shopify client registry.
|
| 2 |
+
|
| 3 |
+
One shared backend serves many merchants (tenants). Each tenant has its own
|
| 4 |
+
branding, knowledge base, and (optional) Shopify credentials. The client's
|
| 5 |
+
Shopify secret is encrypted at rest.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from sqlalchemy import select
|
| 13 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
+
|
| 15 |
+
from app.crypto import decrypt, encrypt
|
| 16 |
+
from app.models import Tenant
|
| 17 |
+
from app.shopify.client import ShopifyGraphQLClient
|
| 18 |
+
from app.shopify.token import ShopifyTokenManager
|
| 19 |
+
|
| 20 |
+
DEFAULT_SLUG = "default"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def get_tenant_by_slug(db: AsyncSession, slug: str) -> Tenant | None:
|
| 24 |
+
return (
|
| 25 |
+
await db.execute(select(Tenant).where(Tenant.slug == slug))
|
| 26 |
+
).scalar_one_or_none()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def list_tenants(db: AsyncSession) -> list[Tenant]:
|
| 30 |
+
return list((await db.execute(select(Tenant).order_by(Tenant.id))).scalars().all())
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def ensure_default_tenant(db: AsyncSession) -> Tenant:
|
| 34 |
+
existing = await get_tenant_by_slug(db, DEFAULT_SLUG)
|
| 35 |
+
if existing is not None:
|
| 36 |
+
return existing
|
| 37 |
+
tenant = Tenant(slug=DEFAULT_SLUG, name="Default")
|
| 38 |
+
db.add(tenant)
|
| 39 |
+
await db.flush()
|
| 40 |
+
return tenant
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
_SHOPIFY_FIELDS = {
|
| 44 |
+
"shopify_shop",
|
| 45 |
+
"shopify_client_id",
|
| 46 |
+
"shopify_api_version",
|
| 47 |
+
"read_all_orders",
|
| 48 |
+
"brand_name",
|
| 49 |
+
"brand_color",
|
| 50 |
+
"welcome",
|
| 51 |
+
"name",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def create_tenant(
|
| 56 |
+
db: AsyncSession, slug: str, *, shopify_client_secret: str | None = None, **fields: Any
|
| 57 |
+
) -> Tenant:
|
| 58 |
+
data = {k: v for k, v in fields.items() if k in _SHOPIFY_FIELDS and v is not None}
|
| 59 |
+
tenant = Tenant(slug=slug, **data)
|
| 60 |
+
if shopify_client_secret:
|
| 61 |
+
tenant.shopify_client_secret_enc = encrypt(shopify_client_secret)
|
| 62 |
+
db.add(tenant)
|
| 63 |
+
await db.flush()
|
| 64 |
+
_registry.invalidate(tenant.id)
|
| 65 |
+
return tenant
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def update_tenant(
|
| 69 |
+
db: AsyncSession, tenant: Tenant, *, shopify_client_secret: str | None = None, **fields: Any
|
| 70 |
+
) -> Tenant:
|
| 71 |
+
for k, v in fields.items():
|
| 72 |
+
if k in _SHOPIFY_FIELDS and v is not None:
|
| 73 |
+
setattr(tenant, k, v)
|
| 74 |
+
if shopify_client_secret is not None:
|
| 75 |
+
tenant.shopify_client_secret_enc = (
|
| 76 |
+
encrypt(shopify_client_secret) if shopify_client_secret else ""
|
| 77 |
+
)
|
| 78 |
+
await db.flush()
|
| 79 |
+
_registry.invalidate(tenant.id)
|
| 80 |
+
return tenant
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class _ShopifyRegistry:
|
| 84 |
+
"""Caches one ShopifyGraphQLClient per tenant (keyed by creds fingerprint)."""
|
| 85 |
+
|
| 86 |
+
def __init__(self) -> None:
|
| 87 |
+
self._cache: dict[int, tuple[str, ShopifyGraphQLClient]] = {}
|
| 88 |
+
|
| 89 |
+
def invalidate(self, tenant_id: int | None) -> None:
|
| 90 |
+
if tenant_id is not None:
|
| 91 |
+
self._cache.pop(tenant_id, None)
|
| 92 |
+
|
| 93 |
+
def for_tenant(self, tenant: Tenant) -> ShopifyGraphQLClient | None:
|
| 94 |
+
secret = (
|
| 95 |
+
decrypt(tenant.shopify_client_secret_enc)
|
| 96 |
+
if tenant.shopify_client_secret_enc
|
| 97 |
+
else ""
|
| 98 |
+
)
|
| 99 |
+
if not (tenant.shopify_shop and tenant.shopify_client_id and secret):
|
| 100 |
+
return None
|
| 101 |
+
fingerprint = (
|
| 102 |
+
f"{tenant.shopify_shop}|{tenant.shopify_client_id}"
|
| 103 |
+
f"|{tenant.shopify_api_version}|{secret[:6]}"
|
| 104 |
+
)
|
| 105 |
+
cached = self._cache.get(tenant.id)
|
| 106 |
+
if cached and cached[0] == fingerprint:
|
| 107 |
+
return cached[1]
|
| 108 |
+
tm = ShopifyTokenManager(tenant.shopify_shop, tenant.shopify_client_id, secret)
|
| 109 |
+
client = ShopifyGraphQLClient(tenant.shopify_shop, tenant.shopify_api_version, tm)
|
| 110 |
+
self._cache[tenant.id] = (fingerprint, client)
|
| 111 |
+
return client
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
_registry = _ShopifyRegistry()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def tenant_shopify_client(tenant: Tenant) -> ShopifyGraphQLClient | None:
|
| 118 |
+
return _registry.for_tenant(tenant)
|
app/tools/__init__.py
ADDED
|
File without changes
|
app/tools/escalate_tool.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool: escalate to a human by emailing the store."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import TYPE_CHECKING, Any
|
| 6 |
+
|
| 7 |
+
from app.config import get_settings
|
| 8 |
+
from app.escalation import create_escalation
|
| 9 |
+
from app.mailer import default_sender
|
| 10 |
+
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from app.tools.registry import ToolContext
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
| 16 |
+
email = (args.get("email") or "").strip()
|
| 17 |
+
question = (args.get("question") or "").strip()
|
| 18 |
+
if not email:
|
| 19 |
+
return {"status": "need_info", "need": ["email"]}
|
| 20 |
+
|
| 21 |
+
settings = get_settings()
|
| 22 |
+
support_email = ctx.support_email or settings.support_email
|
| 23 |
+
if not support_email:
|
| 24 |
+
return {"status": "unavailable"}
|
| 25 |
+
|
| 26 |
+
sender = ctx.escalation_sender or default_sender(settings)
|
| 27 |
+
result = await create_escalation(
|
| 28 |
+
email,
|
| 29 |
+
question,
|
| 30 |
+
session_id=ctx.session.id,
|
| 31 |
+
support_email=support_email,
|
| 32 |
+
sender=sender,
|
| 33 |
+
)
|
| 34 |
+
return {"status": "sent" if result["sent"] else "queued"}
|
app/tools/knowledge_tool.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool: search the knowledge base (RAG)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import TYPE_CHECKING, Any
|
| 6 |
+
|
| 7 |
+
from app.rag import index
|
| 8 |
+
|
| 9 |
+
if TYPE_CHECKING:
|
| 10 |
+
from app.tools.registry import ToolContext
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
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=4, tenant_id=ctx.tenant_id)
|
| 18 |
+
if not results:
|
| 19 |
+
return {"context": "", "sources": [], "note": "no relevant information found"}
|
| 20 |
+
blocks = []
|
| 21 |
+
sources: list[str] = []
|
| 22 |
+
for chunk, _score in results:
|
| 23 |
+
name = (chunk.meta or {}).get("source_name", "fuente")
|
| 24 |
+
blocks.append(f"[{name}] {chunk.text}")
|
| 25 |
+
if name not in sources:
|
| 26 |
+
sources.append(name)
|
| 27 |
+
return {"context": "\n\n".join(blocks), "sources": sources}
|
app/tools/order_tool.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool: verified order status + tracking lookup."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import TYPE_CHECKING, Any
|
| 6 |
+
|
| 7 |
+
from app.ratelimit import get_order_limiter
|
| 8 |
+
from app.shopify.orders import (
|
| 9 |
+
find_orders_by_email,
|
| 10 |
+
normalize_order_number,
|
| 11 |
+
parse_tracking,
|
| 12 |
+
)
|
| 13 |
+
from app.verification import is_locked, verify
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from app.tools.registry import ToolContext
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
| 20 |
+
if ctx.shopify is None:
|
| 21 |
+
return {"status": "unavailable"}
|
| 22 |
+
|
| 23 |
+
if is_locked(ctx.session, ctx.now):
|
| 24 |
+
return {"status": "locked"}
|
| 25 |
+
|
| 26 |
+
email = (args.get("email") or "").strip()
|
| 27 |
+
order_number = (args.get("order_number") or "").strip()
|
| 28 |
+
missing = [f for f in ("email", "order_number") if not (args.get(f) or "").strip()]
|
| 29 |
+
if missing:
|
| 30 |
+
return {"status": "need_info", "need": missing}
|
| 31 |
+
|
| 32 |
+
# Throttle per email (across sessions) so rotating session_id can't reset lockout.
|
| 33 |
+
limiter = get_order_limiter()
|
| 34 |
+
if not limiter.allow(email.lower(), ctx.now):
|
| 35 |
+
return {"status": "locked"}
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
orders = await find_orders_by_email(ctx.shopify, email)
|
| 39 |
+
except ValueError:
|
| 40 |
+
# invalid email format — generic, no field-specific leak
|
| 41 |
+
return {"status": "not_found"}
|
| 42 |
+
result = verify(ctx.session, email, order_number, orders, now=ctx.now)
|
| 43 |
+
if result.locked:
|
| 44 |
+
return {"status": "locked"}
|
| 45 |
+
if not result.ok:
|
| 46 |
+
return {"status": "not_found"}
|
| 47 |
+
|
| 48 |
+
target = normalize_order_number(order_number)
|
| 49 |
+
matched = next(
|
| 50 |
+
(o for o in orders if normalize_order_number(o.get("name", "")) == target), None
|
| 51 |
+
)
|
| 52 |
+
if matched is None: # defensive; verify() already confirmed a match
|
| 53 |
+
return {"status": "not_found"}
|
| 54 |
+
limiter.reset(email.lower()) # successful verification clears the throttle
|
| 55 |
+
return {"status": "verified", "tracking": parse_tracking(matched)}
|
app/tools/products_tool.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool: search the live Shopify product catalog."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import TYPE_CHECKING, Any
|
| 6 |
+
|
| 7 |
+
from app.shopify.products import search_products
|
| 8 |
+
|
| 9 |
+
if TYPE_CHECKING:
|
| 10 |
+
from app.tools.registry import ToolContext
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def run(args: dict[str, Any], ctx: ToolContext) -> dict[str, Any]:
|
| 14 |
+
if ctx.shopify is None:
|
| 15 |
+
return {"status": "unavailable", "products": []}
|
| 16 |
+
query = (args.get("query") or "").strip()
|
| 17 |
+
if not query:
|
| 18 |
+
return {"status": "ok", "products": []}
|
| 19 |
+
products = await search_products(ctx.shopify, query)
|
| 20 |
+
return {"status": "ok", "products": products}
|