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'); } + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64ba58c..4874598 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader'; import { Header } from './components/Header'; import { Footer } from './components/Footer'; import { FloatingContact } from './components/FloatingContact'; +import { ChatWidget } from './components/ChatWidget'; import { Home } from './pages/Home'; import { About } from './pages/About'; import { ServicesPage } from './pages/ServicesPage'; @@ -106,6 +107,7 @@ function AppChrome() { {!isAdminPage &&