victor34593993 commited on
Commit
187966e
·
verified ·
1 Parent(s): bd192b8

deploy flexigo support bot

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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: Flexigo Support Bot
3
- emoji: 🏃
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>&lt;/body&gt;</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}