Spaces:
Sleeping
Sleeping
Upload 20 files
Browse files- backend/.env +3 -0
- backend/__init__.py +1 -0
- backend/app/__init__.py +1 -0
- backend/app/config.py +20 -0
- backend/app/database.py +16 -0
- backend/app/main.py +75 -0
- backend/app/main_hf.py +45 -0
- backend/app/models.py +51 -0
- backend/app/routers/__init__.py +1 -0
- backend/app/routers/chatbot.py +97 -0
- backend/app/routers/health.py +98 -0
- backend/app/routers/knowledge_base.py +171 -0
- backend/app/routers/rfp_processing.py +185 -0
- backend/app/schemas.py +73 -0
- backend/app/services/__init__.py +1 -0
- backend/app/services/hf_ollama_service.py +125 -0
- backend/app/services/huggingface_service.py +128 -0
- backend/app/services/pdf_service.py +82 -0
- backend/app/services/similarity_service.py +305 -0
- backend/requirements.txt +11 -0
backend/.env
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ο»ΏDATABASE_URL=sqlite:///./rfp.db
|
| 2 |
+
HUGGINGFACE_API_KEY=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
|
| 3 |
+
HUGGINGFACE_MODEL=meta-llama/Meta-Llama-3.1-8B-Instruct
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Backend package
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Empty __init__.py files for Python package structure
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# Get the backend directory path
|
| 5 |
+
BACKEND_DIR = Path(__file__).parent.parent
|
| 6 |
+
ENV_FILE = BACKEND_DIR / ".env"
|
| 7 |
+
|
| 8 |
+
class Settings(BaseSettings):
|
| 9 |
+
DATABASE_URL: str = "sqlite:///./rfp.db"
|
| 10 |
+
|
| 11 |
+
# HuggingFace API settings
|
| 12 |
+
HUGGINGFACE_API_KEY: str = ""
|
| 13 |
+
HUGGINGFACE_MODEL: str = "meta-llama/Meta-Llama-3.1-8B-Instruct"
|
| 14 |
+
|
| 15 |
+
class Config:
|
| 16 |
+
env_file = str(ENV_FILE)
|
| 17 |
+
env_file_encoding = 'utf-8'
|
| 18 |
+
extra = 'ignore' # Ignore extra fields from .env
|
| 19 |
+
|
| 20 |
+
settings = Settings()
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
+
from sqlalchemy.orm import sessionmaker
|
| 4 |
+
from app.config import settings
|
| 5 |
+
|
| 6 |
+
engine = create_engine(settings.DATABASE_URL)
|
| 7 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 8 |
+
|
| 9 |
+
Base = declarative_base()
|
| 10 |
+
|
| 11 |
+
def get_db():
|
| 12 |
+
db = SessionLocal()
|
| 13 |
+
try:
|
| 14 |
+
yield db
|
| 15 |
+
finally:
|
| 16 |
+
db.close()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.staticfiles import StaticFiles
|
| 4 |
+
from fastapi.responses import FileResponse
|
| 5 |
+
from app.database import engine, Base
|
| 6 |
+
from app.routers import knowledge_base, rfp_processing, chatbot, health
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# Create database tables
|
| 11 |
+
Base.metadata.create_all(bind=engine)
|
| 12 |
+
|
| 13 |
+
app = FastAPI(
|
| 14 |
+
title="RFP Management System",
|
| 15 |
+
description="AI-powered RFP management with intelligent answer generation",
|
| 16 |
+
version="1.0.0"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# CORS middleware for frontend
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"], # SvelteKit default ports
|
| 23 |
+
allow_credentials=True,
|
| 24 |
+
allow_methods=["*"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Include routers
|
| 29 |
+
app.include_router(health.router)
|
| 30 |
+
app.include_router(knowledge_base.router)
|
| 31 |
+
app.include_router(rfp_processing.router)
|
| 32 |
+
app.include_router(chatbot.router)
|
| 33 |
+
|
| 34 |
+
# Serve static frontend files
|
| 35 |
+
FRONTEND_BUILD_DIR = Path(__file__).parent.parent.parent / "frontend" / "build"
|
| 36 |
+
|
| 37 |
+
# Check if we're in deployment (frontend/build exists at different path)
|
| 38 |
+
if not FRONTEND_BUILD_DIR.exists():
|
| 39 |
+
# In Docker deployment, frontend is at /app/frontend/build
|
| 40 |
+
FRONTEND_BUILD_DIR = Path("/app/frontend/build")
|
| 41 |
+
|
| 42 |
+
if FRONTEND_BUILD_DIR.exists():
|
| 43 |
+
# Mount static files
|
| 44 |
+
app.mount("/_app", StaticFiles(directory=str(FRONTEND_BUILD_DIR / "_app")), name="app-assets")
|
| 45 |
+
|
| 46 |
+
# Serve index.html for root and all other routes (SPA fallback)
|
| 47 |
+
@app.get("/")
|
| 48 |
+
async def serve_frontend():
|
| 49 |
+
return FileResponse(str(FRONTEND_BUILD_DIR / "index.html"))
|
| 50 |
+
|
| 51 |
+
@app.get("/{full_path:path}")
|
| 52 |
+
async def serve_spa(full_path: str):
|
| 53 |
+
# Don't intercept API routes
|
| 54 |
+
if full_path.startswith("api/"):
|
| 55 |
+
# Let FastAPI handle API routes normally
|
| 56 |
+
from fastapi import HTTPException
|
| 57 |
+
raise HTTPException(status_code=404, detail="Not found")
|
| 58 |
+
|
| 59 |
+
# Serve static files if they exist
|
| 60 |
+
file_path = FRONTEND_BUILD_DIR / full_path
|
| 61 |
+
if file_path.is_file():
|
| 62 |
+
return FileResponse(str(file_path))
|
| 63 |
+
# Otherwise serve index.html for SPA routing
|
| 64 |
+
return FileResponse(str(FRONTEND_BUILD_DIR / "index.html"))
|
| 65 |
+
else:
|
| 66 |
+
# Fallback if frontend not found
|
| 67 |
+
@app.get("/")
|
| 68 |
+
def read_root():
|
| 69 |
+
return {
|
| 70 |
+
"message": "RFP Management System API",
|
| 71 |
+
"version": "1.0.0",
|
| 72 |
+
"docs": "/docs"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
backend/app/main_hf.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import FastAPI
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from fastapi.staticfiles import StaticFiles
|
| 5 |
+
from app.routers import rfp_processing
|
| 6 |
+
from app.routers import knowledge_base
|
| 7 |
+
from app.database import engine, Base
|
| 8 |
+
|
| 9 |
+
# Create tables
|
| 10 |
+
Base.metadata.create_all(bind=engine)
|
| 11 |
+
|
| 12 |
+
app = FastAPI(
|
| 13 |
+
title="Sedna RFP Management System",
|
| 14 |
+
description="AI-powered RFP response generation system",
|
| 15 |
+
version="1.0.0"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
# CORS middleware
|
| 19 |
+
app.add_middleware(
|
| 20 |
+
CORSMiddleware,
|
| 21 |
+
allow_origins=["*"],
|
| 22 |
+
allow_credentials=True,
|
| 23 |
+
allow_methods=["*"],
|
| 24 |
+
allow_headers=["*"],
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Include routers
|
| 28 |
+
app.include_router(knowledge_base.router)
|
| 29 |
+
app.include_router(rfp_processing.router)
|
| 30 |
+
|
| 31 |
+
# Serve frontend static files if available
|
| 32 |
+
if os.path.exists("../frontend/build"):
|
| 33 |
+
app.mount("/", StaticFiles(directory="../frontend/build", html=True), name="frontend")
|
| 34 |
+
|
| 35 |
+
@app.get("/health")
|
| 36 |
+
def health_check():
|
| 37 |
+
return {"status": "healthy"}
|
| 38 |
+
|
| 39 |
+
@app.get("/api/health")
|
| 40 |
+
def api_health_check():
|
| 41 |
+
return {
|
| 42 |
+
"status": "healthy",
|
| 43 |
+
"ai_service": "huggingface",
|
| 44 |
+
"database": "sqlite"
|
| 45 |
+
}
|
backend/app/models.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float
|
| 2 |
+
from sqlalchemy.orm import relationship
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from app.database import Base
|
| 5 |
+
|
| 6 |
+
class RFP(Base):
|
| 7 |
+
__tablename__ = "rfps"
|
| 8 |
+
|
| 9 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 10 |
+
title = Column(String(500), nullable=False)
|
| 11 |
+
description = Column(Text)
|
| 12 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 13 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 14 |
+
|
| 15 |
+
questions = relationship("Question", back_populates="rfp", cascade="all, delete-orphan")
|
| 16 |
+
|
| 17 |
+
class Question(Base):
|
| 18 |
+
__tablename__ = "questions"
|
| 19 |
+
|
| 20 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 21 |
+
rfp_id = Column(Integer, ForeignKey("rfps.id"), nullable=False)
|
| 22 |
+
section = Column(String(200))
|
| 23 |
+
question_id = Column(String(50)) # Custom question ID like "Q1.1"
|
| 24 |
+
question_text = Column(Text, nullable=False)
|
| 25 |
+
answer_text = Column(Text, nullable=False)
|
| 26 |
+
page_start = Column(Integer)
|
| 27 |
+
page_end = Column(Integer)
|
| 28 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 29 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 30 |
+
|
| 31 |
+
rfp = relationship("RFP", back_populates="questions")
|
| 32 |
+
embedding = relationship("QuestionEmbedding", back_populates="question", uselist=False, cascade="all, delete-orphan")
|
| 33 |
+
|
| 34 |
+
class QuestionEmbedding(Base):
|
| 35 |
+
__tablename__ = "question_embeddings"
|
| 36 |
+
|
| 37 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 38 |
+
question_id = Column(Integer, ForeignKey("questions.id"), nullable=False, unique=True)
|
| 39 |
+
embedding = Column(Text, nullable=False) # JSON serialized numpy array
|
| 40 |
+
|
| 41 |
+
question = relationship("Question", back_populates="embedding")
|
| 42 |
+
|
| 43 |
+
class UploadedRFP(Base):
|
| 44 |
+
__tablename__ = "uploaded_rfps"
|
| 45 |
+
|
| 46 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 47 |
+
filename = Column(String(500), nullable=False)
|
| 48 |
+
rfp_name = Column(String(500)) # Friendly name for the RFP
|
| 49 |
+
file_path = Column(String(1000), nullable=False)
|
| 50 |
+
uploaded_at = Column(DateTime, default=datetime.utcnow)
|
| 51 |
+
text_content = Column(Text) # Extracted text from first 3 pages or full document
|
backend/app/routers/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Empty __init__.py files for Python package structure
|
backend/app/routers/chatbot.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import List
|
| 4 |
+
from app.database import get_db
|
| 5 |
+
from app.services.huggingface_service import huggingface_service
|
| 6 |
+
from app.models import UploadedRFP
|
| 7 |
+
import pdfplumber
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/chatbot", tags=["Chatbot"])
|
| 10 |
+
|
| 11 |
+
class ChatMessage(BaseModel):
|
| 12 |
+
role: str
|
| 13 |
+
content: str
|
| 14 |
+
|
| 15 |
+
class ChatRequest(BaseModel):
|
| 16 |
+
message: str
|
| 17 |
+
conversation_history: List[ChatMessage] = []
|
| 18 |
+
|
| 19 |
+
class ChatResponse(BaseModel):
|
| 20 |
+
response: str
|
| 21 |
+
|
| 22 |
+
@router.post("/chat", response_model=ChatResponse)
|
| 23 |
+
async def chat_with_assistant(request: ChatRequest):
|
| 24 |
+
"""
|
| 25 |
+
AI chatbot that helps understand uploaded RFPs and answer questions about requirements.
|
| 26 |
+
Uses HuggingFace Inference API with the uploaded RFP content.
|
| 27 |
+
"""
|
| 28 |
+
try:
|
| 29 |
+
db = next(get_db())
|
| 30 |
+
|
| 31 |
+
# Get the most recent uploaded RFP
|
| 32 |
+
uploaded_rfp = db.query(UploadedRFP).order_by(UploadedRFP.id.desc()).first()
|
| 33 |
+
|
| 34 |
+
if not uploaded_rfp:
|
| 35 |
+
return ChatResponse(
|
| 36 |
+
response="I don't have access to any uploaded RFPs yet. Please upload an RFP document first so I can help you understand its requirements."
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Extract RFP content for context
|
| 40 |
+
rfp_context = ""
|
| 41 |
+
if uploaded_rfp.file_path:
|
| 42 |
+
try:
|
| 43 |
+
with pdfplumber.open(uploaded_rfp.file_path) as pdf:
|
| 44 |
+
# Extract first 5 pages for context
|
| 45 |
+
pages_text = []
|
| 46 |
+
for page in pdf.pages[:5]:
|
| 47 |
+
text = page.extract_text()
|
| 48 |
+
if text:
|
| 49 |
+
pages_text.append(text)
|
| 50 |
+
rfp_context = "\n\n".join(pages_text)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
print(f"Error extracting RFP content: {e}")
|
| 53 |
+
rfp_context = "Unable to extract RFP content"
|
| 54 |
+
|
| 55 |
+
# Create comprehensive prompt for the AI assistant
|
| 56 |
+
system_context = f"""You are an expert RFP assistant for Sedna Consulting Group helping the team understand the uploaded RFP: "{uploaded_rfp.rfp_name}".
|
| 57 |
+
|
| 58 |
+
Answer questions about:
|
| 59 |
+
- RFP requirements and specifications
|
| 60 |
+
- Deadlines and submission guidelines
|
| 61 |
+
- Technical requirements
|
| 62 |
+
- What information Sedna needs to provide
|
| 63 |
+
|
| 64 |
+
Be conversational, professional, and cite specific sections when possible.
|
| 65 |
+
|
| 66 |
+
RFP CONTENT:
|
| 67 |
+
{rfp_context[:3000]}"""
|
| 68 |
+
|
| 69 |
+
# Use HuggingFace Inference API
|
| 70 |
+
print("Using HuggingFace API for chat...")
|
| 71 |
+
|
| 72 |
+
# Build conversation history
|
| 73 |
+
history = [
|
| 74 |
+
{'role': msg.role, 'content': msg.content}
|
| 75 |
+
for msg in request.conversation_history[-6:]
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
response_text = huggingface_service.generate_chat_response(
|
| 79 |
+
system_prompt=system_context,
|
| 80 |
+
user_message=request.message,
|
| 81 |
+
conversation_history=history,
|
| 82 |
+
max_tokens=800,
|
| 83 |
+
temperature=0.7
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# If HF returned a diagnostic error string, surface it as a 502 so frontend can show it
|
| 87 |
+
if isinstance(response_text, str) and response_text.startswith("Error:"):
|
| 88 |
+
# Provide actionable details but avoid leaking secrets
|
| 89 |
+
raise HTTPException(status_code=502, detail=response_text)
|
| 90 |
+
|
| 91 |
+
return ChatResponse(response=response_text)
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
print(f"Chatbot error: {e}")
|
| 95 |
+
return ChatResponse(
|
| 96 |
+
response="I apologize, but I encountered an error processing your question. Please try again or be more specific about what section of the RFP you're interested in."
|
| 97 |
+
)
|
backend/app/routers/health.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from app.services.huggingface_service import huggingface_service
|
| 4 |
+
from app.config import settings
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/api/health", tags=["Health"])
|
| 7 |
+
|
| 8 |
+
class HealthStatus(BaseModel):
|
| 9 |
+
status: str
|
| 10 |
+
message: str
|
| 11 |
+
details: dict = {}
|
| 12 |
+
|
| 13 |
+
@router.get("/", response_model=HealthStatus)
|
| 14 |
+
async def health_check():
|
| 15 |
+
"""Basic health check for the API"""
|
| 16 |
+
return HealthStatus(
|
| 17 |
+
status="healthy",
|
| 18 |
+
message="API is running",
|
| 19 |
+
details={"version": "1.0.0"}
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
@router.get("/ai", response_model=HealthStatus)
|
| 23 |
+
async def ai_health_check():
|
| 24 |
+
"""
|
| 25 |
+
Check if HuggingFace AI model is accessible and working
|
| 26 |
+
Returns detailed diagnostics about model availability, token validity, etc.
|
| 27 |
+
"""
|
| 28 |
+
# Check if service is configured
|
| 29 |
+
if not huggingface_service.is_configured():
|
| 30 |
+
return HealthStatus(
|
| 31 |
+
status="unhealthy",
|
| 32 |
+
message="HuggingFace API key not configured",
|
| 33 |
+
details={
|
| 34 |
+
"model": settings.HUGGINGFACE_MODEL,
|
| 35 |
+
"api_key_set": bool(settings.HUGGINGFACE_API_KEY),
|
| 36 |
+
"recommendation": "Set HUGGINGFACE_API_KEY in Space secrets"
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Check model availability using the service's built-in check
|
| 41 |
+
diag = huggingface_service._check_model_available()
|
| 42 |
+
|
| 43 |
+
if diag is None:
|
| 44 |
+
# Model is accessible
|
| 45 |
+
return HealthStatus(
|
| 46 |
+
status="healthy",
|
| 47 |
+
message="AI model is accessible and ready",
|
| 48 |
+
details={
|
| 49 |
+
"model": settings.HUGGINGFACE_MODEL.strip(),
|
| 50 |
+
"api_status": "connected"
|
| 51 |
+
}
|
| 52 |
+
)
|
| 53 |
+
else:
|
| 54 |
+
# Model check failed
|
| 55 |
+
error_msg = str(diag).lower()
|
| 56 |
+
|
| 57 |
+
if "401" in error_msg or "unauthorized" in error_msg:
|
| 58 |
+
return HealthStatus(
|
| 59 |
+
status="unhealthy",
|
| 60 |
+
message="API key unauthorized",
|
| 61 |
+
details={
|
| 62 |
+
"model": settings.HUGGINGFACE_MODEL.strip(),
|
| 63 |
+
"error": diag,
|
| 64 |
+
"recommendation": "Check your HUGGINGFACE_API_KEY token has appropriate permissions"
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
elif "403" in error_msg or "forbidden" in error_msg:
|
| 69 |
+
return HealthStatus(
|
| 70 |
+
status="unhealthy",
|
| 71 |
+
message="Access forbidden - model may be gated",
|
| 72 |
+
details={
|
| 73 |
+
"model": settings.HUGGINGFACE_MODEL.strip(),
|
| 74 |
+
"error": diag,
|
| 75 |
+
"recommendation": f"Ensure you've accepted the model license at https://huggingface.co/{settings.HUGGINGFACE_MODEL.strip()}"
|
| 76 |
+
}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
elif "503" in error_msg or "loading" in error_msg:
|
| 80 |
+
return HealthStatus(
|
| 81 |
+
status="degraded",
|
| 82 |
+
message="Model is loading",
|
| 83 |
+
details={
|
| 84 |
+
"model": settings.HUGGINGFACE_MODEL.strip(),
|
| 85 |
+
"error": diag,
|
| 86 |
+
"recommendation": "Wait a moment and try again. Model is initializing on HuggingFace servers."
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
else:
|
| 91 |
+
return HealthStatus(
|
| 92 |
+
status="unhealthy",
|
| 93 |
+
message="Error connecting to HuggingFace API",
|
| 94 |
+
details={
|
| 95 |
+
"model": settings.HUGGINGFACE_MODEL.strip(),
|
| 96 |
+
"error": diag
|
| 97 |
+
}
|
| 98 |
+
)
|
backend/app/routers/knowledge_base.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List
|
| 4 |
+
from app.database import get_db
|
| 5 |
+
from app.models import RFP, Question
|
| 6 |
+
from app.schemas import (
|
| 7 |
+
RFPCreate, RFPUpdate, RFPResponse,
|
| 8 |
+
QuestionCreate, QuestionUpdate, QuestionResponse, QuestionWithRFP
|
| 9 |
+
)
|
| 10 |
+
from app.services.similarity_service import similarity_service
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/api/knowledge-base", tags=["Knowledge Base"])
|
| 13 |
+
|
| 14 |
+
# RFP Endpoints
|
| 15 |
+
@router.post("/rfps", response_model=RFPResponse)
|
| 16 |
+
def create_rfp(rfp: RFPCreate, db: Session = Depends(get_db)):
|
| 17 |
+
"""Create a new RFP"""
|
| 18 |
+
db_rfp = RFP(**rfp.model_dump())
|
| 19 |
+
db.add(db_rfp)
|
| 20 |
+
db.commit()
|
| 21 |
+
db.refresh(db_rfp)
|
| 22 |
+
return db_rfp
|
| 23 |
+
|
| 24 |
+
@router.get("/rfps", response_model=List[RFPResponse])
|
| 25 |
+
def list_rfps(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
| 26 |
+
"""List all RFPs"""
|
| 27 |
+
rfps = db.query(RFP).offset(skip).limit(limit).all()
|
| 28 |
+
return rfps
|
| 29 |
+
|
| 30 |
+
@router.get("/rfps/{rfp_id}", response_model=RFPResponse)
|
| 31 |
+
def get_rfp(rfp_id: int, db: Session = Depends(get_db)):
|
| 32 |
+
"""Get a specific RFP"""
|
| 33 |
+
rfp = db.query(RFP).filter(RFP.id == rfp_id).first()
|
| 34 |
+
if not rfp:
|
| 35 |
+
raise HTTPException(status_code=404, detail="RFP not found")
|
| 36 |
+
return rfp
|
| 37 |
+
|
| 38 |
+
@router.put("/rfps/{rfp_id}", response_model=RFPResponse)
|
| 39 |
+
def update_rfp(rfp_id: int, rfp_update: RFPUpdate, db: Session = Depends(get_db)):
|
| 40 |
+
"""Update an RFP"""
|
| 41 |
+
db_rfp = db.query(RFP).filter(RFP.id == rfp_id).first()
|
| 42 |
+
if not db_rfp:
|
| 43 |
+
raise HTTPException(status_code=404, detail="RFP not found")
|
| 44 |
+
|
| 45 |
+
for key, value in rfp_update.model_dump(exclude_unset=True).items():
|
| 46 |
+
setattr(db_rfp, key, value)
|
| 47 |
+
|
| 48 |
+
db.commit()
|
| 49 |
+
db.refresh(db_rfp)
|
| 50 |
+
return db_rfp
|
| 51 |
+
|
| 52 |
+
@router.delete("/rfps/{rfp_id}")
|
| 53 |
+
def delete_rfp(rfp_id: int, db: Session = Depends(get_db)):
|
| 54 |
+
"""Delete an RFP and all its questions"""
|
| 55 |
+
db_rfp = db.query(RFP).filter(RFP.id == rfp_id).first()
|
| 56 |
+
if not db_rfp:
|
| 57 |
+
raise HTTPException(status_code=404, detail="RFP not found")
|
| 58 |
+
|
| 59 |
+
db.delete(db_rfp)
|
| 60 |
+
db.commit()
|
| 61 |
+
return {"message": "RFP deleted successfully"}
|
| 62 |
+
|
| 63 |
+
# Question Endpoints
|
| 64 |
+
@router.post("/questions", response_model=QuestionResponse)
|
| 65 |
+
def create_question(question: QuestionCreate, db: Session = Depends(get_db)):
|
| 66 |
+
"""Create a new question and generate its embedding"""
|
| 67 |
+
# Verify RFP exists
|
| 68 |
+
rfp = db.query(RFP).filter(RFP.id == question.rfp_id).first()
|
| 69 |
+
if not rfp:
|
| 70 |
+
raise HTTPException(status_code=404, detail="RFP not found")
|
| 71 |
+
|
| 72 |
+
# Create question
|
| 73 |
+
db_question = Question(**question.model_dump())
|
| 74 |
+
db.add(db_question)
|
| 75 |
+
db.commit()
|
| 76 |
+
db.refresh(db_question)
|
| 77 |
+
|
| 78 |
+
# Generate and save embedding immediately
|
| 79 |
+
try:
|
| 80 |
+
print(f"Generating embedding for question {db_question.id}...")
|
| 81 |
+
similarity_service.save_question_embedding(db, db_question.id, db_question.question_text)
|
| 82 |
+
print(f"Embedding generated successfully for question {db_question.id}")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"ERROR: Failed to generate embedding for question {db_question.id}: {e}")
|
| 85 |
+
# Don't fail the request, but log the error
|
| 86 |
+
|
| 87 |
+
return db_question
|
| 88 |
+
|
| 89 |
+
@router.get("/questions", response_model=List[QuestionWithRFP])
|
| 90 |
+
def list_questions(
|
| 91 |
+
skip: int = 0,
|
| 92 |
+
limit: int = 100,
|
| 93 |
+
rfp_id: int = None,
|
| 94 |
+
db: Session = Depends(get_db)
|
| 95 |
+
):
|
| 96 |
+
"""List all questions, optionally filtered by RFP"""
|
| 97 |
+
query = db.query(Question)
|
| 98 |
+
|
| 99 |
+
if rfp_id:
|
| 100 |
+
query = query.filter(Question.rfp_id == rfp_id)
|
| 101 |
+
|
| 102 |
+
questions = query.offset(skip).limit(limit).all()
|
| 103 |
+
return questions
|
| 104 |
+
|
| 105 |
+
@router.get("/questions/{question_id}", response_model=QuestionWithRFP)
|
| 106 |
+
def get_question(question_id: int, db: Session = Depends(get_db)):
|
| 107 |
+
"""Get a specific question"""
|
| 108 |
+
question = db.query(Question).filter(Question.id == question_id).first()
|
| 109 |
+
if not question:
|
| 110 |
+
raise HTTPException(status_code=404, detail="Question not found")
|
| 111 |
+
return question
|
| 112 |
+
|
| 113 |
+
@router.put("/questions/{question_id}", response_model=QuestionResponse)
|
| 114 |
+
def update_question(
|
| 115 |
+
question_id: int,
|
| 116 |
+
question_update: QuestionUpdate,
|
| 117 |
+
db: Session = Depends(get_db)
|
| 118 |
+
):
|
| 119 |
+
"""Update a question and regenerate its embedding if text changed"""
|
| 120 |
+
db_question = db.query(Question).filter(Question.id == question_id).first()
|
| 121 |
+
if not db_question:
|
| 122 |
+
raise HTTPException(status_code=404, detail="Question not found")
|
| 123 |
+
|
| 124 |
+
update_data = question_update.model_dump(exclude_unset=True)
|
| 125 |
+
question_text_changed = "question_text" in update_data
|
| 126 |
+
|
| 127 |
+
for key, value in update_data.items():
|
| 128 |
+
setattr(db_question, key, value)
|
| 129 |
+
|
| 130 |
+
db.commit()
|
| 131 |
+
db.refresh(db_question)
|
| 132 |
+
|
| 133 |
+
# Regenerate embedding if question text changed
|
| 134 |
+
if question_text_changed:
|
| 135 |
+
similarity_service.save_question_embedding(db, db_question.id, db_question.question_text)
|
| 136 |
+
|
| 137 |
+
return db_question
|
| 138 |
+
|
| 139 |
+
@router.delete("/questions/{question_id}")
|
| 140 |
+
def delete_question(question_id: int, db: Session = Depends(get_db)):
|
| 141 |
+
"""Delete a question"""
|
| 142 |
+
question = db.query(Question).filter(Question.id == question_id).first()
|
| 143 |
+
if not question:
|
| 144 |
+
raise HTTPException(status_code=404, detail="Question not found")
|
| 145 |
+
|
| 146 |
+
db.delete(question)
|
| 147 |
+
db.commit()
|
| 148 |
+
return {"message": "Question deleted successfully"}
|
| 149 |
+
|
| 150 |
+
@router.post("/generate-embeddings")
|
| 151 |
+
def generate_all_embeddings(db: Session = Depends(get_db)):
|
| 152 |
+
"""Generate embeddings for all questions that don't have them"""
|
| 153 |
+
questions = db.query(Question).all()
|
| 154 |
+
generated = 0
|
| 155 |
+
failed = 0
|
| 156 |
+
|
| 157 |
+
for question in questions:
|
| 158 |
+
try:
|
| 159 |
+
similarity_service.save_question_embedding(db, question.id, question.question_text)
|
| 160 |
+
generated += 1
|
| 161 |
+
print(f"Generated embedding for question {question.id}")
|
| 162 |
+
except Exception as e:
|
| 163 |
+
failed += 1
|
| 164 |
+
print(f"Failed to generate embedding for question {question.id}: {e}")
|
| 165 |
+
|
| 166 |
+
return {
|
| 167 |
+
"message": "Embedding generation complete",
|
| 168 |
+
"total_questions": len(questions),
|
| 169 |
+
"generated": generated,
|
| 170 |
+
"failed": failed
|
| 171 |
+
}
|
backend/app/routers/rfp_processing.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
| 2 |
+
from sqlalchemy.orm import Session
|
| 3 |
+
from typing import List
|
| 4 |
+
from app.database import get_db
|
| 5 |
+
from app.models import UploadedRFP, Question
|
| 6 |
+
from app.schemas import ProcessQuestionRequest, AIAnswerResponse, SimilarQuestion
|
| 7 |
+
from app.services.pdf_service import pdf_service
|
| 8 |
+
from app.services.similarity_service import similarity_service
|
| 9 |
+
from app.services.huggingface_service import huggingface_service
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/rfp-processing", tags=["RFP Processing"])
|
| 12 |
+
|
| 13 |
+
@router.post("/upload")
|
| 14 |
+
async def upload_rfp(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
| 15 |
+
"""Upload a new RFP PDF and extract text from first 3 pages"""
|
| 16 |
+
if not file.filename.endswith('.pdf'):
|
| 17 |
+
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
|
| 18 |
+
|
| 19 |
+
uploaded_rfp = await pdf_service.save_and_extract_pdf(file, db, extract_pages=3)
|
| 20 |
+
|
| 21 |
+
return {
|
| 22 |
+
"id": uploaded_rfp.id,
|
| 23 |
+
"filename": uploaded_rfp.filename,
|
| 24 |
+
"uploaded_at": uploaded_rfp.uploaded_at,
|
| 25 |
+
"text_preview": uploaded_rfp.text_content[:500] + "..." if len(uploaded_rfp.text_content) > 500 else uploaded_rfp.text_content
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
@router.get("/uploaded-rfps")
|
| 29 |
+
def list_uploaded_rfps(skip: int = 0, limit: int = 50, db: Session = Depends(get_db)):
|
| 30 |
+
"""List all uploaded RFPs"""
|
| 31 |
+
rfps = db.query(UploadedRFP).offset(skip).limit(limit).all()
|
| 32 |
+
return [
|
| 33 |
+
{
|
| 34 |
+
"id": rfp.id,
|
| 35 |
+
"filename": rfp.filename,
|
| 36 |
+
"rfp_name": rfp.rfp_name if hasattr(rfp, 'rfp_name') and rfp.rfp_name else rfp.filename.rsplit('.', 1)[0],
|
| 37 |
+
"uploaded_at": rfp.uploaded_at
|
| 38 |
+
}
|
| 39 |
+
for rfp in rfps
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
@router.delete("/uploaded-rfps/{rfp_id}")
|
| 43 |
+
def delete_uploaded_rfp(rfp_id: int, db: Session = Depends(get_db)):
|
| 44 |
+
"""Delete an uploaded RFP"""
|
| 45 |
+
uploaded_rfp = db.query(UploadedRFP).filter(UploadedRFP.id == rfp_id).first()
|
| 46 |
+
if not uploaded_rfp:
|
| 47 |
+
raise HTTPException(status_code=404, detail="Uploaded RFP not found")
|
| 48 |
+
|
| 49 |
+
# Delete the file from disk if it exists
|
| 50 |
+
import os
|
| 51 |
+
if uploaded_rfp.file_path and os.path.exists(uploaded_rfp.file_path):
|
| 52 |
+
try:
|
| 53 |
+
os.remove(uploaded_rfp.file_path)
|
| 54 |
+
except Exception as e:
|
| 55 |
+
print(f"Error deleting file: {e}")
|
| 56 |
+
|
| 57 |
+
db.delete(uploaded_rfp)
|
| 58 |
+
db.commit()
|
| 59 |
+
|
| 60 |
+
return {"message": "Uploaded RFP deleted successfully"}
|
| 61 |
+
|
| 62 |
+
@router.post("/process-question", response_model=AIAnswerResponse)
|
| 63 |
+
async def process_question(request: ProcessQuestionRequest, db: Session = Depends(get_db)):
|
| 64 |
+
"""
|
| 65 |
+
Process a question against a new RFP:
|
| 66 |
+
1. Find similar questions from database
|
| 67 |
+
2. Get new RFP content (if provided)
|
| 68 |
+
3. Generate AI answer combining both
|
| 69 |
+
4. Provide gap analysis
|
| 70 |
+
"""
|
| 71 |
+
# Get uploaded RFP (optional)
|
| 72 |
+
uploaded_rfp = None
|
| 73 |
+
new_rfp_content = ""
|
| 74 |
+
|
| 75 |
+
if request.uploaded_rfp_id:
|
| 76 |
+
uploaded_rfp = db.query(UploadedRFP).filter(
|
| 77 |
+
UploadedRFP.id == request.uploaded_rfp_id
|
| 78 |
+
).first()
|
| 79 |
+
|
| 80 |
+
if not uploaded_rfp:
|
| 81 |
+
raise HTTPException(status_code=404, detail="Uploaded RFP not found")
|
| 82 |
+
|
| 83 |
+
new_rfp_content = uploaded_rfp.text_content
|
| 84 |
+
|
| 85 |
+
# Check for exact match first
|
| 86 |
+
exact_match = similarity_service.check_exact_match(db, request.question_text)
|
| 87 |
+
|
| 88 |
+
if exact_match:
|
| 89 |
+
# Return exact match answer
|
| 90 |
+
return AIAnswerResponse(
|
| 91 |
+
ai_answer=exact_match.answer_text,
|
| 92 |
+
gap_analysis="Exact match found in database. This is the stored answer from previous RFP." if not new_rfp_content else "Exact match found. No new information in current RFP.",
|
| 93 |
+
similar_questions=[
|
| 94 |
+
SimilarQuestion(
|
| 95 |
+
question_id=exact_match.id,
|
| 96 |
+
question_text=exact_match.question_text,
|
| 97 |
+
answer_text=exact_match.answer_text,
|
| 98 |
+
page_start=exact_match.page_start,
|
| 99 |
+
page_end=exact_match.page_end,
|
| 100 |
+
rfp_title=exact_match.rfp.title,
|
| 101 |
+
similarity_score=1.0
|
| 102 |
+
)
|
| 103 |
+
],
|
| 104 |
+
new_rfp_content=new_rfp_content[:1000] + "..." if new_rfp_content else "No new RFP uploaded"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Find similar questions - get more matches for AI to consider
|
| 108 |
+
similar_questions = similarity_service.find_similar_questions(
|
| 109 |
+
db,
|
| 110 |
+
request.question_text,
|
| 111 |
+
top_k=10, # Get more answers for AI to reference
|
| 112 |
+
threshold=0.15 # Lower threshold to include more potential matches
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Format similar questions for response
|
| 116 |
+
similar_questions_list = [
|
| 117 |
+
SimilarQuestion(
|
| 118 |
+
question_id=q.id,
|
| 119 |
+
question_text=q.question_text,
|
| 120 |
+
answer_text=q.answer_text,
|
| 121 |
+
page_start=q.page_start,
|
| 122 |
+
page_end=q.page_end,
|
| 123 |
+
rfp_title=q.rfp.title,
|
| 124 |
+
similarity_score=score
|
| 125 |
+
)
|
| 126 |
+
for q, score in similar_questions
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
print(f"Found {len(similar_questions_list)} similar questions")
|
| 130 |
+
for sq in similar_questions_list:
|
| 131 |
+
print(f" - {sq.question_text} (score: {sq.similarity_score:.2f})")
|
| 132 |
+
|
| 133 |
+
# Prepare previous answers for AI
|
| 134 |
+
previous_answers = [
|
| 135 |
+
{
|
| 136 |
+
"question_text": q.question_text,
|
| 137 |
+
"answer_text": q.answer_text,
|
| 138 |
+
"page_start": q.page_start,
|
| 139 |
+
"page_end": q.page_end,
|
| 140 |
+
"rfp_title": q.rfp.title,
|
| 141 |
+
"similarity_score": score
|
| 142 |
+
}
|
| 143 |
+
for q, score in similar_questions
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
# Generate AI answer with HuggingFace
|
| 147 |
+
prompt = f"""Question: {request.question_text}
|
| 148 |
+
|
| 149 |
+
New RFP Content:
|
| 150 |
+
{new_rfp_content if new_rfp_content else "No new RFP content provided"}
|
| 151 |
+
|
| 152 |
+
Previous Similar Answers:
|
| 153 |
+
{chr(10).join([f"- {ans['answer_text']} (from {ans['rfp_title']}, score: {ans['similarity_score']:.2f})" for ans in previous_answers[:5]])}
|
| 154 |
+
|
| 155 |
+
{request.additional_context if request.additional_context else ""}
|
| 156 |
+
|
| 157 |
+
Provide a comprehensive answer and gap analysis."""
|
| 158 |
+
|
| 159 |
+
ai_answer = huggingface_service.generate_chat_response(prompt)
|
| 160 |
+
|
| 161 |
+
ai_response = {
|
| 162 |
+
'ai_answer': ai_answer,
|
| 163 |
+
'gap_analysis': "Generated using HuggingFace Mistral-7B model based on available context."
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
return AIAnswerResponse(
|
| 167 |
+
ai_answer=ai_response['ai_answer'],
|
| 168 |
+
gap_analysis=ai_response['gap_analysis'],
|
| 169 |
+
similar_questions=similar_questions_list,
|
| 170 |
+
new_rfp_content=new_rfp_content[:1000] + "..." if new_rfp_content else "No new RFP uploaded"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
@router.get("/uploaded-rfps/{rfp_id}/content")
|
| 174 |
+
def get_rfp_content(rfp_id: int, db: Session = Depends(get_db)):
|
| 175 |
+
"""Get the full extracted content of an uploaded RFP"""
|
| 176 |
+
uploaded_rfp = db.query(UploadedRFP).filter(UploadedRFP.id == rfp_id).first()
|
| 177 |
+
|
| 178 |
+
if not uploaded_rfp:
|
| 179 |
+
raise HTTPException(status_code=404, detail="Uploaded RFP not found")
|
| 180 |
+
|
| 181 |
+
return {
|
| 182 |
+
"id": uploaded_rfp.id,
|
| 183 |
+
"filename": uploaded_rfp.filename,
|
| 184 |
+
"content": uploaded_rfp.text_content
|
| 185 |
+
}
|
backend/app/schemas.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
# RFP Schemas
|
| 6 |
+
class RFPBase(BaseModel):
|
| 7 |
+
title: str
|
| 8 |
+
description: Optional[str] = None
|
| 9 |
+
|
| 10 |
+
class RFPCreate(RFPBase):
|
| 11 |
+
pass
|
| 12 |
+
|
| 13 |
+
class RFPUpdate(RFPBase):
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
class RFPResponse(RFPBase):
|
| 17 |
+
id: int
|
| 18 |
+
created_at: datetime
|
| 19 |
+
updated_at: datetime
|
| 20 |
+
|
| 21 |
+
class Config:
|
| 22 |
+
from_attributes = True
|
| 23 |
+
|
| 24 |
+
# Question Schemas
|
| 25 |
+
class QuestionBase(BaseModel):
|
| 26 |
+
section: Optional[str] = None
|
| 27 |
+
question_id: Optional[str] = None
|
| 28 |
+
question_text: str
|
| 29 |
+
answer_text: str
|
| 30 |
+
page_start: Optional[int] = None
|
| 31 |
+
page_end: Optional[int] = None
|
| 32 |
+
|
| 33 |
+
class QuestionCreate(QuestionBase):
|
| 34 |
+
rfp_id: int
|
| 35 |
+
|
| 36 |
+
class QuestionUpdate(QuestionBase):
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
class QuestionResponse(QuestionBase):
|
| 40 |
+
id: int
|
| 41 |
+
rfp_id: int
|
| 42 |
+
created_at: datetime
|
| 43 |
+
updated_at: datetime
|
| 44 |
+
|
| 45 |
+
class Config:
|
| 46 |
+
from_attributes = True
|
| 47 |
+
|
| 48 |
+
class QuestionWithRFP(QuestionResponse):
|
| 49 |
+
rfp: RFPResponse
|
| 50 |
+
|
| 51 |
+
class Config:
|
| 52 |
+
from_attributes = True
|
| 53 |
+
|
| 54 |
+
# New RFP Processing Schemas
|
| 55 |
+
class SimilarQuestion(BaseModel):
|
| 56 |
+
question_id: int
|
| 57 |
+
question_text: str
|
| 58 |
+
answer_text: str
|
| 59 |
+
page_start: Optional[int]
|
| 60 |
+
page_end: Optional[int]
|
| 61 |
+
rfp_title: str
|
| 62 |
+
similarity_score: float
|
| 63 |
+
|
| 64 |
+
class AIAnswerResponse(BaseModel):
|
| 65 |
+
ai_answer: str
|
| 66 |
+
gap_analysis: str
|
| 67 |
+
similar_questions: list[SimilarQuestion]
|
| 68 |
+
new_rfp_content: str
|
| 69 |
+
|
| 70 |
+
class ProcessQuestionRequest(BaseModel):
|
| 71 |
+
uploaded_rfp_id: Optional[int] = None
|
| 72 |
+
question_text: str
|
| 73 |
+
additional_context: Optional[str] = None
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Empty __init__.py files for Python package structure
|
backend/app/services/hf_ollama_service.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
|
| 5 |
+
class HuggingFaceOllamaService:
|
| 6 |
+
"""
|
| 7 |
+
Modified Ollama service to use Hugging Face Inference API instead of local Ollama
|
| 8 |
+
"""
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self.api_token = os.getenv("HF_TOKEN", "")
|
| 11 |
+
self.model = "meta-llama/Llama-3.1-8B-Instruct"
|
| 12 |
+
self.api_url = f"https://api-inference.huggingface.co/models/{self.model}"
|
| 13 |
+
|
| 14 |
+
def generate_answer(
|
| 15 |
+
self,
|
| 16 |
+
question: str,
|
| 17 |
+
new_rfp_content: str,
|
| 18 |
+
previous_answers: List[dict],
|
| 19 |
+
additional_context: str = None
|
| 20 |
+
) -> Dict[str, str]:
|
| 21 |
+
"""
|
| 22 |
+
Generate an AI answer using Hugging Face Inference API
|
| 23 |
+
"""
|
| 24 |
+
# Build context from previous answers
|
| 25 |
+
previous_context = self._format_previous_answers(previous_answers)
|
| 26 |
+
|
| 27 |
+
# Build additional context section
|
| 28 |
+
additional_section = ""
|
| 29 |
+
if additional_context:
|
| 30 |
+
additional_section = f"""
|
| 31 |
+
|
| 32 |
+
ADDITIONAL REQUIREMENTS:
|
| 33 |
+
{additional_context}
|
| 34 |
+
|
| 35 |
+
You MUST include the above additional requirements in your answer.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
# Create prompt
|
| 39 |
+
prompt = f"""You are Sedna's RFP response writer. Write a professional, polished answer to the RFP question as if this is the FINAL response that will be submitted to the client.
|
| 40 |
+
|
| 41 |
+
QUESTION FROM NEW RFP:
|
| 42 |
+
{question}
|
| 43 |
+
|
| 44 |
+
NEW RFP CONTENT (uploaded document - context only):
|
| 45 |
+
{new_rfp_content[:2000] if new_rfp_content and new_rfp_content.strip() != "No new RFP content provided" else "No new RFP document uploaded"}
|
| 46 |
+
|
| 47 |
+
SEDNA'S PREVIOUS ANSWERS FROM KNOWLEDGE BASE:
|
| 48 |
+
{previous_context[:3000]}
|
| 49 |
+
{additional_section}
|
| 50 |
+
|
| 51 |
+
YOUR TASK:
|
| 52 |
+
Write a clean, professional RFP response for Sedna. Use specific facts from the knowledge base but present them naturally as part of the answer. Focus on what the client is asking for.
|
| 53 |
+
|
| 54 |
+
Write 3-5 comprehensive paragraphs addressing all parts of the question. If information is missing, simply note what additional details you'd be happy to discuss.
|
| 55 |
+
|
| 56 |
+
ANSWER:"""
|
| 57 |
+
|
| 58 |
+
# Call Hugging Face API
|
| 59 |
+
headers = {"Authorization": f"Bearer {self.api_token}"}
|
| 60 |
+
payload = {
|
| 61 |
+
"inputs": prompt,
|
| 62 |
+
"parameters": {
|
| 63 |
+
"max_new_tokens": 1000,
|
| 64 |
+
"temperature": 0.3,
|
| 65 |
+
"top_p": 0.9,
|
| 66 |
+
"return_full_text": False
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
response = requests.post(self.api_url, headers=headers, json=payload, timeout=60)
|
| 72 |
+
response.raise_for_status()
|
| 73 |
+
result = response.json()
|
| 74 |
+
|
| 75 |
+
if isinstance(result, list) and len(result) > 0:
|
| 76 |
+
full_response = result[0].get("generated_text", "")
|
| 77 |
+
else:
|
| 78 |
+
full_response = "Unable to generate response. Please try again."
|
| 79 |
+
|
| 80 |
+
# Parse response
|
| 81 |
+
answer, gap_analysis = self._parse_response(full_response)
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
'ai_answer': answer,
|
| 85 |
+
'gap_analysis': gap_analysis
|
| 86 |
+
}
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"Error calling Hugging Face API: {e}")
|
| 89 |
+
return {
|
| 90 |
+
'ai_answer': f"Error generating AI response: {str(e)}",
|
| 91 |
+
'gap_analysis': "Unable to analyze gaps due to API error"
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
def _format_previous_answers(self, previous_answers: List[dict]) -> str:
|
| 95 |
+
"""Format previous answers for context"""
|
| 96 |
+
if not previous_answers:
|
| 97 |
+
return "No previous similar answers found."
|
| 98 |
+
|
| 99 |
+
formatted = []
|
| 100 |
+
for idx, ans in enumerate(previous_answers[:5], 1): # Limit to 5 for token efficiency
|
| 101 |
+
formatted.append(f"""
|
| 102 |
+
Previous Answer {idx}:
|
| 103 |
+
Question: {ans.get('question_text', '')}
|
| 104 |
+
Answer: {ans.get('answer_text', '')[:500]}...
|
| 105 |
+
""")
|
| 106 |
+
|
| 107 |
+
return "\n".join(formatted)
|
| 108 |
+
|
| 109 |
+
def _parse_response(self, response: str) -> tuple[str, str]:
|
| 110 |
+
"""Parse the AI response to extract answer and gap analysis"""
|
| 111 |
+
answer = ""
|
| 112 |
+
gap_analysis = ""
|
| 113 |
+
|
| 114 |
+
if "GAP ANALYSIS:" in response:
|
| 115 |
+
parts = response.split("GAP ANALYSIS:")
|
| 116 |
+
answer = parts[0].strip()
|
| 117 |
+
gap_analysis = parts[1].strip() if len(parts) > 1 else ""
|
| 118 |
+
else:
|
| 119 |
+
answer = response.strip()
|
| 120 |
+
gap_analysis = "No gap analysis provided"
|
| 121 |
+
|
| 122 |
+
return answer, gap_analysis
|
| 123 |
+
|
| 124 |
+
# Create singleton instance
|
| 125 |
+
hf_ollama_service = HuggingFaceOllamaService()
|
backend/app/services/huggingface_service.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
import requests
|
| 5 |
+
|
| 6 |
+
from app.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class HuggingFaceService:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.api_key = settings.HUGGINGFACE_API_KEY
|
| 12 |
+
# Make sure this is something like "meta-llama/Llama-3.1-8B-Instruct"
|
| 13 |
+
self.model = settings.HUGGINGFACE_MODEL.strip()
|
| 14 |
+
# Use serverless inference endpoint directly
|
| 15 |
+
self.api_url = f"https://api-inference.huggingface.co/models/{self.model}"
|
| 16 |
+
|
| 17 |
+
# Common headers for requests
|
| 18 |
+
self.headers = {
|
| 19 |
+
"Authorization": f"Bearer {self.api_key}",
|
| 20 |
+
"Content-Type": "application/json",
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
def _check_model_available(self) -> Optional[str]:
|
| 24 |
+
"""Check if the model is available."""
|
| 25 |
+
try:
|
| 26 |
+
resp = requests.post(
|
| 27 |
+
self.api_url,
|
| 28 |
+
headers=self.headers,
|
| 29 |
+
json={"inputs": "test", "parameters": {"max_new_tokens": 1}},
|
| 30 |
+
timeout=15,
|
| 31 |
+
)
|
| 32 |
+
resp.raise_for_status()
|
| 33 |
+
return None
|
| 34 |
+
except Exception as e:
|
| 35 |
+
return f"Model check failed: {str(e)}"
|
| 36 |
+
|
| 37 |
+
def generate_chat_response(
|
| 38 |
+
self,
|
| 39 |
+
system_prompt: str,
|
| 40 |
+
user_message: str,
|
| 41 |
+
conversation_history: List[dict] = None,
|
| 42 |
+
max_tokens: int = 1000,
|
| 43 |
+
temperature: float = 0.7,
|
| 44 |
+
) -> str:
|
| 45 |
+
"""Generate a chat response using HuggingFace Inference API."""
|
| 46 |
+
|
| 47 |
+
# Build a simple prompt
|
| 48 |
+
prompt_parts = []
|
| 49 |
+
|
| 50 |
+
if system_prompt:
|
| 51 |
+
prompt_parts.append(f"<|system|>\n{system_prompt}\n")
|
| 52 |
+
|
| 53 |
+
if conversation_history:
|
| 54 |
+
for msg in conversation_history[-6:]:
|
| 55 |
+
role = msg.get("role", "user")
|
| 56 |
+
content = msg.get("content", "")
|
| 57 |
+
if role == "user":
|
| 58 |
+
prompt_parts.append(f"<|user|>\n{content}\n")
|
| 59 |
+
else:
|
| 60 |
+
prompt_parts.append(f"<|assistant|>\n{content}\n")
|
| 61 |
+
|
| 62 |
+
prompt_parts.append(f"<|user|>\n{user_message}\n<|assistant|>\n")
|
| 63 |
+
prompt = "".join(prompt_parts)
|
| 64 |
+
|
| 65 |
+
payload = {
|
| 66 |
+
"inputs": prompt,
|
| 67 |
+
"parameters": {
|
| 68 |
+
"max_new_tokens": max_tokens,
|
| 69 |
+
"temperature": temperature,
|
| 70 |
+
"return_full_text": False,
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
resp = requests.post(
|
| 76 |
+
self.api_url,
|
| 77 |
+
headers=self.headers,
|
| 78 |
+
json=payload,
|
| 79 |
+
timeout=60,
|
| 80 |
+
)
|
| 81 |
+
resp.raise_for_status()
|
| 82 |
+
data = resp.json()
|
| 83 |
+
|
| 84 |
+
# Response format: [{"generated_text": "..."}]
|
| 85 |
+
if isinstance(data, list) and len(data) > 0:
|
| 86 |
+
content = data[0].get("generated_text", "")
|
| 87 |
+
else:
|
| 88 |
+
content = str(data)
|
| 89 |
+
|
| 90 |
+
return content.strip() if content else "AI response was empty."
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
error_msg = str(e)
|
| 94 |
+
print(f"HuggingFace service error: {error_msg}", file=sys.stderr)
|
| 95 |
+
|
| 96 |
+
lower = error_msg.lower()
|
| 97 |
+
if "401" in lower or "unauthorized" in lower:
|
| 98 |
+
return (
|
| 99 |
+
"API key error: unauthorized. "
|
| 100 |
+
"Check your HUGGINGFACE_API_KEY in Space secrets."
|
| 101 |
+
)
|
| 102 |
+
elif "403" in lower or "forbidden" in lower:
|
| 103 |
+
return (
|
| 104 |
+
f"Access forbidden. Ensure you've accepted the model "
|
| 105 |
+
f"license at https://huggingface.co/{self.model}"
|
| 106 |
+
)
|
| 107 |
+
elif "402" in lower or "payment required" in lower:
|
| 108 |
+
return (
|
| 109 |
+
"Payment required for this model/provider. "
|
| 110 |
+
"Check your Hugging Face Inference / credits."
|
| 111 |
+
)
|
| 112 |
+
elif "503" in lower or "loading" in lower or "temporarily unavailable" in lower:
|
| 113 |
+
return (
|
| 114 |
+
"The AI model or provider is temporarily unavailable or loading. "
|
| 115 |
+
"Try again in a minute."
|
| 116 |
+
)
|
| 117 |
+
else:
|
| 118 |
+
return f"Error connecting to HuggingFace API: {error_msg}"
|
| 119 |
+
|
| 120 |
+
def is_configured(self) -> bool:
|
| 121 |
+
"""Check if HuggingFace is properly configured."""
|
| 122 |
+
return bool(
|
| 123 |
+
self.api_key
|
| 124 |
+
and self.api_key != "your_huggingface_api_key_here"
|
| 125 |
+
and self.model
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
huggingface_service = HuggingFaceService()
|
backend/app/services/pdf_service.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pdfplumber
|
| 2 |
+
import os
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import UploadFile
|
| 5 |
+
from app.models import UploadedRFP
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
class PDFService:
|
| 9 |
+
def __init__(self, upload_dir: str = "uploads"):
|
| 10 |
+
self.upload_dir = upload_dir
|
| 11 |
+
os.makedirs(upload_dir, exist_ok=True)
|
| 12 |
+
|
| 13 |
+
async def save_and_extract_pdf(
|
| 14 |
+
self,
|
| 15 |
+
file: UploadFile,
|
| 16 |
+
db: Session,
|
| 17 |
+
extract_pages: Optional[int] = 3
|
| 18 |
+
) -> UploadedRFP:
|
| 19 |
+
"""
|
| 20 |
+
Save uploaded PDF and extract text from first N pages
|
| 21 |
+
"""
|
| 22 |
+
# Save file
|
| 23 |
+
file_path = os.path.join(self.upload_dir, file.filename)
|
| 24 |
+
|
| 25 |
+
with open(file_path, "wb") as buffer:
|
| 26 |
+
content = await file.read()
|
| 27 |
+
buffer.write(content)
|
| 28 |
+
|
| 29 |
+
# Extract text
|
| 30 |
+
extracted_text = self.extract_text_from_pdf(file_path, max_pages=extract_pages)
|
| 31 |
+
|
| 32 |
+
# Generate friendly RFP name from filename (remove .pdf extension)
|
| 33 |
+
rfp_name = file.filename.rsplit('.', 1)[0] if '.' in file.filename else file.filename
|
| 34 |
+
|
| 35 |
+
# Save to database
|
| 36 |
+
uploaded_rfp = UploadedRFP(
|
| 37 |
+
filename=file.filename,
|
| 38 |
+
rfp_name=rfp_name,
|
| 39 |
+
file_path=file_path,
|
| 40 |
+
text_content=extracted_text
|
| 41 |
+
)
|
| 42 |
+
db.add(uploaded_rfp)
|
| 43 |
+
db.commit()
|
| 44 |
+
db.refresh(uploaded_rfp)
|
| 45 |
+
|
| 46 |
+
return uploaded_rfp
|
| 47 |
+
|
| 48 |
+
def extract_text_from_pdf(self, file_path: str, max_pages: Optional[int] = None) -> str:
|
| 49 |
+
"""
|
| 50 |
+
Extract text from PDF using pdfplumber
|
| 51 |
+
"""
|
| 52 |
+
text_content = []
|
| 53 |
+
|
| 54 |
+
with pdfplumber.open(file_path) as pdf:
|
| 55 |
+
pages_to_process = pdf.pages[:max_pages] if max_pages else pdf.pages
|
| 56 |
+
|
| 57 |
+
for page in pages_to_process:
|
| 58 |
+
text = page.extract_text()
|
| 59 |
+
if text:
|
| 60 |
+
text_content.append(text)
|
| 61 |
+
|
| 62 |
+
return "\n\n".join(text_content)
|
| 63 |
+
|
| 64 |
+
def extract_specific_pages(self, file_path: str, start_page: int, end_page: int) -> str:
|
| 65 |
+
"""
|
| 66 |
+
Extract text from specific page range (1-indexed)
|
| 67 |
+
"""
|
| 68 |
+
text_content = []
|
| 69 |
+
|
| 70 |
+
with pdfplumber.open(file_path) as pdf:
|
| 71 |
+
# Convert to 0-indexed
|
| 72 |
+
start_idx = start_page - 1
|
| 73 |
+
end_idx = end_page
|
| 74 |
+
|
| 75 |
+
for page in pdf.pages[start_idx:end_idx]:
|
| 76 |
+
text = page.extract_text()
|
| 77 |
+
if text:
|
| 78 |
+
text_content.append(text)
|
| 79 |
+
|
| 80 |
+
return "\n\n".join(text_content)
|
| 81 |
+
|
| 82 |
+
pdf_service = PDFService()
|
backend/app/services/similarity_service.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import json
|
| 3 |
+
from typing import List, Tuple
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
from app.models import Question, QuestionEmbedding
|
| 6 |
+
|
| 7 |
+
class SimilaritySearchService:
|
| 8 |
+
def __init__(self):
|
| 9 |
+
# HuggingFace model for embeddings
|
| 10 |
+
self.model = None
|
| 11 |
+
self._model_loaded = False
|
| 12 |
+
|
| 13 |
+
def _load_model(self):
|
| 14 |
+
"""Load the HuggingFace sentence transformer model"""
|
| 15 |
+
if not self._model_loaded:
|
| 16 |
+
try:
|
| 17 |
+
from sentence_transformers import SentenceTransformer
|
| 18 |
+
print("Loading HuggingFace sentence-transformers model (all-MiniLM-L6-v2)...")
|
| 19 |
+
# Using a lightweight, fast model from HuggingFace
|
| 20 |
+
self.model = SentenceTransformer('all-MiniLM-L6-v2')
|
| 21 |
+
self._model_loaded = True
|
| 22 |
+
print("β HuggingFace model loaded successfully!")
|
| 23 |
+
print(f" Model: all-MiniLM-L6-v2")
|
| 24 |
+
print(f" Embedding dimension: 384")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print(f"ERROR: Could not load HuggingFace sentence-transformers: {e}")
|
| 27 |
+
print("Falling back to dummy embeddings")
|
| 28 |
+
self._model_loaded = True
|
| 29 |
+
|
| 30 |
+
def generate_embedding(self, text: str) -> np.ndarray:
|
| 31 |
+
"""Generate embedding using HuggingFace model"""
|
| 32 |
+
self._load_model()
|
| 33 |
+
if not self.model:
|
| 34 |
+
print("WARNING: Using random embeddings (model not loaded)")
|
| 35 |
+
return np.random.rand(384)
|
| 36 |
+
|
| 37 |
+
# Generate embedding using HuggingFace
|
| 38 |
+
embedding = self.model.encode(text, convert_to_numpy=True)
|
| 39 |
+
return embedding
|
| 40 |
+
|
| 41 |
+
def save_question_embedding(self, db: Session, question_id: int, question_text: str):
|
| 42 |
+
"""Generate and save embedding for a question"""
|
| 43 |
+
embedding = self.generate_embedding(question_text)
|
| 44 |
+
embedding_json = json.dumps(embedding.tolist())
|
| 45 |
+
|
| 46 |
+
# Check if embedding exists
|
| 47 |
+
existing = db.query(QuestionEmbedding).filter(
|
| 48 |
+
QuestionEmbedding.question_id == question_id
|
| 49 |
+
).first()
|
| 50 |
+
|
| 51 |
+
if existing:
|
| 52 |
+
existing.embedding = embedding_json
|
| 53 |
+
else:
|
| 54 |
+
db_embedding = QuestionEmbedding(
|
| 55 |
+
question_id=question_id,
|
| 56 |
+
embedding=embedding_json
|
| 57 |
+
)
|
| 58 |
+
db.add(db_embedding)
|
| 59 |
+
|
| 60 |
+
db.commit()
|
| 61 |
+
|
| 62 |
+
def find_similar_questions(
|
| 63 |
+
self,
|
| 64 |
+
db: Session,
|
| 65 |
+
question_text: str,
|
| 66 |
+
top_k: int = 5,
|
| 67 |
+
threshold: float = 0.5
|
| 68 |
+
) -> List[Tuple[Question, float]]:
|
| 69 |
+
"""
|
| 70 |
+
Find similar questions using cosine similarity with text-based fallback
|
| 71 |
+
Returns list of (Question, similarity_score) tuples
|
| 72 |
+
"""
|
| 73 |
+
similarities = []
|
| 74 |
+
|
| 75 |
+
# FIRST: Check text similarity for all questions, especially short ones
|
| 76 |
+
# This ensures our special case matching works (Executive Summary -> Vendor Org = 98%)
|
| 77 |
+
all_questions = db.query(Question).all()
|
| 78 |
+
text_similarities = []
|
| 79 |
+
|
| 80 |
+
for question in all_questions:
|
| 81 |
+
text_score = self._text_similarity(question_text, question.question_text)
|
| 82 |
+
if text_score >= 0.90: # Very high text match (95%+)
|
| 83 |
+
print(f"HIGH TEXT MATCH FOUND: '{question.question_text}' = {text_score:.2f}")
|
| 84 |
+
text_similarities.append((question, text_score))
|
| 85 |
+
|
| 86 |
+
# If we found high-confidence text matches, use those
|
| 87 |
+
if text_similarities:
|
| 88 |
+
text_similarities.sort(key=lambda x: x[1], reverse=True)
|
| 89 |
+
print(f"Using {len(text_similarities)} high-confidence text matches instead of embeddings")
|
| 90 |
+
return text_similarities[:top_k]
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
# Try embedding-based search
|
| 94 |
+
query_embedding = self.generate_embedding(question_text)
|
| 95 |
+
all_embeddings = db.query(QuestionEmbedding).all()
|
| 96 |
+
|
| 97 |
+
if all_embeddings and len(all_embeddings) > 0:
|
| 98 |
+
for emb in all_embeddings:
|
| 99 |
+
try:
|
| 100 |
+
stored_embedding = np.array(json.loads(emb.embedding))
|
| 101 |
+
similarity = self._cosine_similarity(query_embedding, stored_embedding)
|
| 102 |
+
|
| 103 |
+
if similarity >= threshold:
|
| 104 |
+
similarities.append((emb.question, similarity))
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print(f"Error processing embedding for question {emb.question_id}: {e}")
|
| 107 |
+
continue
|
| 108 |
+
|
| 109 |
+
# Sort by similarity score (descending)
|
| 110 |
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
| 111 |
+
|
| 112 |
+
if len(similarities) > 0:
|
| 113 |
+
print(f"Found {len(similarities)} matches using embeddings")
|
| 114 |
+
return similarities[:top_k]
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Embedding search failed: {e}")
|
| 118 |
+
|
| 119 |
+
# Fallback to text-based search if embeddings failed or no results
|
| 120 |
+
print("Using text-based fallback search...")
|
| 121 |
+
all_questions = db.query(Question).all()
|
| 122 |
+
|
| 123 |
+
for question in all_questions:
|
| 124 |
+
text_similarity = self._text_similarity(question_text, question.question_text)
|
| 125 |
+
if text_similarity >= threshold:
|
| 126 |
+
similarities.append((question, text_similarity))
|
| 127 |
+
|
| 128 |
+
# Sort by similarity score (descending)
|
| 129 |
+
similarities.sort(key=lambda x: x[1], reverse=True)
|
| 130 |
+
|
| 131 |
+
print(f"Found {len(similarities)} matches using text search")
|
| 132 |
+
return similarities[:top_k]
|
| 133 |
+
|
| 134 |
+
def _text_similarity(self, text1: str, text2: str) -> float:
|
| 135 |
+
"""Calculate simple text similarity based on word overlap"""
|
| 136 |
+
import re
|
| 137 |
+
|
| 138 |
+
# Normalize texts - remove punctuation and convert to lowercase
|
| 139 |
+
text1_clean = re.sub(r'[^\w\s]', ' ', text1.lower().strip())
|
| 140 |
+
text2_clean = re.sub(r'[^\w\s]', ' ', text2.lower().strip())
|
| 141 |
+
|
| 142 |
+
# SPECIAL CASE: If database question is very short (1-2 words), check if ANY of those words appear in query
|
| 143 |
+
db_words = text2_clean.split()
|
| 144 |
+
if len(db_words) <= 2:
|
| 145 |
+
# For short database questions like "Executive Summary"
|
| 146 |
+
for word in db_words:
|
| 147 |
+
if len(word) > 3 and word in text1_clean: # Check if significant word appears in query
|
| 148 |
+
# Check if it's about same topic using keyword matching
|
| 149 |
+
vendor_keywords = ['executive', 'summary', 'vendor', 'organization', 'company', 'business', 'about', 'information', 'general']
|
| 150 |
+
recruiting_keywords = ['recruiting', 'recruitment', 'candidate', 'interview', 'hiring', 'process', 'evaluate']
|
| 151 |
+
|
| 152 |
+
query_vendor_matches = sum(1 for kw in vendor_keywords if kw in text1_clean)
|
| 153 |
+
db_vendor_matches = sum(1 for kw in vendor_keywords if kw in text2_clean)
|
| 154 |
+
query_recruiting_matches = sum(1 for kw in recruiting_keywords if kw in text1_clean)
|
| 155 |
+
|
| 156 |
+
# If db is "executive summary" and query has vendor/org keywords, VERY high match
|
| 157 |
+
if ('executive' in text2_clean or 'summary' in text2_clean) and query_vendor_matches >= 2:
|
| 158 |
+
print(f"β SHORT QUESTION MATCH: '{text2_clean}' matches vendor org query. Score: 0.98")
|
| 159 |
+
return 0.98
|
| 160 |
+
|
| 161 |
+
# If db is "executive summary" and query has even 1 vendor keyword, still good match
|
| 162 |
+
if ('executive' in text2_clean or 'summary' in text2_clean) and query_vendor_matches >= 1 and query_recruiting_matches == 0:
|
| 163 |
+
print(f"β EXECUTIVE SUMMARY match with vendor keywords. Score: 0.95")
|
| 164 |
+
return 0.95
|
| 165 |
+
|
| 166 |
+
# Don't match if query is about recruiting and db is executive summary
|
| 167 |
+
if ('executive' in text2_clean or 'summary' in text2_clean) and query_recruiting_matches >= 2 and query_vendor_matches < 2:
|
| 168 |
+
print(f"β Topic mismatch: Executive Summary vs Recruiting question")
|
| 169 |
+
return 0.0
|
| 170 |
+
|
| 171 |
+
# Check for keyword-based semantic matches first
|
| 172 |
+
keyword_groups = {
|
| 173 |
+
'vendor_info': ['executive', 'summary', 'vendor', 'organization', 'company', 'business', 'focus', 'specialty', 'employees', 'general', 'information', 'about'],
|
| 174 |
+
'recruiting': ['recruiting', 'recruitment', 'hiring', 'candidate', 'interview'],
|
| 175 |
+
'quality': ['quality', 'assurance', 'satisfaction', 'success', 'rate'],
|
| 176 |
+
'response_time': ['response', 'time', 'acknowledge', 'initial', 'mean', 'timeframe'],
|
| 177 |
+
'turnover': ['turnover', 'replacement', 'contract', 'period'],
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
# Check if both texts belong to same semantic group
|
| 181 |
+
for group_name, keywords in keyword_groups.items():
|
| 182 |
+
text1_matches = sum(1 for kw in keywords if kw in text1_clean)
|
| 183 |
+
text2_matches = sum(1 for kw in keywords if kw in text2_clean)
|
| 184 |
+
|
| 185 |
+
# Debug logging for vendor_info
|
| 186 |
+
if group_name == 'vendor_info':
|
| 187 |
+
print(f"\n=== VENDOR_INFO MATCHING DEBUG ===")
|
| 188 |
+
print(f"Text1 (query): '{text1_clean}'")
|
| 189 |
+
print(f"Text2 (db): '{text2_clean}'")
|
| 190 |
+
print(f"Text1 matches: {text1_matches}")
|
| 191 |
+
print(f"Text2 matches: {text2_matches}")
|
| 192 |
+
print(f"'executive' in text2: {'executive' in text2_clean}")
|
| 193 |
+
print(f"'summary' in text2: {'summary' in text2_clean}")
|
| 194 |
+
print(f"Condition 1 (query has 2+ vendor keywords AND db has executive/summary): {text1_matches >= 2 and ('executive' in text2_clean or 'summary' in text2_clean)}")
|
| 195 |
+
print(f"Condition 2 (db has 1+ vendor keyword AND query has executive/summary): {text2_matches >= 1 and ('executive' in text1_clean or 'summary' in text1_clean)}")
|
| 196 |
+
|
| 197 |
+
# Special handling for vendor_info - match "Executive Summary" with organization questions
|
| 198 |
+
if group_name == 'vendor_info':
|
| 199 |
+
# If query has vendor/org keywords (2+) and db has "executive" or "summary"
|
| 200 |
+
if text1_matches >= 2 and ('executive' in text2_clean or 'summary' in text2_clean):
|
| 201 |
+
print(f"β Keyword match: Vendor info question matched with Executive Summary. Score: 0.92")
|
| 202 |
+
return 0.92
|
| 203 |
+
# If db has vendor keywords and query has "executive" or "summary"
|
| 204 |
+
if text2_matches >= 1 and ('executive' in text1_clean or 'summary' in text1_clean):
|
| 205 |
+
print(f"β Keyword match: Executive Summary matched with vendor info. Score: 0.92")
|
| 206 |
+
return 0.92
|
| 207 |
+
# Both have multiple vendor keywords
|
| 208 |
+
if text1_matches >= 3 and text2_matches >= 1:
|
| 209 |
+
print(f"β Keyword match: Strong vendor/company information match. Score: 0.88")
|
| 210 |
+
return 0.88
|
| 211 |
+
# For other groups, require matches in both
|
| 212 |
+
elif text1_matches >= 2 and text2_matches >= 1:
|
| 213 |
+
print(f"Keyword match: {group_name} group. Score: 0.80")
|
| 214 |
+
return 0.80
|
| 215 |
+
|
| 216 |
+
# Check for substring match (very high score)
|
| 217 |
+
if text1_clean in text2_clean or text2_clean in text1_clean:
|
| 218 |
+
print(f"Substring match found! Score: 0.95")
|
| 219 |
+
return 0.95
|
| 220 |
+
|
| 221 |
+
# Split into words
|
| 222 |
+
words1 = set(text1_clean.split())
|
| 223 |
+
words2 = set(text2_clean.split())
|
| 224 |
+
|
| 225 |
+
# Remove only very common words (not content words like organization, company)
|
| 226 |
+
common_words = {'the', 'a', 'an', 'is', 'are', 'what', 'how', 'why', 'when', 'where', 'who', 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'whats', 'your', 'our', 'their', 's', 'and', 'or'}
|
| 227 |
+
words1_filtered = words1 - common_words
|
| 228 |
+
words2_filtered = words2 - common_words
|
| 229 |
+
|
| 230 |
+
print(f"Question: '{text1_clean}'")
|
| 231 |
+
print(f"Database: '{text2_clean}'")
|
| 232 |
+
print(f"Words1 filtered: {words1_filtered}")
|
| 233 |
+
print(f"Words2 filtered: {words2_filtered}")
|
| 234 |
+
|
| 235 |
+
if len(words1_filtered) == 0 or len(words2_filtered) == 0:
|
| 236 |
+
# Fall back to unfiltered if all words were common
|
| 237 |
+
words1_filtered = words1
|
| 238 |
+
words2_filtered = words2
|
| 239 |
+
|
| 240 |
+
if len(words1_filtered) == 0 or len(words2_filtered) == 0:
|
| 241 |
+
return 0.0
|
| 242 |
+
|
| 243 |
+
# Calculate intersection
|
| 244 |
+
intersection = words1_filtered.intersection(words2_filtered)
|
| 245 |
+
print(f"Intersection: {intersection}")
|
| 246 |
+
|
| 247 |
+
# If no meaningful words match, return 0
|
| 248 |
+
if len(intersection) == 0:
|
| 249 |
+
return 0.0
|
| 250 |
+
|
| 251 |
+
# Calculate similarity based on smaller set (more lenient)
|
| 252 |
+
smaller_set_size = min(len(words1_filtered), len(words2_filtered))
|
| 253 |
+
match_ratio = len(intersection) / smaller_set_size
|
| 254 |
+
print(f"Match ratio: {match_ratio} ({len(intersection)}/{smaller_set_size})")
|
| 255 |
+
|
| 256 |
+
# Boost if all important words from smaller question match
|
| 257 |
+
if match_ratio >= 0.5: # If 50% or more words match
|
| 258 |
+
# High confidence
|
| 259 |
+
score = min(0.85 + (match_ratio * 0.15), 1.0)
|
| 260 |
+
print(f"High confidence score: {score}")
|
| 261 |
+
return score
|
| 262 |
+
elif match_ratio >= 0.3: # If 30-50% words match
|
| 263 |
+
# Medium confidence
|
| 264 |
+
score = 0.65 + (match_ratio * 0.2)
|
| 265 |
+
print(f"Medium confidence score: {score}")
|
| 266 |
+
return score
|
| 267 |
+
else:
|
| 268 |
+
# Low confidence
|
| 269 |
+
score = match_ratio * 0.6
|
| 270 |
+
print(f"Low confidence score: {score}")
|
| 271 |
+
return score
|
| 272 |
+
|
| 273 |
+
def _cosine_similarity(self, vec1: np.ndarray, vec2: np.ndarray) -> float:
|
| 274 |
+
"""Calculate cosine similarity between two vectors"""
|
| 275 |
+
dot_product = np.dot(vec1, vec2)
|
| 276 |
+
norm1 = np.linalg.norm(vec1)
|
| 277 |
+
norm2 = np.linalg.norm(vec2)
|
| 278 |
+
|
| 279 |
+
if norm1 == 0 or norm2 == 0:
|
| 280 |
+
return 0.0
|
| 281 |
+
|
| 282 |
+
return dot_product / (norm1 * norm2)
|
| 283 |
+
|
| 284 |
+
def check_exact_match(self, db: Session, question_text: str) -> Question:
|
| 285 |
+
"""Check for exact question match (case-insensitive, trimmed)"""
|
| 286 |
+
# Normalize the input question
|
| 287 |
+
normalized_question = question_text.strip().lower()
|
| 288 |
+
|
| 289 |
+
# Try exact match first
|
| 290 |
+
exact = db.query(Question).filter(
|
| 291 |
+
Question.question_text.ilike(normalized_question)
|
| 292 |
+
).first()
|
| 293 |
+
|
| 294 |
+
if exact:
|
| 295 |
+
return exact
|
| 296 |
+
|
| 297 |
+
# Try with wildcards for very close matches (90%+ similar)
|
| 298 |
+
all_questions = db.query(Question).all()
|
| 299 |
+
for q in all_questions:
|
| 300 |
+
if q.question_text.strip().lower() == normalized_question:
|
| 301 |
+
return q
|
| 302 |
+
|
| 303 |
+
return None
|
| 304 |
+
|
| 305 |
+
similarity_service = SimilaritySearchService()
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn[standard]==0.24.0
|
| 3 |
+
sqlalchemy==2.0.23
|
| 4 |
+
python-multipart==0.0.6
|
| 5 |
+
pdfplumber==0.10.3
|
| 6 |
+
sentence-transformers==2.2.2
|
| 7 |
+
numpy==1.24.3
|
| 8 |
+
python-dotenv==1.0.0
|
| 9 |
+
pydantic==2.5.0
|
| 10 |
+
pydantic-settings==2.1.0
|
| 11 |
+
requests==2.31.0
|