diff --git a/.env.example b/.env.example
index 0369333..65efee3 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
+# راه سادهتر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی میسازد.
# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
# --- رمزهای اصلی (اجباری) ---
@@ -14,10 +15,43 @@ ADMIN_PASSWORD=change-me-admin-password
# پورت ورود Traefik در توسعه (پیشفرض 5173)
TRAEFIK_HTTP_PORT=5173
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
+# در تولید این مقدار هم پایهٔ پروب بیروننگر مانیتورینگ است.
SITE_URL=
# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
# CORS_ORIGINS=["http://localhost:5173"]
+# --- بیلد فرانتاند (در زمان npm run build روی هاست خوانده میشود — stack.sh پاس میدهد) ---
+# اگر API روی مبدأ دیگری است (مثل توسعه ویندوز با پورت 19001) تنظیم کنید؛ هممبدأ یعنی خالی
+VITE_API_URL=
+# اسکریپت ردیاب Umami — خالی یعنی آنالیتیکس خاموش؛ در تولید مثل:
+# VITE_UMAMI_SRC=https://analytics.aramesh.example/script.js
+VITE_UMAMI_SRC=
+# شناسهٔ وبسایت از داشبورد Umami (اولین بار: وبسایت را در analytics.localhost بسازید)
+VITE_UMAMI_WEBSITE_ID=
+
+# --- چتبات (دستیار هوشمند) ---
+# LLM سازگار با OpenAI — اجباری برای پاسخگویی چتبات؛ خالی یعنی سرویس بالا میآید اما پاسخ نمیدهد
+LLM_BASE_URL=
+LLM_API_KEY=
+LLM_MODEL=openai/gpt-oss-120b
+# توکن محافظت از POST /chat/reindex — خالی یعنی فقط از شبکه داخلی
+CHAT_REINDEX_TOKEN=
+
+# --- مانیتورینگ (docker-compose.monitoring.yml) ---
+GRAFANA_ADMIN_USER=admin
+GRAFANA_ADMIN_PASSWORD=change-me-grafana-password
+# هشدارها به بله — توکن بات (همان شکل تلگرام؛ از BotFather بله) و شناسه چت/گروه
+# شناسه چت: به بات یک پیام بدهید سپس:
+# curl "https://tapi.bale.ai/bot<توکن>/getUpdates"
+ALERT_BOT_TOKEN=
+ALERT_CHAT_ID=0
+
+# --- عملیات ---
+# نگهداری پشتیبانهای شبانه (روز) — scripts/backup.sh
+RETENTION_DAYS=14
+# انتشار پشتیبان خارج از سرور (rclone remote مثل storage:aramesh-backups) — خالی یعنی خاموش
+OFFSITE_RCLONE_REMOTE=
+
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
# DOMAIN=aramesh.example
# ACME_EMAIL=admin@aramesh.example
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..74c9bad
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+# اسکریپتهای شل باید در لینوکس هم LF باشند — CRLF در bash میشکند
+*.sh text eol=lf
+*.yml text
+*.yaml text
+*.json text
+Dockerfile text
+*.py text
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f695783
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,47 @@
+name: CI
+
+# گیت سرور self-hosted شما (Gitea Actions / Forgejo) همین سینتکس را اجرا میکند؛
+# روی GitHub هم بیتغییر کار میکند.
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+
+ - name: Install backend deps
+ run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
+
+ - name: Run backend tests
+ working-directory: backend
+ run: pytest -q
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+
+ - name: Frontend typecheck/build
+ working-directory: frontend
+ run: |
+ npm ci
+ npm run build
+
+ images:
+ runs-on: ubuntu-latest
+ needs: tests
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build images
+ run: docker compose -f docker-compose.yml build backend frontend chatbot
diff --git a/.gitignore b/.gitignore
index da36e75..572d40b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,12 @@ env/
# Environment
.env
+# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
+monitoring/alertmanager/alertmanager.rendered.yml
+
+# تنظیمات محلی هر ماشین
+docker-compose.override.yml
+
# پشتیبانگیری محلی
backups/
@@ -22,6 +28,7 @@ dist/
.vscode/
.DS_Store
Thumbs.db
+.zcode/
# Database
*.sqlite3
diff --git a/README.md b/README.md
index 7c24c5e..124fd85 100644
--- a/README.md
+++ b/README.md
@@ -3,15 +3,20 @@
مونوریپوی سایت گروه ساختمانی آرامش شامل:
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
-- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
+- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
+- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
+- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami بهصورت لایههای compose
## معماری
```
-Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
-├── / → Caddy (فایلهای استاتیک SPA)
-├── /api → FastAPI (بکاند)
-└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
+Traefik (تنها نقطه ورود — 80/443 در تولید)
+├── / → Caddy (فایلهای استاتیک SPA)
+├── /api → FastAPI (بکاند) + متریک /metrics فقط داخلی
+├── /media → MinIO (باکت aramesh-media)
+├── /chat → چتبات (SSE استریم پاسخ)
+├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
+└── analytics.* → Umami (آنالیتیکس محصول)
```
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
@@ -19,107 +24,178 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
`/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
+- شبکههای compose تفکیک شدهاند: `edge` (سرویسهای پشت Traefik) و `internal`
+ (دیتابیس/ذخیرهسازی) — فرانتاند هیچ راه مستقیمی به دیتابیس ندارد
+- **لاگها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
+ Loki میفرستد تا در Grafana جستوجو شوند
## ساختار
```
-├── frontend/ # سایت React
-│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
-│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
-│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
-├── backend/ # بکاند FastAPI
-│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
-│ ├── tests/ # تستهای API (pytest)
-│ ├── requirements.txt
-│ ├── Dockerfile
-│ └── .env.example
-├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
-├── docker-compose.yml # اجرای کامل با یک دستور
-└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
+├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو میکند)
+│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
+│ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
+│ └── Dockerfile # Caddy — بدون root
+├── backend/ # بکاند FastAPI
+│ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
+│ ├── migrations/ # مهاجرتهای نسخهدار Alembic
+│ ├── tests/ # تستهای API (pytest)
+│ ├── alembic.ini
+│ └── Dockerfile # بدون root — خاموشی تمیز با exec
+├── chatbot/ # دستیار هوشمند (RAG + pgvector)
+├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
+├── scripts/
+│ ├── stack.sh # ابزار اصلی عملیات — deploy/logs/health/backup/restore/update
+│ ├── backup.sh # پشتیبان کامل (DB + تصاویر) با چکسام و گزینه انتشار بیرونی
+│ ├── restore.sh # بازگردانی پشتیبان دیتابیس
+│ ├── gen-env.sh # ساخت .env با رمزهای قوی تصادفی
+│ ├── audit.sh # ممیزی آسیبپذیری وابستگیها و ایمیجها
+│ ├── migrate-media.py # انتقال یکباره تصاویر CDN خارجی به MinIO
+│ └── ci.sh # تست + بیلد ایمیج (محلی یا هر CI)
+├── docker-compose.yml # استک اصلی (شامل چتبات و Umami)
+├── docker-compose.monitoring.yml # لایه مانیتورینگ
+├── docker-compose.prod.yml # لایه تولید: TLS، دامنه، سختسازی پورتها
+└── docker-compose.override.yml # تنظیمات محلی این ماشین (در گیت نیست)
```
-## اجرا با Docker (سادهترین راه)
+## اجرا با Docker
پیشنیاز: Docker Desktop. بیلد فرانتاند روی هاست انجام میشود (کانتینر فقط فایلهای
-`dist/` را سرو میکند)، پس اولین بار باید یکبار `npm run build` بزنید:
+`dist/` را سرو میکند) — `scripts/stack.sh` همین را به یک دستور تبدیل میکند:
```bash
-cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
-cd frontend && npm install && npm run build && cd ..
-docker compose up -d --build
+cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
+cd frontend && npm install && cd ..
+bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
```
-سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`)
-
-- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
+- سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
+- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
+- مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
-- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
-- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
- ارتقای نسخه دادههای فعلی را از بین نمیبرد
-- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد، خواندن عمومی را
- فعال میکند و تصاویر داخل `seed-media/` را در باکت میریزد — پس یک کلون تازه هم
- همان تصاویر سایت را دارد
+- دیتابیس PostgreSQL (تصویر pgvector)، آبجکتاستور MinIO و کش مدلهای چتبات هر یک
+ در کانتینر جدا با volume دائمی
+- بکاند هنگام شروع، جداول پایه را میسازد، **مهاجرتهای Alembic** را اعمال میکند و
+ **فقط جدولهای خالی** را seed میکند — ارتقای نسخه دادههای فعلی را از بین نمیبرد
+- `minio-setup` باکت `aramesh-media` را میسازد و فقط **وقتی باکت خالی است** تصاویر
+ `seed-media/` را میریزد — `db-init` هم دیتابیسهای `umami` و `aramesh_chatbot` را
+ روی همان نمونهٔ Postgres میسازد
> نکته برای تازهواردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
> فایلهای `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
+>
+> پایهٔ API فرانتاند در زمان بیلد از `VITE_API_URL` در `.env` خوانده میشود —
+> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
## انتشار واقعی (TLS)
-برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
+پیشنیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
+است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
+`SITE_URL=https://example.com`:
```bash
-# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
-docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
+bash scripts/stack.sh deploy-prod
```
- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
-- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
+- **پورتهای مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
+ بسته میشوند** — دسترسی از راه دور فقط با تونل SSH:
+ `ssh -L 9001:127.0.0.1:9001 server`
+- همه روترهای websecure هدرهای امنیتی میگیرند (HSTS + nosniff)
-## اجرای محلی (توسعه)
+### چکلیست رفتن به تولید
-### بکاند
+1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
+2. DNS: رکوردهای دامنه و زیردامنهها + باز بودن پورتهای ۸۰/۴۴۳ در فایروال
+3. `DOMAIN`، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
+4. اولین انتشار را روی یک زیردامنه آزمایشی انجام دهید (نقش staging) — `deploy-prod`
+5. `python scripts/migrate-media.py` — انتقال یکباره تصاویر CDN خارجی به MinIO
+6. پشتیبانگیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
+ + `scripts/backup.sh` شبانه با cron/Task Scheduler
+7. یک تمرین بازگردانی: `bash scripts/restore.sh backups/db-<زمان>.sql.gz` روی محیط آزمایشی
+8. مانیتورینگ (بخش پایین) و سپس چتبات/آنالیتیکس را با متغیرهای LLM و Umami فعال کنید
+
+## مانیتورینگ (Prometheus + Grafana + Loki)
```bash
-cd backend
-python -m venv .venv
-.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate
-pip install -r requirements.txt
-
-# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
-python -m app.seed # ساخت جداول + محتوای اولیه
-uvicorn app.main:app --reload
+bash scripts/stack.sh monitoring
+# در تولید (سه فایل با هم):
+docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
```
-### فرانتاند
+- داشبورد: `grafana.localhost` (توسعه) یا `grafana.example.com` (تولید — برچسبهای
+ روتر آن را در `docker-compose.override.yml` سرور بگذارید؛ نمونه در انتهای
+ `docker-compose.prod.yml`) — کاربر/رمز از `GRAFANA_ADMIN_*` در `.env`
+- دو داشبورد آمادهٔ «زیرساخت» و «اپلیکیشن» بهصورت code-provisioned سوار میشوند
+- **هشدارها به بله**: در `.env` مقدار `ALERT_BOT_TOKEN` (بات بله — همان شکل API تلگرام،
+ از BotFather بله) و `ALERT_CHAT_ID` را بگذارید (شناسه چت: به بات یک پیام بدهید سپس
+ `curl "https://tapi.bale.ai/bot<توکن>/getUpdates"`) — قواعد آماده در
+ `monitoring/prometheus/alerts.yml`:
+ پایینبودن هر سرویس، شکست بررسی عمق سلامت، پرشدن دیسک، نرخ خطای 5xx، انقضای گواهی TLS،
+ **کهنهشدن پشتیبان** (سنجهٔ `backup_success_time` از `scripts/backup.sh`) و...
+- **لاگها در Grafana**: Promtail لاگ همهٔ کانتینرهای استک را به Loki میفرستد —
+ برای خطاهای بکاند از داشبورد «اپلیکیشن» پنل لاگ را ببینید
+- **سلامت واقعی**: `/api/health` اتصال DB و MinIO را واقعاً میآزماید (503 در خرابی)؛
+ پروب blackbox هم همین را از بیرون و نشانی `SITE_URL` را عمومی چک میکند
+- مصرف منابع مجموع لایه حدود ۱ تا ۱.۵ گیگابایت RAM است؛ حذف هر سرویس بقیه را نمیشکند
-```bash
-cd frontend
-npm install
-npm run dev
-```
+## آنالیتیکس محصول (Umami)
-در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
-پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
-`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
+استک شامل سرویس Umami است؛ داشبورد: `analytics.localhost` (توسعه) یا
+`analytics.example.com` (تولید). بدون کوکی — بدون نیاز به بنر رضایت.
+
+راهاندازی (یکبار):
+
+1. استک را بالا بیاورید و به `analytics.localhost` وارد شوید (پیشفرض `admin`/`umami`
+ — بلافاصله رمز را عوض کنید)
+2. یک وبسایت با دامنهٔ سایت بسازید؛ «شناسه وبسایت» را در `.env` بگذارید:
+ `VITE_UMAMI_WEBSITE_ID=...` و آدرس اسکریپت: `VITE_UMAMI_SRC=https://analytics.example.com/script.js`
+3. `bash scripts/stack.sh deploy` — اسکریپت ردیاب در `index.html` تزریق میشود
+
+رخدادهای سفارشی که خودکار ارسال میشوند: ارسال فرم تماس (`contact-submit`)، عضویت
+خبرنامه (`newsletter-signup`)، کلیک تلفن (`phone-click`)، باز کردن چتبات
+(`chat-open`)، پرسش از چتبات (`chat-question`)، تغییر زبان (`lang-switch`) —
+سرچشمه: `frontend/src/analytics.ts`.
+
+## چتبات (دستیار هوشمند)
+
+- RAG روی محتوای همان دیتابیس سایت: امبدینگ MiniLM چندزبانه + BM25 + رنکر — بردارها
+ در دیتابیس جدا `aramesh_chatbot` (pgvector) روی همان نمونهٔ Postgres
+- مسیرها: `GET /chat/healthz`، `GET /chat/suggestions`، `GET /chat/stream` (SSE) —
+ رابط کاربری: ویجت شناور پایین صفحه (`ChatWidget.tsx`)
+- راهاندازی: `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` در `.env` (هر LLM سازگار با
+ OpenAI) — بدون آن سرویس بالا میآید اما پاسخ نمیدهد
+- اولین اجرا مدلها را دانلود میکند (~۵۰۰MB در حجم `chatbot_hf`) — کند است، طبیعی است
+- ایندکس هر ۱۰ دقیقه خودش با تغییرهای محتوا همگام میشود؛ همگامسازی فوری با
+ `POST /chat/reindex` و هدر `x-reindex-token` (از `CHAT_REINDEX_TOKEN`)
## امکانات بکاند
- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
-- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
-- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
- و سپس CRUD کامل همه محتواها در `/api/admin/...`
+- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
+ محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
+ (honeypot) ضدربات + میانافزار محدودسازی سیلاب روی Traefik
+- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
+ هیچ رمز پیشفرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
+ (IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده میشود)
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
- ذخیره و URL نسبی `/media/...` برمیگردد
+ ذخیره و URL نسبی `/media/...` برمیگردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
+ فشرده میشود (Pillow) تا سرعت صفحه حفظ شود
+- **خروجی CSV** از نوار مدیریت: پیامهای تماس و مشترکان خبرنامه (با BOM برای Excel)
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
-- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
+- **مهاجرتهای اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
+ revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
+ commit؛ در استارت کانتینر خودکار `upgrade head` اجرا میشود
+- **تستها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
+ honeypot، محدودسازی نرخ و پاکسازی)
-> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
-> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
+> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
+> بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
## ویرایش زنده سایت (دوقلوی دیجیتال)
@@ -157,7 +233,9 @@ npm run dev
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
-در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
+در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند. در زمان ذخیره، آدرسهای
+`href/src` سند در سمت سرور پاکسازی میشوند (`app/sanitize.py`) تا اسکیمهای خطرناک
+مثل `javascript:` هرگز ذخیره نشوند.
## سئو
@@ -179,24 +257,39 @@ npm run dev
دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
همیشگی را میبینند
-## پشتیبانگیری
+## پشتیبانگیری و بازگردانی
-همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
+همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
```bash
-bash scripts/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
+bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
+docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
```
-- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
-- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
-- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
+- خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
+- اسکریپت پشتیبان، خروجی خالی/شکسته را خطا میگیرد، سنجهٔ تازگی برای هشدار Grafana
+ مینویسد و اگر `OFFSITE_RCLONE_REMOTE` در `.env` تنظیم باشد، پشتیبان را به حافظهٔ
+ دیگری هم کپی میکند (rclone)
+- **بازگردانی**: `bash scripts/stack.sh restore backups/db-<زمان>.sql.gz`
+ (احتیاط: محتوای فعلی جایگزین میشود — قبل از انتشار واقعی یکبار تمرین کنید!)
+- زمانبندی پشتیبان کامل با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
+
+## تست و CI
+
+- `bash scripts/ci.sh` — تستهای pytest + بیلد ایمیجها + ممیزی وابستگیها
+- `bash scripts/audit.sh` — pip-audit / npm audit / trivy (هر کدام نصب بود)
+- `.github/workflows/ci.yml` — همین مراحل؛ سینتکس GitHub Actions روی Gitea Actions/
+ Forgejo هم اجرا میشود
## نکات امنیتی
-- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
- با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
+- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
+ `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید؛ هیچ رمز پیشفرضی در کد نیست
- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
-- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
-- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
+- توکنهای JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج میکند)
+- همهٔ کانتینرها **بدون root** اجرا میشوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
+ باریک است و لاگها سقف دارند
+- فرمهای عمومی ضدرباتاند (honeypot + محدودسازی نرخ در بکاند + سقف سیلاب در Traefik)
+- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترساند (تونل SSH)
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..b4e2fca
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,13 @@
+# کانتکست بیلد باریک — .venv و کشهای تست محلی جایی در تصویر ندارند
+.venv/
+venv/
+tests/
+.pytest_cache/
+__pycache__/
+**/__pycache__/
+*.pyc
+README.md
+.env
+.env.*
+Dockerfile
+.dockerignore
diff --git a/backend/Dockerfile b/backend/Dockerfile
index b060621..18f9f85 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,3 +1,4 @@
+# بکاند FastAPI — تصویر باریک و بدون root
FROM python:3.13-slim
WORKDIR /app
@@ -9,8 +10,16 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
+COPY alembic.ini ./
+COPY migrations ./migrations
+
+# کاربر بدون دسترسی root — برنامه چیزی روی دیسک محلی نمینویسد
+RUN useradd --system --create-home appuser \
+ && chown -R appuser:appuser /app
+USER appuser
EXPOSE 8000
-
-# ساخت جداول و seed (بدون حذف دادههای موجود) و سپس اجرای سرور
-CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
+# ساخت جداول پایه، مهاجرتهای Alembic و seed (بدون حذف دادههای موجود) و سپس
+# اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
+# تمیز انجام شود؛ --proxy-headers برای دیدن IP واقعی کلاینت پشت Traefik
+CMD ["sh", "-c", "python -m app.db_init && alembic upgrade head && python -m app.seed && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..cdbc8ab
--- /dev/null
+++ b/backend/alembic.ini
@@ -0,0 +1,40 @@
+[alembic]
+script_location = migrations
+prepend_sys_path = .
+
+# آدرس دیتابیس از DATABASE_URL (محیط) یا تنظیمات برنامه خوانده میشود — env.py
+sqlalchemy.url =
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index eedbea4..9e84a87 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -1,10 +1,13 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
+import csv
+import io
import uuid
from datetime import datetime, timezone
import boto3
-from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
+from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
+from PIL import Image, ImageOps
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -263,6 +266,33 @@ ALLOWED_IMAGE_TYPES = {
"image/avif": "avif",
}
+# بهینهسازی در زمان آپلود — ابعاد بزرگ و فشردهسازی ضعیف، اصلیترین عامل کندی سایت است
+MAX_DIMENSION = 2000
+REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
+
+
+def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
+ """تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده میکند؛
+ اگر خروجی بزرگتر از اصلی شد، همان اصلی برگردد. (gif/avif دستنخورده)"""
+ img = Image.open(io.BytesIO(data))
+ img = ImageOps.exif_transpose(img)
+ orig_w, orig_h = img.size
+ if max(img.size) > MAX_DIMENSION:
+ img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
+ buf = io.BytesIO()
+ if content_type == "image/jpeg":
+ if img.mode not in ("RGB", "L"):
+ img = img.convert("RGB")
+ img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
+ elif content_type == "image/png":
+ img.save(buf, "PNG", optimize=True)
+ else:
+ img.save(buf, "WEBP", quality=82, method=4)
+ optimized = buf.getvalue()
+ if len(optimized) < len(data):
+ return optimized, content_type, *img.size
+ return data, content_type, orig_w, orig_h
+
@router.post("/upload")
async def upload_media(
@@ -287,6 +317,14 @@ async def upload_media(
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
)
+ width = height = None
+ if content_type in REENCODE_TYPES:
+ try:
+ data, content_type, width, height = _optimize_image(data, content_type)
+ except Exception:
+ # تصویر خراب/ناشناخته — همان داده اصلی ذخیره میشود
+ pass
+
now = datetime.now(timezone.utc)
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
@@ -307,9 +345,49 @@ async def upload_media(
"key": key,
"size": len(data),
"content_type": content_type,
+ "width": width,
+ "height": height,
}
+# ---------- خروجی CSV (برای تیم فروش) ----------
+
+def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(header)
+ writer.writerows(rows)
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
+ return Response(
+ # BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
+ content="\ufeff" + buf.getvalue(),
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
+ )
+
+
+@router.get("/messages/export.csv")
+def export_messages(db: Session = Depends(get_db)):
+ rows = [
+ [m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
+ for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
+ ]
+ return _csv_response(
+ "contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
+ )
+
+
+@router.get("/subscribers/export.csv")
+def export_subscribers(db: Session = Depends(get_db)):
+ rows = [
+ [s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
+ for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
+ ]
+ return _csv_response(
+ "newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
+ )
+
+
# پیامهای تماس
@router.get("/messages", response_model=list[ContactMessageOut])
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py
index 9692b40..fea7411 100644
--- a/backend/app/api/forms.py
+++ b/backend/app/api/forms.py
@@ -1,17 +1,48 @@
-"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه."""
+"""انتهای فرمها — ثبت پیام تماس و عضویت در خبرنامه.
-from fastapi import APIRouter, Depends
+هر فرم یک دامنهٔ پنهان (honeypot) دارد: رباتها همهٔ فیلدها را پُر میکنند؛
+در آن حالت پاسخ موفقِ قلابی برمیگردد و چیزی ذخیره نمیشود.
+"""
+
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
-from ..database import get_db
from .. import models
+from ..database import get_db
+from ..rate_limit import contact_limiter, newsletter_limiter
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
router = APIRouter()
+def _fake_contact(payload: ContactIn) -> ContactMessageOut:
+ """پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
+ return ContactMessageOut(
+ id=0,
+ name=payload.name.strip(),
+ phone=payload.phone.strip(),
+ note="",
+ is_read=True,
+ created_at=datetime.now(timezone.utc),
+ )
+
+
+def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
+ return SubscriberOut(
+ id=0,
+ email=str(payload.email).lower(),
+ is_active=True,
+ created_at=datetime.now(timezone.utc),
+ )
+
+
@router.post("/contact", response_model=ContactMessageOut, status_code=201)
-def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
+def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
+ contact_limiter.hit(request)
+ if payload.website:
+ return _fake_contact(payload)
message = models.ContactMessage(
name=payload.name.strip(),
phone=payload.phone.strip(),
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
@router.post("/newsletter", response_model=SubscriberOut, status_code=201)
-def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)):
+def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
+ newsletter_limiter.hit(request)
+ if payload.website:
+ return _fake_subscriber(payload)
+
email = str(payload.email).lower()
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
diff --git a/backend/app/api/public.py b/backend/app/api/public.py
index 25a312d..5a30799 100644
--- a/backend/app/api/public.py
+++ b/backend/app/api/public.py
@@ -1,8 +1,13 @@
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
+import boto3
+from botocore.config import Config as BotoConfig
from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import JSONResponse
+from sqlalchemy import text
from sqlalchemy.orm import Session
+from ..config import settings
from ..database import get_db
from .. import models
from ..schemas import (
@@ -22,9 +27,44 @@ from ..schemas import (
router = APIRouter()
+@router.get("/live")
+def live():
+ """زندهبودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمیشود."""
+ return {"status": "ok"}
+
+
@router.get("/health")
-def health():
- return {"status": "ok", "app": "Aramesh Building Group API"}
+def health(db: Session = Depends(get_db)):
+ """بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده میشود؛
+ در صورت خرابی هر وابستگی، 503 با جزئیات برمیگردد."""
+ checks: dict[str, str] = {}
+
+ try:
+ db.execute(text("SELECT 1"))
+ checks["database"] = "ok"
+ except Exception:
+ checks["database"] = "error"
+
+ try:
+ s3 = boto3.client(
+ "s3",
+ endpoint_url=settings.s3_endpoint,
+ aws_access_key_id=settings.s3_access_key,
+ aws_secret_access_key=settings.s3_secret_key,
+ region_name="us-east-1",
+ config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
+ )
+ s3.head_bucket(Bucket=settings.s3_bucket)
+ checks["storage"] = "ok"
+ except Exception:
+ checks["storage"] = "error"
+
+ if any(state != "ok" for state in checks.values()):
+ return JSONResponse(
+ status_code=503,
+ content={"status": "degraded", "checks": checks},
+ )
+ return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
@router.get("/brand", response_model=BrandOut)
diff --git a/backend/app/config.py b/backend/app/config.py
index 2d97aeb..c2285c9 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
secret_key: str = "dev-secret-change-me-in-production"
algorithm: str = "HS256"
- access_token_expire_minutes: int = 1440
+ # ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاهتر ریسک نشت را کم میکند
+ access_token_expire_minutes: int = 480
cors_origins: list[str] = ["*"]
admin_username: str = "admin"
admin_password: str = "aramesh123"
diff --git a/backend/app/db_init.py b/backend/app/db_init.py
new file mode 100644
index 0000000..4e7a4a4
--- /dev/null
+++ b/backend/app/db_init.py
@@ -0,0 +1,11 @@
+"""ساخت جداول پایه (baseline) — پیش از مهاجرتهای Alembic.
+
+روی دیتابیس تازه، همهٔ جدولها از مدلهای فعلی ساخته میشوند؛ روی دیتابیس موجود
+بیاثر است (create_all جدولهای موجود را دست نمیزند). از اینجا به بعد تغییرات
+اسکیما فقط با revisionهای Alembic اعمال میشوند.
+"""
+
+from . import models # noqa: F401 — ثبت مدلها
+from .database import Base, engine
+
+Base.metadata.create_all(bind=engine)
diff --git a/backend/app/main.py b/backend/app/main.py
index e04fea7..fa55667 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,15 +1,17 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.middleware.gzip import GZipMiddleware
from . import models # noqa: F401 — ثبت مدلها پیش از ساخت جداول
from .api import admin, forms, public
from .config import settings
from .database import Base, engine
-from .migrations import run_all as run_migrations
from .seo_pages import router as seo_router
+# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال میشوند
+# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
+# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام میدهد.
Base.metadata.create_all(bind=engine)
-run_migrations()
app = FastAPI(
title="Aramesh Building Group API",
@@ -29,6 +31,8 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
+# پاسخهای JSON /api — فشردهسازی وقتی بدنه قابلتوجه است
+app.add_middleware(GZipMiddleware, minimum_size=1024)
app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
@@ -37,6 +41,12 @@ app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
app.include_router(seo_router, tags=["سئو"])
+# متریکهای Prometheus — فقط Prometheus در شبکه داخلی اسکرپ میکند
+# (Traefik مسیر /metrics را نمیشناسد، پس از بیرون در دسترس نیست)
+from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
+
+Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
+
@app.get("/")
def root():
diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py
index ec35dc3..14bc462 100644
--- a/backend/app/rate_limit.py
+++ b/backend/app/rate_limit.py
@@ -1,38 +1,74 @@
-"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
+"""محدودسازی نرخ درخواست — پنجرهٔ لغزان درونحافظهای.
-پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
-شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
+برای ورود ادمین (بر پایه تلاش ناموفق) و فرمهای عمومی (بر پایه هر ارسال)
+استفاده میشود. شمارندهها در حافظهٔ همین پروسهاند — با ریاستارت پاک میشوند؛
+به همین دلیل uvicorn تککارگر نگه داشته شده است.
"""
import time
from fastapi import HTTPException, Request, status
-MAX_FAILED_ATTEMPTS = 5
-WINDOW_SECONDS = 15 * 60
-
-_failed: dict[str, list[float]] = {}
-
def client_ip(request: Request) -> str:
+ """IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده میشود
+ (uvicorn با --proxy-headers هم client.host را درست میکند)."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
-def _prune(key: str, now: float) -> list[float]:
- recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
- _failed[key] = recent
- return recent
+class RateLimiter:
+ """پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی میشوند."""
+
+ def __init__(self, max_events: int, window_seconds: int, message: str):
+ self.max_events = max_events
+ self.window_seconds = window_seconds
+ self.message = message
+ self._events: dict[str, list[float]] = {}
+
+ def _recent(self, key: str, now: float) -> list[float]:
+ recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
+ self._events[key] = recent
+ return recent
+
+ def _reject(self, recent: list[float]) -> None:
+ now = time.monotonic()
+ retry_after = max(1, int(self.window_seconds - (now - recent[0])))
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=self.message,
+ headers={"Retry-After": str(retry_after)},
+ )
+
+ def hit(self, request: Request) -> None:
+ """یک رویداد ثبت میکند؛ اگر سهمیه کلید پر باشد 429 برمیگرداند."""
+ key = client_ip(request)
+ now = time.monotonic()
+ recent = self._recent(key, now)
+ if len(recent) >= self.max_events:
+ self._reject(recent)
+ self._events.setdefault(key, []).append(now)
+
+ def clear(self, request: Request) -> None:
+ self._events.pop(client_ip(request), None)
+
+
+# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکستها شمرده میشوند
+_failed: dict[str, list[float]] = {}
+LOGIN_MAX_FAILED_ATTEMPTS = 5
+LOGIN_WINDOW_SECONDS = 15 * 60
def ensure_allowed(request: Request) -> None:
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
key = client_ip(request)
- recent = _prune(key, time.monotonic())
- if len(recent) >= MAX_FAILED_ATTEMPTS:
- retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
+ now = time.monotonic()
+ recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
+ _failed[key] = recent
+ if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
+ retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
def clear(request: Request) -> None:
"""با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
_failed.pop(client_ip(request), None)
+
+
+# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
+contact_limiter = RateLimiter(
+ max_events=5,
+ window_seconds=15 * 60,
+ message="تعداد پیامهای ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
+)
+
+# خبرنامه: ۳ عضویت در ساعت برای هر IP
+newsletter_limiter = RateLimiter(
+ max_events=3,
+ window_seconds=60 * 60,
+ message="تعداد درخواستها زیاد است — کمی بعد دوباره امتحان کنید",
+)
diff --git a/backend/app/sanitize.py b/backend/app/sanitize.py
new file mode 100644
index 0000000..c631772
--- /dev/null
+++ b/backend/app/sanitize.py
@@ -0,0 +1,58 @@
+"""پاکسازی ورودیهای غنی در زمان ذخیره.
+
+بدنه مقالهها سند TipTap (ProseMirror) است و فرانتاند همان را با
+renderToHTMLString به HTML تبدیل و مستقیم تزریق میکند؛ متن گرهها در آن
+مسیر escape میشود، اما آدرسهای href/src عیناً وارد HTML میشوند — پس
+اسکیمهای خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+from urllib.parse import urlparse
+
+# اسکیمهای مجاز برای href/src — مسیرهای نسبی و لنگر هم بیاسکیماند و میگذرند
+SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
+URL_ATTRS = ("href", "src")
+
+
+def _clean_url(value: Any) -> str | None:
+ """آدرس امن را برمیگرداند؛ در غیر این صورت None (مشخصه حذف میشود)."""
+ if not isinstance(value, str):
+ return value if value is not None else None
+ stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
+ if not stripped:
+ return None
+ parsed = urlparse(stripped)
+ if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
+ return None
+ return stripped
+
+
+def _walk(node: Any) -> Any:
+ if isinstance(node, list):
+ return [_walk(item) for item in node]
+ if not isinstance(node, dict):
+ return node
+ cleaned: dict[str, Any] = {}
+ for key, value in node.items():
+ if key == "attrs" and isinstance(value, dict):
+ attrs = dict(value)
+ for attr in URL_ATTRS:
+ if attr in attrs:
+ cleaned_url = _clean_url(attrs[attr])
+ if cleaned_url is None:
+ attrs.pop(attr, None)
+ else:
+ attrs[attr] = cleaned_url
+ cleaned[key] = attrs
+ else:
+ cleaned[key] = _walk(value)
+ return cleaned
+
+
+def sanitize_doc(doc: Any) -> Any:
+ """سند TipTap را درجا پاک میکند (سایر ساختارها دستنخورده برمیگردند)."""
+ if not isinstance(doc, dict):
+ return doc
+ return _walk(doc)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 5eda507..b830dcb 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -3,6 +3,8 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
+from .sanitize import sanitize_doc
+
# ---------- پایه ----------
@@ -386,10 +388,14 @@ class ContactIn(BaseModel):
name: str = Field(min_length=3, max_length=128)
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
note: str = Field(default="", max_length=2000)
+ # honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
+ website: str = Field(default="", max_length=128)
class NewsletterIn(BaseModel):
email: EmailStr
+ # honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمیکند
+ website: str = Field(default="", max_length=128)
# ---------- ورودیهای پنل مدیریت ----------
@@ -460,12 +466,13 @@ EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
def validate_doc_body(value: object) -> dict:
- """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
+ """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
+ آدرسهای href/src آن به اسکیمهای امن محدود شوند."""
if not value:
return dict(EMPTY_DOC)
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
- return value
+ return sanitize_doc(value)
class ArticleIn(BaseModel):
diff --git a/backend/migrations/env.py b/backend/migrations/env.py
new file mode 100644
index 0000000..a0ea697
--- /dev/null
+++ b/backend/migrations/env.py
@@ -0,0 +1,49 @@
+"""محیط Alembic — متادیتا از مدلهای SQLAlchemy میآید و آدرس دیتابیس از تنظیمات."""
+
+import os
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+
+from app import models # noqa: F401 — ثبت مدلها در متادیتا
+from app.config import settings
+from app.database import Base
+
+config = context.config
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def get_url() -> str:
+ return os.environ.get("DATABASE_URL", settings.database_url)
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=get_url(),
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ configuration = config.get_section(config.config_ini_section, {})
+ configuration["sqlalchemy.url"] = get_url()
+ connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako
new file mode 100644
index 0000000..17dcba0
--- /dev/null
+++ b/backend/migrations/script.py.mako
@@ -0,0 +1,25 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/migrations/versions/0001_baseline.py b/backend/migrations/versions/0001_baseline.py
new file mode 100644
index 0000000..8349a9d
--- /dev/null
+++ b/backend/migrations/versions/0001_baseline.py
@@ -0,0 +1,25 @@
+"""نقطه شروع زنجیره مهاجرتها — جداول پایه با create_all ساخته میشوند.
+
+ساخت اولیه ۲۹ جدول بر عهده Base.metadata.create_all است (در CMD کانتینر پیش از
+این مهاجرت اجرا میشود و برای دیتابیسهای موجود بیاثر است). از این نسخه به بعد
+هر تغییر اسکیما باید یک revision واقعی Alembic باشد — نه create_all و نه
+تغییر دستی — تا مسیر ارتقا و بازگشت آزمایشپذیر بماند.
+
+Revision ID: 0001
+Revises:
+Create Date: 2026-09-18
+"""
+from typing import Sequence, Union
+
+revision: str = "0001"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ pass
+
+
+def downgrade() -> None:
+ pass
diff --git a/backend/requirements.txt b/backend/requirements.txt
index bc23690..26442da 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -11,3 +11,11 @@ bcrypt==4.2.1
email-validator==2.2.0
boto3==1.35.0
python-multipart==0.0.18
+# پاکسازی HTML سمت سرور (تیپتپ و فیلدهای غنی)
+nh3==0.2.20
+# بهینهسازی تصویر در زمان آپلود (تغییر اندازه/فشردهسازی مجدد)
+Pillow==11.1.0
+# متریکهای Prometheus برای مانیتورینگ (در /metrics داخلی)
+prometheus-fastapi-instrumentator==7.0.0
+# مهاجرتهای نسخهدار اسکیما
+alembic==1.14.0
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 7277948..3838abd 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -74,8 +74,12 @@ def reset_rate_limiter():
from app import rate_limit
rate_limit._failed.clear()
+ rate_limit.contact_limiter._events.clear()
+ rate_limit.newsletter_limiter._events.clear()
yield
rate_limit._failed.clear()
+ rate_limit.contact_limiter._events.clear()
+ rate_limit.newsletter_limiter._events.clear()
@pytest.fixture()
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 174c8a6..eabc012 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -8,12 +8,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ----------
-def test_health(client):
- res = client.get("/api/health")
+def test_live(client):
+ """liveness — پروسه زنده است، بدون وابستگی."""
+ res = client.get("/api/live")
assert res.status_code == 200
assert res.json()["status"] == "ok"
+def test_health_deep(client):
+ """بررسی عمق — DB (SQLite در تست) سالم است؛ MinIO در محیط تست در دسترس نیست
+ پس /api/health باید 503 و وضعیت degraded با جزئیات هر وابستگی بدهد."""
+ res = client.get("/api/health")
+ assert res.status_code == 503
+ body = res.json()
+ assert body["status"] == "degraded"
+ assert body["checks"]["database"] == "ok"
+ assert body["checks"]["storage"] == "error"
+
+
def test_site_hides_draft_articles(client):
res = client.get("/api/site")
assert res.status_code == 200
@@ -268,3 +280,84 @@ def test_en_backfill_fills_only_empty_fields(auth_client, client):
assert res.status_code == 200
backfill_en_content()
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
+
+
+# ---------- محافظت فرمها و پاکسازی ----------
+
+
+def test_contact_honeypot_saves_nothing(client, auth_client):
+ """دامنهٔ پنهان پرشده یعنی ربات — پاسخ موفقِ قلابی، بدون ذخیره."""
+ res = client.post(
+ "/api/contact",
+ json={"name": "ربات تستی", "phone": "09120000000", "website": "spam"},
+ )
+ assert res.status_code == 201
+ assert res.json()["id"] == 0 # رکورد قلابی — در دیتابیس نیست
+ assert auth_client.get("/api/admin/messages").json() == []
+
+
+def test_contact_rate_limited(client, auth_client):
+ """ارسال ششم در پنجرهٔ ۱۵ دقیقهای رد میشود (۵ مجاز برای هر IP)."""
+ for _ in range(5):
+ res = client.post(
+ "/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"}
+ )
+ assert res.status_code == 201, res.text
+ res = client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
+ assert res.status_code == 429
+ assert "Retry-After" in res.headers
+
+
+def test_newsletter_honeypot_saves_nothing(client, auth_client):
+ res = client.post(
+ "/api/newsletter", json={"email": "bot@example.com", "website": "spam"}
+ )
+ assert res.status_code == 201
+ assert res.json()["id"] == 0
+ assert auth_client.get("/api/admin/subscribers").json() == []
+
+
+def test_message_and_subscriber_csv_export(auth_client):
+ auth_client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
+ res = auth_client.get("/api/admin/messages/export.csv")
+ assert res.status_code == 200
+ assert res.headers["content-type"].startswith("text/csv")
+ assert "کاربر تماس" in res.text
+
+ auth_client.post("/api/newsletter", json={"email": "sale@example.com"})
+ res = auth_client.get("/api/admin/subscribers/export.csv")
+ assert res.status_code == 200
+ assert "sale@example.com" in res.text
+
+
+def test_article_body_url_sanitization(auth_client):
+ """اسکیمهای خطرناک در href/src سند TipTap در زمان ذخیره حذف میشوند."""
+ res = auth_client.post(
+ "/api/admin/articles",
+ json={
+ "slug": "sanitized-post",
+ "title": "مقاله پاکسازیشده",
+ "status": "published",
+ "body": {
+ "type": "doc",
+ "content": [
+ {
+ "type": "paragraph",
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {"type": "link", "attrs": {"href": "javascript:alert(1)"}},
+ ],
+ "text": "لینک خطرناک",
+ }
+ ],
+ }
+ ],
+ },
+ },
+ )
+ assert res.status_code == 201, res.text
+ body = res.json()["body"]
+ marks = body["content"][0]["content"][0].get("marks", [])
+ assert all("href" not in m.get("attrs", {}) for m in marks)
diff --git a/chatbot/.dockerignore b/chatbot/.dockerignore
new file mode 100644
index 0000000..3a2bb79
--- /dev/null
+++ b/chatbot/.dockerignore
@@ -0,0 +1,12 @@
+# کانتکست بیلد باریک
+__pycache__/
+**/__pycache__/
+*.pyc
+.venv/
+venv/
+.env
+.env.*
+README.md
+Dockerfile
+.dockerignore
+test_pure.py
diff --git a/chatbot/.env.example b/chatbot/.env.example
new file mode 100644
index 0000000..f46f029
--- /dev/null
+++ b/chatbot/.env.example
@@ -0,0 +1,28 @@
+# سرویس چتبات — این مقادیر را در .env ریشه پروژه بگذارید (نه اینجا)
+# این فایل فقط مستندسازی متغیرهاست.
+
+# --- اجباری ---
+# دیتابیس سایت (فقط خواندن) — در داکر از docker-compose.yml تزریق میشود
+# DATABASE_URL=postgresql+psycopg://aramesh:PASSWORD@db:5432/arameshplaza
+
+# LLM سازگار با OpenAI — بدون این چتبات پاسخ نمیدهد
+# مثال سرور داخلی: LLM_BASE_URL=http://172.16.22.21:4460/v1
+# مثال سرویس ابری: LLM_BASE_URL=https://api.example.com/v1
+LLM_BASE_URL=
+LLM_API_KEY=
+LLM_MODEL=openai/gpt-oss-120b
+
+# --- اختیاری ---
+# توکن محافظت از POST /chat/reindex — اگر خالی باشد مسیر ۴۰۳ میدهد
+CHAT_REINDEX_TOKEN=
+# ریرنکر را روی سرور کمرم خاموش کنید (RERANKER_ENABLED=false)
+RERANKER_ENABLED=true
+# وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) — پیشفرض ۰.۵
+ALPHA_DENSE=0.5
+# تعداد سند نهایی به LLM — پیشفرض ۳
+BEST_OF_K=3
+# بازسازی خودکار ایندکس پس از تغییر محتوا (ثانیه، ۰ = خاموش)
+INDEX_REFRESH_SECONDS=600
+# محدودیت نرخ: حداکثر پیام در پنجره زمانی (پیشفرض ۲۰ پیام در ۳۰۰ ثانیه)
+RATE_LIMIT_MESSAGES=20
+RATE_LIMIT_WINDOW=300
diff --git a/chatbot/Dockerfile b/chatbot/Dockerfile
new file mode 100644
index 0000000..275f125
--- /dev/null
+++ b/chatbot/Dockerfile
@@ -0,0 +1,31 @@
+# سرویس چتبات — پایتون + مدلهای بازیابی (CPU)
+FROM python:3.11-slim
+
+WORKDIR /srv
+
+# وابستگیهای سیستمی برای numpy/scikit-learn
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ g++ \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt /srv/chatbot/requirements.txt
+RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
+
+COPY . /srv/chatbot/
+
+# مدل امبدینگ/ریرنکر در /data کش میشود (والیوم داکر) — بردارها در pgvector میروند و فایل ایندکس جدا وجود ندارد
+ENV HF_HOME=/data/hf \
+ HF_CACHE_DIR=/data/hf
+VOLUME /data
+
+# کاربر بدون دسترسی root — فقط /data باید نوشتنی باشد (کش مدلها)
+RUN useradd --system --create-home appuser \
+ && chown -R appuser:appuser /srv/chatbot /data
+USER appuser
+
+EXPOSE 8000
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/chat/healthz')" || exit 1
+
+CMD ["uvicorn", "chatbot.app:api", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
diff --git a/chatbot/README.md b/chatbot/README.md
new file mode 100644
index 0000000..1b8d1fb
--- /dev/null
+++ b/chatbot/README.md
@@ -0,0 +1,121 @@
+# دستیار هوشمند سایت (چتبات)
+
+سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال میپرسد و
+دستیار **فقط از روی محتوای خود سایت** (مقالهها، پروژهها، خدمات، سؤالات متداول، اطلاعات تماس و…)
+پاسخ میدهد؛ اگر پاسخ در محتوا نباشد، بهجای ساخت جواب، صادقانه اعلام میکند.
+
+این سرویس پورت خط لولهٔ پروژهٔ `llm-srerver-chatbot` (نسخه v13) با چهار تغییر اصلی برای این سایت است:
+
+1. **پایگاه sparse بهجای SPLADE از BM25 استفاده میکند** — SPLADE انگلیسیمحور است و روی
+ فارسی عملاً بیفایده؛ BM25 با نرمالسازی فارسی (ی/ک عربی، اعراب، ارقام، نیمفاصله) جایگزین شده است.
+2. **ذخیرهساز برداری pgvector است، نه FAISS** — بردارها در یک دیتابیس جدا (`aramesh_chatbot`)
+ روی همان نمونه Postgres نگهداری میشوند؛ همگامسازی افزایشی است (فقط سطرهای جدید/تغییریافته
+ امبد میشوند)، بکاپگیری با خود دیتابیس است و فایل ایندکس جدا وجود ندارد.
+3. **تأخیر کمتر** — مسیریابی `alpha` با LLM و گسترش چندپرسشی (multi-query) بهصورت پیشفرض
+ خاموشاند (با متغیر محیطی قابل فعالسازی)؛ بازنویسی پرسش فقط وقتی تاریخچه گفتگو وجود دارد اجرا میشود.
+4. **استریم واقعی توکنبهتوکن** با `astream_events` بهجای دیف پیامهای کامل.
+
+## خط لوله هر پیام
+
+```
+پرسش کاربر
+ │
+ ├─[اگر تاریخچه هست] بازنویسی پرسش با LLM (حل ضمیرها و اشارهها: «اون دومی» → موضوع واقعی)
+ │
+ ├─ بازیابی ترکیبی: pgvector (معنایی، کسینوسی روی HNSW — MiniLM چندزبانه)
+ │ + BM25 (کلیدواژه، متن نرمالشده فارسی)
+ │ نمرهها min-max → ترکیب α×معنایی + (1-α)×کلیدواژه
+ │
+ ├─ برش خوشهای (K-Means K=2 + silhouette ≥ 0.55) — مرز طبیعی کیفیت
+ │
+ ├─ ریرنک با BAAI/bge-reranker-v2-m3 → نگهداشتن ۳ سند برتر
+ │
+ └─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
+```
+
+ایندکس از محتوای PostgreSQL سایت ساخته میشود (`articles` منتشرشده، `projects`، `services`،
+`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
+`testimonials`، `timeline`، `story`). متنهای بلند به قطعات ~۷۰۰ نویسهای همپوشان برش میخورند.
+
+- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
+ `aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
+ ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگامسازی
+ فقط سطرهای جدید/تغییریافته را امبد میکند. دیتابیس و جدول در شروع سرویس خودکار ساخته میشوند.
+- **بازسازی خودکار**: یک حلقهٔ پسزمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
+ میکند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام میشود
+ (امبد اجباری همه: `POST /chat/reindex`).
+- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگگراف) — با ریاستارت پاک میشود.
+
+## متغیرهای محیطی (در `.env` ریشه پروژه)
+
+| متغیر | پیشفرض | توضیح |
+|---|---|---|
+| `LLM_BASE_URL` | خالی | **اجباری** — نشانی سرویس LLM سازگار با OpenAI. بدون آن چتبات بالا میآید ولی پاسخ نمیدهد |
+| `LLM_API_KEY` | خالی | کلید API (برای سرور داخلی `not-needed` بگذارید) |
+| `LLM_MODEL` | `openai/gpt-oss-120b` | نام مدل |
+| `CHAT_REINDEX_TOKEN` | خالی | توکن `POST /chat/reindex` — اگر خالی باشد آن مسیر ۴۰۳ میدهد |
+| `CHATBOT_DB_NAME` | `aramesh_chatbot` | نام دیتابیس اختصاصی چتبات روی همان نمونه Postgres (بردارها اینجا میروند) |
+| `EMBEDDING_DIM` | `384` | بعد بردار مدل امبدینگ — با تعویض مدل امبدینگ باید هماهنگ شود |
+| `RERANKER_ENABLED` | `true` | روی سرور کمرم (زیر ~۴GB) `false` بگذارید |
+| `ALPHA_DENSE` | `0.5` | وزن جستوجوی معنایی در ترکیب نمرهها (۰ تا ۱) |
+| `BEST_OF_K` | `3` | تعداد سند نهایی که به LLM میرود |
+| `INDEX_REFRESH_SECONDS` | `600` | بازسازی خودکار ایندکس پس از تغییر محتوا (۰ = خاموش) |
+| `RATE_LIMIT_MESSAGES` / `RATE_LIMIT_WINDOW` | `20` / `300` | سقف پیام هر IP در پنجره زمانی (ثانیه) |
+
+`DATABASE_URL` در داکر از `docker-compose.yml` تزریق میشود — دیتابیس سایت فقط خوانده
+میشود و نوشتن (بردارها) فقط در دیتابیس جدا `aramesh_chatbot` انجام میگیرد.
+
+## پیشنیاز pgvector
+
+سرویس `db` استک از ایمیج `pgvector/pgvector:pg17` استفاده میکند (همان postgres:17 +
+افزونهٔ vector). اگر Postgres سایت روی نمونهٔ دیگری است، باید افزونهٔ pgvector روی آن
+فعال باشد؛ در غیر این صورت شروع چتبات با خطای واضح متوقف میشود.
+
+## مسیرها
+
+| مسیر | توضیح |
+|---|---|
+| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکنبهتوکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
+| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
+| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده با thread تازه |
+| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
+| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
+
+مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
+`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
+
+## اجرا
+
+با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
+
+```bash
+# در .env ریشه:
+LLM_BASE_URL=http://172.16.22.21:4460/v1 # یا هر سرویس سازگار با OpenAI
+LLM_API_KEY=not-needed
+```
+
+اولین بوت مدلها را دانلود میکند (~۱.۵GB: امبدینگ + ریرنکر) و در والیوم
+`chatbot_cache` کش میشود؛ بوتهای بعدی چند ثانیه طول میکشد.
+
+اجرای محلی بدون داکر:
+
+```bash
+cd chatbot
+python -m venv .venv && source .venv/bin/activate # ویندوز: .venv\Scripts\activate
+pip install -r requirements.txt
+export DATABASE_URL="postgresql+psycopg://aramesh:PASS@localhost:5432/arameshplaza"
+export LLM_BASE_URL="http://…" LLM_MODEL="openai/gpt-oss-120b"
+uvicorn chatbot.app:api --port 8000 # از ریشه پوشه والد
+```
+
+## منابع مورد نیاز
+
+- **رم**: با ریرنکر ~۴GB (مدل ۵۶۸M پارامتری روی CPU)؛ بدون ریرنکر ~۲GB
+- **CPU**: امبدینگ و ریرنک روی CPU اجرا میشوند؛ GPU لازم نیست
+- **شبکه**: دسترسی به PostgreSQL و به نشانی `LLM_BASE_URL`
+
+## نکات امنیتی
+
+- دیتابیس سایت فقط **خواندن**؛ نوشتن فقط در دیتابیس اختصاصی `aramesh_chatbot` انجام میشود
+- محدودسازی نرخ per-IP فعال است (پیشفرض ۲۰ پیام در ۵ دقیقه)
+- CORS باز است ولی کوکی/اعتبارنامهای رد و بدل نمیشود؛ در تولید همهچیز هممبدأ (پشت Traefik) است
diff --git a/chatbot/__init__.py b/chatbot/__init__.py
new file mode 100644
index 0000000..a1516de
--- /dev/null
+++ b/chatbot/__init__.py
@@ -0,0 +1 @@
+"""سرویس چتبات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
diff --git a/chatbot/app.py b/chatbot/app.py
new file mode 100644
index 0000000..188b1ec
--- /dev/null
+++ b/chatbot/app.py
@@ -0,0 +1,284 @@
+"""سرویس چتبات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
+
+import asyncio
+import json
+import logging
+import time
+import uuid
+from collections import defaultdict, deque
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException, Query, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+
+from langchain_core.messages import AIMessage, HumanMessage
+
+from . import content as content_mod
+from . import pipeline
+from . import vectorstore
+from .config import settings
+from .indexing import INDEX, sync_index
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
+)
+log = logging.getLogger("chatbot")
+
+
+# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
+
+_refresh_task: asyncio.Task | None = None
+
+
+@asynccontextmanager
+async def lifespan(_app: FastAPI):
+ try:
+ await asyncio.to_thread(sync_index)
+ except Exception as e:
+ log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
+ global _refresh_task
+ _refresh_task = asyncio.create_task(_refresh_loop())
+ yield
+ _refresh_task.cancel()
+
+
+async def _refresh_loop() -> None:
+ interval = settings.index_refresh_seconds
+ if interval <= 0:
+ return
+ while True:
+ await asyncio.sleep(interval)
+ try:
+ # همگامسازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد میشوند
+ await asyncio.to_thread(sync_index)
+ except Exception as e:
+ log.warning("Background index refresh failed: %s", e)
+
+
+api = FastAPI(
+ title="Aramesh Site Chatbot",
+ version="1.0.0",
+ description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ریرنکر BGE",
+ lifespan=lifespan,
+)
+
+api.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=False,
+ allow_methods=["GET", "POST"],
+ allow_headers=["*"],
+)
+
+
+# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
+
+_hits: dict[str, deque] = defaultdict(deque)
+_last_sweep = 0.0
+
+
+def _rate_limited(ip: str) -> bool:
+ global _last_sweep
+ now = time.time()
+ # پاکسازی دورهای کلیدهای بیمصرف تا دیکشنری بینهایت بزرگ نشود
+ if now - _last_sweep > settings.rate_limit_window:
+ stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
+ for k in stale:
+ _hits.pop(k, None)
+ _last_sweep = now
+ window = _hits[ip]
+ while window and now - window[0] > settings.rate_limit_window:
+ window.popleft()
+ if len(window) >= settings.rate_limit_messages:
+ return True
+ window.append(now)
+ return False
+
+
+def _client_ip(request: Request | None) -> str:
+ if request is None:
+ return "?"
+ forwarded = request.headers.get("x-forwarded-for")
+ return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
+
+
+# --- کمکیهای SSE ---
+
+def _sse(payload: dict) -> str:
+ return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
+
+
+def _chunk_text(chunk) -> str:
+ """استخراج متن از قطعه استریم LLM (رشته یا لیست بخشها)."""
+ content = getattr(chunk, "content", "")
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ return "".join(p.get("text", "") for p in content if isinstance(p, dict))
+ return ""
+
+
+async def _stream_answer(message: str, thread_id: str):
+ if not INDEX.ready:
+ yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
+ return
+ if pipeline.get_llm() is None:
+ yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
+ return
+
+ config = {"configurable": {"thread_id": thread_id}}
+ inputs = {"messages": [HumanMessage(content=message)]}
+ try:
+ async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
+ # فقط توکنهای پاسخ نهایی — نه فراخوانیهای کمکی (بازنویسی/گسترش پرسش)
+ if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
+ text = _chunk_text(event["data"]["chunk"])
+ if text:
+ yield _sse({"content": text, "done": False})
+ yield _sse({"content": "", "done": True})
+ except Exception as e:
+ log.exception("Streaming failed")
+ yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
+
+
+# --- مسیرها ---
+
+@api.get("/chat/healthz")
+async def healthz():
+ return {
+ "status": "ok",
+ "index_ready": INDEX.ready,
+ "documents": len(INDEX.docs),
+ "vectors": await asyncio.to_thread(vectorstore.vector_count),
+ "llm_configured": bool(settings.llm_base_url),
+ "reranker_enabled": settings.reranker_enabled,
+ }
+
+
+@api.get("/chat/suggestions")
+async def suggestions():
+ return {"questions": _get_suggestions()}
+
+
+@api.get("/chat/eval")
+async def eval_question(message: str = Query(...)):
+ """مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابیشده، با thread تازه."""
+ if not INDEX.ready:
+ return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
+ if pipeline.get_llm() is None:
+ return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
+
+ thread_id = f"eval_{uuid.uuid4().hex[:10]}"
+ config = {"configurable": {"thread_id": thread_id}}
+ inputs = {"messages": [HumanMessage(content=message)]}
+ final_state = await pipeline.graph.ainvoke(inputs, config=config)
+
+ answer = ""
+ for msg in reversed(final_state.get("messages", [])):
+ if isinstance(msg, AIMessage):
+ answer = msg.content if isinstance(msg.content, str) else str(msg.content)
+ break
+ return {
+ "question": message,
+ "contextualized_query": final_state.get("contextualized_query") or message,
+ "answer": answer,
+ "contexts": final_state.get("documents", []),
+ "thread_id": thread_id,
+ }
+
+
+@api.post("/chat/reindex")
+async def reindex(request: Request):
+ """همگامسازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت میشود."""
+ if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
+ raise HTTPException(status_code=403, detail="invalid reindex token")
+ stats = await asyncio.to_thread(sync_index, True)
+ return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
+
+
+# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval میسازد
+
+@api.get("/chat/{scope}/stream")
+async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
+ return await stream_chat(message=message, thread_id=thread_id, request=request)
+
+
+@api.get("/chat/{scope}/eval")
+async def eval_scoped(scope: str, message: str = Query(...)):
+ return await eval_question(message=message)
+
+
+@api.get("/chat/{scope}/suggestions")
+async def suggestions_scoped(scope: str):
+ return {"questions": _get_suggestions()}
+
+
+@api.get("/chat/stream")
+async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
+ """استریم توکنبهتوکن پاسخ با SSE."""
+ if _rate_limited(_client_ip(request)):
+ raise HTTPException(status_code=429, detail="تعداد پیامهای شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
+
+ thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
+
+ async def generate():
+ async for chunk in _stream_answer(message, thread_id):
+ yield chunk
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
+ )
+
+
+# --- پیشنهاد پرسشها (کششده) ---
+
+_suggestions_cache: tuple[float, list[str]] = (0.0, [])
+
+
+def _get_suggestions() -> list[str]:
+ global _suggestions_cache
+ now = time.time()
+ if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
+ return _suggestions_cache[1]
+
+ questions: list[str] = []
+ if INDEX.ready:
+ faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
+ titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
+ model = pipeline.get_llm()
+ if model and (faqs or titles):
+ material = "\n".join([d["text"][:200] for d in faqs] + titles)
+ try:
+ prompt = (
+ "بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
+ "احتمالاً میپرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
+ 'فقط آرایه JSON رشتهای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
+ f"محتوا:\n{material[:2000]}"
+ )
+ resp = model.invoke([HumanMessage(content=prompt)])
+ text = resp.content if isinstance(resp.content, str) else ""
+ if text.startswith("```"):
+ text = text.split("```")[1]
+ text = text[4:] if text.startswith("json") else text
+ import re
+ match = re.search(r"\[.*\]", text, re.DOTALL)
+ if match:
+ questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
+ except Exception as e:
+ log.warning("Suggestion generation failed: %s", e)
+ if not questions and faqs:
+ # جایگزین: خود پرسشهای متداول
+ questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
+
+ _suggestions_cache = (now, questions)
+ return questions
+
+
+if __name__ == "__main__":
+ import uvicorn
+
+ uvicorn.run(api, host="0.0.0.0", port=8000)
diff --git a/chatbot/config.py b/chatbot/config.py
new file mode 100644
index 0000000..778f5d2
--- /dev/null
+++ b/chatbot/config.py
@@ -0,0 +1,57 @@
+"""تنظیمات سرویس چتبات — از متغیرهای محیطی یا فایل .env خوانده میشود."""
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
+
+ # دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بکاند
+ database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
+ # دیتابیس اختصاصی چتبات روی همان نمونه Postgres — بردارها اینجا نوشته میشوند
+ # تا دیتابیس سایت فقطخواندنی بماند؛ در شروع سرویس خودکار ساخته میشود
+ chatbot_db_name: str = "aramesh_chatbot"
+
+ # LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخگویی غیرفعال است
+ llm_base_url: str = ""
+ llm_api_key: str = ""
+ llm_model: str = "openai/gpt-oss-120b"
+
+ # مدلهای بازیابی
+ embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
+ # بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
+ embedding_dim: int = 384
+ reranker_model: str = "BAAI/bge-reranker-v2-m3"
+ reranker_enabled: bool = True
+ reranker_device: str = "cpu"
+
+ # وزن جستوجوی معنایی در ترکیب نمرهها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
+ alpha_dense: float = 0.5
+ # تعداد سند نهایی که به LLM داده میشود
+ best_of_k: int = 3
+ # تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرسوجو
+ retrieve_k: int = 12
+ # بازنویسی پرسوجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده میشود)
+ contextualize_enabled: bool = True
+ # گسترش پرسوجو به چند نوع — برای پیکره کوچک پیشفرض خاموش (تأخیر کمتر)
+ query_expansion: bool = False
+
+ # مسیر کش مدلها (در داکر روی والیوم /data سوار میشود)
+ hf_cache_dir: str = "/data/hf"
+
+ # محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
+ rate_limit_messages: int = 20
+ rate_limit_window: int = 300
+
+ # توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار میکند
+ reindex_token: str = ""
+
+ # ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
+ suggestions_ttl: int = 600
+ site_context_ttl: int = 120
+
+ # بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
+ index_refresh_seconds: int = 600
+
+
+settings = Settings()
diff --git a/chatbot/content.py b/chatbot/content.py
new file mode 100644
index 0000000..185898f
--- /dev/null
+++ b/chatbot/content.py
@@ -0,0 +1,315 @@
+"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
+
+هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقالهها و
+پروژهها هم میتواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
+پشتیبانی میشوند. مقالههای پیشنویس ایندکس نمیشوند.
+"""
+
+import logging
+from typing import Any
+
+from .persian import normalize
+
+log = logging.getLogger(__name__)
+
+# URL پایه صفحات — چتبات مسیر نسبی میدهد؛ فرانتاند روی همان دامنه است
+_ARTICLE_URL = "/blog/{slug}"
+_PROJECT_URL = "/projects/{slug}"
+
+
+def _psycopg_dsn(database_url: str) -> str:
+ """DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل میکند."""
+ return database_url.replace("+psycopg", "").replace("+asyncpg", "")
+
+
+def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
+ import psycopg
+ from psycopg.rows import dict_row
+
+ with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
+ cur = conn.execute(sql)
+ return cur.fetchall()
+
+
+def tiptap_blocks(node: Any) -> list[str]:
+ """متن بلوکبهبلوک از سند TipTap (یا هر JSON تو در تو) بیرون میکشد."""
+ if not isinstance(node, dict):
+ return []
+ ntype = node.get("type")
+ if ntype in ("paragraph", "heading", "codeBlock"):
+ text = _inline_text(node).strip()
+ return [text] if text else []
+ blocks: list[str] = []
+ for child in node.get("content") or []:
+ blocks.extend(tiptap_blocks(child))
+ return blocks
+
+
+def _inline_text(node: dict) -> str:
+ parts: list[str] = []
+ for child in node.get("content") or []:
+ ctype = child.get("type")
+ if ctype == "text":
+ parts.append(child.get("text", ""))
+ elif ctype == "hardBreak":
+ parts.append(" ")
+ elif ctype == "image":
+ alt = (child.get("attrs") or {}).get("alt") or ""
+ if alt:
+ parts.append(f"[تصویر: {alt}]")
+ else:
+ parts.append(_inline_text(child))
+ return "".join(parts)
+
+
+def body_to_text(body: Any) -> str:
+ """بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
+ if not body:
+ return ""
+ if isinstance(body, list):
+ # فرمت قدیمی: آرایه رشتهای پاراگرافها
+ return "\n".join(str(p).strip() for p in body if str(p).strip())
+ blocks = tiptap_blocks(body)
+ return "\n".join(b for b in blocks if b)
+
+
+def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
+ text = (text or "").strip()
+ if not text:
+ return None
+ return {"text": text, "type": dtype, "title": title, "url": url}
+
+
+def _clean(value: Any) -> str:
+ return normalize(str(value or "")).strip()
+
+
+def fetch_site_documents(database_url: str) -> list[dict]:
+ """همه محتوای عمومی سایت را به سند تبدیل میکند (بدون وابستگی به ORM بکاند)."""
+ dsn = _psycopg_dsn(database_url)
+ docs: list[dict] = []
+
+ # --- برند و اطلاعات تماس (رکورد یگانه) ---
+ brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
+ if brand_rows:
+ b = brand_rows[0]
+ contact = "\n".join(
+ filter(None, [
+ f"نام مجموعه: {_clean(b['name'])}",
+ f"تلفن تماس: {_clean(b['phone'])}",
+ f"ایمیل: {_clean(b['email'])}",
+ f"آدرس: {_clean(b['address'])}",
+ ])
+ )
+ d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
+ if d:
+ docs.append(d)
+
+ # --- دفاتر ---
+ for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
+ d = _doc(
+ f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
+ "office", f"دفتر {_clean(row['city'])}", "/contact",
+ )
+ if d:
+ docs.append(d)
+
+ # --- خدمات ---
+ for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"):
+ d = _doc(
+ f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
+ "service", _clean(row["title"]), "/services",
+ )
+ if d:
+ docs.append(d)
+
+ # --- پرسشهای متداول ---
+ for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
+ d = _doc(
+ f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
+ "faq", _clean(row["question"]), "/faq",
+ )
+ if d:
+ docs.append(d)
+
+ # --- پروژهها ---
+ for row in _fetch_rows(
+ dsn,
+ "SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
+ ):
+ meta = "\n".join(filter(None, [
+ f"مکان: {_clean(row['location'])}" if row["location"] else "",
+ f"سال: {_clean(row['year'])}" if row["year"] else "",
+ f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیلشده'}",
+ f"متراژ: {_clean(row['area'])}" if row["area"] else "",
+ f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
+ f"واحدها: {_clean(row['units'])}" if row["units"] else "",
+ f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
+ ]))
+ text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
+ d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
+ if d:
+ docs.append(d)
+
+ # --- مقالهها (فقط منتشرشده) ---
+ for row in _fetch_rows(
+ dsn,
+ "SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
+ ):
+ text = "\n".join(filter(None, [
+ f"دستهبندی: {_clean(row['category'])}" if row["category"] else "",
+ _clean(row["excerpt"]),
+ body_to_text(row["body"]),
+ ]))
+ d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
+ if d:
+ docs.append(d)
+
+ # --- مدلهای سرمایهگذاری ---
+ for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"):
+ points = "؛ ".join(_clean(p) for p in (row["points"] or []))
+ d = _doc(
+ "\n".join(filter(None, [
+ f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
+ f"نکات: {points}" if points else "",
+ ])),
+ "investment", _clean(row["title"]), "/investment",
+ )
+ if d:
+ docs.append(d)
+
+ # --- مراحل همکاری ---
+ for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"):
+ d = _doc(
+ f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
+ "process", _clean(row["title"]), "/investment",
+ )
+ if d:
+ docs.append(d)
+
+ # --- چرا ما / تیم / نظرسنجی / تایملاین / داستان ما ---
+ for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"):
+ d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
+ if d:
+ docs.append(d)
+
+ for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
+ d = _doc(
+ f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
+ "team", _clean(row["name"]), "/about",
+ )
+ if d:
+ docs.append(d)
+
+ for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
+ d = _doc(
+ f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
+ "testimonial", _clean(row["name"]), "/",
+ )
+ if d:
+ docs.append(d)
+
+ for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"):
+ d = _doc(
+ f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
+ "timeline", _clean(row["title"]), "/about",
+ )
+ if d:
+ docs.append(d)
+
+ story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
+ if story_rows and story_rows[0]:
+ s = story_rows[0]
+ paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
+ text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
+ d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
+ if d:
+ docs.append(d)
+
+ log.info("Fetched %d documents from site database", len(docs))
+ return docs
+
+
+def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
+ """متنهای بلند (مقاله/پروژه) را به قطعات همپوشان تقسیم میکند.
+
+ هر قطعه یک doc_id پایدار میگیرد («type:ترتیب در همان url:شماره قطعه») تا
+ همگامسازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
+ """
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
+
+ from .vectorstore import chunk_content_hash
+
+ splitter = RecursiveCharacterTextSplitter(
+ chunk_size=chunk_size,
+ chunk_overlap=overlap,
+ separators=["\n", "۔", ".", "،", " ", ""],
+ )
+
+ # شماره ردیف هر سند در میان سندهای همtype و هم-url (برای FAQs که url یکسان دارند)
+ occurrence: dict[str, int] = {}
+
+ out: list[dict] = []
+ for doc in docs:
+ key = f"{doc['type']}|{doc['url']}"
+ occ = occurrence.get(key, 0)
+ occurrence[key] = occ + 1
+
+ pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
+ for i, piece in enumerate(pieces, 1):
+ chunk = {
+ "doc_id": f"{doc['type']}:{occ}:{i}",
+ "text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
+ "type": doc["type"],
+ "title": doc["title"],
+ "url": doc["url"],
+ }
+ chunk["content_hash"] = chunk_content_hash(chunk)
+ out.append(chunk)
+
+ log.info("Chunked %d documents into %d pieces", len(docs), len(out))
+ return out
+
+
+def content_fingerprint(docs: list[dict]) -> str:
+ """هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام میشود."""
+ import hashlib
+ import json
+
+ payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
+
+
+# --- زمینه همیشهتازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
+_site_context_cache: tuple[float, str] | None = None
+
+
+def fetch_site_context(database_url: str, ttl: int = 120) -> str:
+ """نام برند، تماس و فهرست خدمات — با کش کوتاهمدت تا هر پیام یک کوئری سبک بزند."""
+ global _site_context_cache
+ import time as _time
+
+ now = _time.time()
+ if _site_context_cache and now - _site_context_cache[0] < ttl:
+ return _site_context_cache[1]
+ try:
+ dsn = _psycopg_dsn(database_url)
+ brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
+ services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
+ lines: list[str] = []
+ if brand:
+ b = brand[0]
+ lines += [
+ f"نام مجموعه: {_clean(b['name'])}",
+ f"تلفن تماس: {_clean(b['phone'])}",
+ f"ایمیل: {_clean(b['email'])}",
+ f"آدرس: {_clean(b['address'])}",
+ ]
+ if services:
+ lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
+ ctx = "\n".join(lines)
+ _site_context_cache = (now, ctx)
+ return ctx
+ except Exception as e:
+ log.warning("Site context fetch failed: %s", e)
+ return _site_context_cache[1] if _site_context_cache else ""
diff --git a/chatbot/embeddings.py b/chatbot/embeddings.py
new file mode 100644
index 0000000..336183a
--- /dev/null
+++ b/chatbot/embeddings.py
@@ -0,0 +1,20 @@
+"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تستهای سبک، torch لود نمیشود)."""
+
+import os
+
+from functools import lru_cache
+
+from .config import settings
+
+
+@lru_cache(maxsize=1)
+def get_embeddings():
+ from langchain_huggingface import HuggingFaceEmbeddings
+
+ os.makedirs(settings.hf_cache_dir, exist_ok=True)
+ return HuggingFaceEmbeddings(
+ model_name=settings.embedding_model,
+ model_kwargs={"device": "cpu"},
+ encode_kwargs={"normalize_embeddings": False},
+ cache_folder=settings.hf_cache_dir,
+ )
diff --git a/chatbot/indexing.py b/chatbot/indexing.py
new file mode 100644
index 0000000..5409d30
--- /dev/null
+++ b/chatbot/indexing.py
@@ -0,0 +1,55 @@
+"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
+
+بردارها در pgvector (دیتابیس اختصاصی چتبات) بهصورت افزایشی همگام میشوند —
+فقط سطرهای جدید یا تغییر یافته امبد میشوند. BM25 چون سبک است در هر شروع
+از روی همان سطرها ساخته میشود و نیازی به کش دیسکی ندارد.
+"""
+
+import logging
+import time
+
+from rank_bm25 import BM25Okapi
+
+from . import content as content_mod
+from .config import settings
+from .persian import tokenize
+from . import vectorstore
+
+log = logging.getLogger(__name__)
+
+
+class SiteIndex:
+ """حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
+
+ def __init__(self) -> None:
+ self.docs: list[dict] = []
+ self.bm25: BM25Okapi | None = None
+
+ @property
+ def ready(self) -> bool:
+ return self.bm25 is not None and bool(self.docs)
+
+
+INDEX = SiteIndex()
+
+
+def sync_index(force_rebuild: bool = False) -> dict:
+ """همگامسازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
+
+ خروجی: آمار همگامسازی. خطای اتصال به دیتابیس بالا فرستاده میشود.
+ """
+ start = time.time()
+
+ vectorstore.ensure_schema()
+
+ docs = content_mod.fetch_site_documents(settings.database_url)
+ chunks = content_mod.chunk_documents(docs)
+ stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
+
+ INDEX.docs = vectorstore.fetch_all_chunks()
+ corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
+ INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
+
+ log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
+ time.time() - start, len(INDEX.docs), len(chunks), stats)
+ return stats
diff --git a/chatbot/persian.py b/chatbot/persian.py
new file mode 100644
index 0000000..3a81dc5
--- /dev/null
+++ b/chatbot/persian.py
@@ -0,0 +1,79 @@
+"""نرمالسازی متن فارسی و توکنسازی برای جستوجوی کلیدواژه (BM25).
+
+بدون وابستگی سنگین — فقط استانداردسازی نویسهها، حذف اعراب و نیمفاصلههای
+مشکلی و شکستن کلمههای مرکب روی نیمفاصله برای بهبود بازیابی.
+"""
+
+import re
+
+# نویسههای عربی که در متن فارسی اشتباهاً استفاده میشوند
+_CHAR_MAP = {
+ "\u064a": "\u06cc", # ي → ی
+ "\u0643": "\u06a9", # ك → ک
+ "\u0623": "\u0627", # أ → ا
+ "\u0625": "\u0627", # إ → ا
+ "\u0671": "\u0627", # ٱ → ا
+ "\u0629": "\u0647", # ة → ه
+ "\u0624": "\u0648", # ؤ → و
+ "\u0626": "\u06cc", # ئ → ی
+ "\u0640": "", # ـ (کشیده) حذف
+}
+
+# اعراب و علامتهای اضافی
+_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
+
+# ارقام فارسی و عربی → ASCII
+_DIGITS = str.maketrans({
+ "\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
+ "\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
+ "\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
+ "\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
+})
+
+# واژههای دستوری پرتکرار — در جستوجو بیمعنا هستند
+STOPWORDS = frozenset("""
+و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
+برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
+چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
+کدام کی چی چهقدر چقدر
+می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
+بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
+اساساً لطفاً میشه میکنه هستن هستم
+""".split())
+
+# علائم نگارشی فارسی/لاتین — قبل از توکنسازی حذف میشوند (نیمفاصله جزو اینها نیست)
+_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
+
+# توکن: حروف/ارقام فارسی و لاتین + نیمفاصله در میان کلمه
+_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
+
+
+def normalize(text: str) -> str:
+ """نویسههای غیراستاندارد عربی/فارسی را به شکل یکدست فارسی میبرد."""
+ if not text:
+ return ""
+ out = []
+ for ch in text:
+ out.append(_CHAR_MAP.get(ch, ch))
+ text = "".join(out)
+ text = _DIACRITICS.sub("", text)
+ text = text.translate(_DIGITS)
+ # فاصلههای تکراری را یکی کن — نیمفاصله عمداً حفظ میشود (میشود)
+ text = re.sub(r"[ \t\r\n]+", " ", text)
+ return text.strip()
+
+
+def tokenize(text: str) -> list[str]:
+ """توکنهای نرمالشده برای BM25 — کلمههای مرکب روی نیمفاصله هم جدا ایندکس میشوند."""
+ text = _PUNCT_RE.sub(" ", normalize(text).lower())
+ tokens = []
+ for raw in _TOKEN_RE.findall(text):
+ if raw in STOPWORDS or len(raw) < 2:
+ continue
+ tokens.append(raw)
+ # «میشود» → می + شود (فراخوانی بیشتر برای بازیابی)
+ if "\u200c" in raw:
+ for part in raw.split("\u200c"):
+ if len(part) >= 2 and part not in STOPWORDS:
+ tokens.append(part)
+ return tokens
diff --git a/chatbot/pipeline.py b/chatbot/pipeline.py
new file mode 100644
index 0000000..23a630d
--- /dev/null
+++ b/chatbot/pipeline.py
@@ -0,0 +1,191 @@
+"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
+
+۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفهجویی در تأخیر)
+۲) بازیابی ترکیبی با چند نوعواژه (اختیاری)
+۳) تولید پاسخ فارسی با قواعد تکیه بر داده
+"""
+
+import logging
+import re
+import json as _json
+from typing import Annotated, List, Optional
+
+from typing_extensions import TypedDict
+
+from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
+from langchain_openai import ChatOpenAI
+from langgraph.checkpoint.memory import MemorySaver
+from langgraph.graph import END, StateGraph
+from langgraph.graph.message import add_messages
+
+from . import content as content_mod
+from .config import settings
+from .retrieval import retrieve_final
+
+log = logging.getLogger(__name__)
+
+FALLBACK_MESSAGE = (
+ "متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
+ "برای راهنمایی بیشتر میتوانید از صفحه «تماس با ما» با ما در ارتباط باشید."
+)
+LLM_UNAVAILABLE = (
+ "سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
+ "متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چتبات تنظیم کنید."
+)
+
+_llm = None
+
+
+def get_llm() -> ChatOpenAI | None:
+ """LLM تولید — اگر پیکربندی نشده باشد None برمیگرداند."""
+ global _llm
+ if _llm is None and settings.llm_base_url:
+ _llm = ChatOpenAI(
+ model=settings.llm_model,
+ base_url=settings.llm_base_url,
+ api_key=settings.llm_api_key or "not-needed",
+ temperature=0,
+ streaming=True,
+ )
+ return _llm
+
+
+class GraphState(TypedDict):
+ messages: Annotated[List, add_messages]
+ documents: List[str]
+ contextualized_query: Optional[str]
+
+
+# --- گام ۱: بازنویسی پرسش با تاریخچه ---
+
+def contextualize_query(question: str, history: list) -> str:
+ """ضمیرها و اشارههای پرسش را با تاریخچه گفتگو مشخص میکند («اون دومی» → موضوع واقعی)."""
+ model = get_llm()
+ if model is None or not history:
+ return question
+ try:
+ history_text = "\n".join(
+ f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
+ for m in history[-6:]
+ )
+ prompt = (
+ "با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
+ "- ضمیرها و اشارهها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
+ "- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
+ "- فقط متن پرسش بازنویسیشده را برگردان؛ هیچ توضیحی ننویس.\n\n"
+ f"تاریخچه گفتگو:\n{history_text}\n\n"
+ f"پرسش کاربر: {question}\n\n"
+ "پرسش مستقل:"
+ )
+ resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
+ rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
+ if not rewritten or len(rewritten) > len(question) * 4:
+ return question
+ if rewritten != question:
+ log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
+ return rewritten
+ except Exception as e:
+ log.warning("Contextualization failed: %s", e)
+ return question
+
+
+def expand_query(query: str) -> list[str]:
+ """گسترش پرسش به چند نوعواژه (اختیاری — پیشفرض خاموش برای پیکره کوچک)."""
+ model = get_llm()
+ if model is None:
+ return [query]
+ try:
+ prompt = (
+ "برای جستوجو در محتوای سایت یک گروه ساختمانی، ۳ نوعواژه جایگزین برای این پرسش بساز: "
+ "بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
+ 'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
+ f"پرسش: {query}"
+ )
+ resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
+ text = resp.content if isinstance(resp.content, str) else ""
+ match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
+ if match:
+ variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
+ if variants:
+ log.info("Query variants: %s", [query] + variants)
+ return [query] + variants
+ except Exception as e:
+ log.warning("Query expansion failed: %s", e)
+ return [query]
+
+
+# --- گام ۲: بازیابی ---
+
+def retrieve_node(state: GraphState) -> dict:
+ messages = state.get("messages", [])
+ if not messages:
+ return {"documents": []}
+ question = messages[-1].content
+ history = messages[:-1]
+
+ query = contextualize_query(question, history) if settings.contextualize_enabled else question
+ variants = expand_query(query) if settings.query_expansion else [query]
+
+ docs = retrieve_final(query, variants)
+ doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
+ log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
+ return {
+ "documents": doc_texts,
+ "contextualized_query": query if query != question else None,
+ }
+
+
+# --- گام ۳: تولید پاسخ ---
+
+def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
+ site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
+ docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
+ reminder = (
+ f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
+ if contextualized_query else ""
+ )
+ return (
+ "تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ میدهی.\n"
+ "دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
+ f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
+ f"--- اسناد بازیابیشده مرتبط با پرسش ---\n{docs_block}\n"
+ f"{reminder}"
+ "--- قوانین پاسخ ---\n"
+ "۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ بههیچوجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
+ "۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفهای و محترمانه داشته باشد.\n"
+ f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
+ "۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
+ "۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در دادهها آمده است."
+ )
+
+
+def generate_node(state: GraphState) -> dict:
+ messages = state.get("messages", [])
+ question = messages[-1].content if messages else ""
+
+ model = get_llm()
+ if model is None:
+ return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
+
+ documents = state.get("documents") or []
+ system = build_system_prompt(documents, state.get("contextualized_query"))
+ log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
+
+ history = messages[:-1]
+ reply = model.invoke(
+ [SystemMessage(content=system), *history, HumanMessage(content=question)],
+ config={"tags": ["generate"]},
+ )
+ return {"messages": [reply]}
+
+
+# --- گراف ---
+
+memory = MemorySaver()
+workflow = StateGraph(GraphState)
+workflow.add_node("retrieve", retrieve_node)
+workflow.add_node("generate", generate_node)
+workflow.set_entry_point("retrieve")
+workflow.add_edge("retrieve", "generate")
+workflow.add_edge("generate", END)
+graph = workflow.compile(checkpointer=memory)
diff --git a/chatbot/requirements.txt b/chatbot/requirements.txt
new file mode 100644
index 0000000..4287188
--- /dev/null
+++ b/chatbot/requirements.txt
@@ -0,0 +1,20 @@
+# سرویس چتبات — بازیابی ترکیبی روی محتوای سایت
+fastapi>=0.115
+uvicorn[standard]>=0.30
+pydantic-settings>=2.3
+
+# گراف گفتگو و LLM
+langgraph>=0.2.60
+langchain>=0.3
+langchain-openai>=0.2
+langchain-text-splitters>=0.3
+
+# بازیابی — بردارها در pgvector میروند؛ BM25 و خوشهبندی در حافظه
+sentence-transformers>=3.0
+langchain-huggingface>=0.1
+rank-bm25>=0.2.2
+scikit-learn>=1.4
+numpy>=1.26
+
+# دیتابیس (فقط خواندن)
+psycopg[binary]>=3.1
diff --git a/chatbot/reranker.py b/chatbot/reranker.py
new file mode 100644
index 0000000..b145cf9
--- /dev/null
+++ b/chatbot/reranker.py
@@ -0,0 +1,50 @@
+"""مدل ریرنکر BGE — با لود تنبل و امتیازدهی دستهای."""
+
+import logging
+import os
+
+from functools import lru_cache
+
+from .config import settings
+
+log = logging.getLogger(__name__)
+
+
+class _CrossEncoder:
+ """غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
+ def __init__(self) -> None:
+ import torch
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
+
+ os.makedirs(settings.hf_cache_dir, exist_ok=True)
+ self._torch = torch
+ self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
+ self.model = AutoModelForSequenceClassification.from_pretrained(
+ settings.reranker_model, cache_dir=settings.hf_cache_dir
+ )
+ device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
+ self.model.to(device)
+ self.model.eval()
+ self.device = device
+ log.info("Reranker loaded on %s", device)
+
+ def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
+ torch = self._torch
+ out: list[float] = []
+ with torch.no_grad():
+ for i in range(0, len(pairs), batch_size):
+ inputs = self.tokenizer(
+ pairs[i:i + batch_size],
+ padding=True,
+ truncation=True,
+ max_length=512,
+ return_tensors="pt",
+ ).to(self.device)
+ logits = self.model(**inputs).logits.squeeze(-1)
+ out.extend(logits.cpu().tolist())
+ return out
+
+
+@lru_cache(maxsize=1)
+def get_reranker() -> _CrossEncoder:
+ return _CrossEncoder()
diff --git a/chatbot/retrieval.py b/chatbot/retrieval.py
new file mode 100644
index 0000000..884110f
--- /dev/null
+++ b/chatbot/retrieval.py
@@ -0,0 +1,165 @@
+"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
+
+۱) پایگاه sparse بهجای SPLADE (انگلیسیمحور و بیفایده روی فارسی) از BM25
+ با نرمالسازی فارسی استفاده میکند.
+۲) جستوجوی معنایی بهجای FAISS با pgvector و فاصله کسینوسی در SQL انجام
+ میشود (همگامسازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
+۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیمپذیر است — برای پیکرهٔ
+ کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی بهازای هر پیام نمیارزد.
+بقیهٔ منطق (نرمالسازی نمره، فیوژن وزندار، برش خوشهبندی، ریرنک) عین v13 است.
+"""
+
+import logging
+import time
+
+from . import vectorstore
+from .config import settings
+from .indexing import INDEX
+from .persian import tokenize
+
+log = logging.getLogger(__name__)
+
+
+def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
+ """جستوجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
+ return vectorstore.dense_search(query, top_k)
+
+
+def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
+ """جستوجوی کلیدواژه BM25 روی متن نرمالشده فارسی."""
+ if INDEX.bm25 is None:
+ return []
+ try:
+ tokens = tokenize(query)
+ if not tokens:
+ return []
+ scores = INDEX.bm25.get_scores(tokens)
+ ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
+ return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
+ except Exception as e:
+ log.error("BM25 retrieval failed: %s", e)
+ return []
+
+
+def _minmax(scores: list[float]) -> list[float]:
+ if not scores:
+ return []
+ lo, hi = min(scores), max(scores)
+ if hi == lo:
+ return [0.5] * len(scores)
+ return [(s - lo) / (hi - lo) for s in scores]
+
+
+def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
+ """ترکیب وزندار نمره معنایی و کلیدواژه؛ خروجی مرتبشده بر اساس نمره ترکیبی."""
+ alpha = settings.alpha_dense if alpha is None else alpha
+ top_k = top_k or settings.retrieve_k
+
+ dense = dense_retrieve(query, top_k)
+ keyword = keyword_retrieve(query, top_k)
+ log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
+ if not dense and not keyword:
+ return []
+
+ dense_norm = _minmax([s for _, s in dense])
+ keyword_norm = _minmax([s for _, s in keyword])
+
+ pool: dict = {} # doc_id → {doc, dense_max, kw_max}
+ for (doc, _), norm in zip(dense, dense_norm):
+ entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
+ entry["dense_max"] = max(entry["dense_max"], norm)
+ for (doc, _), norm in zip(keyword, keyword_norm):
+ entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
+ entry["kw_max"] = max(entry["kw_max"], norm)
+
+ combined = [
+ (entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
+ for entry in pool.values()
+ ]
+ combined.sort(key=lambda x: x[1], reverse=True)
+ return combined
+
+
+def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
+ """برش خوشهای v13 — مرز طبیعی کیفیت بهجای top-k دلخواه.
+
+ روی نمرههای ۱بعدی K-Means با K=2 میزند؛ اگر جدایی خوشهها معنادار
+ باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه میدارد.
+ """
+ import numpy as np
+
+ n = len(scored)
+ if n < 4:
+ return scored
+ try:
+ from sklearn.cluster import KMeans
+ from sklearn.metrics import silhouette_score
+
+ scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
+ kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
+ labels = kmeans.fit_predict(scores)
+ sil = silhouette_score(scores, labels)
+ if sil < silhouette_threshold:
+ log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
+ return scored
+ centers = kmeans.cluster_centers_.flatten()
+ good_label = int(np.argmax(centers))
+ cutoff = int(np.sum(labels == good_label))
+ cutoff = max(cutoff, 1)
+ log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
+ return scored[:cutoff]
+ except Exception as e:
+ log.warning("Clustering failed (%s) — keeping all docs", e)
+ return scored
+
+
+def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
+ """ریرنک کراسانکودر BGE — مرتبسازی نهایی بر اساس شباهت واقعی پرسش/سند."""
+ if not scored:
+ return []
+ if not settings.reranker_enabled:
+ return scored[:top_k]
+
+ try:
+ from .reranker import get_reranker
+
+ start = time.time()
+ pairs = [[query, doc["text"]] for doc, _ in scored]
+ scores = get_reranker().score(pairs)
+ rescored = sorted(
+ ((doc, float(s)) for (doc, _), s in zip(scored, scores)),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+ log.info("Reranked %d docs in %.2fs — top scores: %s",
+ len(scored), time.time() - start,
+ [f"{s:.2f}" for _, s in rescored[:3]])
+ return rescored[:top_k]
+ except Exception as e:
+ log.error("Reranking failed (%s) — falling back to fusion order", e)
+ return scored[:top_k]
+
+
+def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
+ """خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ریرنک → K نهایی."""
+ variants = query_variants or [query]
+
+ pool: dict = {}
+ for variant in variants:
+ for doc, score in hybrid_retrieve(variant):
+ entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
+ entry["score"] = max(entry["score"], score)
+
+ combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
+ key=lambda x: x[1], reverse=True)
+ if not combined:
+ return []
+
+ filtered = cluster_cutoff(combined)
+ # ریرنک روی نتیجههای برشخورده (سقف retrieve_k تا تأخیر کنترل شود)
+ rerank_candidates = filtered[: settings.retrieve_k]
+ final = rerank(query, rerank_candidates, settings.best_of_k)
+
+ log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
+ len(final), len(combined), len(variants))
+ return [doc for doc, _ in final]
diff --git a/chatbot/test_pure.py b/chatbot/test_pure.py
new file mode 100644
index 0000000..a278b16
--- /dev/null
+++ b/chatbot/test_pure.py
@@ -0,0 +1,70 @@
+"""تستهای خالص (بدون مدل/دیتابیس) برای ماژولهای چتبات — اجرا: python test_pure.py"""
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
+from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
+from chatbot.persian import normalize, tokenize
+
+docs = [
+ {"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
+ {"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
+ {"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
+ {"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
+]
+chunks = chunk_documents(docs)
+ids = [c["doc_id"] for c in chunks]
+assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
+
+faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
+assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
+
+art = [c for c in chunks if c["type"] == "article"]
+assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
+assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
+
+c = chunks[0]
+assert c["content_hash"] == chunk_content_hash(c)
+c2 = dict(c, title="عنوان عوض شد")
+# hash فیلد کپیشده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
+assert chunk_content_hash(c2) != c["content_hash"]
+
+chunks2 = chunk_documents(docs)
+assert [x["doc_id"] for x in chunks2] == ids
+assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
+
+settings_dsn = chatbot_dsn()
+assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
+assert "+psycopg" not in settings_dsn
+
+# نرمالسازی و توکنسازی فارسی
+assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
+t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
+assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
+assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
+
+# استخراج متن TipTap و فرمت قدیمی
+tiptap_doc = {
+ "type": "doc",
+ "content": [
+ {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
+ {"type": "paragraph", "content": [
+ {"type": "text", "text": "این برج در تهران "},
+ {"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
+ {"type": "hardBreak"},
+ {"type": "image", "attrs": {"alt": "نمای بیرونی"}},
+ ]},
+ {"type": "bulletList", "content": [
+ {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
+ ]},
+ {"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
+ ],
+}
+blocks = tiptap_blocks(tiptap_doc)
+assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
+assert body_to_text(["الف", "ب"]) == "الف\nب"
+assert body_to_text(None) == ""
+
+print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
diff --git a/chatbot/vectorstore.py b/chatbot/vectorstore.py
new file mode 100644
index 0000000..79b933c
--- /dev/null
+++ b/chatbot/vectorstore.py
@@ -0,0 +1,211 @@
+"""ذخیرهساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
+
+بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
+نگهداری میشوند تا دیتابیس سایت فقطخواندنی بماند. همگامسازی افزایشی است:
+فقط سطرهای جدید/تغییریافته امبد میشوند (کلید: doc_id پایدار + hash محتوا).
+"""
+
+import hashlib
+import json
+import logging
+from typing import Any
+from urllib.parse import urlsplit, urlunsplit
+
+from .config import settings
+
+log = logging.getLogger(__name__)
+
+
+def _strip_driver(dsn: str) -> str:
+ return dsn.replace("+psycopg", "").replace("+asyncpg", "")
+
+
+def chatbot_dsn() -> str:
+ """DSN دیتابیس اختصاصی چتبات — از DATABASE_URL با تعویض نام دیتابیس ساخته میشود."""
+ parts = urlsplit(_strip_driver(settings.database_url))
+ return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
+
+
+def _admin_dsn() -> str:
+ """DSN دیتابیس postgres برای ساخت دیتابیس چتبات (فقط یکبار)."""
+ parts = urlsplit(_strip_driver(settings.database_url))
+ return urlunsplit(parts._replace(path="/postgres"))
+
+
+def ensure_schema() -> None:
+ """ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا میشود."""
+ import psycopg
+ from psycopg import errors as pg_errors
+
+ try:
+ with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
+ try:
+ conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
+ log.info("Created chatbot database '%s'", settings.chatbot_db_name)
+ except pg_errors.DuplicateDatabase:
+ pass
+ except Exception as e:
+ raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
+
+ with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
+ conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
+ conn.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS chatbot_chunks (
+ doc_id TEXT PRIMARY KEY,
+ content_hash TEXT NOT NULL,
+ model TEXT NOT NULL,
+ content JSONB NOT NULL,
+ embedding vector({settings.embedding_dim}) NOT NULL
+ )
+ """
+ )
+ conn.execute(
+ """
+ CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
+ ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
+ """
+ )
+
+
+def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
+ """همگامسازی افزایشی قطعات با جدول pgvector.
+
+ - سطرهایی که دیگر وجود ندارند حذف میشوند
+ - سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert میشوند
+ - force=True همه سطرها را از نو امبد میکند
+ خروجی: آمار عملیات برای لاگ.
+ """
+ import psycopg
+ from psycopg.types.json import Jsonb
+
+ from .embeddings import get_embeddings
+
+ model = settings.embedding_model
+ wanted = {c["doc_id"]: c for c in chunks}
+
+ with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
+ existing: dict[str, str] = {}
+ if not force:
+ for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
+ if row["model"] == model:
+ existing[row["doc_id"]] = row["content_hash"]
+ else:
+ conn.execute("DELETE FROM chatbot_chunks")
+
+ to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
+ if to_delete:
+ conn.execute(
+ "DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
+ [to_delete],
+ )
+
+ to_upsert = [
+ c for c in chunks
+ if existing.get(c["doc_id"]) != c["content_hash"]
+ ]
+ inserted = updated = 0
+
+ if to_upsert:
+ vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
+ for chunk, vec in zip(to_upsert, vectors):
+ literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
+ conn.execute(
+ """
+ INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
+ VALUES (%s, %s, %s, %s, %s::vector)
+ ON CONFLICT (doc_id) DO UPDATE SET
+ content_hash = EXCLUDED.content_hash,
+ model = EXCLUDED.model,
+ content = EXCLUDED.content,
+ embedding = EXCLUDED.embedding
+ """,
+ (chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
+ )
+ if chunk["doc_id"] in existing:
+ updated += 1
+ else:
+ inserted += 1
+
+ stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
+ "unchanged": len(wanted) - len(to_upsert)}
+ log.info("Vector sync: %s", stats)
+ return stats
+
+
+def fetch_all_chunks() -> list[dict]:
+ """همه قطعات ذخیرهشده (برای ساخت BM25 در حافظه)."""
+ import psycopg
+
+ with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
+ rows = conn.execute(
+ "SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
+ ).fetchall()
+ return [
+ {
+ "doc_id": r["doc_id"],
+ "content_hash": r["content_hash"],
+ **(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
+ }
+ for r in rows
+ ]
+
+
+def vector_count() -> int:
+ """تعداد بردارهای ذخیرهشده — برای healthz (خطا → ۰)."""
+ try:
+ import psycopg
+
+ with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
+ return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
+ except Exception:
+ return 0
+
+
+def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
+ """جستوجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
+
+ خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
+ """
+ try:
+ import psycopg
+ from psycopg.rows import dict_row as _dict_row
+
+ from .embeddings import get_embeddings
+
+ vec = get_embeddings().embed_query(query)
+ literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
+ with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
+ rows = conn.execute(
+ f"""
+ SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
+ FROM chatbot_chunks
+ ORDER BY embedding <=> %s::vector
+ LIMIT %s
+ """,
+ (literal, literal, top_k),
+ ).fetchall()
+ return [(_row_to_doc(r), float(r["sim"])) for r in rows]
+ except Exception as e:
+ log.warning("Dense search failed (%s) — falling back to keyword only", e)
+ return []
+
+
+def _row_to_doc(row: Any) -> dict:
+ content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
+ return {
+ "doc_id": row["doc_id"],
+ "text": content.get("text", ""),
+ "type": content.get("type", ""),
+ "title": content.get("title", ""),
+ "url": content.get("url", ""),
+ }
+
+
+def chunk_content_hash(chunk: dict) -> str:
+ """هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید بهروز شود."""
+ payload = json.dumps(
+ {k: chunk[k] for k in ("text", "type", "title", "url")},
+ ensure_ascii=False, sort_keys=True,
+ )
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml
new file mode 100644
index 0000000..b7415b6
--- /dev/null
+++ b/docker-compose.monitoring.yml
@@ -0,0 +1,185 @@
+# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکنندهها
+#
+# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
+# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
+#
+# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
+# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
+# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا میمانند.
+
+x-default-logging: &default-logging
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+services:
+ prometheus:
+ image: prom/prometheus:v2.53.0
+ restart: unless-stopped
+ logging: *default-logging
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.path=/prometheus
+ - --storage.tsdb.retention.time=15d
+ - --storage.tsdb.retention.size=2GB
+ volumes:
+ - ./monitoring/prometheus:/etc/prometheus:ro
+ - prometheus_data:/prometheus
+ networks: [monitoring, edge]
+ healthcheck:
+ test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ start_period: 15s
+ deploy:
+ resources:
+ limits:
+ memory: 768M
+
+ alertmanager:
+ image: prom/alertmanager:v0.27.0
+ restart: unless-stopped
+ logging: *default-logging
+ command:
+ - --config.file=/etc/alertmanager/alertmanager.rendered.yml
+ - --storage.path=/alertmanager
+ # فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
+ # alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) میسازد —
+ # Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمیکند
+ volumes:
+ - ./monitoring/alertmanager:/etc/alertmanager:ro
+ - alertmanager_data:/alertmanager
+ networks: [monitoring]
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+
+ # منابع هر کانتینر — روی Linux سرور کامل کار میکند؛ روی Docker Desktop ویندوز
+ # ممکن است محدود باشد (حذفش بقیه استک را نمیشکند)
+ cadvisor:
+ image: gcr.io/cadvisor/cadvisor:v0.49.1
+ restart: unless-stopped
+ logging: *default-logging
+ privileged: true
+ volumes:
+ - /:/rootfs:ro
+ - /var/run:/var/run:ro
+ - /sys:/sys:ro
+ - /var/lib/docker/:/var/lib/docker:ro
+ networks: [monitoring]
+ deploy:
+ resources:
+ limits:
+ memory: 192M
+
+ # منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
+ node-exporter:
+ image: quay.io/prometheus/node-exporter:v1.8.2
+ restart: unless-stopped
+ logging: *default-logging
+ command:
+ - --path.rootfs=/host
+ - --collector.textfile.directory=/textfile
+ volumes:
+ - /:/host:ro
+ - ./backups:/textfile:ro
+ networks: [monitoring]
+ deploy:
+ resources:
+ limits:
+ memory: 64M
+
+ postgres-exporter:
+ image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
+ restart: unless-stopped
+ logging: *default-logging
+ environment:
+ DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
+ networks: [internal, monitoring]
+ deploy:
+ resources:
+ limits:
+ memory: 64M
+
+ blackbox-exporter:
+ image: prom/blackbox-exporter:v0.25.0
+ restart: unless-stopped
+ logging: *default-logging
+ networks: [monitoring]
+ deploy:
+ resources:
+ limits:
+ memory: 64M
+
+ # لاگ همهٔ کانتینرها — جستوجو در Grafana؛ خطاهای بکاند از همینجا دیده میشوند
+ loki:
+ image: grafana/loki:3.1.1
+ restart: unless-stopped
+ logging: *default-logging
+ command: -config.file=/etc/loki/loki-config.yml
+ volumes:
+ - ./monitoring/loki:/etc/loki:ro
+ - loki_data:/loki
+ networks: [monitoring]
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+
+ promtail:
+ image: grafana/promtail:3.1.1
+ restart: unless-stopped
+ logging: *default-logging
+ command: -config.file=/etc/promtail/promtail-config.yml
+ volumes:
+ - ./monitoring/promtail:/etc/promtail:ro
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - promtail_positions:/positions
+ networks: [monitoring]
+ depends_on: [loki]
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+
+ grafana:
+ image: grafana/grafana:11.1.0
+ restart: unless-stopped
+ logging: *default-logging
+ environment:
+ GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
+ GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
+ GF_USERS_ALLOW_SIGN_UP: "false"
+ volumes:
+ - grafana_data:/var/lib/grafana
+ - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
+ networks: [monitoring, edge]
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
+ - traefik.http.routers.grafana.entrypoints=web
+ - traefik.http.services.grafana.loadbalancer.server.port=3000
+ deploy:
+ resources:
+ limits:
+ memory: 384M
+
+networks:
+ monitoring:
+
+volumes:
+ prometheus_data:
+ alertmanager_data:
+ loki_data:
+ grafana_data:
+ promtail_positions:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index e0ec848..f774939 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1,15 +1,18 @@
-# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۴۳.
+# لایه تولید — TLS با Let's Encrypt و انتشار روی پورتهای استاندارد ۸۰/۴۳.
# پیشنیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ بهعلاوه رکورد DNS دامنه به این سرور.
#
-# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
+# bash scripts/stack.sh deploy-prod
+# (معادل: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build)
#
# همه درخواستهای HTTP به HTTPS هدایت میشوند و مسیریابیها فقط روی دامنه DOMAIN پاسخ میدهند.
+# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
services:
traefik:
command: !override
- --providers.docker=true
- --providers.docker.exposedbydefault=false
+ - --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
@@ -18,6 +21,9 @@ services:
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
+ - --ping=true
+ - --accesslog=true
+ - --metrics.prometheus=true
ports: !override
- "80:80"
- "443:443"
@@ -25,6 +31,23 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt
+ # MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
+ # از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
+ minio:
+ ports: !override
+ - "127.0.0.1:9001:9001"
+ - "127.0.0.1:19000:9000"
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
+ - traefik.http.routers.media.entrypoints=websecure
+ - traefik.http.routers.media.tls.certresolver=le
+ - traefik.http.routers.media.priority=100
+ - traefik.http.routers.media.middlewares=media-rewrite,sec-headers
+ - traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
+ - traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
+ - traefik.http.services.media.loadbalancer.server.port=9000
+
backend:
environment:
# سایت هممبدأ است — CORS لازم نیست
@@ -35,16 +58,11 @@ services:
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
+ - traefik.http.routers.api.middlewares=api-flood,sec-headers
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le
-
- minio:
- labels:
- - traefik.enable=true
- - traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- - traefik.http.routers.media.entrypoints=websecure
- - traefik.http.routers.media.tls.certresolver=le
+ - traefik.http.routers.seo.middlewares=sec-headers
frontend:
labels:
@@ -52,6 +70,42 @@ services:
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=le
+ - traefik.http.routers.web.middlewares=sec-headers
+ # تعریف یکبارهٔ میانافزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده میشود
+ - traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
+ - traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
+ - traefik.http.middlewares.sec-headers.headers.stsPreload=true
+ - traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
+
+ chatbot:
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.chat.rule=Host(`${DOMAIN}`) && PathPrefix(`/chat`)
+ - traefik.http.routers.chat.entrypoints=websecure
+ - traefik.http.routers.chat.tls.certresolver=le
+ - traefik.http.routers.chat.priority=100
+ - traefik.http.routers.chat.middlewares=api-flood,sec-headers
+
+ umami:
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.analytics.rule=Host(`analytics.${DOMAIN}`)
+ - traefik.http.routers.analytics.entrypoints=websecure
+ - traefik.http.routers.analytics.tls.certresolver=le
+ - traefik.http.routers.analytics.middlewares=sec-headers
+ - traefik.http.services.analytics.loadbalancer.server.port=3000
+
+ # داشبورد Grafana (docker-compose.monitoring.yml) در تولید با TLS نیاز به روتر
+ # Host(`grafana.DOMAIN`) دارد — چون آن سرویس فقط با فایل monitoring بالا میآید،
+ # برچسبهایش را در override سرور بگذارید (README، بخش مانیتورینگ):
+ # docker-compose.override.yml روی سرور:
+ # services:
+ # grafana:
+ # labels:
+ # - traefik.http.routers.grafana.rule=Host(`grafana.YOURDOMAIN`)
+ # - traefik.http.routers.grafana.entrypoints=websecure
+ # - traefik.http.routers.grafana.tls.certresolver=le
+ # - traefik.http.services.grafana.loadbalancer.server.port=3000
volumes:
letsencrypt_data:
diff --git a/docker-compose.yml b/docker-compose.yml
index 27f2e42..3ba33b0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,71 +1,117 @@
# مقادیر محرمانه از فایل .env ریشه خوانده میشوند — از .env.example کپی کنید:
-# cp .env.example .env
-# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README).
+# cp .env.example .env (یا: bash scripts/gen-env.sh)
+# برای انتشار با TLS، روی این فایل docker-compose.prod.yml هم سوار شود (README):
+# bash scripts/stack.sh deploy-prod
+#
+# لایههای اختیاری:
+# docker-compose.monitoring.yml — Prometheus/Grafana/Loki (پروفایل زیرساخت)
+# پروفایل ops — پشتیبان ساعتی دیتابیس
+# پروفایل tools — ابزار mc برای پشتیبانگیری دستی
+
+x-default-logging: &default-logging
+ # بدون این، لاگ json-file بیمرز رشد میکند و دیسک سرور را پر میکند
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "3"
services:
- # نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند) و /media (MinIO)
+ # نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بکاند)، /media (MinIO)،
+ # /chat (چتبات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
traefik:
- image: traefik:v3
+ image: traefik:v3.3
restart: unless-stopped
+ logging: *default-logging
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استکهای دیگر هممیزبان تداخل نام روتر پیش نمیآید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80
+ # healthcheck داخلی + متریکها برای Prometheus + لاگ دسترسی
+ - --ping=true
+ - --accesslog=true
+ - --metrics.prometheus=true
ports:
- "${TRAEFIK_HTTP_PORT:-5173}:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
+ networks: [edge]
+ healthcheck:
+ test: ["CMD", "traefik", "healthcheck", "--ping"]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 10s
+ deploy:
+ resources:
+ limits:
+ memory: 256M
db:
- image: postgres:17-alpine
+ # pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چتبات
+ image: pgvector/pgvector:pg17
restart: unless-stopped
+ logging: *default-logging
environment:
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza
volumes:
- db_data:/var/lib/postgresql/data
+ shm_size: 256m
+ networks: [internal]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
interval: 5s
timeout: 5s
retries: 12
+ start_period: 10s
+ deploy:
+ resources:
+ limits:
+ memory: 512M
minio:
- image: minio/minio:latest
+ image: minio/minio:RELEASE.2025-04-22T22-12-26Z
restart: unless-stopped
+ logging: *default-logging
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes:
- minio_data:/data
+ networks: [edge, internal]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 12
+ start_period: 10s
ports:
- - "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید
- - "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو میشوند)
+ # در تولید، docker-compose.prod.yml اینها را فقط روی 127.0.0.1 میبندد —
+ # کنسول رمز root دارد و نباید به اینترنت باز باشد
+ - "9001:9001"
+ - "19000:9000"
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=web
- traefik.http.routers.media.priority=100
- - traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
+ - traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.services.media.loadbalancer.server.port=9000
+ deploy:
+ resources:
+ limits:
+ memory: 512M
- # یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed.
- # تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبتاند) روی یک حجم تازه
- # وارد باکت میشوند تا هر نسخه تازهکلون/بیلد، همان عکسها را داشته باشد؛
- # روی حجم موجود، mc cp فایلهای تکراری را رد میکند و دادهتازه را نمیخورد.
+ # یکبار اجرا میشود: ساخت باکت، بازگذاشتن خواندن عمومی و پیشبارگذاری تصویرهای seed
+ # فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
minio-setup:
- image: minio/mc:latest
+ image: minio/mc:RELEASE.2025-04-08T15-39-49Z
depends_on:
minio:
condition: service_healthy
@@ -76,19 +122,49 @@ services:
volumes:
- ./seed-media:/seed-media:ro
restart: "no"
+ networks: [internal]
+ logging: *default-logging
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/$$MINIO_BUCKET &&
mc anonymous set download local/$$MINIO_BUCKET &&
- echo 'minio-setup: bucket ready' &&
- cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ &&
- echo 'minio-setup: seed media mirrored'
+ COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
+ if [ \"$$COUNT\" -eq 0 ]; then
+ echo 'minio-setup: bucket ready — mirroring seed media';
+ mc mirror --overwrite /seed-media local/$$MINIO_BUCKET/;
+ else
+ echo \"minio-setup: bucket ready — $$COUNT object(s) already present, seed mirror skipped\";
+ fi
+ "
+
+ # یکبار اجرا میشود: دیتابیسهای جانبی روی همان نمونهٔ Postgres
+ # (umami برای آنالیتیکس، aramesh_chatbot برای بردارهای چتبات)
+ db-init:
+ image: postgres:17-alpine
+ restart: "no"
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ PGHOST: db
+ PGUSER: aramesh
+ PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
+ networks: [internal]
+ logging: *default-logging
+ entrypoint: >
+ /bin/sh -c "
+ for DBN in umami aramesh_chatbot; do
+ psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$$DBN'\" | grep -q 1 ||
+ psql -c \"CREATE DATABASE $$DBN\";
+ echo \"db-init: $$DBN ready\";
+ done
"
backend:
build: ./backend
restart: unless-stopped
+ logging: *default-logging
environment:
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
@@ -106,11 +182,14 @@ services:
FRONTEND_DIST: /srv/frontend
volumes:
- ./frontend/dist:/srv/frontend:ro
+ networks: [edge, internal]
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
+ db-init:
+ condition: service_completed_successfully
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s
@@ -122,6 +201,12 @@ services:
- traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100
+ # لایه دوم محافظت از فرمها — سیلاب درخواست را پیش از رسیدن به بکاند میبُرد؛
+ # محدودسازی دقیق هر فرم در خود بکاند است (rate_limit.py)
+ - traefik.http.routers.api.middlewares=api-flood
+ - traefik.http.middlewares.api-flood.ratelimit.average=50
+ - traefik.http.middlewares.api-flood.ratelimit.period=1s
+ - traefik.http.middlewares.api-flood.ratelimit.burst=150
- traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
# مسیرهای /en/... هم همه به بکاند میروند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
@@ -129,29 +214,106 @@ services:
- traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api
+ # /metrics فقط برای Prometheus — روتری تعریف نمیشود، از بیرون در دسترس نیست
+ deploy:
+ resources:
+ limits:
+ memory: 768M
# فقط سرو فایلهای استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend:
build: ./frontend
restart: unless-stopped
+ logging: *default-logging
depends_on:
- - backend
- - minio
+ backend:
+ condition: service_healthy
+ networks: [edge]
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s
timeout: 5s
retries: 5
+ start_period: 5s
labels:
- traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=web
- traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80
+ deploy:
+ resources:
+ limits:
+ memory: 64M
+
+ # دستیار هوشمند — RAG روی محتوای سایت با pgvector
+ # اولین اجرا مدلها را دانلود میکند (~۵۰۰MB روی حجم chatbot_hf) — کند است، طبیعی است
+ chatbot:
+ build: ./chatbot
+ restart: unless-stopped
+ logging: *default-logging
+ environment:
+ DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
+ LLM_BASE_URL: ${LLM_BASE_URL:-}
+ LLM_API_KEY: ${LLM_API_KEY:-}
+ LLM_MODEL: ${LLM_MODEL:-openai/gpt-oss-120b}
+ CHAT_REINDEX_TOKEN: ${CHAT_REINDEX_TOKEN:-}
+ volumes:
+ - chatbot_hf:/data
+ networks: [edge, internal]
+ depends_on:
+ db:
+ condition: service_healthy
+ db-init:
+ condition: service_completed_successfully
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.chat.rule=PathPrefix(`/chat`)
+ - traefik.http.routers.chat.entrypoints=web
+ - traefik.http.routers.chat.priority=100
+ - traefik.http.routers.chat.middlewares=api-flood
+ - traefik.http.services.chat.loadbalancer.server.port=8000
+ deploy:
+ resources:
+ limits:
+ memory: 2G
+
+ # آنالیتیکس محصول (Umami) — بازدید، ارجاع، رخدادهای سفارشی (فرم/تلفن/چتبات)
+ # داشبورد: analytics.localhost (توسعه) یا analytics.DOMAIN (تولید)
+ umami:
+ image: ghcr.io/umami-software/umami:postgresql-v2.17.0
+ restart: unless-stopped
+ logging: *default-logging
+ environment:
+ DATABASE_URL: postgresql://aramesh:${POSTGRES_PASSWORD}@db:5432/umami
+ # اسکریپت ردیاب به ارجاعدهندهٔ واقعی نیاز دارد — پشت Traefik
+ CLIENT_IP_HEADER: x-forwarded-for
+ DISABLE_TELEMETRY: 1
+ networks: [edge, internal]
+ depends_on:
+ db:
+ condition: service_healthy
+ db-init:
+ condition: service_completed_successfully
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/heartbeat || exit 1"]
+ interval: 30s
+ timeout: 5s
+ retries: 5
+ start_period: 60s
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.analytics.rule=Host(`analytics.localhost`)
+ - traefik.http.routers.analytics.entrypoints=web
+ - traefik.http.services.analytics.loadbalancer.server.port=3000
+ deploy:
+ resources:
+ limits:
+ memory: 512M
# ابزار پشتیبانگیری — فقط با پروفایل tools اجرا میشود (scripts/backup.sh این کار را میکند)
mc:
- image: minio/mc:latest
+ image: minio/mc:RELEASE.2025-04-08T15-39-49Z
profiles: ["tools"]
depends_on:
minio:
@@ -159,7 +321,42 @@ services:
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
+ networks: [internal]
+
+ # پشتیبان ساعتی خودکار دیتابیس — بدون نیاز به cron میزبان؛ فقط با پروفایل ops
+ # docker compose --profile ops up -d db-backup
+ # RPO را از ۲۴ ساعت (cron شبانه) به ۱ ساعت میرساند؛ scripts/backup.sh برای
+ # پشتیبان کامل (دیتابیس + تصاویر MinIO) سر جایش است.
+ db-backup:
+ image: prodrigestivill/postgres-backup-local:17
+ profiles: ["ops"]
+ restart: unless-stopped
+ logging: *default-logging
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ POSTGRES_HOST: db
+ POSTGRES_DB: arameshplaza
+ POSTGRES_USER: aramesh
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
+ SCHEDULE: "@hourly"
+ BACKUP_NUM_KEEP: "48"
+ BACKUP_DIR: /backups
+ volumes:
+ - backups:/backups
+ networks: [internal]
+ deploy:
+ resources:
+ limits:
+ memory: 128M
+
+networks:
+ edge: # سرویسهایی که Traefik به آنها مسیریابی میکند
+ internal: # دیتابیس و ذخیرهسازی — فرانتاند راهی به این شبکه ندارد
volumes:
db_data:
minio_data:
+ chatbot_hf:
+ backups:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..4020290
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,4 @@
+# فقط Caddyfile و dist/ به کانتکست بیلد میروند — node_modules و سورس عظیماند و لازم نیستند
+*
+!Caddyfile
+!dist
diff --git a/frontend/Caddyfile b/frontend/Caddyfile
index 71db35c..4e214cb 100644
--- a/frontend/Caddyfile
+++ b/frontend/Caddyfile
@@ -1,12 +1,21 @@
+{
+ # پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
+ admin off
+}
+
:80 {
root * /srv
- encode gzip
+ encode zstd gzip
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمیگردند
try_files {path} /index.html
file_server
- # کش بلندمدت برای فایلهای هشدار بیلد
+ # فایلهای هشدار بیلد: کش بلندمدت بیرحم
@assets path /assets/*
header @assets Cache-Control "public, immutable, max-age=31536000"
+
+ # بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
+ @notassets not path /assets/*
+ header @notassets Cache-Control "no-cache"
}
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 496113a..3b3c31c 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -5,6 +5,9 @@
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
-COPY dist /srv
+COPY --chown=caddy:caddy dist /srv
+
+# بدون root — فقط فایلهای استاتیک سرو میشوند و TLS را Traefik تمام میکند
+USER caddy
EXPOSE 80
diff --git a/frontend/index.html b/frontend/index.html
index c2934a1..dd9d7f7 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -24,6 +24,16 @@
document.documentElement.setAttribute('dir', 'ltr');
}
+