"""سرویس چت‌بات سایت آرامش — 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)]} 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}) # وضعیت نهایی گراف: تعداد اسناد بازیابی‌شده — برای تشخیص «پاسخی نبود» 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}) # --- مسیرها --- @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.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): """همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود.""" 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)