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