Repository navigation
Home
A drop-in Persian/RTL chat widget that triages a visitor, screens them with GHQ-28, matches them to the right counselor — and gives every clinic its own API key, admin panel and configurable intake flow. One
<script>tag on a clinic's website. No SDK, no build step, no third-party AI service.
This project is an AI-assisted intake system for counseling centers, shipped as an embeddable chat widget. A visitor who lands on a clinic's website gets a floating "دستیار انتخاب مشاور" bubble; clicking it opens a chat panel that runs a structured intake (name, contact, reason for the visit, …), optionally administers the GHQ-28 wellbeing screen, and ends with either a ranked shortlist of two counselors or a captured lead for the staff to call back.
It serves two audiences at once:
- The visitor — a private, structured way to find the right counselor instead of phone-tag, in a language and layout that feel native (full RTL, Persian numerals, Persian date-free formatting).
- The clinic's staff — a web admin panel where they read every conversation, export leads to Persian Excel, edit the intake flow live, upload a new counselor roster, broadcast an announcement, trigger the availability crawler, and rate the engine's recommendations so the matcher improves.
The part worth understanding before reading further: the "AI" is not a hosted language model. It is a local, deterministic engine — clinical concept extraction from free text, fuzzy counselor-name matching, age-bound parsing, weighted scoring over a counselor roster, and a feedback table that re-weights future matches. Nothing a visitor types leaves the server, there is no API key for an LLM vendor, and there is no per-message cost.
| Page | What you will find there |
|---|---|
| Architecture | The four layers (browser → HTTP API → flow engine → storage), the tenant-isolation model, and the request lifecycle of a single widget session. |
| Embedding the Widget | The one-line <script> embed, the loader's DOM contract, mobile behaviour, and the demo page. |
| API Reference | Every HTTP endpoint, request and response shape, status codes, rate limits, and the session lifecycle. |
| Multi-Tenancy | How tenants, API keys, domain allow-lists, per-tenant flows and per-tenant data isolation work. |
| Matching Engine |
SpiralMatchEngine in depth: hard filters, the three fallback passes, the full scoring table, concept extraction, and the self-learning weights. |
| GHQ-28 Screening | The 28 questions, the four subscales, the scoring function, the interpretation bands, and how the score feeds ranking. |
| Admin Panel | Every panel page and form as reference tables: dashboard, inbox, leads, conflicts, flow editor, consultants, feedback, crawl, super-admin. |
| Setup and Deployment | Clone → venv → pip install → .env → run. Every environment variable, Docker, Nginx, and the production checklist. |
| Troubleshooting | Symptom → cause → fix table for the problems that actually happen: invalid keys, 403/404/429, missing tables, failed crawls, lost sessions. |
| Contributing | Dev setup, the code conventions the repo observes, the data pipeline, and how to add a synonym or a flow step safely. |
| Capability | Implementation | What it actually does |
|---|---|---|
| Persian RTL widget |
static/widget.html, static/widget.css, static/widget-app.js
|
A 380×580 panel that becomes fullscreen under 480px, with quick-reply chips, a typing indicator, and a keyboard-aware layout on Android. |
| One-line embed | static/widget.js |
A loader that injects its own CSS, creates the bubble and the iframe, and persists the open/closed state in localStorage. |
| Configurable intake flow |
app/session_store.py, app/tenants.py
|
A per-tenant flow of numbered steps — text / phone / number / choice — with conditional steps, an optional GHQ stage, and two endings. Edited live from the panel, no redeploy. |
| GHQ-28 screening | app/ghq_analyzer.py |
28 questions across four subscales, scored server-side, interpreted into bands, and used as a ranking signal. |
| Concept extraction | app/internal_ai_engine.py |
A curated Persian/Arabic synonym lexicon that maps free text to 11 clinical concepts (اضطراب, افسردگی, وسواس, زوج_ازدواج, والد_فرزند, …). |
| Counselor matching | SpiralMatchEngine.match() |
Hard filters (gender, branch, age, legal-specialty guard) followed by weighted scoring over topic fit, clinical concepts, ability, live free slots, and learning weights. Returns the top 2. |
| Self-learning weights |
concept_feedback table, get_learning_weight()
|
Every panel rating (and every successful recommendation) updates a per-(tenant, counselor, concept) weight in the range [0.6, 1.6]. |
| Lead capture that never drops | save_user_consultation() |
The request is persisted before matching runs, so a visitor who needs a counselor is recorded even when no counselor matches. |
| Availability crawler | app/crawler.py |
Scrapes the center's public team pages every 2 hours for open appointment slots, with retry, backoff and a direct-connection fallback. |
| Multi-tenant by design | app/tenants.py |
Per-tenant API key, admin accounts, allowed domains, flow configuration, consultant roster, and learning weights. |
| Admin panel |
app/routers/admin.py, app/templates/
|
Dashboard, full conversation inbox, lead table with Persian Excel export, conflict resolution, announcement broadcast, flow editor, consultant roster manager, feedback, crawl control, super-admin tenant management. |
| Signed sessions & hardened cookies | app/auth.py |
PBKDF2-SHA256 password hashing (100k iterations), itsdangerous signed cookies, httponly + samesite=lax, optional secure flag. |
| Security headers & CORS | app/main.py |
nosniff, X-Frame-Options: SAMEORIGIN (except /widget, which must be embeddable), strict-origin-when-cross-origin, and an explicit origin allow-list. |
| Rate limiting |
app/routers/chat.py, app/routers/admin.py
|
30 requests/minute per IP+session on the chat API; 10 failed logins per IP per 5 minutes on the panel. |
graph TD
A["Visitor loads the clinic's page"] --> B["static/widget.js injects<br/>bubble + iframe"]
B --> C["iframe loads /widget?key=nk_…"]
C --> D["widget-app.js reads the key<br/>from the query string"]
D --> E["POST /api/chat/session"]
E --> F["Server: resolve tenant by key<br/>→ check domain allow-list"]
F --> G["Create or restore a session row<br/>(state, data, tenant_id, expires_at)"]
G --> H["Return the welcome message<br/>+ the first prompt"]
H --> I["Visitor types or taps a chip"]
I --> J["POST /api/chat/message"]
J --> K["State machine advances one step"]
K --> L{"More steps?"}
L -->|"yes"| H
L -->|"GHQ enabled"| M["28 questions, scored"]
L -->|"no"| N["Ending"]
M --> N
N --> O{"ending"}
O -->|"recommend"| P["engine.match() → top 2 counselors<br/>+ reasons + booking links"]
O -->|"lead_capture"| Q["Persist the request<br/>→ thank-you message"]
P --> R["Request persisted first,<br/>then the recommendation is shown"]
R --> S["Panel shows the transcript<br/>and the lead row"]
The state machine, its negative states, and the conditional-step rules are documented in Architecture and API Reference.
| Layer | Choice | Note |
|---|---|---|
| HTTP framework | FastAPI 0.115.6 | Pydantic request models, lifespan hooks, APIRouter for the two route groups. |
| ASGI server | uvicorn 0.34.0 |
app.main:app, one worker — the scheduler and the in-process rate-limit state assume a single worker. |
| Templates | Jinja2 3.1.5 | The admin panel and the landing page. |
| Storage | SQLite (3 files) |
user_records.db, appointments.db, ai_knowledge.db under data/. WAL mode, busy_timeout=5000. |
| Scheduling | APScheduler 3.10.4 | AsyncIOScheduler: the crawl every 2 h, the session GC every 60 min. |
| Scraping | requests + beautifulsoup4 | The availability crawler. |
| Excel | pandas + openpyxl | Consultant roster import, Persian lead export. |
| Auth | itsdangerous 2.2.0 | Signed, timed session cookies. |
| Frontend | vanilla JS + CSS | No framework, no bundler, no npm. The widget ships as three static files. |
One worker, on purpose. docker-compose.yml starts uvicorn with --workers 1, and the
scheduler jobs are registered in lifespan. Multiple workers would start multiple crawlers
and split the in-memory rate-limit counters. Scale vertically, not horizontally, unless you
move the scheduler out of the process.
The API key is the tenant identity. It is a bearer credential carried in the iframe's query string, mapped to a tenant row. It scopes the flow, the counselor roster, the learning weights and the leads. Rotate it from the super-admin panel; the widget picks up the new key on the next page load.
Domain allow-list is optional but recommended. A tenant may list the domains its widget
may be embedded on. The check is an exact host match or a subdomain match — deliberately
not a substring check, so notnikravan.org cannot impersonate nikravan.org.
The lead is written before the match runs. This was a deliberate fix (see
CHANGELOG.md): if the engine returns nothing, the request still exists and the staff can
call the person.
The engine never states a diagnosis. It extracts concepts, scores, ranks and explains ("تخصص در درمان تخصصی اضطراب و استرس"). It does not tell a visitor what condition they have. The GHQ screen is a screening instrument, and the widget presents it as such.
ai-counseling-widget/
├── app/
│ ├── main.py FastAPI app, security headers, scheduler, page routes
│ ├── config.py Env-based configuration (.env)
│ ├── db.py SQLite init, tenant-column migration, session GC
│ ├── auth.py PBKDF2 hashing, signed session cookies, role checks
│ ├── tenants.py Tenants, API keys, seed data, default flows
│ ├── session_store.py The per-tenant flow engine (steps, GHQ, endings)
│ ├── internal_ai_engine.py SpiralMatchEngine — concept extraction + learning weights
│ ├── ghq_analyzer.py GHQ-28 instrument, scoring and interpretation
│ ├── crawler.py Availability crawler
│ ├── user_db.py Users, requests, consultant roster, conflict handling
│ ├── admin_tools.py Persian Excel export, broadcast helpers
│ ├── excel_to_json.py Spreadsheet → roster JSON converter
│ ├── routers/
│ │ ├── chat.py /api/chat/* — the widget's HTTP surface
│ │ └── admin.py /admin/* — the panel
│ └── templates/ Jinja2 templates for the panel
├── static/
│ ├── widget.js The embed loader (the <script> tag)
│ ├── widget-app.js The in-iframe application
│ ├── widget.html The iframe document
│ ├── widget.css Widget styling
│ ├── landing.html The product landing page at /
│ └── demo.html The per-tenant demo page
├── data/ SQLite files + roster JSON (git-ignored)
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
└── .env.example
- You want to put it on a website → Embedding the Widget, then Setup and Deployment.
- You want to call the API yourself → API Reference.
- You want to understand the ranking → Matching Engine and GHQ-28 Screening.
- You are running the clinic's side → Admin Panel and Multi-Tenancy.
- You want to change the code → Contributing and Architecture.
MIT license — see LICENSE
in the repository. The source lives at
github.com/Mohammad-Hasan-Kaman/ai-counseling-widget,
and the changelog is in CHANGELOG.md.