Repository navigation
Deployment
The widget ships as a single Python process — FastAPI behind Uvicorn, with SQLite for storage. There is no external database, no Redis, and no message broker. That is what makes the deployment small enough to run on a modest VPS.
| Python | 3.11+ (the Docker image is python:3.12-slim) |
| RAM | ~300 MB is enough for a single worker |
| Disk | The data/ folder, plus room for the Docker image |
| Network | Outbound HTTPS to the source site, for the crawler |
Dependencies are pinned exactly in requirements.txt:
fastapi==0.115.6
uvicorn[standard]==0.34.0
jinja2==3.1.5
python-multipart==0.0.20
itsdangerous==2.2.0
python-dotenv==1.0.1
requests==2.31.0
beautifulsoup4==4.12.3
APScheduler==3.10.4
pandas==2.2.3
openpyxl==3.1.5
httpx==0.28.1
No PyTorch, no transformer, no model weights — the "AI" is rule-based and runs on the CPU in milliseconds.
app/config.py reads everything from the environment via python-dotenv. Copy
.env.example to .env and edit it:
cp .env.example .env| Variable | Default | What it does |
|---|---|---|
SECRET_KEY |
"" |
Signs the session cookies. Must be set in production. |
ADMIN_PANEL_PASSWORD |
"" |
Fallback password for a tenant admin. |
SUPER_ADMIN_USERNAME |
superadmin |
The super-admin username. |
SUPER_ADMIN_PASSWORD |
falls back to ADMIN_PANEL_PASSWORD
|
The super-admin password. |
CORS_ORIGINS |
http://localhost:8000 |
Comma-separated list of allowed origins. Never *.
|
WIDGET_ORIGIN |
https://nikravan.org |
The widget host, used for the embed snippet. |
CRAWLER_ENABLED |
1 |
Set to 0 to stop scheduled crawling. |
CRAWL_STALE_AFTER_SECONDS |
21600 (6 hours) |
When the panel warns that availability data is old. |
COOKIE_SECURE |
0 |
Set to 1 behind HTTPS. |
python -c "import secrets; print(secrets.token_hex(32))"COOKIE_SECURE = os.getenv("COOKIE_SECURE", "0") == "1"Behind an HTTPS reverse proxy, COOKIE_SECURE=1 makes the panel cookie HTTPS-only
— it is never sent over plain HTTP. The default is 0 so that local development over
http://localhost still works.
git clone https://github.com/Mohammad-Hasan-Kaman/ai-counseling-widget.git
cd ai-counseling-widget
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# edit .env
uvicorn app.main:app --host 0.0.0.0 --port 8000Then:
| URL | Purpose |
|---|---|
/ |
Product landing page |
/widget?key=nk_… |
The widget, standalone |
/demo/{api_key} |
A tenant's live demo |
/admin |
The admin panel |
/api/chat/health |
Health check → {"status":"ok"}
|
The database is created and seeded on the first boot. There is no migration step —
init_all_dbs() runs every CREATE TABLE IF NOT EXISTS and seeds the Nikravan
tenant, the super-admin account, and the consultant roster from the JSON.
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY static ./static
RUN mkdir -p data
EXPOSE 8000
HEALTHCHECK --interval=60s --timeout=10s --start-period=20s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/chat/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]Three details worth noting:
- A slim base image — no build tools in the final image.
-
A built-in health check that hits
/api/chat/healthevery 60 seconds, with a 20-second grace period for the first boot. -
--workers 1— a single worker. This is deliberate: the rate limiter and the scheduler live in process memory, so a second worker would double the rate limit and start a second crawler. Scale with a reverse proxy, not with workers.
services:
widget:
build: .
container_name: nikravan-widget
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000"
env_file: .env
environment:
- CRAWLER_ENABLED=1
volumes:
- ./data:/app/data
- ./.env:/app/.env:ro- The port is bound to
127.0.0.1— the container is not reachable from the internet directly; only the reverse proxy talks to it. -
data/is a bind mount, so the databases survive a rebuild. -
.envis mounted read-only (ro).
docker compose up -d --build
docker compose logs -f widget
curl http://127.0.0.1:8000/api/chat/health # must return {"status":"ok"}server {
listen 443 ssl;
server_name widget.nikravan.org;
client_max_body_size 12m;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}client_max_body_size 12m is needed because the admin panel uploads the consultant
Excel file. The X-Forwarded-For header is what the rate limiter reads the client IP
from — without it every request would look like it comes from the proxy.
- Sign in at
/adminas the super admin and change both passwords from the Change password page. - The first crawl runs automatically at startup — watch it in the dashboard's Availability crawl section.
- Upload the real consultant Excel from the panel so the production roster replaces the seed data.
- Update the embed snippets on the tenant sites with the real
data-origin="https://widget.nikravan.org".
Back up the data/ folder daily — it holds all three .db files. The simplest
reliable form is a plain file copy on a schedule:
tar czf /backup/data-$(date +%F).tar.gz -C /opt/ai-counseling-widget data/Because the databases use WAL mode, a tar of a live directory can capture a
half-written WAL. For a consistent copy, stop the container first, or use
sqlite3 data/user_records.db ".backup out.db" while it runs.
git pull && docker compose up -d --buildThe startup sequence is idempotent — every CREATE TABLE is IF NOT EXISTS and the
JSON roster is only imported into an empty consultants table, so an update never
overwrites the data an operator has uploaded.
| Health endpoint |
GET /api/chat/health → {"status":"ok"}
|
| Container logs | docker compose logs -f widget |
| Crawl health | The crawl_runs table, shown in the panel |
| Job | Schedule | What it does |
|---|---|---|
| Crawler | every 2 hours | Refreshes the appointment availability. |
| GC | every 60 minutes | Deletes expired sessions, orphan messages, and messages older than 30 days. |
The GC never deletes requests — user_requests is the business record.
-
SECRET_KEYis random, secret, and at least 32 bytes -
COOKIE_SECURE=1when the site is behind HTTPS -
CORS_ORIGINSlists only real domains — never* - Both admin passwords are strong (8+ characters) and different from each other
-
allowed_domainsis set for every tenant in the super-admin panel -
.envis not tracked by git — it is in.gitignore -
data/is not tracked by git either - The panel is reachable only over HTTPS
-
--workers 1is required. The rate limiter is in-process, and the scheduler would run twice with more workers. Horizontal scaling needs a shared store for the limiter and a separate scheduler process. - SQLite, not Postgres. It is fine for a single-process deployment with a moderate number of tenants; a busy multi-tenant deployment would want a real database.
-
No
Content-Security-Policyheader. The frame-embedding control isX-Frame-Optionsalone, and/widgetis deliberately excluded from it. -
The crawler depends on the source site's HTML. If the page structure changes,
the parse silently finds no free slots. The
crawl_runstable and the staleness warning are how you notice.
- Architecture — how the process is put together.
- Embedding & Integration — the snippet and the domain rules.
- Data & Storage — the databases you need to back up.
- Crawler — the schedule and the failure handling.