feat(blog): جدول — ستون‌ها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگ‌های نسخه انگلیسی بزرگ‌تر

This commit is contained in:
aramesh-site deploy
2026-10-06 10:57:01 +00:00
parent 61a2e95278
commit 1b491357ab
45 changed files with 2341 additions and 81 deletions
+30 -7
View File
@@ -33,18 +33,27 @@
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
```
ایندکس از محتوای PostgreSQL سایت ساخته می‌شود (`articles` منتشرشده، `projects`، `services`،
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
`testimonials`، `timeline`، `story`). متن‌های بلند به قطعات ~۷۰۰ نویسه‌ای هم‌پوشان برش می‌خورند.
ایندکس از محتوای PostgreSQL سایت ساخته می‌شود (`articles` منتشرشده + پرسش‌وپاسخ‌های
پایان مقاله، `projects` با مشخصات کامل، `services`، `faqs`، `brand`، `offices`،
`investment_models`، `process_steps`، `why_us`، `team`، `testimonials`، `timeline`،
`story`، و متن‌های مدیریت محتوا: `stats`، `hero`، `page_heroes`، `section_headings`،
`footer`، `before_after`). سطرهای جای‌گاه‌خالی (متن زیر ~۳۰ نویسه) ایندکس نمی‌شوند.
متن‌های بلند به قطعات ~۷۰۰ نویسه‌ای هم‌پوشان برش می‌خورند.
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگام‌سازی
فقط سطرهای جدید/تغییریافته را امبد می‌کند. دیتابیس و جدول در شروع سرویس خودکار ساخته می‌شوند.
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار (شامل url: `type:url-key:occ:chunk`)
و `content_hash` دارد و همگام‌سازی فقط سطرهای جدید/تغییریافته را امبد می‌کند.
دیتابیس و جدول در شروع سرویس خودکار ساخته می‌شوند.
- **تیتر در متن قطعه**: در سندهای تک‌قطعه‌ای که تیترشان در متن نیست (مثلاً نام پروژه)،
تیتر به ابتدای متن می‌چسبد تا BM25 و جست‌وجوی معنایی پیدایش کنند.
- **بازسازی خودکار**: یک حلقهٔ پس‌زمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
می‌کند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام می‌شود
(امبد اجباری همه: `POST /chat/reindex`).
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگ‌گراف) — با ری‌استارت پاک می‌شود.
- **گزارش گفتگوها**: هر پاسخ (به‌همراه تعداد سند بازیابی‌شده، تشخیص «پاسخی نبود» و تأخیر)
در جدول `chat_logs` همان دیتابیس ذخیره می‌شود تا پرسش‌های بی‌پاسخ — یعنی شکاف‌های
دانش — قابل کشف باشند؛ `GET /chat/stats` جمع‌بندی می‌دهد.
## متغیرهای محیطی (در `.env` ریشه پروژه)
@@ -75,15 +84,29 @@
| مسیر | توضیح |
|---|---|
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکن‌به‌توکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکن‌به‌توکن پاسخ (`{"content": …}` و در پایان `{"done": true, "fallback": true|false}`) |
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده با thread تازه |
| `GET /chat/eval?message=…` | مخصوص ارزیابی — پاسخ + اسناد بازیابی‌شده با thread تازه |
| `GET /chat/stats` | آمار گفتگوها (کل، ۷ روز اخیر، نرخ پیام جایگزین)؛ فهرست پرسش‌های بی‌پاسخ اخیر فقط با هدر `X-Reindex-Token` |
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
## ارزیابی
مجموعه طلایی `eval_set.json` (پرسش‌های واقعی از محتوای دیتابیس + یک کنترل مثبت برای
«نباید قیمت بسازد») با `eval_run.py` اجرا می‌شود — داخل کانتینر:
```bash
docker exec aramesh-site-chatbot-1 python /srv/chatbot/eval_run.py --out /tmp/report.md
docker exec aramesh-site-chatbot-1 cat /tmp/report.md
```
خروجی: وضعیت هر پرسش (`pass` / `miss` / `manual` / `fallback-expected` / `unexpected-fallback`)،
منابع بازیابی‌شده و زمان پاسخ — برای سنجش اثر تغییرات دانش و کشف پسرفت‌ها.
## اجرا
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
+32 -1
View File
@@ -130,14 +130,31 @@ async def _stream_answer(message: str, thread_id: str):
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
started = time.time()
answer_parts: list[str] = []
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:
answer_parts.append(text)
yield _sse({"content": text, "done": False})
yield _sse({"content": "", "done": True})
# وضعیت نهایی گراف: تعداد اسناد بازیابی‌شده — برای تشخیص «پاسخی نبود»
n_docs = 0
try:
state = await pipeline.graph.aget_state(config)
n_docs = len(state.values.get("documents") or [])
except Exception as e:
log.warning("Final state fetch failed: %s", e)
answer = "".join(answer_parts)
is_fallback = n_docs == 0 or answer.strip() == pipeline.FALLBACK_MESSAGE
await asyncio.to_thread(
vectorstore.log_chat, thread_id, message, answer, n_docs, is_fallback,
int((time.time() - started) * 1000),
)
yield _sse({"content": "", "done": True, "fallback": is_fallback})
except Exception as e:
log.exception("Streaming failed")
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
@@ -189,6 +206,20 @@ async def eval_question(message: str = Query(...)):
}
@api.get("/chat/stats")
async def chat_stats(request: Request):
"""آمار گفتگوها — جمع‌بندی برای همه؛ پرسش‌های بی‌پاسخ اخیر فقط با توکن."""
stats = await asyncio.to_thread(vectorstore.chat_stats)
payload = {
"total_logged": stats["total_logged"],
"last_7d": stats["last_7d"],
"fallback_7d": stats["fallback_7d"],
}
if settings.reindex_token and request.headers.get("x-reindex-token") == settings.reindex_token:
payload["recent_fallback_questions"] = stats["recent_fallback_questions"]
return payload
@api.post("/chat/reindex")
async def reindex(request: Request):
"""همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود."""
+5 -1
View File
@@ -1,5 +1,6 @@
"""تنظیمات سرویس چت‌بات — از متغیرهای محیطی یا فایل .env خوانده می‌شود."""
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -44,7 +45,10 @@ class Settings(BaseSettings):
rate_limit_window: int = 300
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار می‌کند
reindex_token: str = ""
# (.env از نام CHAT_REINDEX_TOKEN استفاده می‌کند — هر دو نام پذیرفته می‌شود)
reindex_token: str = Field(
default="", validation_alias=AliasChoices("REINDEX_TOKEN", "CHAT_REINDEX_TOKEN")
)
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
suggestions_ttl: int = 600
+173 -15
View File
@@ -2,7 +2,12 @@
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و
پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند.
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند؛ اما پرسش‌وپاسخ‌های
پایان مقاله‌های منتشرشده به‌عنوان سند faq ایندکس می‌شوند.
به‌جز جدول‌های محتوایی، متن‌های مدیریت محتوا (آمار، هیرو، هیرو صفحه‌ها،
تیتر بخش‌ها، فوتر، قبل/بعد) هم ایندکس می‌شوند — ادعاهای مرکزی سایت
(سال تجربه، تعداد پروژه و…) فقط آنجا ثبت شده‌اند.
"""
import logging
@@ -16,6 +21,19 @@ log = logging.getLogger(__name__)
_ARTICLE_URL = "/blog/{slug}"
_PROJECT_URL = "/projects/{slug}"
# بخش‌های صفحه اصلی و صفحات داخلی — برای نسبت دادن URL به تیتر بخش‌ها
_SECTION_URL = {
"services": "/services",
"featured_projects": "/projects",
"articles": "/blog",
"faq": "/faq",
"investment_models": "/investment",
"process": "/investment",
}
# سندهای کوتاه‌تر از این جای‌گاه‌خالی‌اند («خدمت جدید» و امثال آن) — ایندکس نمی‌شوند
_MIN_DOC_CHARS = 30
def _psycopg_dsn(database_url: str) -> str:
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند."""
@@ -75,7 +93,9 @@ def body_to_text(body: Any) -> str:
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
text = (text or "").strip()
if not text:
if len(text) < _MIN_DOC_CHARS:
if text:
log.info("Skipped tiny doc (%d chars, type=%s, title=%s)", len(text), dtype, title)
return None
return {"text": text, "type": dtype, "title": title, "url": url}
@@ -84,6 +104,19 @@ def _clean(value: Any) -> str:
return normalize(str(value or "")).strip()
def _format_stat_rows(rows: list[dict[str, Any]]) -> list[str]:
"""سطرهای جدول stats → «۲۳+ سال تجربه ساخت» (پسوند تک‌حرفی می‌چسبد، بقیه با فاصله)."""
parts: list[str] = []
for r in rows:
suffix = _clean(r.get("suffix"))
value = str(r.get("value") or "").strip()
if not value:
continue
num = f"{value}{suffix}" if len(suffix) == 1 else f"{value} {suffix}".strip()
parts.append(f"{num} {_clean(r.get('label'))}".strip())
return parts
def fetch_site_documents(database_url: str) -> list[dict]:
"""همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند)."""
dsn = _psycopg_dsn(database_url)
@@ -105,6 +138,36 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
# --- آمار و افتخارات — اعداد مرکزی سایت فقط اینجا ثبت‌اند ---
stats = _format_stat_rows(_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position"))
if stats:
d = _doc("آمار و افتخارات مجموعه: " + "؛ ".join(stats), "stats", "آمار و افتخارات", "/")
if d:
docs.append(d)
# --- هیرو صفحه اصلی (رکورد یگانه) ---
hero_rows = _fetch_rows(
dsn,
"SELECT kicker, title, title_highlight, subtitle, badge_value, badge_label FROM hero LIMIT 1",
)
if hero_rows:
h = hero_rows[0]
title = _clean(h["title"])
if _clean(h["title_highlight"]):
title = f"{title} (کلمهٔ برجسته: {_clean(h['title_highlight'])})"
lines = list(filter(None, [
f"شعار بالای تیتر: {_clean(h['kicker'])}" if _clean(h["kicker"]) else "",
f"تیتر اصلی: {title}" if title else "",
f"زیرتیتر: {_clean(h['subtitle'])}" if _clean(h["subtitle"]) else "",
(
f"نشان: {_clean(h['badge_value'])} {_clean(h['badge_label'])}"
if _clean(h["badge_value"]) or _clean(h["badge_label"]) else ""
),
]))
d = _doc("\n".join(lines), "hero", "معرفی صفحه اصلی", "/")
if d:
docs.append(d)
# --- دفاتر ---
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
d = _doc(
@@ -132,29 +195,50 @@ def fetch_site_documents(database_url: str) -> list[dict]:
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",
"""
SELECT slug, title, location, year, status, area, floors, land_area, pilot_floors,
units_residential, units_commercial, units_office, client,
project_type, usage, summary, body
FROM projects ORDER BY position
""",
):
units = "، ".join(
f"{v} واحد {label}"
for label, col in (("مسکونی", row["units_residential"]),
("تجاری", row["units_commercial"]),
("اداری", row["units_office"]))
if (v := _clean(col)) and v != "0"
)
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['project_type'])}" if row["project_type"] else "",
f"کاربری: {_clean(row['usage'])}" if row["usage"] else "",
f"متراژ زیربنا: {_clean(row['area'])}" if row["area"] else "",
f"عرصه: {_clean(row['land_area'])}" if row["land_area"] else "",
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
f"طبقات پیلوت: {_clean(row['pilot_floors'])}" if row["pilot_floors"] else "",
f"واحدها: {units}" if units else "",
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
]))
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
text = "\n".join(filter(None, [
f"پروژه «{_clean(row['title'])}»",
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",
"SELECT slug, title, date, category, excerpt, body, faqs FROM articles WHERE status = 'published' ORDER BY position",
):
text = "\n".join(filter(None, [
f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "",
@@ -165,6 +249,15 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
article_url = _ARTICLE_URL.format(slug=row["slug"])
for f in row["faqs"] or []:
if not isinstance(f, dict):
continue
q, a = _clean(f.get("question")), _clean(f.get("answer"))
d = _doc(f"پرسش: {q}\nپاسخ: {a}", "faq", q, article_url)
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 []))
@@ -187,7 +280,29 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما ---
# --- تیتر و توضیح بخش‌ها (مدیریت محتوا) ---
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM section_headings ORDER BY key"):
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
d = _doc(text, "section", _clean(row["title"]), _SECTION_URL.get(row["key"], "/"))
if d:
docs.append(d)
# --- هیرو صفحه‌های داخلی (about/services/projects/… ) ---
for row in _fetch_rows(dsn, "SELECT key, title, lead FROM page_heroes ORDER BY key"):
text = "\n".join(filter(None, [_clean(row["title"]), _clean(row["lead"])]))
d = _doc(text, "page_hero", _clean(row["title"]), f"/{row['key']}")
if d:
docs.append(d)
# --- قبل/بعد (بخش صفحه اصلی) ---
ba_rows = _fetch_rows(dsn, "SELECT title, lead FROM before_after LIMIT 1")
if ba_rows:
text = "\n".join(filter(None, [_clean(ba_rows[0]["title"]), _clean(ba_rows[0]["lead"])]))
d = _doc(text, "before_after", _clean(ba_rows[0]["title"]) or "قبل و بعد", "/")
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:
@@ -201,13 +316,24 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
testimonial_rows = _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position")
for row in testimonial_rows:
d = _doc(
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
"testimonial", _clean(row["name"]), "/",
)
if d:
docs.append(d)
# سند تجمیعی — پرسش‌های طبیعی مثل «نظرات مشتریان چیست؟» به تک‌نفرها نمی‌رسند
# (بی‌ریشه‌سازی BM25: «نظرات» با «نظر» و «مشتریان» با «کارفرما» هیچ اشتراکی ندارند)
if len(testimonial_rows) >= 2:
agg = "؛ ".join(
f"{_clean(t['name'])} ({_clean(t['role'])}): {_clean(t['quote'])}"
for t in testimonial_rows
)
d = _doc(f"نظرات مشتریان و کارفرمایان پروژه‌ها:\n{agg}", "testimonial", "نظرات مشتریان", "/")
if d:
docs.append(d)
for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'):
d = _doc(
@@ -226,15 +352,31 @@ def fetch_site_documents(database_url: str) -> list[dict]:
if d:
docs.append(d)
footer_rows = _fetch_rows(dsn, "SELECT about_text FROM footer LIMIT 1")
if footer_rows and _clean(footer_rows[0]["about_text"]):
d = _doc(
_clean(footer_rows[0]["about_text"]),
"about", "درباره مجموعه (فوتر)", "/",
)
if d:
docs.append(d)
log.info("Fetched %d documents from site database", len(docs))
return docs
def _url_key(url: str) -> str:
"""URL → کلید یکتا برای doc_id («/projects/x» → «projects-x»)."""
return url.strip("/").replace("/", "-") or "home"
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
"""متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند.
هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا
هر قطعه یک doc_id پایدار می‌گیرد («type:url-key:ترتیب در همان url:شماره قطعه») تا
همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
URL بخشی از doc_id است — بدون آن همه پروژه‌ها/مقاله‌ها که url یکتا دارند
روی همان id می‌افتند و در جدول بردار یکدیگر را بازنویسی می‌کنند.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
@@ -257,9 +399,15 @@ def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120)
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
for i, piece in enumerate(pieces, 1):
if len(pieces) == 1:
# تیتر باید در متن امبدشده باشد تا BM25/معنایی پیدایش کند —
# در سندهای تک‌قطعه‌ای که تیترشان در متن نیست، به ابتدای قطعه می‌چسبد
text = piece if not doc["title"] or doc["title"] in piece[:150] else f"{doc['title']}\n{piece}"
else:
text = f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}"
chunk = {
"doc_id": f"{doc['type']}:{occ}:{i}",
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
"doc_id": f"{doc['type']}:{_url_key(doc['url'])}:{occ}:{i}",
"text": text,
"type": doc["type"],
"title": doc["title"],
"url": doc["url"],
@@ -285,7 +433,7 @@ _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
@@ -296,6 +444,10 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
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")
stats = _format_stat_rows(
_fetch_rows(dsn, "SELECT value, suffix, label FROM stats ORDER BY position")
)
offices = _fetch_rows(dsn, "SELECT city, address FROM offices ORDER BY position")
lines: list[str] = []
if brand:
b = brand[0]
@@ -307,6 +459,12 @@ def fetch_site_context(database_url: str, ttl: int = 120) -> str:
]
if services:
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
if stats:
lines.append("آمار: " + "؛ ".join(stats))
if offices:
lines.append("دفاتر: " + "؛ ".join(
f"{_clean(o['city'])} ({_clean(o['address'])})" for o in offices
))
ctx = "\n".join(lines)
_site_context_cache = (now, ctx)
return ctx
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""اجرای مجموعه طلایی روی سرویس چت‌بات و ساخت گزارش مارک‌داون.
استفاده (داخل کانتینر یا هر جا که به سرویس دسترسی هست):
python eval_run.py --base http://localhost:8000
python eval_run.py --out eval-report.md
هر پرسش از /chat/eval سرویس می‌شود؛ پاسخ با زیررشته‌های انتظار
(پس از نرمال‌سازی فارسی) مقایسه و رتبه‌بندی می‌شود:
pass — همه/یکی از انتظارها برآورده شد
miss — انتظار برآورده نشد (پاسخ احتمالاً نادرست یا بی‌ربط)
manual — انتظار عددی تعریف نشده؛ بازبینی دستی
fallback-expected / unexpected-fallback — پیام جایگزین
"""
import argparse
import json
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
FALLBACK_MESSAGE = (
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
"برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید."
)
def ask(base: str, question: str, timeout: int = 180) -> dict:
url = f"{base}/chat/eval?{urllib.parse.urlencode({'message': question})}"
with urllib.request.urlopen(url, timeout=timeout) as r:
return json.load(r)
def main() -> int:
ap = argparse.ArgumentParser(description="اجرای مجموعه طلایی ارزیابی چت‌بات")
ap.add_argument("--base", default="http://localhost:8000", help="نشانی سرویس چت‌بات")
ap.add_argument("--set", dest="eval_set",
default=str(Path(__file__).with_name("eval_set.json")))
ap.add_argument("--out", default="-", help="فایل خروجی گزارش (- = stdout)")
ap.add_argument("--sleep", type=float, default=0.5, help="مکث بین پرسش‌ها (ثانیه)")
args = ap.parse_args()
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from chatbot.persian import normalize
data = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
rows: list[dict] = []
for item in data["questions"]:
q = item["q"]
t0 = time.time()
try:
res = ask(args.base, q)
except Exception as e:
rows.append({**item, "answer": f"ERROR: {e}", "sources": [], "seconds": 0.0,
"status": "error", "fallback": False})
print(f" error: {q[:50]}", file=sys.stderr)
continue
seconds = time.time() - t0
answer = str(res.get("answer", ""))
contexts = res.get("contexts") or []
is_fallback = answer.strip() == FALLBACK_MESSAGE or not contexts
if item.get("expect_fallback"):
status = "fallback-expected" if is_fallback else "unexpected-answer"
elif is_fallback:
status = "unexpected-fallback"
else:
needles = [normalize(s) for s in item.get("expect_any", [])]
if not needles:
status = "manual"
elif any(n in normalize(answer) for n in needles):
status = "pass"
else:
status = "miss"
rows.append({**item, "answer": answer, "sources": contexts, "seconds": seconds,
"status": status, "fallback": is_fallback})
print(f" [{status}] {q[:50]}", file=sys.stderr)
time.sleep(args.sleep)
# --- گزارش ---
lines = [f"# گزارش ارزیابی چت‌بات — {time.strftime('%Y-%m-%d %H:%M')}", ""]
counts: dict[str, int] = {}
for r in rows:
counts[r["status"]] = counts.get(r["status"], 0) + 1
graded = [r for r in rows if r["status"] in ("pass", "miss", "unexpected-fallback", "unexpected-answer")]
ok = sum(1 for r in rows if r["status"] in ("pass", "fallback-expected"))
lines += [
f"- مجموع پرسش‌ها: {len(rows)} — درست: {ok} — خطا: {counts.get('miss', 0) + counts.get('unexpected-fallback', 0) + counts.get('unexpected-answer', 0)}",
f"- بازبینی دستی: {counts.get('manual', 0)} — خطای فنی: {counts.get('error', 0)}",
f"- میانگین زمان پاسخ: {sum(r['seconds'] for r in rows) / max(len(rows), 1):.1f}s",
"",
]
for r in rows:
srcs = [c.rsplit("(منبع:", 1)[-1].strip(" ()\n") for c in r["sources"]] if r["sources"] else []
lines += [
f"## [{r['status']}] {r['q']}",
f"*موضوع: {r.get('topic', '?')} — {r['seconds']:.1f}s — منابع: {'، '.join(srcs) or '—'}*",
"",
r["answer"],
"",
]
report = "\n".join(lines)
if args.out == "-":
print(report)
else:
Path(args.out).write_text(report, encoding="utf-8")
print(f"Report written to {args.out}", file=sys.stderr)
return 1 if any(r["status"] in ("miss", "unexpected-answer") for r in rows) else 0
if __name__ == "__main__":
sys.exit(main())
+26
View File
@@ -0,0 +1,26 @@
{
"description": "مجموعه طلایی ارزیابی چت‌بات — پرسش‌ها از واقعیت‌های زنده دیتابیس سایت ساخته شده‌اند. expect_any: حداقل یکی از این زیررشته‌ها (پس از نرمال‌سازی) در پاسخ باشد؛ expect_fallback: انتظار پیام جایگزین (شکاف دانش). رشته‌های خالی = بررسی دستی.",
"questions": [
{"q": "پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟", "topic": "projects", "expect_any": ["8100"]},
{"q": "پروژه آرامش - بابک کی و کجا تحویل شده؟", "topic": "projects", "expect_any": ["1405", "6200"]},
{"q": "وضعیت پروژه میرزاطاهر ۶ چطور است؟", "topic": "projects", "expect_any": ["در حال ساخت"]},
{"q": "کدام پروژه شما در محله گورتان اصفهان است؟", "topic": "projects", "expect_any": ["وحدت"]},
{"q": "پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟", "topic": "projects", "expect_any": ["29"]},
{"q": "گروه ساختمانی آرامش چند سال سابقه دارد؟", "topic": "stats", "expect_any": ["23", "1381"]},
{"q": "تا امروز چند پروژه تحویل داده‌اید؟", "topic": "stats", "expect_any": ["64"]},
{"q": "مجموع متراژ ساخت‌وساز شما چقدر است؟", "topic": "stats", "expect_any": ["111"]},
{"q": "چند خانواده در پروژه‌های شما ساکن هستند؟", "topic": "stats", "expect_any": ["500"]},
{"q": "شماره تماس شما چیست؟", "topic": "contact", "expect_any": ["09133107809"]},
{"q": "دفتر تهران کجاست؟", "topic": "offices", "expect_any": ["ولیعصر"]},
{"q": "آدرس ایمیل مجموعه چیست؟", "topic": "contact", "expect_any": ["arameshsakhtemani"]},
{"q": "دفاتر شما در چه شهرهایی است؟", "topic": "offices", "expect_any": ["نوشهر"]},
{"q": "گروه آرامش چه سالی تأسیس شده؟", "topic": "faqs", "expect_any": ["1381"]},
{"q": "زمین دارم، ساخت را از کجا شروع کنم؟", "topic": "faqs", "expect_any": []},
{"q": "شرایط مشارکت در ساخت چگونه است؟", "topic": "investment", "expect_any": []},
{"q": "مدل صندوق سرمایه‌گذاری چطور کار می‌کند؟", "topic": "investment", "expect_any": ["24"]},
{"q": "پیش‌خرید واحد یعنی چه؟", "topic": "investment", "expect_any": ["پرداخت", "شخصی‌سازی", "تحویل"]},
{"q": "خدمات گروه آرامش چیست؟", "topic": "services", "expect_any": []},
{"q": "نظرات مشتریان قبلی درباره شما چیست؟", "topic": "testimonials", "expect_any": []},
{"q": "هزینه ساخت هر متر مربع چقدر است؟", "topic": "canary", "expect_fallback": true}
]
}
+14 -2
View File
@@ -19,10 +19,22 @@ 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
assert faq_ids == ["faq:faq:0:1", "faq:faq:1:1"], faq_ids
# سندهایی که url یکتا دارند (پروژه/مقاله) نباید روی هم بیفتند — url بخشی از doc_id است
two_projects = [
{"text": "پروژه الف " + "ت" * 60, "type": "project", "title": "الف", "url": "/projects/alpha"},
{"text": "پروژه ب " + "ت" * 60, "type": "project", "title": "ب", "url": "/projects/beta"},
]
pids = sorted(c["doc_id"] for c in chunk_documents(two_projects))
assert pids == ["project:projects-alpha:0:1", "project:projects-beta:0:1"], pids
# تیتر سندهای تک‌قطعه‌ای باید در متن امبدشده باشد تا جست‌وجو پیدایش کند
faq_chunks = [c for c in chunks if c["type"] == "faq"]
assert all(c["text"].startswith(c["title"]) for c in faq_chunks), [c["text"] for c in faq_chunks]
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 len(art) >= 2 and art[1]["doc_id"] == "article:blog-long:0:2", [x["doc_id"] for x in art]
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
c = chunks[0]
+71
View File
@@ -66,6 +66,22 @@ def ensure_schema() -> None:
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
"""
)
# گزارش گفتگوها — حلقه بازخورد برای کشف پرسش‌های بی‌پاسخ
conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_logs (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
thread_id TEXT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
n_docs INTEGER NOT NULL DEFAULT 0,
is_fallback BOOLEAN NOT NULL DEFAULT FALSE,
latency_ms INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS chat_logs_ts_idx ON chat_logs (ts DESC)")
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
@@ -168,6 +184,61 @@ def vector_count() -> int:
return 0
def log_chat(thread_id: str, question: str, answer: str, n_docs: int,
is_fallback: bool, latency_ms: int) -> None:
"""ثبت یک گفتگو در chat_logs — هرگز خطا نمی‌دهد (لاگ نباید جریان پاسخ را بشکند)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
conn.execute(
"""
INSERT INTO chat_logs (thread_id, question, answer, n_docs, is_fallback, latency_ms)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(thread_id, question, answer[:4000], n_docs, is_fallback, latency_ms),
)
except Exception as e:
log.warning("chat_logs insert failed: %s", e)
def chat_stats(days: int = 7) -> dict:
"""آمار تجمعی گفتگوها — بدون متن پرسش‌ها (متن‌ها فقط با توکن در /chat/stats)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
total = int(conn.execute("SELECT count(*) FROM chat_logs").fetchone()[0])
row = conn.execute(
"""
SELECT count(*), count(*) FILTER (WHERE is_fallback)
FROM chat_logs WHERE ts > now() - (%s || ' days')::interval
""",
(str(days),),
).fetchone()
recent_fallbacks: list[str] = []
if row and row[1]:
recent_fallbacks = [
r[0] for r in conn.execute(
"""
SELECT question FROM chat_logs
WHERE is_fallback AND ts > now() - (%s || ' days')::interval
ORDER BY ts DESC LIMIT 20
""",
(str(days),),
).fetchall()
]
return {
"total_logged": total,
"last_7d": int(row[0]) if row else 0,
"fallback_7d": int(row[1]) if row else 0,
"recent_fallback_questions": recent_fallbacks,
}
except Exception as e:
log.warning("chat_stats failed: %s", e)
return {"total_logged": 0, "last_7d": 0, "fallback_7d": 0, "recent_fallback_questions": []}
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).