From 1b491357ab2cae066c6041cb7af9207b73bdc349 Mon Sep 17 00:00:00 2001 From: aramesh-site deploy Date: Tue, 6 Oct 2026 10:57:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(blog):=20=D8=AC=D8=AF=D9=88=D9=84=20?= =?UTF-8?q?=E2=80=94=20=D8=B3=D8=AA=D9=88=D9=86=E2=80=8C=D9=87=D8=A7=20?= =?UTF-8?q?=D9=82=D8=A7=D8=A8=D9=84=20=D8=AA=D8=BA=DB=8C=DB=8C=D8=B1=20?= =?UTF-8?q?=D8=A7=D9=86=D8=AF=D8=A7=D8=B2=D9=87/=D8=A7=D9=81=D8=B2=D9=88?= =?UTF-8?q?=D8=AF=D9=86=20=D9=88=20=D8=AD=D8=B0=D9=81=D8=9B=20=D9=84=DB=8C?= =?UTF-8?q?=D9=86=DA=A9=20=DA=A9=D9=BE=D8=B4=D9=86=20=D8=AA=D8=B5=D9=88?= =?UTF-8?q?=DB=8C=D8=B1=D8=9B=20=D9=87=D8=AF=DB=8C=D9=86=DA=AF=E2=80=8C?= =?UTF-8?q?=D9=87=D8=A7=DB=8C=20=D9=86=D8=B3=D8=AE=D9=87=20=D8=A7=D9=86?= =?UTF-8?q?=DA=AF=D9=84=DB=8C=D8=B3=DB=8C=20=D8=A8=D8=B2=D8=B1=DA=AF?= =?UTF-8?q?=E2=80=8C=D8=AA=D8=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .backups-chatbot-10x/ChatWidget.tsx | 251 ++++++++++++++ .backups-chatbot-10x/app.py | 284 ++++++++++++++++ .backups-chatbot-10x/content.py | 315 ++++++++++++++++++ .backups-chatbot-10x/eval-final.md | 130 ++++++++ .backups-chatbot-10x/pipeline.py | 191 +++++++++++ .backups-chatbot-10x/test_pure.py | 70 ++++ .backups-chatbot-10x/vectorstore.py | 217 ++++++++++++ backend/app/migrations.py | 10 + chatbot/README.md | 37 +- chatbot/app.py | 33 +- chatbot/config.py | 6 +- chatbot/content.py | 188 ++++++++++- chatbot/eval_run.py | 119 +++++++ chatbot/eval_set.json | 26 ++ chatbot/test_pure.py | 16 +- chatbot/vectorstore.py | 71 ++++ docker-compose.prod.yml | 4 +- frontend/index.html | 4 + frontend/public/og-default.jpg | Bin 0 -> 54486 bytes frontend/src/App.tsx | 3 + frontend/src/admin/entities.ts | 3 +- frontend/src/components/ArticleCard.tsx | 2 + frontend/src/components/ChatWidget.tsx | 14 +- frontend/src/components/ContactSection.tsx | 2 +- frontend/src/components/Footer.tsx | 2 +- frontend/src/components/PageHero.tsx | 2 +- frontend/src/components/Preloader.tsx | 10 +- frontend/src/components/ProjectCard.tsx | 2 + .../src/components/admin/ArticleEditor.tsx | 32 ++ frontend/src/components/admin/ArticleImage.ts | 17 +- .../src/components/admin/ImageNodeView.tsx | 14 +- .../src/components/admin/SeoChecklist.tsx | 30 ++ frontend/src/components/home/BeforeAfter.tsx | 4 + frontend/src/components/home/Gallery.tsx | 2 +- frontend/src/components/home/Story.tsx | 2 +- frontend/src/components/home/WhyUs.tsx | 4 +- frontend/src/index.css | 39 +++ frontend/src/map/ProjectImageSlider.tsx | 2 +- frontend/src/pages/ArticleDetail.tsx | 63 +++- frontend/src/pages/Blog.tsx | 2 +- frontend/src/pages/ProjectDetail.tsx | 30 +- frontend/src/seo.ts | 64 +++- frontend/src/utils/date.ts | 30 ++ frontend/src/utils/tiptapDoc.ts | 5 + scripts/launch-checklist.md | 70 ++++ 45 files changed, 2341 insertions(+), 81 deletions(-) create mode 100644 .backups-chatbot-10x/ChatWidget.tsx create mode 100644 .backups-chatbot-10x/app.py create mode 100644 .backups-chatbot-10x/content.py create mode 100644 .backups-chatbot-10x/eval-final.md create mode 100644 .backups-chatbot-10x/pipeline.py create mode 100644 .backups-chatbot-10x/test_pure.py create mode 100644 .backups-chatbot-10x/vectorstore.py create mode 100644 chatbot/eval_run.py create mode 100644 chatbot/eval_set.json create mode 100644 frontend/public/og-default.jpg create mode 100644 scripts/launch-checklist.md diff --git a/.backups-chatbot-10x/ChatWidget.tsx b/.backups-chatbot-10x/ChatWidget.tsx new file mode 100644 index 0000000..b8bf77d --- /dev/null +++ b/.backups-chatbot-10x/ChatWidget.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const scrollEndRef = useRef(null); + const inputRef = useRef(null); + const assistantIndexRef = useRef(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) => { + 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 ( + <> + {/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */} + + + {/* پنل گفتگو */} + {open && ( +
+ {/* سربرگ */} +
+ + + +
+

دستیار هوشمند آرامش

+

پاسخ از محتوای همین سایت

+
+ +
+ + {/* پیام‌ها */} +
+ {messages.length === 0 && ( +
+ +

+ سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات + گروه ساختمانی آرامش + دارید بپرسید. +

+
+ )} + + {messages.map((msg, i) => ( +
+
+ {msg.content || (msg.streaming ? '…' : '')} + {msg.streaming && msg.content && ( + + )} +
+
+ ))} +
+
+ + {/* پیشنهاد پرسش‌ها */} + {suggestions.length > 0 && messages.length === 0 && ( +
+ {suggestions.map((q) => ( + + ))} +
+ )} + + {/* ورودی */} +
{ + e.preventDefault(); + sendMessage(input); + }} + className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3"> + 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" + /> + +
+
+ )} + + ); +} diff --git a/.backups-chatbot-10x/app.py b/.backups-chatbot-10x/app.py new file mode 100644 index 0000000..188b1ec --- /dev/null +++ b/.backups-chatbot-10x/app.py @@ -0,0 +1,284 @@ +"""سرویس چت‌بات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس.""" + +import asyncio +import json +import logging +import time +import uuid +from collections import defaultdict, deque +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse + +from langchain_core.messages import AIMessage, HumanMessage + +from . import content as content_mod +from . import pipeline +from . import vectorstore +from .config import settings +from .indexing import INDEX, sync_index + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", +) +log = logging.getLogger("chatbot") + + +# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود --- + +_refresh_task: asyncio.Task | None = None + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + try: + await asyncio.to_thread(sync_index) + except Exception as e: + log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e) + global _refresh_task + _refresh_task = asyncio.create_task(_refresh_loop()) + yield + _refresh_task.cancel() + + +async def _refresh_loop() -> None: + interval = settings.index_refresh_seconds + if interval <= 0: + return + while True: + await asyncio.sleep(interval) + try: + # همگام‌سازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد می‌شوند + await asyncio.to_thread(sync_index) + except Exception as e: + log.warning("Background index refresh failed: %s", e) + + +api = FastAPI( + title="Aramesh Site Chatbot", + version="1.0.0", + description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ری‌رنکر BGE", + lifespan=lifespan, +) + +api.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) + + +# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) --- + +_hits: dict[str, deque] = defaultdict(deque) +_last_sweep = 0.0 + + +def _rate_limited(ip: str) -> bool: + global _last_sweep + now = time.time() + # پاک‌سازی دوره‌ای کلیدهای بی‌مصرف تا دیکشنری بی‌نهایت بزرگ نشود + if now - _last_sweep > settings.rate_limit_window: + stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window] + for k in stale: + _hits.pop(k, None) + _last_sweep = now + window = _hits[ip] + while window and now - window[0] > settings.rate_limit_window: + window.popleft() + if len(window) >= settings.rate_limit_messages: + return True + window.append(now) + return False + + +def _client_ip(request: Request | None) -> str: + if request is None: + return "?" + forwarded = request.headers.get("x-forwarded-for") + return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?") + + +# --- کمکی‌های SSE --- + +def _sse(payload: dict) -> str: + return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + +def _chunk_text(chunk) -> str: + """استخراج متن از قطعه استریم LLM (رشته یا لیست بخش‌ها).""" + content = getattr(chunk, "content", "") + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join(p.get("text", "") for p in content if isinstance(p, dict)) + return "" + + +async def _stream_answer(message: str, thread_id: str): + if not INDEX.ready: + yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True}) + return + if pipeline.get_llm() is None: + yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True}) + return + + config = {"configurable": {"thread_id": thread_id}} + inputs = {"messages": [HumanMessage(content=message)]} + try: + async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"): + # فقط توکن‌های پاسخ نهایی — نه فراخوانی‌های کمکی (بازنویسی/گسترش پرسش) + if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []): + text = _chunk_text(event["data"]["chunk"]) + if text: + yield _sse({"content": text, "done": False}) + yield _sse({"content": "", "done": True}) + except Exception as e: + log.exception("Streaming failed") + yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True}) + + +# --- مسیرها --- + +@api.get("/chat/healthz") +async def healthz(): + return { + "status": "ok", + "index_ready": INDEX.ready, + "documents": len(INDEX.docs), + "vectors": await asyncio.to_thread(vectorstore.vector_count), + "llm_configured": bool(settings.llm_base_url), + "reranker_enabled": settings.reranker_enabled, + } + + +@api.get("/chat/suggestions") +async def suggestions(): + return {"questions": _get_suggestions()} + + +@api.get("/chat/eval") +async def eval_question(message: str = Query(...)): + """مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده، با thread تازه.""" + if not INDEX.ready: + return {"error": "index not ready", "question": message, "answer": "", "contexts": []} + if pipeline.get_llm() is None: + return {"error": "llm not configured", "question": message, "answer": "", "contexts": []} + + thread_id = f"eval_{uuid.uuid4().hex[:10]}" + config = {"configurable": {"thread_id": thread_id}} + inputs = {"messages": [HumanMessage(content=message)]} + final_state = await pipeline.graph.ainvoke(inputs, config=config) + + answer = "" + for msg in reversed(final_state.get("messages", [])): + if isinstance(msg, AIMessage): + answer = msg.content if isinstance(msg.content, str) else str(msg.content) + break + return { + "question": message, + "contextualized_query": final_state.get("contextualized_query") or message, + "answer": answer, + "contexts": final_state.get("documents", []), + "thread_id": thread_id, + } + + +@api.post("/chat/reindex") +async def reindex(request: Request): + """همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود.""" + if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token: + raise HTTPException(status_code=403, detail="invalid reindex token") + stats = await asyncio.to_thread(sync_index, True) + return {"status": "ok", "documents": len(INDEX.docs), "stats": stats} + + +# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval می‌سازد + +@api.get("/chat/{scope}/stream") +async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None): + return await stream_chat(message=message, thread_id=thread_id, request=request) + + +@api.get("/chat/{scope}/eval") +async def eval_scoped(scope: str, message: str = Query(...)): + return await eval_question(message=message) + + +@api.get("/chat/{scope}/suggestions") +async def suggestions_scoped(scope: str): + return {"questions": _get_suggestions()} + + +@api.get("/chat/stream") +async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None): + """استریم توکن‌به‌توکن پاسخ با SSE.""" + if _rate_limited(_client_ip(request)): + raise HTTPException(status_code=429, detail="تعداد پیام‌های شما زیاد است؛ کمی بعد دوباره تلاش کنید.") + + thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}" + + async def generate(): + async for chunk in _stream_answer(message, thread_id): + yield chunk + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"}, + ) + + +# --- پیشنهاد پرسش‌ها (کش‌شده) --- + +_suggestions_cache: tuple[float, list[str]] = (0.0, []) + + +def _get_suggestions() -> list[str]: + global _suggestions_cache + now = time.time() + if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl: + return _suggestions_cache[1] + + questions: list[str] = [] + if INDEX.ready: + faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8] + titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10] + model = pipeline.get_llm() + if model and (faqs or titles): + material = "\n".join([d["text"][:200] for d in faqs] + titles) + try: + prompt = ( + "بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده " + "احتمالاً می‌پرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n" + 'فقط آرایه JSON رشته‌ای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n' + f"محتوا:\n{material[:2000]}" + ) + resp = model.invoke([HumanMessage(content=prompt)]) + text = resp.content if isinstance(resp.content, str) else "" + if text.startswith("```"): + text = text.split("```")[1] + text = text[4:] if text.startswith("json") else text + import re + match = re.search(r"\[.*\]", text, re.DOTALL) + if match: + questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4] + except Exception as e: + log.warning("Suggestion generation failed: %s", e) + if not questions and faqs: + # جایگزین: خود پرسش‌های متداول + questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]] + + _suggestions_cache = (now, questions) + return questions + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(api, host="0.0.0.0", port=8000) diff --git a/.backups-chatbot-10x/content.py b/.backups-chatbot-10x/content.py new file mode 100644 index 0000000..9e08bee --- /dev/null +++ b/.backups-chatbot-10x/content.py @@ -0,0 +1,315 @@ +"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس. + +هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و +پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو +پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند. +""" + +import logging +from typing import Any + +from .persian import normalize + +log = logging.getLogger(__name__) + +# URL پایه صفحات — چت‌بات مسیر نسبی می‌دهد؛ فرانت‌اند روی همان دامنه است +_ARTICLE_URL = "/blog/{slug}" +_PROJECT_URL = "/projects/{slug}" + + +def _psycopg_dsn(database_url: str) -> str: + """DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند.""" + return database_url.replace("+psycopg", "").replace("+asyncpg", "") + + +def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]: + import psycopg + from psycopg.rows import dict_row + + with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn: + cur = conn.execute(sql) + return cur.fetchall() + + +def tiptap_blocks(node: Any) -> list[str]: + """متن بلوک‌به‌بلوک از سند TipTap (یا هر JSON تو در تو) بیرون می‌کشد.""" + if not isinstance(node, dict): + return [] + ntype = node.get("type") + if ntype in ("paragraph", "heading", "codeBlock"): + text = _inline_text(node).strip() + return [text] if text else [] + blocks: list[str] = [] + for child in node.get("content") or []: + blocks.extend(tiptap_blocks(child)) + return blocks + + +def _inline_text(node: dict) -> str: + parts: list[str] = [] + for child in node.get("content") or []: + ctype = child.get("type") + if ctype == "text": + parts.append(child.get("text", "")) + elif ctype == "hardBreak": + parts.append(" ") + elif ctype == "image": + alt = (child.get("attrs") or {}).get("alt") or "" + if alt: + parts.append(f"[تصویر: {alt}]") + else: + parts.append(_inline_text(child)) + return "".join(parts) + + +def body_to_text(body: Any) -> str: + """بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه.""" + if not body: + return "" + if isinstance(body, list): + # فرمت قدیمی: آرایه رشته‌ای پاراگراف‌ها + return "\n".join(str(p).strip() for p in body if str(p).strip()) + blocks = tiptap_blocks(body) + return "\n".join(b for b in blocks if b) + + +def _doc(text: str, dtype: str, title: str, url: str) -> dict | None: + text = (text or "").strip() + if not text: + return None + return {"text": text, "type": dtype, "title": title, "url": url} + + +def _clean(value: Any) -> str: + return normalize(str(value or "")).strip() + + +def fetch_site_documents(database_url: str) -> list[dict]: + """همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند).""" + dsn = _psycopg_dsn(database_url) + docs: list[dict] = [] + + # --- برند و اطلاعات تماس (رکورد یگانه) --- + brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1") + if brand_rows: + b = brand_rows[0] + contact = "\n".join( + filter(None, [ + f"نام مجموعه: {_clean(b['name'])}", + f"تلفن تماس: {_clean(b['phone'])}", + f"ایمیل: {_clean(b['email'])}", + f"آدرس: {_clean(b['address'])}", + ]) + ) + d = _doc(contact, "contact", "اطلاعات تماس", "/contact") + if d: + docs.append(d) + + # --- دفاتر --- + for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"): + d = _doc( + f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}", + "office", f"دفتر {_clean(row['city'])}", "/contact", + ) + if d: + docs.append(d) + + # --- خدمات --- + for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM services ORDER BY position'): + d = _doc( + f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}", + "service", _clean(row["title"]), "/services", + ) + if d: + docs.append(d) + + # --- پرسش‌های متداول --- + for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"): + d = _doc( + f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}", + "faq", _clean(row["question"]), "/faq", + ) + if d: + docs.append(d) + + # --- پروژه‌ها --- + for row in _fetch_rows( + dsn, + "SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position", + ): + meta = "\n".join(filter(None, [ + f"مکان: {_clean(row['location'])}" if row["location"] else "", + f"سال: {_clean(row['year'])}" if row["year"] else "", + f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}", + f"متراژ: {_clean(row['area'])}" if row["area"] else "", + f"طبقات: {_clean(row['floors'])}" if row["floors"] else "", + f"واحدها: {_clean(row['units'])}" if row["units"] else "", + f"کارفرما: {_clean(row['client'])}" if row["client"] else "", + ])) + text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])])) + d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"])) + if d: + docs.append(d) + + # --- مقاله‌ها (فقط منتشرشده) --- + for row in _fetch_rows( + dsn, + "SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position", + ): + text = "\n".join(filter(None, [ + f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "", + _clean(row["excerpt"]), + body_to_text(row["body"]), + ])) + d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"])) + if d: + docs.append(d) + + # --- مدل‌های سرمایه‌گذاری --- + for row in _fetch_rows(dsn, 'SELECT title, "desc", points FROM investment_models ORDER BY position'): + points = "؛ ".join(_clean(p) for p in (row["points"] or [])) + d = _doc( + "\n".join(filter(None, [ + f"«{_clean(row['title'])}»: {_clean(row['desc'])}", + f"نکات: {points}" if points else "", + ])), + "investment", _clean(row["title"]), "/investment", + ) + if d: + docs.append(d) + + # --- مراحل همکاری --- + for row in _fetch_rows(dsn, 'SELECT step, title, "desc" FROM process_steps ORDER BY position'): + d = _doc( + f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}", + "process", _clean(row["title"]), "/investment", + ) + if d: + docs.append(d) + + # --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما --- + for row in _fetch_rows(dsn, 'SELECT title, "desc" FROM why_us ORDER BY position'): + d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about") + if d: + docs.append(d) + + for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"): + d = _doc( + f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}", + "team", _clean(row["name"]), "/about", + ) + if d: + docs.append(d) + + for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"): + d = _doc( + f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}", + "testimonial", _clean(row["name"]), "/", + ) + if d: + docs.append(d) + + for row in _fetch_rows(dsn, 'SELECT year, title, "desc" FROM timeline ORDER BY position'): + d = _doc( + f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}", + "timeline", _clean(row["title"]), "/about", + ) + if d: + docs.append(d) + + story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1") + if story_rows and story_rows[0]: + s = story_rows[0] + paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p)) + text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])])) + d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about") + if d: + docs.append(d) + + log.info("Fetched %d documents from site database", len(docs)) + return docs + + +def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]: + """متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند. + + هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا + همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند. + """ + from langchain_text_splitters import RecursiveCharacterTextSplitter + + from .vectorstore import chunk_content_hash + + splitter = RecursiveCharacterTextSplitter( + chunk_size=chunk_size, + chunk_overlap=overlap, + separators=["\n", "۔", ".", "،", " ", ""], + ) + + # شماره ردیف هر سند در میان سندهای هم‌type و هم-url (برای FAQs که url یکسان دارند) + occurrence: dict[str, int] = {} + + out: list[dict] = [] + for doc in docs: + key = f"{doc['type']}|{doc['url']}" + occ = occurrence.get(key, 0) + occurrence[key] = occ + 1 + + pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]] + for i, piece in enumerate(pieces, 1): + chunk = { + "doc_id": f"{doc['type']}:{occ}:{i}", + "text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}", + "type": doc["type"], + "title": doc["title"], + "url": doc["url"], + } + chunk["content_hash"] = chunk_content_hash(chunk) + out.append(chunk) + + log.info("Chunked %d documents into %d pieces", len(docs), len(out)) + return out + + +def content_fingerprint(docs: list[dict]) -> str: + """هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام می‌شود.""" + import hashlib + import json + + payload = json.dumps(docs, ensure_ascii=False, sort_keys=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +# --- زمینه همیشه‌تازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) --- +_site_context_cache: tuple[float, str] | None = None + + +def fetch_site_context(database_url: str, ttl: int = 120) -> str: + """نام برند، تماس و فهرست خدمات — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند.""" + global _site_context_cache + import time as _time + + now = _time.time() + if _site_context_cache and now - _site_context_cache[0] < ttl: + return _site_context_cache[1] + try: + dsn = _psycopg_dsn(database_url) + brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1") + services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position") + lines: list[str] = [] + if brand: + b = brand[0] + lines += [ + f"نام مجموعه: {_clean(b['name'])}", + f"تلفن تماس: {_clean(b['phone'])}", + f"ایمیل: {_clean(b['email'])}", + f"آدرس: {_clean(b['address'])}", + ] + if services: + lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services)) + ctx = "\n".join(lines) + _site_context_cache = (now, ctx) + return ctx + except Exception as e: + log.warning("Site context fetch failed: %s", e) + return _site_context_cache[1] if _site_context_cache else "" diff --git a/.backups-chatbot-10x/eval-final.md b/.backups-chatbot-10x/eval-final.md new file mode 100644 index 0000000..631ef26 --- /dev/null +++ b/.backups-chatbot-10x/eval-final.md @@ -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* + +متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید. diff --git a/.backups-chatbot-10x/pipeline.py b/.backups-chatbot-10x/pipeline.py new file mode 100644 index 0000000..23a630d --- /dev/null +++ b/.backups-chatbot-10x/pipeline.py @@ -0,0 +1,191 @@ +"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام: + +۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفه‌جویی در تأخیر) +۲) بازیابی ترکیبی با چند نوع‌واژه (اختیاری) +۳) تولید پاسخ فارسی با قواعد تکیه بر داده +""" + +import logging +import re +import json as _json +from typing import Annotated, List, Optional + +from typing_extensions import TypedDict + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, StateGraph +from langgraph.graph.message import add_messages + +from . import content as content_mod +from .config import settings +from .retrieval import retrieve_final + +log = logging.getLogger(__name__) + +FALLBACK_MESSAGE = ( + "متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. " + "برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید." +) +LLM_UNAVAILABLE = ( + "سرویس هوش مصنوعی هنوز پیکربندی نشده است. " + "متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چت‌بات تنظیم کنید." +) + +_llm = None + + +def get_llm() -> ChatOpenAI | None: + """LLM تولید — اگر پیکربندی نشده باشد None برمی‌گرداند.""" + global _llm + if _llm is None and settings.llm_base_url: + _llm = ChatOpenAI( + model=settings.llm_model, + base_url=settings.llm_base_url, + api_key=settings.llm_api_key or "not-needed", + temperature=0, + streaming=True, + ) + return _llm + + +class GraphState(TypedDict): + messages: Annotated[List, add_messages] + documents: List[str] + contextualized_query: Optional[str] + + +# --- گام ۱: بازنویسی پرسش با تاریخچه --- + +def contextualize_query(question: str, history: list) -> str: + """ضمیرها و اشاره‌های پرسش را با تاریخچه گفتگو مشخص می‌کند («اون دومی» → موضوع واقعی).""" + model = get_llm() + if model is None or not history: + return question + try: + history_text = "\n".join( + f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}" + for m in history[-6:] + ) + prompt = ( + "با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n" + "- ضمیرها و اشاره‌ها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n" + "- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n" + "- فقط متن پرسش بازنویسی‌شده را برگردان؛ هیچ توضیحی ننویس.\n\n" + f"تاریخچه گفتگو:\n{history_text}\n\n" + f"پرسش کاربر: {question}\n\n" + "پرسش مستقل:" + ) + resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]}) + rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else "" + if not rewritten or len(rewritten) > len(question) * 4: + return question + if rewritten != question: + log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60]) + return rewritten + except Exception as e: + log.warning("Contextualization failed: %s", e) + return question + + +def expand_query(query: str) -> list[str]: + """گسترش پرسش به چند نوع‌واژه (اختیاری — پیش‌فرض خاموش برای پیکره کوچک).""" + model = get_llm() + if model is None: + return [query] + try: + prompt = ( + "برای جست‌وجو در محتوای سایت یک گروه ساختمانی، ۳ نوع‌واژه جایگزین برای این پرسش بساز: " + "بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n" + 'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n' + f"پرسش: {query}" + ) + resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]}) + text = resp.content if isinstance(resp.content, str) else "" + match = re.search(r'\{.*"variants".*\}', text, re.DOTALL) + if match: + variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3] + if variants: + log.info("Query variants: %s", [query] + variants) + return [query] + variants + except Exception as e: + log.warning("Query expansion failed: %s", e) + return [query] + + +# --- گام ۲: بازیابی --- + +def retrieve_node(state: GraphState) -> dict: + messages = state.get("messages", []) + if not messages: + return {"documents": []} + question = messages[-1].content + history = messages[:-1] + + query = contextualize_query(question, history) if settings.contextualize_enabled else question + variants = expand_query(query) if settings.query_expansion else [query] + + docs = retrieve_final(query, variants) + doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs] + log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60]) + return { + "documents": doc_texts, + "contextualized_query": query if query != question else None, + } + + +# --- گام ۳: تولید پاسخ --- + +def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str: + site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl) + docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)" + reminder = ( + f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n" + if contextualized_query else "" + ) + return ( + "تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ می‌دهی.\n" + "دانش تو اکیداً محدود به اطلاعات زیر است.\n\n" + f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n" + f"--- اسناد بازیابی‌شده مرتبط با پرسش ---\n{docs_block}\n" + f"{reminder}" + "--- قوانین پاسخ ---\n" + "۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ به‌هیچ‌وجه از دانش بیرونی استفاده نکن و چیزی نساز.\n" + "۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفه‌ای و محترمانه داشته باشد.\n" + f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n" + "۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n" + "۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در داده‌ها آمده است." + ) + + +def generate_node(state: GraphState) -> dict: + messages = state.get("messages", []) + question = messages[-1].content if messages else "" + + model = get_llm() + if model is None: + return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]} + + documents = state.get("documents") or [] + system = build_system_prompt(documents, state.get("contextualized_query")) + log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system)) + + history = messages[:-1] + reply = model.invoke( + [SystemMessage(content=system), *history, HumanMessage(content=question)], + config={"tags": ["generate"]}, + ) + return {"messages": [reply]} + + +# --- گراف --- + +memory = MemorySaver() +workflow = StateGraph(GraphState) +workflow.add_node("retrieve", retrieve_node) +workflow.add_node("generate", generate_node) +workflow.set_entry_point("retrieve") +workflow.add_edge("retrieve", "generate") +workflow.add_edge("generate", END) +graph = workflow.compile(checkpointer=memory) diff --git a/.backups-chatbot-10x/test_pure.py b/.backups-chatbot-10x/test_pure.py new file mode 100644 index 0000000..a278b16 --- /dev/null +++ b/.backups-chatbot-10x/test_pure.py @@ -0,0 +1,70 @@ +"""تست‌های خالص (بدون مدل/دیتابیس) برای ماژول‌های چت‌بات — اجرا: python test_pure.py""" +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from chatbot.content import chunk_documents, tiptap_blocks, body_to_text +from chatbot.vectorstore import chunk_content_hash, chatbot_dsn +from chatbot.persian import normalize, tokenize + +docs = [ + {"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"}, + {"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"}, + {"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"}, + {"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"}, +] +chunks = chunk_documents(docs) +ids = [c["doc_id"] for c in chunks] +assert len(ids) == len(set(ids)), f"duplicate ids: {ids}" + +faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq") +assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids + +art = [c for c in chunks if c["type"] == "article"] +assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art] +assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"] + +c = chunks[0] +assert c["content_hash"] == chunk_content_hash(c) +c2 = dict(c, title="عنوان عوض شد") +# hash فیلد کپی‌شده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی +assert chunk_content_hash(c2) != c["content_hash"] + +chunks2 = chunk_documents(docs) +assert [x["doc_id"] for x in chunks2] == ids +assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks] + +settings_dsn = chatbot_dsn() +assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn +assert "+psycopg" not in settings_dsn + +# نرمال‌سازی و توکن‌سازی فارسی +assert normalize("كامل يك ۱۲۳") == "کامل یک 123" +t = tokenize("کدام شرکت ساختمانی خوب در تهران؟") +assert "شرکت" in t and "تهران" in t and "کدام" not in t, t +assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم") + +# استخراج متن TipTap و فرمت قدیمی +tiptap_doc = { + "type": "doc", + "content": [ + {"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]}, + {"type": "paragraph", "content": [ + {"type": "text", "text": "این برج در تهران "}, + {"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"}, + {"type": "hardBreak"}, + {"type": "image", "attrs": {"alt": "نمای بیرونی"}}, + ]}, + {"type": "bulletList", "content": [ + {"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]}, + ]}, + {"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]}, + ], +} +blocks = tiptap_blocks(tiptap_doc) +assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks +assert body_to_text(["الف", "ب"]) == "الف\nب" +assert body_to_text(None) == "" + +print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs") diff --git a/.backups-chatbot-10x/vectorstore.py b/.backups-chatbot-10x/vectorstore.py new file mode 100644 index 0000000..5094f79 --- /dev/null +++ b/.backups-chatbot-10x/vectorstore.py @@ -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] diff --git a/backend/app/migrations.py b/backend/app/migrations.py index e116e10..9f7a855 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -153,6 +153,16 @@ def run_ddl_migrations() -> None: conn.execute( text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''") ) + # نام نویسنده و توضیحات سئوی اختصاصی مقاله — فیلدهایی که پنل قبلاً نمایش می‌داد ولی در دیتابیس نبودند + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS author VARCHAR(128) NOT NULL DEFAULT ''") + ) + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description TEXT NOT NULL DEFAULT ''") + ) + conn.execute( + text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS meta_description_en TEXT NOT NULL DEFAULT ''") + ) # نقش‌ها — 'admin' همه‌کاره، 'creator' فقط نویسندهٔ محتوا conn.execute( text("ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS role VARCHAR(16) NOT NULL DEFAULT 'admin'") diff --git a/chatbot/README.md b/chatbot/README.md index 1b8d1fb..abee1fd 100644 --- a/chatbot/README.md +++ b/chatbot/README.md @@ -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`): diff --git a/chatbot/app.py b/chatbot/app.py index 188b1ec..5d47d4b 100644 --- a/chatbot/app.py +++ b/chatbot/app.py @@ -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): """همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود.""" diff --git a/chatbot/config.py b/chatbot/config.py index 778f5d2..18702d3 100644 --- a/chatbot/config.py +++ b/chatbot/config.py @@ -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 diff --git a/chatbot/content.py b/chatbot/content.py index 9e08bee..ee8c71c 100644 --- a/chatbot/content.py +++ b/chatbot/content.py @@ -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 diff --git a/chatbot/eval_run.py b/chatbot/eval_run.py new file mode 100644 index 0000000..a2d156a --- /dev/null +++ b/chatbot/eval_run.py @@ -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()) diff --git a/chatbot/eval_set.json b/chatbot/eval_set.json new file mode 100644 index 0000000..e445451 --- /dev/null +++ b/chatbot/eval_set.json @@ -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} + ] +} diff --git a/chatbot/test_pure.py b/chatbot/test_pure.py index a278b16..9cfe731 100644 --- a/chatbot/test_pure.py +++ b/chatbot/test_pure.py @@ -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] diff --git a/chatbot/vectorstore.py b/chatbot/vectorstore.py index 5094f79..c039f0e 100644 --- a/chatbot/vectorstore.py +++ b/chatbot/vectorstore.py @@ -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 با فاصله کسینوسی — خروجی: (سند، شباهت). diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3d73c1c..e790a0b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -71,7 +71,9 @@ services: - traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.tls.certresolver=le - traefik.http.routers.api.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}api-flood,sec-headers - - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)) + # سئو: همهٔ صفحات عمومی از بک‌اند سرو می‌شوند تا متا در HTML اولیه تزریق شود — + # مسیرهای استاتیک دقیق‌اند (Path) تا assets/ فونت‌ها از Caddy سرو شوند + - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (Path(`/`) || Path(`/about`) || Path(`/services`) || Path(`/investment`) || Path(`/faq`) || Path(`/contact`) || PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`) || PathPrefix(`/llms.txt`)) - traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.middlewares=${PREVIEW_MIDDLEWARES:+$PREVIEW_MIDDLEWARES,}sec-headers diff --git a/frontend/index.html b/frontend/index.html index dd9d7f7..48308a6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,10 @@ + + + + گروه ساختمانی آرامش diff --git a/frontend/public/og-default.jpg b/frontend/public/og-default.jpg new file mode 100644 index 0000000000000000000000000000000000000000..20a8272f1e940109db4a968820ae8399a62bbbed GIT binary patch literal 54486 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%0AAde!$F+nOYq}A1>`5lzL!D&_L%t`fHR=QiA+uTl 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 lQN|0IUi`eQWNYLc; literal 0 HcmV?d00001 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eaad8aa..2235a81 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -96,11 +96,14 @@ function AppChrome() { const meta = data.seo_meta?.find((s) => s.page_key === key); const brand = data.brand.name; const navLabel = data.nav.find((n) => n.href === `/${key}`)?.label; + // alternate انگلیسی فقط وقتی اعلام می‌شود که متای انگلیسی واقعاً ثبت شده باشد + const hasEn = !!(meta && ((meta.title_en ?? '').trim() || (meta.description_en ?? '').trim())); applySeo({ title: meta?.title || (key === 'home' ? brand : navLabel ? `${navLabel} | ${brand}` : brand), description: meta?.description ?? '', path: pathname, lang, + hasEn, }); }, [pathname, data, isAdminPage, lang]); diff --git a/frontend/src/admin/entities.ts b/frontend/src/admin/entities.ts index cabe94c..d54e079 100644 --- a/frontend/src/admin/entities.ts +++ b/frontend/src/admin/entities.ts @@ -84,7 +84,7 @@ export const ENTITIES: Record = { F('title', 'عنوان'), F('body', 'متن مقاله', 'wysiwyg'), F('excerpt', 'چکیده (روی کارت مقاله)', 'textarea'), - F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود', true), + F('meta_description', 'توضیحات سئو (Meta Description)', 'textarea', '۱۵۰ تا ۱۶۰ کاراکتر — زیر عنوان در نتایج گوگل نمایش داده می‌شود'), F('image', 'تصویر شاخص', 'image', undefined, { altFallbackSuffix: FEATURED_IMAGE_ALT_SUFFIX, shared: true }), F('category', 'دسته‌بندی (Category)', 'select', 'می‌توانید دسته جدید هم بسازید', { options: ARTICLE_CATEGORIES, creatable: true, shared: true }), F('tags', 'هشتگ‌ها / برچسب‌ها', 'list', 'با Enter هر هشتگ جدید را اضافه کنید', { ui: 'tags', shared: true }), @@ -104,6 +104,7 @@ export const ENTITIES: Record = { image_title: '', author: '', meta_description: '', + meta_description_en: '', read: '۵ دقیقه', excerpt: '', seo_keyphrase: '', diff --git a/frontend/src/components/ArticleCard.tsx b/frontend/src/components/ArticleCard.tsx index efd725e..c651017 100644 --- a/frontend/src/components/ArticleCard.tsx +++ b/frontend/src/components/ArticleCard.tsx @@ -19,6 +19,8 @@ export function ArticleCard({ article }: { article: Article }) { src={article.image} alt={`${t('articleImagePrefix')} ${article.title}`} className="h-52 w-full object-cover transition-transform duration-700 ease-lux group-hover:scale-105" + loading="lazy" + decoding="async" /> {/* برچسب دسته‌بندی کپسولی شیشه‌ای روی تصویر */}
diff --git a/frontend/src/components/ChatWidget.tsx b/frontend/src/components/ChatWidget.tsx index b8bf77d..3e2bdb0 100644 --- a/frontend/src/components/ChatWidget.tsx +++ b/frontend/src/components/ChatWidget.tsx @@ -13,6 +13,8 @@ type SseFrame = { content?: string; error?: string; done?: boolean; + /** true یعنی دستیار چیزی پیدا نکرده و پیام جایگزین داده */ + fallback?: boolean; }; /** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */ @@ -78,7 +80,9 @@ export function ChatWidget() { const sendMessage = async (text: string) => { const question = text.trim(); if (!question || isLoading) return; - track('chat-question') + // نتیجهٔ هر پرسش برای تحلیل گزارش می‌شود — پرسش‌های بی‌پاسخ = شکاف دانش + let hadError = false; + let wasFallback = false; setInput(''); setIsLoading(true); setMessages((prev) => { @@ -111,6 +115,7 @@ export function ChatWidget() { let accumulated = ''; await readSse(response, (frame) => { if (frame.error) { + hadError = true; patchAssistant({ content: frame.error, streaming: false, error: true }); return; } @@ -118,16 +123,21 @@ export function ChatWidget() { accumulated += frame.content; patchAssistant({ content: accumulated }); } - if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false }); + if (frame.done) { + wasFallback = frame.fallback === true; + patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false }); + } }); patchAssistant({ streaming: false }); } catch (err) { + hadError = true; patchAssistant({ content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.', streaming: false, error: true, }); } finally { + track('chat-question', { fallback: wasFallback, error: hadError }); setIsLoading(false); inputRef.current?.focus(); } diff --git a/frontend/src/components/ContactSection.tsx b/frontend/src/components/ContactSection.tsx index 4c5952e..0d71e09 100644 --- a/frontend/src/components/ContactSection.tsx +++ b/frontend/src/components/ContactSection.tsx @@ -104,7 +104,7 @@ export function ContactSection() { src={brand.contact_bg} alt="" aria-hidden="true" - className="absolute inset-0 h-full w-full object-cover" + className="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async" />
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 2ce76dc..f8031ef 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -39,7 +39,7 @@ export function Footer() {
- {brand.name} + {brand.name}

diff --git a/frontend/src/components/PageHero.tsx b/frontend/src/components/PageHero.tsx index ee685c3..cfd822e 100644 --- a/frontend/src/components/PageHero.tsx +++ b/frontend/src/components/PageHero.tsx @@ -27,7 +27,7 @@ export function PageHero({ pageKey }: PageHeroProps) { return (

- {hero.image && } + {hero.image && } diff --git a/frontend/src/components/home/Gallery.tsx b/frontend/src/components/home/Gallery.tsx index 7429a0a..abbd774 100644 --- a/frontend/src/components/home/Gallery.tsx +++ b/frontend/src/components/home/Gallery.tsx @@ -51,7 +51,7 @@ export function Gallery() { {image.alt} diff --git a/frontend/src/components/home/Story.tsx b/frontend/src/components/home/Story.tsx index 6c81289..8cd985d 100644 --- a/frontend/src/components/home/Story.tsx +++ b/frontend/src/components/home/Story.tsx @@ -111,7 +111,7 @@ export function Story({ showLink = true, showLabel = true, showQuote = false, al {item.alt}
diff --git a/frontend/src/components/home/WhyUs.tsx b/frontend/src/components/home/WhyUs.tsx index 4fae903..4ae1d81 100644 --- a/frontend/src/components/home/WhyUs.tsx +++ b/frontend/src/components/home/WhyUs.tsx @@ -33,7 +33,9 @@ export function WhyUs() { {heading.title} + className="mt-10 h-80 w-full object-cover" + loading="lazy" + decoding="async" /> )} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5773052..1d7030f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -89,10 +89,21 @@ html[lang='en'] .article-body { html[lang='en'] .article-body p { margin-top: 1.25rem; } +html[lang='en'] .article-body h1, html[lang='en'] .article-body h2, html[lang='en'] .article-body h3 { line-height: 1.4; } +/* هدینگ‌ها نسبت به متن ۱۷px انگلیسی بزرگ‌تر — همان نسبت نسخه فارسی */ +html[lang='en'] .article-body h1 { + font-size: 22px; +} +html[lang='en'] .article-body h2 { + font-size: 19px; +} +html[lang='en'] .article-body h3 { + font-size: 16px; +} ::selection { background-color: #d5874c; color: #1e1a16; @@ -482,9 +493,37 @@ html[lang='en'] .article-body h3 { .article-body table, .ProseMirror table { width: 100%; + table-layout: fixed; border-collapse: collapse; margin-top: 1.5rem; font-size: 0.92em; + overflow: hidden; +} +.ProseMirror td, +.ProseMirror th { + position: relative; + box-sizing: border-box; +} +.ProseMirror .column-resize-handle { + position: absolute; + top: 0; + bottom: -1px; + left: -2px; + width: 4px; + z-index: 20; + background-color: rgba(213, 135, 76, 0.6); + pointer-events: none; +} +.ProseMirror.resize-cursor { + cursor: col-resize; +} +.ProseMirror .selectedCell::after { + content: ''; + position: absolute; + z-index: 2; + inset: 0; + background: rgba(213, 135, 76, 0.12); + pointer-events: none; } .article-body th, .article-body td, diff --git a/frontend/src/map/ProjectImageSlider.tsx b/frontend/src/map/ProjectImageSlider.tsx index 7bd7bde..6483e89 100644 --- a/frontend/src/map/ProjectImageSlider.tsx +++ b/frontend/src/map/ProjectImageSlider.tsx @@ -41,7 +41,7 @@ export function ProjectImageSlider({ images, alt, className = '' }: ProjectImage return (
- {alt} + {alt} {images.length > 1 && ( <> diff --git a/frontend/src/pages/ArticleDetail.tsx b/frontend/src/pages/ArticleDetail.tsx index eb3c80f..9009d84 100644 --- a/frontend/src/pages/ArticleDetail.tsx +++ b/frontend/src/pages/ArticleDetail.tsx @@ -13,9 +13,10 @@ import { useLang } from '../i18n/lang'; import { applySeo } from '../seo'; import { useEffect } from 'react'; import { ARTICLE_EXTENSIONS } from '../components/admin/ArticleEditor'; -import { docToText } from '../utils/tiptapDoc'; +import { docToText, docHasText } from '../utils/tiptapDoc'; import { detectDirection } from '../utils/direction'; import { wrapScriptRuns } from '../utils/bidiText'; +import { toPublishedISO } from '../utils/date'; import type { Article } from '../data/site'; /** بدنه مقاله را به HTML و فهرست مطالب تبدیل می‌کند — سند TipTap جدید یا پاراگراف‌های قدیمی */ @@ -44,28 +45,60 @@ export function ArticleDetail() { const { t, href, lang } = useLang(); const article = articles.find((a) => a.slug === slug); - // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله + // عنوان تب، توضیح متا، OG و JSON-LD از خود مقاله — هم‌ارز نسخهٔ سمت سرور (seo_pages.py) useEffect(() => { if (!article) return; + const pageUrl = new URL(href(`/blog/${article.slug}`), window.location.origin).href; + const absolute = (path: string) => new URL(path, window.location.origin).href; + const authorName = (article.author ?? '').trim(); + const description = article.meta_description || article.excerpt; + const articleSchema: Record = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: article.title, + description, + image: article.image ? absolute(article.image) : undefined, + inLanguage: lang, + // نویسندهٔ انسانی ثبت‌شده → Person (سیگنال E-E-A-T)؛ وگرنه برند + author: authorName + ? { '@type': 'Person', name: authorName } + : { '@type': 'Organization', name: brand.name }, + publisher: { '@type': 'Organization', name: brand.name }, + mainEntityOfPage: pageUrl, + }; + const published = toPublishedISO(article.date); + if (published) articleSchema.datePublished = published; + const breadcrumbSchema = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: t('home'), item: absolute(href('/')) }, + { '@type': 'ListItem', position: 2, name: t('blog'), item: absolute(href('/blog')) }, + { '@type': 'ListItem', position: 3, name: article.title, item: pageUrl }, + ], + }; + // پرسش‌وپاسخ‌های پایان مقاله → FAQPage (برای موتورهای هوش مصنوعی) + const faqEntities = (article.faqs ?? []) + .filter((f) => (f.question ?? '').trim() && (f.answer ?? '').trim()) + .map((f) => ({ + '@type': 'Question', + name: f.question.trim(), + acceptedAnswer: { '@type': 'Answer', text: f.answer.trim() }, + })); + const faqSchema = faqEntities.length + ? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqEntities } + : null; applySeo({ title: `${article.title} | ${brand.name}`, // توضیحات سئوی اختصاصی در اولویت است؛ در نبود آن، چکیده - description: article.meta_description || article.excerpt, + description, path: href(`/blog/${article.slug}`), image: article.image, type: 'article', lang, - jsonLd: { - '@context': 'https://schema.org', - '@type': 'Article', - headline: article.title, - description: article.meta_description || article.excerpt, - image: article.image ? new URL(article.image, window.location.origin).href : undefined, - inLanguage: lang, - author: { '@type': 'Organization', name: article.author?.trim() || brand.name }, - publisher: { '@type': 'Organization', name: brand }, - mainEntityOfPage: new URL(href(`/blog/${article.slug}`), window.location.origin).href, - }, + // alternate انگلیسی فقط با وجود ترجمهٔ واقعی + hasEn: !!(article.title_en?.trim() || article.excerpt_en?.trim() || docHasText(article.body_en)), + jsonLd: [articleSchema, breadcrumbSchema, ...(faqSchema ? [faqSchema] : [])], }); }, [article?.id, lang, href]); @@ -104,6 +137,8 @@ export function ArticleDetail() { alt={article.image_alt || article.title} title={article.image_title || undefined} className="absolute inset-0 h-full w-full object-cover" + fetchPriority="high" + decoding="async" />