Updated vectorstore
Browse files- app/main.py +18 -0
- app/mobile_usability/prompts.py +4 -0
- app/page_speed/config.py +11 -6
- app/rag/chat_history.py +7 -8
- app/rag/db.py +46 -27
- app/rag/routes.py +184 -24
- app/rag/utils.py +253 -28
- app/seo/prompts.py +4 -0
- app/uiux/prompts.py +4 -0
- requirements.txt +1 -1
app/main.py
CHANGED
|
@@ -119,6 +119,24 @@ async def health_check():
|
|
| 119 |
uptime=uptime_str
|
| 120 |
)
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
@app.exception_handler(404)
|
| 123 |
async def not_found_handler(request, exc):
|
| 124 |
logger.warning("404 Not Found: %s %s", request.method, request.url.path)
|
|
|
|
| 119 |
uptime=uptime_str
|
| 120 |
)
|
| 121 |
|
| 122 |
+
# from app.rag.utils import cleanup_old_vectorstores
|
| 123 |
+
|
| 124 |
+
# @asynccontextmanager
|
| 125 |
+
# async def lifespan(app: FastAPI):
|
| 126 |
+
# global startup_time
|
| 127 |
+
# startup_time = time.time()
|
| 128 |
+
# logger.info("🚀 Starting %s v%s", settings.app_name, settings.app_version)
|
| 129 |
+
# logger.info("📊 Server will run on %s:%s", settings.host, settings.port)
|
| 130 |
+
|
| 131 |
+
# # Trigger cleanup on startup
|
| 132 |
+
# deleted = cleanup_old_vectorstores(days=7)
|
| 133 |
+
# logger.info("🧹 Cleanup complete. %s old sessions removed.", deleted)
|
| 134 |
+
|
| 135 |
+
# yield
|
| 136 |
+
|
| 137 |
+
# logger.info("📊 Shutting down %s", settings.app_name)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
@app.exception_handler(404)
|
| 141 |
async def not_found_handler(request, exc):
|
| 142 |
logger.warning("404 Not Found: %s %s", request.method, request.url.path)
|
app/mobile_usability/prompts.py
CHANGED
|
@@ -31,6 +31,10 @@ Formatting rules:
|
|
| 31 |
- Each list may contain zero or more items, but critical items must appear in "high".
|
| 32 |
- Ensure items are specific enough for a developer to action (mention affected selector(s) when possible).
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
{format_instructions}
|
| 35 |
|
| 36 |
Use the following to guide prioritization:
|
|
|
|
| 31 |
- Each list may contain zero or more items, but critical items must appear in "high".
|
| 32 |
- Ensure items are specific enough for a developer to action (mention affected selector(s) when possible).
|
| 33 |
|
| 34 |
+
Important:
|
| 35 |
+
- Respond with *only* a valid JSON object.
|
| 36 |
+
- Do NOT include any commentary or explanation outside the JSON.
|
| 37 |
+
|
| 38 |
{format_instructions}
|
| 39 |
|
| 40 |
Use the following to guide prioritization:
|
app/page_speed/config.py
CHANGED
|
@@ -12,6 +12,11 @@ class Settings(BaseSettings):
|
|
| 12 |
pagespeed_api_key: str
|
| 13 |
gemini_api_key: str
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
# ───────────────────────────────────────────────────────────────────────────
|
| 17 |
# Chat & RAG Configuration
|
|
@@ -30,18 +35,18 @@ class Settings(BaseSettings):
|
|
| 30 |
|
| 31 |
@property
|
| 32 |
def mongo_uri(self) -> str:
|
| 33 |
-
pw = quote_plus(self.mongo_password)
|
| 34 |
-
return (
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
)
|
| 38 |
|
| 39 |
|
| 40 |
# ───────────────────────────────────────────────────────────────────────────
|
| 41 |
# local MongoDB Connection
|
| 42 |
# ───────────────────────────────────────────────────────────────────────────
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
|
| 47 |
# ───────────────────────────────────────────────────────────────────────────
|
|
|
|
| 12 |
pagespeed_api_key: str
|
| 13 |
gemini_api_key: str
|
| 14 |
|
| 15 |
+
# Qdrant (vector DB) connection (optional; if not set, QdrantClient will use defaults)
|
| 16 |
+
qdrant_url: str
|
| 17 |
+
qdrant_api_key: str
|
| 18 |
+
# Optional timeout (seconds) to use when creating clients or making calls
|
| 19 |
+
qdrant_timeout: int = 60
|
| 20 |
|
| 21 |
# ───────────────────────────────────────────────────────────────────────────
|
| 22 |
# Chat & RAG Configuration
|
|
|
|
| 35 |
|
| 36 |
@property
|
| 37 |
def mongo_uri(self) -> str:
|
| 38 |
+
# pw = quote_plus(self.mongo_password)
|
| 39 |
+
# return (
|
| 40 |
+
# f"mongodb+srv://{self.mongo_user}:{pw}@{self.mongo_host}/"
|
| 41 |
+
# f"{self.mongo_db}?retryWrites=true&w=majority&ssl=true"
|
| 42 |
+
# )
|
| 43 |
|
| 44 |
|
| 45 |
# ───────────────────────────────────────────────────────────────────────────
|
| 46 |
# local MongoDB Connection
|
| 47 |
# ───────────────────────────────────────────────────────────────────────────
|
| 48 |
|
| 49 |
+
return f"mongodb://localhost:27017/{self.mongo_db}"
|
| 50 |
|
| 51 |
|
| 52 |
# ───────────────────────────────────────────────────────────────────────────
|
app/rag/chat_history.py
CHANGED
|
@@ -1,12 +1,10 @@
|
|
| 1 |
-
import os
|
| 2 |
import time
|
| 3 |
from typing import List, Dict, Any
|
| 4 |
from pymongo import ReturnDocument
|
| 5 |
|
| 6 |
from app.page_speed.config import settings
|
| 7 |
-
from .db import mongo_client, chat_collection_name
|
| 8 |
from .embeddings import get_llm
|
| 9 |
-
from .utils import get_vectorstore_path # make sure this util is available
|
| 10 |
from langchain.prompts import ChatPromptTemplate
|
| 11 |
from .logging_config import logger
|
| 12 |
|
|
@@ -21,6 +19,7 @@ summarization_prompt = ChatPromptTemplate.from_messages([
|
|
| 21 |
("human", "{chat_history}")
|
| 22 |
])
|
| 23 |
|
|
|
|
| 24 |
class ChatHistoryManager:
|
| 25 |
@staticmethod
|
| 26 |
def create_session(chat_id: str) -> None:
|
|
@@ -82,13 +81,13 @@ class ChatHistoryManager:
|
|
| 82 |
return True
|
| 83 |
|
| 84 |
@staticmethod
|
| 85 |
-
def vectorstore_exists(
|
| 86 |
"""
|
| 87 |
-
Check if a
|
| 88 |
"""
|
| 89 |
-
|
| 90 |
-
exists =
|
| 91 |
-
logger.debug("
|
| 92 |
return exists
|
| 93 |
|
| 94 |
@staticmethod
|
|
|
|
|
|
|
| 1 |
import time
|
| 2 |
from typing import List, Dict, Any
|
| 3 |
from pymongo import ReturnDocument
|
| 4 |
|
| 5 |
from app.page_speed.config import settings
|
| 6 |
+
from .db import mongo_client, chat_collection_name, qdrant_client
|
| 7 |
from .embeddings import get_llm
|
|
|
|
| 8 |
from langchain.prompts import ChatPromptTemplate
|
| 9 |
from .logging_config import logger
|
| 10 |
|
|
|
|
| 19 |
("human", "{chat_history}")
|
| 20 |
])
|
| 21 |
|
| 22 |
+
|
| 23 |
class ChatHistoryManager:
|
| 24 |
@staticmethod
|
| 25 |
def create_session(chat_id: str) -> None:
|
|
|
|
| 81 |
return True
|
| 82 |
|
| 83 |
@staticmethod
|
| 84 |
+
def vectorstore_exists(collection_name: str) -> bool:
|
| 85 |
"""
|
| 86 |
+
Check if a Qdrant collection exists instead of local FAISS path.
|
| 87 |
"""
|
| 88 |
+
collections = qdrant_client.get_collections().collections
|
| 89 |
+
exists = any(c.name == collection_name for c in collections)
|
| 90 |
+
logger.debug("Qdrant collection %s exists: %s", collection_name, exists)
|
| 91 |
return exists
|
| 92 |
|
| 93 |
@staticmethod
|
app/rag/db.py
CHANGED
|
@@ -1,38 +1,57 @@
|
|
| 1 |
-
# db.py
|
| 2 |
-
from pymongo import MongoClient
|
| 3 |
-
from app.page_speed.config import settings
|
|
|
|
| 4 |
|
| 5 |
-
# ──────────────────────────────────────────────────────────────────────────────
|
| 6 |
-
# MongoDB Initialization
|
| 7 |
-
# ──────────────────────────────────────────────────────────────────────────────
|
| 8 |
|
| 9 |
-
# Connect to MongoDB using the URI from settings
|
| 10 |
-
mongo_client = MongoClient(settings.mongo_uri)
|
| 11 |
|
| 12 |
-
# Use the renamed settings attributes
|
| 13 |
-
mongo_db = mongo_client[settings.mongo_db]
|
| 14 |
|
| 15 |
-
# Collection to store metadata that maps user_id → vectorstore_path
|
| 16 |
-
vectorstore_meta_coll = mongo_db["vectorstore_metadata"]
|
| 17 |
|
| 18 |
-
# Name of the collection that MongoDBChatMessageHistory will write to
|
| 19 |
-
chat_collection_name = settings.mongo_collection
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
#
|
| 24 |
-
#
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
-
#
|
| 31 |
-
|
| 32 |
|
| 33 |
-
#
|
| 34 |
-
|
| 35 |
|
| 36 |
-
#
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# # db.py
|
| 2 |
+
# from pymongo import MongoClient
|
| 3 |
+
# from app.page_speed.config import settings
|
| 4 |
+
#from qdrant_client import QdrantClient
|
| 5 |
|
| 6 |
+
# # ──────────────────────────────────────────────────────────────────────────────
|
| 7 |
+
# # MongoDB Initialization
|
| 8 |
+
# # ──────────────────────────────────────────────────────────────────────────────
|
| 9 |
|
| 10 |
+
# # Connect to MongoDB using the URI from settings
|
| 11 |
+
# mongo_client = MongoClient(settings.mongo_uri)
|
| 12 |
|
| 13 |
+
# # Use the renamed settings attributes
|
| 14 |
+
# mongo_db = mongo_client[settings.mongo_db]
|
| 15 |
|
| 16 |
+
# # Collection to store metadata that maps user_id → vectorstore_path
|
| 17 |
+
# vectorstore_meta_coll = mongo_db["vectorstore_metadata"]
|
| 18 |
|
| 19 |
+
# # Name of the collection that MongoDBChatMessageHistory will write to
|
| 20 |
+
# chat_collection_name = settings.mongo_collection
|
| 21 |
|
| 22 |
+
# # ─────────────────────────────────────────────
|
| 23 |
+
# # Qdrant Setup
|
| 24 |
+
# # ─────────────────────────────────────────────
|
| 25 |
+
# # If Qdrant is running locally
|
| 26 |
+
# qdrant_client = QdrantClient(
|
| 27 |
+
# url=settings.qdrant_url, # e.g. "http://localhost:6333"
|
| 28 |
+
# api_key=settings.qdrant_api_key or None
|
| 29 |
+
# )
|
| 30 |
|
| 31 |
+
# ____________________________________________________________
|
| 32 |
+
#Local MongoDB Connection
|
| 33 |
+
# ____________________________________________________________
|
| 34 |
|
| 35 |
+
# db.py
|
| 36 |
+
from pymongo import MongoClient
|
| 37 |
+
from app.page_speed.config import settings
|
| 38 |
+
from qdrant_client import QdrantClient
|
| 39 |
|
| 40 |
+
# Always connect to local MongoDB
|
| 41 |
+
mongo_client = MongoClient("mongodb://localhost:27017/")
|
| 42 |
|
| 43 |
+
# Select the database from settings
|
| 44 |
+
mongo_db = mongo_client[settings.mongo_db]
|
| 45 |
|
| 46 |
+
# Collections
|
| 47 |
+
vectorstore_meta_coll = mongo_db["vectorstore_metadata"]
|
| 48 |
+
chat_collection_name = settings.mongo_collection
|
| 49 |
+
|
| 50 |
+
# ─────────────────────────────────────────────
|
| 51 |
+
# Qdrant Setup
|
| 52 |
+
# ─────────────────────────────────────────────
|
| 53 |
+
# If Qdrant is running locally
|
| 54 |
+
qdrant_client = QdrantClient(
|
| 55 |
+
url=settings.qdrant_url, # e.g. "http://localhost:6333"
|
| 56 |
+
api_key=settings.qdrant_api_key or None
|
| 57 |
+
)
|
app/rag/routes.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
|
|
|
|
|
| 1 |
import os
|
|
|
|
| 2 |
import uuid
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi import APIRouter, HTTPException, Path, Query
|
| 4 |
|
| 5 |
from .schemas import SetupRequest, ChatRequest, SetupResponse, ChatResponse
|
| 6 |
from .utils import (
|
| 7 |
get_vectorstore_path,
|
| 8 |
-
text_splitter,
|
| 9 |
-
embeddings,
|
| 10 |
save_vectorstore_to_disk,
|
| 11 |
upsert_vectorstore_metadata,
|
| 12 |
get_vectorstore_metadata,
|
|
@@ -15,8 +19,49 @@ from .utils import (
|
|
| 15 |
from .chat_history import ChatHistoryManager
|
| 16 |
from .logging_config import logger
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
router = APIRouter(prefix="/rag", tags=["rag"])
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
@router.post("/initialization/{onboarding_id}/{doc_type}", response_model=SetupResponse)
|
| 21 |
async def setup_rag_session(
|
| 22 |
onboarding_id: str = Path(..., description="Unique onboarding identifier"),
|
|
@@ -25,34 +70,35 @@ async def setup_rag_session(
|
|
| 25 |
):
|
| 26 |
"""
|
| 27 |
Ingest documents under a specific document type and create a chat session.
|
| 28 |
-
- If vectorstore exists for onboarding_id and doc_type, skip ingestion.
|
| 29 |
- Always create a new chat_id for this session.
|
|
|
|
| 30 |
"""
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
if os.path.isdir(os.path.join(vectorstore_path, "faiss_index")):
|
| 35 |
logger.info(
|
| 36 |
-
"Vectorstore exists for onboarding_id=%s, doc_type=%s; skipping ingestion",
|
| 37 |
onboarding_id, doc_type
|
| 38 |
)
|
| 39 |
-
metadata =
|
| 40 |
if metadata and metadata.get("chat_id"):
|
| 41 |
chat_id = metadata["chat_id"]
|
| 42 |
else:
|
| 43 |
chat_id = str(uuid.uuid4())
|
| 44 |
ChatHistoryManager.create_session(chat_id)
|
| 45 |
-
|
|
|
|
| 46 |
return SetupResponse(
|
| 47 |
success=True,
|
| 48 |
-
message="RAG setup completed with existing vectorstore.",
|
| 49 |
onboarding_id=onboarding_id,
|
| 50 |
doc_type=doc_type,
|
| 51 |
chat_id=chat_id,
|
| 52 |
-
vectorstore_path=vectorstore_path
|
| 53 |
)
|
| 54 |
|
| 55 |
-
# New ingestion
|
| 56 |
if not body.documents:
|
| 57 |
logger.error(
|
| 58 |
"Missing documents for onboarding_id=%s, doc_type=%s",
|
|
@@ -63,14 +109,123 @@ async def setup_rag_session(
|
|
| 63 |
# Create session and ingest
|
| 64 |
chat_id = str(uuid.uuid4())
|
| 65 |
ChatHistoryManager.create_session(chat_id)
|
|
|
|
| 66 |
all_text = "\n\n".join(body.documents)
|
| 67 |
text_chunks = text_splitter.split_text(all_text)
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
)
|
| 72 |
-
vs_path = save_vectorstore_to_disk(vs, onboarding_id, doc_type)
|
| 73 |
-
upsert_vectorstore_metadata(onboarding_id, doc_type, vs_path, chat_id)
|
| 74 |
|
| 75 |
return SetupResponse(
|
| 76 |
success=True,
|
|
@@ -81,6 +236,7 @@ async def setup_rag_session(
|
|
| 81 |
vectorstore_path=vs_path
|
| 82 |
)
|
| 83 |
|
|
|
|
| 84 |
@router.post("/chat/{onboarding_id}/{doc_type}/{chat_id}", response_model=ChatResponse)
|
| 85 |
async def chat_with_user(
|
| 86 |
onboarding_id: str = Path(...),
|
|
@@ -92,21 +248,25 @@ async def chat_with_user(
|
|
| 92 |
"""
|
| 93 |
Chat endpoint using a specific document-type vectorstore.
|
| 94 |
"""
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
if not ChatHistoryManager.chat_exists(chat_id):
|
| 100 |
raise HTTPException(status_code=404, detail=f"Chat session {chat_id} not found.")
|
| 101 |
|
| 102 |
-
question = body.question.strip()
|
|
|
|
|
|
|
|
|
|
| 103 |
ChatHistoryManager.summarize_if_needed(chat_id, threshold=10)
|
| 104 |
ChatHistoryManager.add_message(chat_id, role="human", content=question)
|
| 105 |
|
| 106 |
chain = build_rag_chain(onboarding_id, doc_type, chat_id, prompt_type)
|
| 107 |
history = ChatHistoryManager.get_messages(chat_id)
|
| 108 |
result = chain.invoke({"question": question, "chat_history": history})
|
| 109 |
-
answer = result.get("answer") or result.get("output_text")
|
| 110 |
ChatHistoryManager.add_message(chat_id, role="ai", content=answer)
|
| 111 |
|
| 112 |
return ChatResponse(
|
|
@@ -116,4 +276,4 @@ async def chat_with_user(
|
|
| 116 |
chat_id=chat_id,
|
| 117 |
onboarding_id=onboarding_id,
|
| 118 |
doc_type=doc_type
|
| 119 |
-
)
|
|
|
|
| 1 |
+
# app/rag/routes.py
|
| 2 |
+
|
| 3 |
import os
|
| 4 |
+
import json
|
| 5 |
import uuid
|
| 6 |
+
import time
|
| 7 |
+
from typing import List, Optional, Iterable
|
| 8 |
+
|
| 9 |
from fastapi import APIRouter, HTTPException, Path, Query
|
| 10 |
|
| 11 |
from .schemas import SetupRequest, ChatRequest, SetupResponse, ChatResponse
|
| 12 |
from .utils import (
|
| 13 |
get_vectorstore_path,
|
|
|
|
|
|
|
| 14 |
save_vectorstore_to_disk,
|
| 15 |
upsert_vectorstore_metadata,
|
| 16 |
get_vectorstore_metadata,
|
|
|
|
| 19 |
from .chat_history import ChatHistoryManager
|
| 20 |
from .logging_config import logger
|
| 21 |
|
| 22 |
+
from qdrant_client import QdrantClient
|
| 23 |
+
from qdrant_client.models import VectorParams, PointStruct, Distance
|
| 24 |
+
from app.page_speed.config import settings
|
| 25 |
+
from .embeddings import embeddings, text_splitter # kept here for ingestion
|
| 26 |
+
|
| 27 |
router = APIRouter(prefix="/rag", tags=["rag"])
|
| 28 |
|
| 29 |
+
|
| 30 |
+
def _get_embeddings_for_texts(texts: List[str]) -> List[List[float]]:
|
| 31 |
+
"""
|
| 32 |
+
Try common embedding API names (embed_documents, embed_texts, embed).
|
| 33 |
+
Falls back to calling embed_query per text (slower).
|
| 34 |
+
"""
|
| 35 |
+
if not texts:
|
| 36 |
+
return []
|
| 37 |
+
|
| 38 |
+
# Preferred bulk API
|
| 39 |
+
for attr in ("embed_documents", "embed_texts", "embed_batch", "embed"):
|
| 40 |
+
fn = getattr(embeddings, attr, None)
|
| 41 |
+
if callable(fn):
|
| 42 |
+
try:
|
| 43 |
+
return fn(texts)
|
| 44 |
+
except Exception:
|
| 45 |
+
logger.debug("Embedding method %s failed; trying next option", attr, exc_info=True)
|
| 46 |
+
|
| 47 |
+
# Fallback: try single-item embedding function repeatedly
|
| 48 |
+
single_fn = getattr(embeddings, "embed_query", None) or getattr(embeddings, "embed", None)
|
| 49 |
+
if callable(single_fn):
|
| 50 |
+
vecs = []
|
| 51 |
+
for t in texts:
|
| 52 |
+
vec = single_fn(t)
|
| 53 |
+
if isinstance(vec, dict) and "embedding" in vec:
|
| 54 |
+
vecs.append(vec["embedding"])
|
| 55 |
+
else:
|
| 56 |
+
vecs.append(vec)
|
| 57 |
+
return vecs
|
| 58 |
+
|
| 59 |
+
raise RuntimeError(
|
| 60 |
+
"Embeddings object does not expose a supported embedding method "
|
| 61 |
+
"(embed_documents/embed_texts/embed_query)."
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
@router.post("/initialization/{onboarding_id}/{doc_type}", response_model=SetupResponse)
|
| 66 |
async def setup_rag_session(
|
| 67 |
onboarding_id: str = Path(..., description="Unique onboarding identifier"),
|
|
|
|
| 70 |
):
|
| 71 |
"""
|
| 72 |
Ingest documents under a specific document type and create a chat session.
|
| 73 |
+
- If vectorstore metadata exists for onboarding_id and doc_type in MongoDB, skip ingestion.
|
| 74 |
- Always create a new chat_id for this session.
|
| 75 |
+
NOTE: This implementation does NOT create or rely on any local files on disk for metadata.
|
| 76 |
"""
|
| 77 |
+
# Use DB metadata instead of local filesystem marker
|
| 78 |
+
existing_meta = get_vectorstore_metadata(onboarding_id, doc_type)
|
| 79 |
+
if existing_meta:
|
|
|
|
| 80 |
logger.info(
|
| 81 |
+
"Vectorstore metadata exists for onboarding_id=%s, doc_type=%s; skipping ingestion",
|
| 82 |
onboarding_id, doc_type
|
| 83 |
)
|
| 84 |
+
metadata = existing_meta
|
| 85 |
if metadata and metadata.get("chat_id"):
|
| 86 |
chat_id = metadata["chat_id"]
|
| 87 |
else:
|
| 88 |
chat_id = str(uuid.uuid4())
|
| 89 |
ChatHistoryManager.create_session(chat_id)
|
| 90 |
+
# ensure DB has chat_id
|
| 91 |
+
upsert_vectorstore_metadata(onboarding_id, doc_type, metadata.get("vectorstore_path"), chat_id, metadata.get("collection_name"))
|
| 92 |
return SetupResponse(
|
| 93 |
success=True,
|
| 94 |
+
message="RAG setup completed with existing vectorstore metadata.",
|
| 95 |
onboarding_id=onboarding_id,
|
| 96 |
doc_type=doc_type,
|
| 97 |
chat_id=chat_id,
|
| 98 |
+
vectorstore_path=metadata.get("vectorstore_path")
|
| 99 |
)
|
| 100 |
|
| 101 |
+
# New ingestion flow
|
| 102 |
if not body.documents:
|
| 103 |
logger.error(
|
| 104 |
"Missing documents for onboarding_id=%s, doc_type=%s",
|
|
|
|
| 109 |
# Create session and ingest
|
| 110 |
chat_id = str(uuid.uuid4())
|
| 111 |
ChatHistoryManager.create_session(chat_id)
|
| 112 |
+
|
| 113 |
all_text = "\n\n".join(body.documents)
|
| 114 |
text_chunks = text_splitter.split_text(all_text)
|
| 115 |
+
|
| 116 |
+
# Build Qdrant client from settings (with timeout + optional prefer_grpc)
|
| 117 |
+
client_kwargs = {}
|
| 118 |
+
if getattr(settings, "qdrant_url", None):
|
| 119 |
+
client_kwargs["url"] = settings.qdrant_url
|
| 120 |
+
if getattr(settings, "qdrant_api_key", None):
|
| 121 |
+
client_kwargs["api_key"] = settings.qdrant_api_key
|
| 122 |
+
|
| 123 |
+
# sensible defaults; override via app config
|
| 124 |
+
qdrant_timeout = getattr(settings, "qdrant_timeout", 60) # seconds (default 60)
|
| 125 |
+
prefer_grpc = getattr(settings, "qdrant_prefer_grpc", False) # set True to use gRPC if available
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
if client_kwargs:
|
| 129 |
+
qdrant_client = QdrantClient(**client_kwargs, timeout=qdrant_timeout, prefer_grpc=prefer_grpc)
|
| 130 |
+
else:
|
| 131 |
+
qdrant_client = QdrantClient(timeout=qdrant_timeout, prefer_grpc=prefer_grpc)
|
| 132 |
+
except TypeError as e:
|
| 133 |
+
logger.exception("Failed to instantiate QdrantClient: %s", e)
|
| 134 |
+
raise HTTPException(status_code=500, detail=f"Failed to construct Qdrant client: {e}")
|
| 135 |
+
|
| 136 |
+
# Deterministic collection name for each onboarding/doc_type
|
| 137 |
+
collection_name = f"vs_{onboarding_id}_{doc_type}"
|
| 138 |
+
|
| 139 |
+
# --------------------------
|
| 140 |
+
# INGEST: compute embeddings
|
| 141 |
+
# --------------------------
|
| 142 |
+
try:
|
| 143 |
+
vectors = _get_embeddings_for_texts(text_chunks)
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.exception("Failed to compute embeddings: %s", e)
|
| 146 |
+
raise HTTPException(status_code=500, detail=f"Embedding error: {e}")
|
| 147 |
+
|
| 148 |
+
if not vectors or len(vectors) != len(text_chunks):
|
| 149 |
+
logger.error("Embeddings length mismatch: vectors=%s texts=%s", len(vectors), len(text_chunks))
|
| 150 |
+
raise HTTPException(status_code=500, detail="Embedding generation failed or returned unexpected shape.")
|
| 151 |
+
|
| 152 |
+
vector_size = len(vectors[0])
|
| 153 |
+
if vector_size == 0:
|
| 154 |
+
raise HTTPException(status_code=500, detail="Embedding returned empty vectors")
|
| 155 |
+
|
| 156 |
+
# Recreate collection (idempotent for onboarding+doc_type)
|
| 157 |
+
try:
|
| 158 |
+
qdrant_client.recreate_collection(
|
| 159 |
+
collection_name=collection_name,
|
| 160 |
+
vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE)
|
| 161 |
+
)
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.exception("Failed to create/recreate qdrant collection '%s': %s", collection_name, e)
|
| 164 |
+
raise HTTPException(status_code=500, detail=f"Failed to create qdrant collection: {e}")
|
| 165 |
+
|
| 166 |
+
# Helper: safe upsert with retries/backoff
|
| 167 |
+
def safe_upsert(client: QdrantClient, collection_name: str, points: Iterable[PointStruct], max_retries: int = 3):
|
| 168 |
+
attempt = 0
|
| 169 |
+
backoff = 1.0
|
| 170 |
+
last_exc: Optional[Exception] = None
|
| 171 |
+
while attempt < max_retries:
|
| 172 |
+
try:
|
| 173 |
+
client.upsert(collection_name=collection_name, points=points)
|
| 174 |
+
return
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
last_exc = exc
|
| 177 |
+
attempt += 1
|
| 178 |
+
logger.warning("Qdrant upsert attempt %d/%d failed: %s", attempt, max_retries, exc)
|
| 179 |
+
if attempt >= max_retries:
|
| 180 |
+
logger.exception("Qdrant upsert failed after %d attempts", max_retries)
|
| 181 |
+
raise
|
| 182 |
+
# exponential backoff
|
| 183 |
+
time.sleep(backoff)
|
| 184 |
+
backoff *= 2.0
|
| 185 |
+
# if loop finishes without returning, raise last exception
|
| 186 |
+
if last_exc:
|
| 187 |
+
raise last_exc
|
| 188 |
+
|
| 189 |
+
# Upsert points in smaller batches and use safe_upsert
|
| 190 |
+
batch_size = getattr(settings, "qdrant_upsert_batch_size", 64) # smaller default batch size
|
| 191 |
+
points_batch: List[PointStruct] = []
|
| 192 |
+
try:
|
| 193 |
+
for i, (vec, txt) in enumerate(zip(vectors, text_chunks)):
|
| 194 |
+
payload = {"text": txt}
|
| 195 |
+
# Use UUID string for id to avoid collisions across sessions
|
| 196 |
+
point_id = str(uuid.uuid4())
|
| 197 |
+
point = PointStruct(id=point_id, vector=vec, payload=payload)
|
| 198 |
+
points_batch.append(point)
|
| 199 |
+
|
| 200 |
+
if len(points_batch) >= batch_size:
|
| 201 |
+
logger.debug("Upserting batch of %d points to collection %s", len(points_batch), collection_name)
|
| 202 |
+
safe_upsert(qdrant_client, collection_name, points_batch)
|
| 203 |
+
points_batch = []
|
| 204 |
+
|
| 205 |
+
# final flush
|
| 206 |
+
if points_batch:
|
| 207 |
+
logger.debug("Upserting final batch of %d points to collection %s", len(points_batch), collection_name)
|
| 208 |
+
safe_upsert(qdrant_client, collection_name, points_batch)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.exception("Failed to upsert points into qdrant: %s", e)
|
| 211 |
+
raise HTTPException(status_code=500, detail=f"Failed to upsert points into Qdrant: {e}")
|
| 212 |
+
|
| 213 |
+
# Create an in-application "vectorstore_path" (URI-style) and store metadata in DB
|
| 214 |
+
vs_path = save_vectorstore_to_disk(
|
| 215 |
+
onboarding_id,
|
| 216 |
+
doc_type,
|
| 217 |
+
collection_name,
|
| 218 |
+
getattr(settings, "qdrant_url", None),
|
| 219 |
+
getattr(settings, "qdrant_api_key", None)
|
| 220 |
+
)
|
| 221 |
+
# Persist metadata into MongoDB (no local disk involved)
|
| 222 |
+
# Persist extra metadata fields so retrieval can use same connection details (if desired)
|
| 223 |
+
upsert_vectorstore_metadata(onboarding_id, doc_type, vs_path, chat_id, collection_name)
|
| 224 |
+
|
| 225 |
+
logger.info(
|
| 226 |
+
"Created Qdrant collection %s for %s/%s (points=%d)",
|
| 227 |
+
collection_name, onboarding_id, doc_type, len(text_chunks)
|
| 228 |
)
|
|
|
|
|
|
|
| 229 |
|
| 230 |
return SetupResponse(
|
| 231 |
success=True,
|
|
|
|
| 236 |
vectorstore_path=vs_path
|
| 237 |
)
|
| 238 |
|
| 239 |
+
|
| 240 |
@router.post("/chat/{onboarding_id}/{doc_type}/{chat_id}", response_model=ChatResponse)
|
| 241 |
async def chat_with_user(
|
| 242 |
onboarding_id: str = Path(...),
|
|
|
|
| 248 |
"""
|
| 249 |
Chat endpoint using a specific document-type vectorstore.
|
| 250 |
"""
|
| 251 |
+
# Use DB metadata instead of local filesystem marker
|
| 252 |
+
metadata = get_vectorstore_metadata(onboarding_id, doc_type)
|
| 253 |
+
if not metadata:
|
| 254 |
+
raise HTTPException(status_code=400, detail="Vectorstore metadata not found; run initialization first.")
|
| 255 |
|
| 256 |
if not ChatHistoryManager.chat_exists(chat_id):
|
| 257 |
raise HTTPException(status_code=404, detail=f"Chat session {chat_id} not found.")
|
| 258 |
|
| 259 |
+
question = (body.question or "").strip()
|
| 260 |
+
if not question:
|
| 261 |
+
raise HTTPException(status_code=400, detail="Question cannot be empty.")
|
| 262 |
+
|
| 263 |
ChatHistoryManager.summarize_if_needed(chat_id, threshold=10)
|
| 264 |
ChatHistoryManager.add_message(chat_id, role="human", content=question)
|
| 265 |
|
| 266 |
chain = build_rag_chain(onboarding_id, doc_type, chat_id, prompt_type)
|
| 267 |
history = ChatHistoryManager.get_messages(chat_id)
|
| 268 |
result = chain.invoke({"question": question, "chat_history": history})
|
| 269 |
+
answer = result.get("answer") or result.get("output_text") or ""
|
| 270 |
ChatHistoryManager.add_message(chat_id, role="ai", content=answer)
|
| 271 |
|
| 272 |
return ChatResponse(
|
|
|
|
| 276 |
chat_id=chat_id,
|
| 277 |
onboarding_id=onboarding_id,
|
| 278 |
doc_type=doc_type
|
| 279 |
+
)
|
app/rag/utils.py
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi import HTTPException
|
| 4 |
|
| 5 |
-
from
|
|
|
|
|
|
|
| 6 |
from langchain_mongodb.chat_message_histories import MongoDBChatMessageHistory
|
| 7 |
from langchain.memory import ConversationBufferMemory
|
| 8 |
from langchain.chains import ConversationalRetrievalChain
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
from app.page_speed.config import settings
|
| 11 |
from .db import vectorstore_meta_coll, chat_collection_name
|
|
@@ -20,46 +31,160 @@ from .prompt_library import (
|
|
| 20 |
mobile_usability_prompt
|
| 21 |
)
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def get_vectorstore_path(onboarding_id: str, doc_type: str) -> str:
|
| 25 |
"""
|
| 26 |
-
Returns
|
|
|
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
-
|
| 29 |
-
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
"""
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
"""
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
vectorstore.save_local(folder_path=faiss_index_path)
|
| 40 |
-
return faiss_index_path
|
| 41 |
|
| 42 |
-
# 3. Metadata now includes doc_type
|
| 43 |
def upsert_vectorstore_metadata(
|
| 44 |
onboarding_id: str,
|
| 45 |
doc_type: str,
|
| 46 |
vectorstore_path: str,
|
| 47 |
-
chat_id: str
|
|
|
|
|
|
|
|
|
|
| 48 |
) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
vectorstore_meta_coll.update_one(
|
| 50 |
{"onboarding_id": onboarding_id, "doc_type": doc_type},
|
| 51 |
-
{"$set":
|
| 52 |
upsert=True
|
| 53 |
)
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
def get_vectorstore_metadata(
|
| 57 |
onboarding_id: str,
|
| 58 |
doc_type: str
|
| 59 |
) -> Optional[Dict[str, Any]]:
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
def build_rag_chain(
|
| 65 |
onboarding_id: str,
|
|
@@ -67,14 +192,112 @@ def build_rag_chain(
|
|
| 67 |
chat_id: str,
|
| 68 |
prompt_type: str
|
| 69 |
) -> ConversationalRetrievalChain:
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
)
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
# History & memory
|
| 80 |
chat_history = MongoDBChatMessageHistory(
|
|
@@ -85,10 +308,12 @@ def build_rag_chain(
|
|
| 85 |
)
|
| 86 |
memory = ConversationBufferMemory(
|
| 87 |
memory_key="chat_history",
|
| 88 |
-
|
|
|
|
| 89 |
)
|
| 90 |
|
| 91 |
llm = get_llm()
|
|
|
|
| 92 |
# Choose prompt
|
| 93 |
if prompt_type == "page_speed":
|
| 94 |
user_prompt = page_speed_prompt
|
|
|
|
| 1 |
+
# app/rag/utils.py
|
| 2 |
+
|
| 3 |
import os
|
| 4 |
+
import json
|
| 5 |
+
from typing import Optional, Dict, Any, List
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
|
| 8 |
from fastapi import HTTPException
|
| 9 |
|
| 10 |
+
from qdrant_client import QdrantClient
|
| 11 |
+
from qdrant_client.http import models as qdrant_models
|
| 12 |
+
|
| 13 |
from langchain_mongodb.chat_message_histories import MongoDBChatMessageHistory
|
| 14 |
from langchain.memory import ConversationBufferMemory
|
| 15 |
from langchain.chains import ConversationalRetrievalChain
|
| 16 |
+
from langchain_core.retrievers import BaseRetriever
|
| 17 |
+
from langchain_core.documents import Document
|
| 18 |
+
|
| 19 |
+
from pydantic import ConfigDict # Pydantic v2 config for BaseModel-based classes
|
| 20 |
|
| 21 |
from app.page_speed.config import settings
|
| 22 |
from .db import vectorstore_meta_coll, chat_collection_name
|
|
|
|
| 31 |
mobile_usability_prompt
|
| 32 |
)
|
| 33 |
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 36 |
+
# Paths & metadata helpers (diskless)
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
def get_vectorstore_path(onboarding_id: str, doc_type: str) -> str:
|
| 40 |
"""
|
| 41 |
+
Returns a non-disk URI-like path for a vectorstore.
|
| 42 |
+
Example: 'qdrant://<onboarding_id>/<doc_type>'
|
| 43 |
+
This avoids creating a local folder while preserving a string that identifies
|
| 44 |
+
the logical vectorstore for other components and logs.
|
| 45 |
"""
|
| 46 |
+
return f"qdrant://{onboarding_id}/{doc_type}"
|
| 47 |
+
|
| 48 |
|
| 49 |
+
def save_vectorstore_to_disk(
|
| 50 |
+
onboarding_id: str,
|
| 51 |
+
doc_type: str,
|
| 52 |
+
collection_name: str,
|
| 53 |
+
qdrant_url: Optional[str],
|
| 54 |
+
qdrant_api_key: Optional[str]
|
| 55 |
+
) -> str:
|
| 56 |
"""
|
| 57 |
+
Previously this created a small local marker file with Qdrant connection details.
|
| 58 |
+
In the diskless version we simply return a logical vectorstore path (URI-style).
|
| 59 |
+
Persisting of metadata is done via `upsert_vectorstore_metadata`.
|
| 60 |
"""
|
| 61 |
+
vs_path = get_vectorstore_path(onboarding_id, doc_type)
|
| 62 |
+
return vs_path
|
| 63 |
+
|
|
|
|
|
|
|
| 64 |
|
|
|
|
| 65 |
def upsert_vectorstore_metadata(
|
| 66 |
onboarding_id: str,
|
| 67 |
doc_type: str,
|
| 68 |
vectorstore_path: str,
|
| 69 |
+
chat_id: str,
|
| 70 |
+
collection_name: Optional[str] = None,
|
| 71 |
+
qdrant_url: Optional[str] = None,
|
| 72 |
+
qdrant_api_key: Optional[str] = None
|
| 73 |
) -> None:
|
| 74 |
+
"""
|
| 75 |
+
Store metadata in MongoDB. Saves useful fields to allow build_rag_chain to
|
| 76 |
+
reconstruct a working Qdrant client later.
|
| 77 |
+
"""
|
| 78 |
+
update = {
|
| 79 |
+
"onboarding_id": onboarding_id,
|
| 80 |
+
"doc_type": doc_type,
|
| 81 |
+
"vectorstore_path": vectorstore_path,
|
| 82 |
+
"chat_id": chat_id,
|
| 83 |
+
"updated_at": datetime.utcnow(),
|
| 84 |
+
}
|
| 85 |
+
if collection_name:
|
| 86 |
+
update["collection_name"] = collection_name
|
| 87 |
+
if qdrant_url:
|
| 88 |
+
update["qdrant_url"] = qdrant_url
|
| 89 |
+
if qdrant_api_key:
|
| 90 |
+
update["qdrant_api_key"] = qdrant_api_key
|
| 91 |
+
|
| 92 |
+
# Upsert the document
|
| 93 |
vectorstore_meta_coll.update_one(
|
| 94 |
{"onboarding_id": onboarding_id, "doc_type": doc_type},
|
| 95 |
+
{"$set": update, "$setOnInsert": {"created_at": datetime.utcnow()}},
|
| 96 |
upsert=True
|
| 97 |
)
|
| 98 |
+
logger.debug("Upserted vectorstore metadata for %s/%s into Mongo", onboarding_id, doc_type)
|
| 99 |
|
| 100 |
|
| 101 |
def get_vectorstore_metadata(
|
| 102 |
onboarding_id: str,
|
| 103 |
doc_type: str
|
| 104 |
) -> Optional[Dict[str, Any]]:
|
| 105 |
+
"""
|
| 106 |
+
Read vectorstore metadata from MongoDB (no local files).
|
| 107 |
+
"""
|
| 108 |
+
meta = vectorstore_meta_coll.find_one({"onboarding_id": onboarding_id, "doc_type": doc_type})
|
| 109 |
+
if meta:
|
| 110 |
+
# convert ObjectId or other non-serializable fields if necessary
|
| 111 |
+
return meta
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 116 |
+
# Qdrant Retriever (pure Qdrant, Pydantic v2-compatible)
|
| 117 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 118 |
+
|
| 119 |
+
class QdrantTextRetriever(BaseRetriever):
|
| 120 |
+
"""
|
| 121 |
+
Minimal retriever that queries Qdrant directly and returns LangChain Documents.
|
| 122 |
+
Assumes payload stores the raw chunk under key 'text'.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
client: QdrantClient
|
| 126 |
+
collection_name: str
|
| 127 |
+
k: int = 5
|
| 128 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 129 |
+
|
| 130 |
+
def _get_relevant_documents(self, query: str, *, run_manager=None) -> List[Document]:
|
| 131 |
+
# Embed the query. Try multiple attribute names safely.
|
| 132 |
+
query_vec = None
|
| 133 |
+
for attr in ("embed_query", "embed_documents", "embed_texts", "embed"):
|
| 134 |
+
fn = getattr(embeddings, attr, None)
|
| 135 |
+
if callable(fn):
|
| 136 |
+
try:
|
| 137 |
+
if attr == "embed_query":
|
| 138 |
+
query_vec = fn(query)
|
| 139 |
+
else:
|
| 140 |
+
q_res = fn([query])
|
| 141 |
+
if isinstance(q_res, list) and q_res:
|
| 142 |
+
query_vec = q_res[0]
|
| 143 |
+
else:
|
| 144 |
+
query_vec = q_res
|
| 145 |
+
break
|
| 146 |
+
except Exception:
|
| 147 |
+
continue
|
| 148 |
+
if query_vec is None:
|
| 149 |
+
raise RuntimeError("No usable embedding function available on embeddings object.")
|
| 150 |
+
|
| 151 |
+
# If embedding helpers return dicts
|
| 152 |
+
if isinstance(query_vec, dict) and "embedding" in query_vec:
|
| 153 |
+
query_vec = query_vec["embedding"]
|
| 154 |
|
| 155 |
+
# Search Qdrant
|
| 156 |
+
results = self.client.search(
|
| 157 |
+
collection_name=self.collection_name,
|
| 158 |
+
query_vector=query_vec,
|
| 159 |
+
limit=self.k
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
docs: List[Document] = []
|
| 163 |
+
for r in results:
|
| 164 |
+
payload = r.payload or {}
|
| 165 |
+
text = payload.get("text")
|
| 166 |
+
if not isinstance(text, str):
|
| 167 |
+
logger.warning(
|
| 168 |
+
"Qdrant payload missing 'text' or not a string; skipping. Payload: %s",
|
| 169 |
+
payload
|
| 170 |
+
)
|
| 171 |
+
continue
|
| 172 |
+
|
| 173 |
+
metadata = {k: v for k, v in payload.items() if k != "text"}
|
| 174 |
+
metadata["score"] = r.score
|
| 175 |
+
|
| 176 |
+
docs.append(Document(page_content=text, metadata=metadata))
|
| 177 |
+
|
| 178 |
+
return docs
|
| 179 |
+
|
| 180 |
+
async def _aget_relevant_documents(self, query: str, *, run_manager=None) -> List[Document]:
|
| 181 |
+
# For simplicity, use sync path
|
| 182 |
+
return self._get_relevant_documents(query, run_manager=run_manager)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 186 |
+
# Build RAG chain (pure Qdrant), using DB metadata (no local files)
|
| 187 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 188 |
|
| 189 |
def build_rag_chain(
|
| 190 |
onboarding_id: str,
|
|
|
|
| 192 |
chat_id: str,
|
| 193 |
prompt_type: str
|
| 194 |
) -> ConversationalRetrievalChain:
|
| 195 |
+
"""
|
| 196 |
+
Builds a ConversationalRetrievalChain using pure Qdrant as backend.
|
| 197 |
+
Loads connection details from the MongoDB metadata collection instead of a file.
|
| 198 |
+
If metadata is missing, tries to detect an existing Qdrant collection named
|
| 199 |
+
'vs_{onboarding_id}_{doc_type}' and auto-registers it in Mongo.
|
| 200 |
+
"""
|
| 201 |
+
meta = get_vectorstore_metadata(onboarding_id, doc_type)
|
| 202 |
+
|
| 203 |
+
# If metadata missing — attempt a Qdrant-side fallback detection
|
| 204 |
+
if not meta:
|
| 205 |
+
logger.warning("Vectorstore metadata not found for %s/%s in Mongo; attempting Qdrant fallback detection", onboarding_id, doc_type)
|
| 206 |
+
|
| 207 |
+
# Build a Qdrant client from global settings to detect existing collection
|
| 208 |
+
qdrant_url = getattr(settings, "qdrant_url", None)
|
| 209 |
+
qdrant_api_key = getattr(settings, "qdrant_api_key", None)
|
| 210 |
+
client_kwargs = {}
|
| 211 |
+
if qdrant_url:
|
| 212 |
+
client_kwargs["url"] = qdrant_url
|
| 213 |
+
if qdrant_api_key:
|
| 214 |
+
client_kwargs["api_key"] = qdrant_api_key
|
| 215 |
+
|
| 216 |
+
qdrant_timeout = getattr(settings, "qdrant_timeout", 60)
|
| 217 |
+
prefer_grpc = getattr(settings, "qdrant_prefer_grpc", False)
|
| 218 |
+
|
| 219 |
+
try:
|
| 220 |
+
if client_kwargs:
|
| 221 |
+
qdrant_client = QdrantClient(**client_kwargs, timeout=qdrant_timeout, prefer_grpc=prefer_grpc)
|
| 222 |
+
else:
|
| 223 |
+
qdrant_client = QdrantClient(timeout=qdrant_timeout, prefer_grpc=prefer_grpc)
|
| 224 |
+
except Exception as e:
|
| 225 |
+
logger.exception("Failed to create Qdrant client during fallback detection: %s", e)
|
| 226 |
+
raise HTTPException(status_code=500, detail="Vectorstore metadata not found and failed to connect to Qdrant for fallback detection.")
|
| 227 |
+
|
| 228 |
+
guessed_collection = f"vs_{onboarding_id}_{doc_type}"
|
| 229 |
+
try:
|
| 230 |
+
# get_collection raises if not present; get_collections returns list
|
| 231 |
+
info = None
|
| 232 |
+
try:
|
| 233 |
+
info = qdrant_client.get_collection(collection_name=guessed_collection)
|
| 234 |
+
except Exception:
|
| 235 |
+
# try listing collections (less strict)
|
| 236 |
+
collections_info = qdrant_client.get_collections()
|
| 237 |
+
# get_collections returns a dict-like structure; search names
|
| 238 |
+
found = False
|
| 239 |
+
for c in collections_info.get("collections", []) if isinstance(collections_info, dict) else collections_info:
|
| 240 |
+
name = c.get("name") if isinstance(c, dict) else getattr(c, "name", None)
|
| 241 |
+
if name == guessed_collection:
|
| 242 |
+
found = True
|
| 243 |
+
break
|
| 244 |
+
if not found:
|
| 245 |
+
info = None
|
| 246 |
+
else:
|
| 247 |
+
info = {"name": guessed_collection}
|
| 248 |
+
|
| 249 |
+
if info:
|
| 250 |
+
logger.info("Detected existing Qdrant collection '%s' via fallback; auto-registering metadata in Mongo", guessed_collection)
|
| 251 |
+
# auto-register minimal metadata so chat can proceed
|
| 252 |
+
vs_path = get_vectorstore_path(onboarding_id, doc_type)
|
| 253 |
+
# we don't have a chat_id to store here; store empty string and let setup create chat sessions later
|
| 254 |
+
upsert_vectorstore_metadata(onboarding_id, doc_type, vs_path, chat_id="", collection_name=guessed_collection, qdrant_url=qdrant_url, qdrant_api_key=qdrant_api_key)
|
| 255 |
+
meta = get_vectorstore_metadata(onboarding_id, doc_type)
|
| 256 |
+
else:
|
| 257 |
+
logger.info("Qdrant fallback detection found no collection named '%s'", guessed_collection)
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.exception("Error while checking Qdrant collections for fallback detection: %s", e)
|
| 260 |
+
# continue; meta still None and we'll raise below
|
| 261 |
+
|
| 262 |
+
if not meta:
|
| 263 |
+
# Final: helpful error message with actionable next steps
|
| 264 |
+
raise HTTPException(
|
| 265 |
+
status_code=400,
|
| 266 |
+
detail=(
|
| 267 |
+
"Vectorstore metadata not found; run initialization first. "
|
| 268 |
+
"Call POST /rag/initialization/{onboarding_id}/{doc_type} with documents to ingest. "
|
| 269 |
+
"If you already initialized, check server logs for ingestion errors and verify Mongo collection "
|
| 270 |
+
"'vectorstore_meta_coll' contains the record for this onboarding/doc_type."
|
| 271 |
+
)
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
collection_name = meta.get("collection_name")
|
| 275 |
+
if not collection_name:
|
| 276 |
+
raise HTTPException(status_code=500, detail="Qdrant collection name missing in metadata.")
|
| 277 |
+
|
| 278 |
+
# Prefer values from marker; fall back to app settings if needed
|
| 279 |
+
qdrant_url = meta.get("qdrant_url") or getattr(settings, "qdrant_url", None)
|
| 280 |
+
qdrant_api_key = meta.get("qdrant_api_key") or getattr(settings, "qdrant_api_key", None)
|
| 281 |
+
|
| 282 |
+
client_kwargs = {}
|
| 283 |
+
if qdrant_url:
|
| 284 |
+
client_kwargs["url"] = qdrant_url
|
| 285 |
+
if qdrant_api_key:
|
| 286 |
+
client_kwargs["api_key"] = qdrant_api_key
|
| 287 |
+
|
| 288 |
+
qdrant_timeout = getattr(settings, "qdrant_timeout", 60)
|
| 289 |
+
prefer_grpc = getattr(settings, "qdrant_prefer_grpc", False)
|
| 290 |
+
|
| 291 |
+
try:
|
| 292 |
+
if client_kwargs:
|
| 293 |
+
qdrant_client = QdrantClient(**client_kwargs, timeout=qdrant_timeout, prefer_grpc=prefer_grpc)
|
| 294 |
+
else:
|
| 295 |
+
qdrant_client = QdrantClient(timeout=qdrant_timeout, prefer_grpc=prefer_grpc)
|
| 296 |
+
except Exception as e:
|
| 297 |
+
logger.exception("Failed to construct Qdrant client for retrieval: %s", e)
|
| 298 |
+
raise HTTPException(status_code=500, detail=f"Failed to connect to Qdrant: {e}")
|
| 299 |
+
|
| 300 |
+
retriever = QdrantTextRetriever(client=qdrant_client, collection_name=collection_name, k=5)
|
| 301 |
|
| 302 |
# History & memory
|
| 303 |
chat_history = MongoDBChatMessageHistory(
|
|
|
|
| 308 |
)
|
| 309 |
memory = ConversationBufferMemory(
|
| 310 |
memory_key="chat_history",
|
| 311 |
+
chat_memory=chat_history,
|
| 312 |
+
return_messages=True,
|
| 313 |
)
|
| 314 |
|
| 315 |
llm = get_llm()
|
| 316 |
+
|
| 317 |
# Choose prompt
|
| 318 |
if prompt_type == "page_speed":
|
| 319 |
user_prompt = page_speed_prompt
|
app/seo/prompts.py
CHANGED
|
@@ -19,6 +19,10 @@ Return *only* a JSON object that has a single top-level key, `priority_suggestio
|
|
| 19 |
|
| 20 |
Each list item must be a **plain-English sentence**, prefixed with its SEO category tag (e.g. `[On-Page]` or `[Schema]`), and suffixed with `(Effort Level: high|medium|low)`.
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
{format_instructions}
|
| 23 |
|
| 24 |
Performance Report:
|
|
|
|
| 19 |
|
| 20 |
Each list item must be a **plain-English sentence**, prefixed with its SEO category tag (e.g. `[On-Page]` or `[Schema]`), and suffixed with `(Effort Level: high|medium|low)`.
|
| 21 |
|
| 22 |
+
Important:
|
| 23 |
+
- Respond with *only* a valid JSON object.
|
| 24 |
+
- Do NOT include any commentary or explanation outside the JSON.
|
| 25 |
+
|
| 26 |
{format_instructions}
|
| 27 |
|
| 28 |
Performance Report:
|
app/uiux/prompts.py
CHANGED
|
@@ -21,6 +21,10 @@ Requirements:
|
|
| 21 |
6. Ensure the output is strictly JSON—no additional text, comments, or keys.
|
| 22 |
7. Validate JSON syntax: keys and strings must be enclosed in double quotes.
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
{format_instructions}
|
| 25 |
|
| 26 |
Input Report Data:
|
|
|
|
| 21 |
6. Ensure the output is strictly JSON—no additional text, comments, or keys.
|
| 22 |
7. Validate JSON syntax: keys and strings must be enclosed in double quotes.
|
| 23 |
|
| 24 |
+
Important:
|
| 25 |
+
- Respond with *only* a valid JSON object.
|
| 26 |
+
- Do NOT include any commentary or explanation outside the JSON.
|
| 27 |
+
|
| 28 |
{format_instructions}
|
| 29 |
|
| 30 |
Input Report Data:
|
requirements.txt
CHANGED
|
@@ -7,7 +7,7 @@ pydantic
|
|
| 7 |
pydantic_settings
|
| 8 |
langchain_groq
|
| 9 |
langchain_community
|
| 10 |
-
faiss-cpu
|
| 11 |
pymongo
|
| 12 |
langchain-mongodb
|
| 13 |
langchain_google_genai
|
|
|
|
|
|
| 7 |
pydantic_settings
|
| 8 |
langchain_groq
|
| 9 |
langchain_community
|
|
|
|
| 10 |
pymongo
|
| 11 |
langchain-mongodb
|
| 12 |
langchain_google_genai
|
| 13 |
+
qdrant-client
|