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
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
import { track } from '../analytics';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
streaming?: boolean;
error?: boolean;
};
type SseFrame = {
content?: string;
error?: string;
done?: boolean;
};
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
function getThreadId(): string {
const KEY = 'chat_thread_id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
sessionStorage.setItem(KEY, id);
}
return id;
}
/** خواندن فریم‌های SSE از پاسخ fetch (استریم توکن‌به‌توکن) */
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
const reader = response.body?.getReader();
if (!reader) throw new Error('پاسخ استریمی ندارد');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
try {
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
} catch {
// فریم ناقص — نادیده بگیر
}
}
}
}
}
export function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const assistantIndexRef = useRef<number | null>(null);
// پیشنهاد پرسش‌ها فقط یک بار برای هر بازشدن اول
useEffect(() => {
if (!open || suggestions.length > 0) return;
fetch('/chat/suggestions')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, [open, suggestions.length]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, open]);
const sendMessage = async (text: string) => {
const question = text.trim();
if (!question || isLoading) return;
track('chat-question')
setInput('');
setIsLoading(true);
setMessages((prev) => {
const next: ChatMessage[] = [
...prev,
{ role: 'user', content: question },
{ role: 'assistant', content: '', streaming: true },
];
assistantIndexRef.current = next.length - 1;
return next;
});
const patchAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const idx = assistantIndexRef.current;
if (idx === null || !prev[idx]) return prev;
const updated = [...prev];
updated[idx] = { ...updated[idx], ...patch };
return updated;
});
};
try {
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
const response = await fetch(`/chat/stream?${params.toString()}`, {
headers: { Accept: 'text/event-stream' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let accumulated = '';
await readSse(response, (frame) => {
if (frame.error) {
patchAssistant({ content: frame.error, streaming: false, error: true });
return;
}
if (frame.content) {
accumulated += frame.content;
patchAssistant({ content: accumulated });
}
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
});
patchAssistant({ streaming: false });
} catch (err) {
patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false,
error: true,
});
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
return (
<>
{/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */}
<button
type="button"
onClick={() => {
setOpen((v) => {
if (!v) track('chat-open');
return !v;
});
}}
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
</button>
{/* پنل گفتگو */}
{open && (
<div
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
{/* سربرگ */}
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
<SparklesIcon className="h-4 w-4" />
</span>
<div className="flex-1">
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* پیام‌ها */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="mt-6 text-center">
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
دارید بپرسید.
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
msg.role === 'user'
? 'rounded-bl-md bg-copper text-char'
: msg.error
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
}`}>
{msg.content || (msg.streaming ? '…' : '')}
{msg.streaming && msg.content && (
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
)}
</div>
</div>
))}
<div ref={scrollEndRef} />
</div>
{/* پیشنهاد پرسش‌ها */}
{suggestions.length > 0 && messages.length === 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
{suggestions.map((q) => (
<button
key={q}
type="button"
onClick={() => sendMessage(q)}
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
{q}
</button>
))}
</div>
)}
{/* ورودی */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
}}
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="پرسش خود را بنویسید…"
disabled={isLoading}
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
aria-label="ارسال"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
<SendIcon className="h-4 w-4 -scale-x-100" />
</button>
</form>
</div>
)}
</>
);
}
+284
View File
@@ -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)
+315
View File
@@ -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 ""
+130
View File
@@ -0,0 +1,130 @@
# گزارش ارزیابی چت‌بات — 2026-10-01 21:05
- مجموع پرسش‌ها: 21 — درست: 17 — خطا: 0
- بازبینی دستی: 4 — خطای فنی: 0
- میانگین زمان پاسخ: 9.1s
## [pass] پروژه میرزا طاهر ۵ کجا ساخته شده و چند متر زیربنا دارد؟
*موضوع: projects — 8.2s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5*
پروژه «آرامش - میرزا طاهر 5» در اصفهان، وحدت، شهریار (محله‌ی زهران) ساخته شده و متراژ زیربنای آن ۸۱۰۰ مترمربع است.
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
## [pass] پروژه آرامش - بابک کی و کجا تحویل شده؟
*موضوع: projects — 10.4s — منابع: معرفی صفحه اصلی — /، آخرین مقالات — /blog، آرامش - بابک — /projects/آرامش---بابک، پروژه‌ها — /projects، تحویل و پشتیبانی — /investment*
پروژه «آرامش - بابک» طبق اطلاعات سایت در سال ۱۴۰۵ تکمیل شده و در اصفهان، شهید رضایی، بین باغ مسجد و ارشاد (محله برزان) قرار دارد.
جزئیات کامل در صفحه /projects/آرامش---بابک در دسترس است.
## [pass] وضعیت پروژه میرزاطاهر ۶ چطور است؟
*موضوع: projects — 8.7s — منابع: آرامش - میرزاطاهر 6 — /projects/آرامش---میرزاطاهر-6، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، کوثر- میرزاطاهر — /projects/کوثر--میرزاطاهر، نیکان میرزاطاهر — /projects/نیکان-میرزاطاهر، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر*
پروژه «آرامش - میرزاطاهر ۶» در اصفهان، میرزاطاهر شرقی (محله زهران) قرار دارد و وضعیت آن **در حال ساخت** است؛ عرصه نیز **در حال اخذ مجوز** است.
جزئیات کامل در صفحه /projects/آرامش---میرزاطاهر-6 در دسترس است.
## [pass] کدام پروژه شما در محله گورتان اصفهان است؟
*موضوع: projects — 8.2s — منابع: آرامش - وحدت — /projects/آرامش---وحدت، سال‌های توسعه و تجربه — /about، آرامش - آتشگاه — /projects/آرامش---آتشگاه*
پروژه «آرامش - وحدت» در اصفهان، آتشگاه، محله‌ی گورتان واقع شده و وضعیت آن تکمیل‌شده است. این پروژه کاربری مسکونی-تجاری دارد و شامل ۸ واحد مسکونی و ۱ واحد تجاری است.
جزئیات کامل در صفحه /projects/آرامش---وحدت موجود است.
## [pass] پروژه میرزا طاهر ۵ چند واحد مسکونی دارد؟
*موضوع: projects — 9.0s — منابع: آرامش - میرزا طاهر 4 — /projects/آرامش---میرزا-طاهر-4، آرامش- میرزا طاهر 1 — /projects/آرامش--میرزا-طاهر-1، آرامش - میرزا طاهر 5 — /projects/آرامش---میرزا-طاهر-5، نگین- میرزاطاهر — /projects/نگین--میرزاطاهر، آرامش میرزا طاهر 2 — /projects/آرامش-میرزا-طاهر-2*
پروژه «آرامش - میرزا طاهر 5» دارای **۲۹ واحد مسکونی** است.
جزئیات کامل در صفحه /projects/آرامش---میرزا-طاهر-5
## [pass] گروه ساختمانی آرامش چند سال سابقه دارد؟
*موضوع: stats — 10.3s — منابع: آخرین مقالات — /blog، شرکت آرامش از چه سالی فعالیت می‌کند؟ — /faq، درباره ما — /about، تجربه بیش از دو دهه — /about، گروه ساختمانی آرامش — /about*
گروه ساختمانی آرامش بیش از ۲۳ سال سابقه فعالیت در حوزه ساخت‌وساز دارد و فعالیت مستمر خود را از سال ۱۳۸۱ آغاز کرده است.
## [pass] تا امروز چند پروژه تحویل داده‌اید؟
*موضوع: stats — 8.7s — منابع: پروژه‌ها — /projects، آمار و افتخارات — /، تعهدی فراتر از تحویل ساختمان — /about، تحویل و پشتیبانی — /investment*
طبق اطلاعات ثبت‌شده در سایت، گروه ساختمانی آرامش تاکنون **۶۴ پروژه تحویل‌داده** است.
## [pass] مجموع متراژ ساخت‌وساز شما چقدر است؟
*موضوع: stats — 9.2s — منابع: آمار و افتخارات — /، شروع مسیر ساخت‌وساز — /about، سرمایه‌گذاری — /investment، درباره ما — /about، گروه ساختمانی آرامش — /about*
مجموع زیربنای ساخت‌وساز گروه ساختمانی آرامش ۱۱۱ هزار مترمربع است.
## [pass] چند خانواده در پروژه‌های شما ساکن هستند؟
*موضوع: stats — 7.2s — منابع: آمار و افتخارات — /*
بر اساس آمار ثبت‌شده، بیش از ۵۰۰ خانواده در پروژه‌های گروه ساختمانی آرامش ساکن هستند.
## [pass] شماره تماس شما چیست؟
*موضوع: contact — 6.6s — منابع: اطلاعات تماس — /contact*
شماره تماس گروه ساختمانی آرامش: **۰۹۱۳۳۱۰۷۸۰۹**
جزئیات بیشتر در صفحه «تماس با ما» موجود است.
## [pass] دفتر تهران کجاست؟
*موضوع: offices — 7.6s — منابع: دفتر تهران — /contact*
دفتر تهران گروه ساختمانی آرامش در خیابان ولیعصر، برج آرامش، طبقه هجدهم واقع شده است.
جزئیات بیشتر در صفحه /contact در دسترس است.
## [pass] آدرس ایمیل مجموعه چیست؟
*موضوع: contact — 8.7s — منابع: اطلاعات تماس — /contact، دفتر نوشهر — /contact، دفتر اصفهان — /contact، تماس با ما — /contact*
ایمیل مجموعه: arameshsakhtemani@gmail.com
## [pass] دفاتر شما در چه شهرهایی است؟
*موضوع: offices — 9.2s — منابع: ساختمان اداری — /services، ساختمان تجاری — /services*
دفاتر گروه ساختمانی آرامش در سه شهر زیر فعال هستند:
- **تهران:** خیابان ولیعصر، برج آرامش، طبقه هجدهم
- **اصفهان:** خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم
- **نوشهر:** بلوار دریا، دفتر پروژه دریاکنار
## [pass] گروه آرامش چه سالی تأسیس شده؟
*موضوع: faqs — 18.5s — منابع: شرکت آرامش از چه سالی فعالیت می‌کند؟ — /faq، آخرین مقالات — /blog، ورود به صنعت ساختمان — /about*
بر اساس اطلاعات موجود، شرکت ساختمانی آرامش از سال ۱۳۸۱ فعالیت خود را آغاز کرده است. همچنین در صفحه «درباره ما» سابقه ورود مجموعه به صنعت ساختمان از سال ۱۳۷۷ با تأسیس شرکت پیمانکاری ذکر شده است. جزئیات بیشتر در صفحات /faq و /about در دسترس است.
## [manual] زمین دارم، ساخت را از کجا شروع کنم؟
*موضوع: faqs — 7.9s — منابع: اگر زمین داشته باشیم، از کجا باید برای ساخت شروع کنیم؟ — /faq*
اولین قدم، بررسی زمین و شرایط پروژه است. موقعیت، ضوابط و تراکم، شرایط فنی و ظرفیت اقتصادی زمین بررسی می‌شود تا پیش از ورود به طراحی و اجرا، مسیر مناسب پروژه مشخص شود. جزئیات کامل در صفحه /faq موجود است.
## [manual] شرایط مشارکت در ساخت چگونه است؟
*موضوع: investment — 8.5s — منابع: آیا امکان مشارکت در ساخت هم دارید؟ — /faq، مشارکت در ساخت — /investment، وبلاگ — /blog، تحویل و پشتیبانی — /investment*
شرایط مشارکت در ساخت به این صورت است که مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک می‌شوند و اجرا، دریافت مجوزها و فروش بر عهده ماست. ابتدا ملک به‌صورت کارشناسی بررسی می‌شود، سپس قرارداد شفاف با ضمانت اجرا تنظیم می‌گردد و در طول پروژه گزارش پیشرفت ارائه می‌شود. جزئیات کامل در صفحه /investment قابل مشاهده است.
## [pass] مدل صندوق سرمایه‌گذاری چطور کار می‌کند؟
*موضوع: investment — 8.1s — منابع: صندوق سرمایه‌گذاری — /investment، مدل‌های همکاری — /investment، سرمایه‌گذاری ساختمانی — /services، سرمایه‌گذاری — /investment*
صندوق سرمایه‌گذاری به این صورت کار می‌کند که سرمایه‌گذاران واحدهایی از پروژه‌های آرامش را خریداری می‌کنند و بازدهی آن‌ها در پایان دوره محاسبه می‌شود. حداقل دوره سرمایه‌گذاری ۲۴ ماه است، گزارش ارزش‌گذاری به‌صورت فصلی ارائه می‌شود و امکان انتقال واحد سرمایه نیز وجود دارد.
جزئیات کامل در صفحه /investment در دسترس است.
## [pass] پیش‌خرید واحد یعنی چه؟
*موضوع: investment — 8.0s — منابع: پیش‌خرید واحد — /investment*
پیش‌خرید واحد یعنی خرید واحد مسکونی، اداری یا تجاری در مراحل مختلف ساخت. از جمله نکات آن می‌توان به امکان شخصی‌سازی پلان، تضمین زمان تحویل و پرداخت توافقی اشاره کرد. جزئیات کامل در صفحه /investment.
## [manual] خدمات گروه آرامش چیست؟
*موضوع: services — 7.7s — منابع: خدمات شرکت آرامش شامل چه مواردی است؟ — /faq، آخرین مقالات — /blog، آیا امکان مشارکت در ساخت هم دارید؟ — /faq، همراهی از صفر تا صد — /about*
خدمات گروه ساختمانی آرامش شامل طراحی معماری، ساخت ساختمان‌های مسکونی، تجاری و اداری، طراحی و ویلاسازی، طراحی فضاهای عمومی و رفاهی، بازسازی، مدیریت پروژه و سرمایه‌گذاری ساختمانی است. ما خدمات کامل صفر تا صد را از طراحی تا تحویل کلید پوشش می‌دهیم. جزئیات بیشتر در صفحه /faq قابل مشاهده است.
## [manual] نظرات مشتریان قبلی درباره شما چیست؟
*موضوع: testimonials — 11.2s — منابع: همراهی با تغییر نیازهای بازار مسکن — /about، نظرات مشتریان — /، سوالات متداول — /faq، سوالات متداول — /faq*
بر اساس نظرات ثبت‌شده در سایت، مشتریان قبلی رضایت خود را این‌گونه اعلام کرده‌اند:
- مهندس سارا کیانی، سرمایه‌گذار پروژه پارسه: گزارش‌های ماهانه آن‌قدر دقیق بود که نیازی به نگرانی درباره سرمایه نبود و پروژه دو ماه زودتر از موعد به بهره‌برداری رسید.
- دکتر امیرحسین راد، ساکن برج الهیه: کیفیت اجرا در جزئیاتی مانند عایق صوتی پنجره‌ها و نور غیرمستقیم راهروها دیده می‌شود.
- مریم دلاور، مدیر مجموعه ویلاهای دریاکنار: همکاری با تیم طراحی حرفه‌ای بود و ایده‌ها به زبان معماری ترجمه شد.
جزئیات بیشتر در صفحه اصلی سایت (/) در بخش نظرات مشتریان آمده است.
## [fallback-expected] هزینه ساخت هر متر مربع چقدر است؟
*موضوع: canary — 9.7s — منابع: آمار و افتخارات — /، اجرا و کنترل کیفیت — /investment، مدیریت پروژه — /services، شفافیت کامل — /about*
متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید.
+191
View File
@@ -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)
+70
View File
@@ -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")
+217
View File
@@ -0,0 +1,217 @@
"""ذخیره‌ساز برداری 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.rows import dict_row
from psycopg.types.json import Jsonb
from .embeddings import get_embeddings
model = settings.embedding_model
wanted = {c["doc_id"]: c for c in chunks}
# row_factory لازم است: پایین‌تر row["model"]/row["doc_id"] خوانده می‌شود و
# پیش‌فرض psycopg3 تاپل برمی‌گرداند (خطای tuple indices must be integers).
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) 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
from psycopg.rows import dict_row
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10,
row_factory=dict_row) 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]