Varun10000 commited on
Commit
0e674ab
Β·
verified Β·
1 Parent(s): f97abaf

Upload 20 files

Browse files
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