feat(blog): جدول — ستونها قابل تغییر اندازه/افزودن و حذف؛ لینک کپشن تصویر؛ هدینگهای نسخه انگلیسی بزرگتر
This commit is contained in:
+30
-7
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 با فاصله کسینوسی — خروجی: (سند، شباهت).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user