Spaces:
Runtime error
Runtime error
Merge pull request #1 from CUNYTechPrep/origin/alt/LanguageModelServices
Browse files- .dockerignore +3 -3
- .env.template +27 -11
- .gitignore +3 -3
- README.MD +7 -0
- pyproject.toml +1 -0
- src/ctp_slack_bot/api/main.py +21 -13
- src/ctp_slack_bot/core/config.py +32 -35
- src/ctp_slack_bot/core/response_rendering.py +13 -0
- src/ctp_slack_bot/db/MongoDB.py +122 -0
- src/ctp_slack_bot/models/VectorQuery.py +17 -0
- src/ctp_slack_bot/models/content.py +19 -0
- src/ctp_slack_bot/services/AnswerQuestionService.py +60 -0
- src/ctp_slack_bot/services/ContextRetrievalService.py +76 -0
- src/ctp_slack_bot/services/VectorDatabaseService.py +124 -0
.dockerignore
CHANGED
@@ -59,11 +59,11 @@ venv.bak/
|
|
59 |
# PyCharm
|
60 |
.idea/
|
61 |
|
62 |
-
# Jupyter notebooks
|
63 |
-
notebooks/
|
64 |
-
|
65 |
# Documentation
|
66 |
docs/
|
67 |
|
68 |
# MacOS
|
69 |
.DS_Store
|
|
|
|
|
|
|
|
59 |
# PyCharm
|
60 |
.idea/
|
61 |
|
|
|
|
|
|
|
62 |
# Documentation
|
63 |
docs/
|
64 |
|
65 |
# MacOS
|
66 |
.DS_Store
|
67 |
+
|
68 |
+
# Application logs
|
69 |
+
/logs
|
.env.template
CHANGED
@@ -1,25 +1,41 @@
|
|
1 |
# Copy this file and modify. Do not save or commit the secrets!
|
2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
# API Configuration
|
4 |
API_HOST=0.0.0.0
|
5 |
API_PORT=8000
|
6 |
-
DEBUG=false
|
7 |
-
|
8 |
-
# MongoDB Configuration
|
9 |
-
MONGODB_URI=mongodb+srv://username:[email protected]/database?retryWrites=true&w=majority
|
10 |
-
MONGODB_DB_NAME=ctp_slack_bot
|
11 |
|
12 |
# Slack Configuration
|
13 |
SLACK_BOT_TOKEN=🪙
|
14 |
SLACK_SIGNING_SECRET=🔏
|
15 |
SLACK_APP_TOKEN=🦥
|
16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
# Hugging Face Configuration
|
18 |
HF_API_TOKEN=🤗
|
19 |
|
20 |
-
#
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
|
|
1 |
# Copy this file and modify. Do not save or commit the secrets!
|
2 |
|
3 |
+
# Application Configuration
|
4 |
+
DEBUG=false
|
5 |
+
|
6 |
+
# Logging Configuration
|
7 |
+
LOG_LEVEL=INFO
|
8 |
+
LOG_FORMAT=text
|
9 |
+
|
10 |
+
# APScheduler Configuration
|
11 |
+
SCHEDULER_TIMEZONE=UTC
|
12 |
+
|
13 |
# API Configuration
|
14 |
API_HOST=0.0.0.0
|
15 |
API_PORT=8000
|
|
|
|
|
|
|
|
|
|
|
16 |
|
17 |
# Slack Configuration
|
18 |
SLACK_BOT_TOKEN=🪙
|
19 |
SLACK_SIGNING_SECRET=🔏
|
20 |
SLACK_APP_TOKEN=🦥
|
21 |
|
22 |
+
# Vectorization Configuration
|
23 |
+
EMBEDDING_MODEL=🌮
|
24 |
+
VECTOR_DIMENSION=9001
|
25 |
+
CHUNK_SIZE=42
|
26 |
+
CHUNK_OVERLAP=37
|
27 |
+
TOP_K_MATCHES=1
|
28 |
+
|
29 |
+
# MongoDB Configuration
|
30 |
+
MONGODB_URI=mongodb+srv://username:[email protected]/database?retryWrites=true&w=majority
|
31 |
+
MONGODB_NAME=ctp_slack_bot
|
32 |
+
|
33 |
# Hugging Face Configuration
|
34 |
HF_API_TOKEN=🤗
|
35 |
|
36 |
+
# OpenAI Configuration
|
37 |
+
OPENAI_API_KEY=😐
|
38 |
+
CHAT_MODEL=🙊
|
39 |
+
MAX_TOKENS=42
|
40 |
+
TEMPERATURE=0.5
|
41 |
+
SYSTEM_PROMPT="You are a helpful teaching assistant for a data science class.\nBased on the students question, you will be given context retreived from class transcripts and materials to answer their question.\nYour responses should be:\n\n1. Accurate and based on the class content\n2. Clear and educational\n3. Concise but complete\nIf you're unsure about something, acknowledge it and suggest asking the professor."
|
.gitignore
CHANGED
@@ -91,8 +91,8 @@ dmypy.json
|
|
91 |
# PyCharm
|
92 |
.idea/
|
93 |
|
94 |
-
# Jupyter notebooks
|
95 |
-
notebooks/
|
96 |
-
|
97 |
# MacOS
|
98 |
.DS_Store
|
|
|
|
|
|
|
|
91 |
# PyCharm
|
92 |
.idea/
|
93 |
|
|
|
|
|
|
|
94 |
# MacOS
|
95 |
.DS_Store
|
96 |
+
|
97 |
+
# Application logs
|
98 |
+
/logs
|
README.MD
CHANGED
@@ -14,6 +14,7 @@
|
|
14 |
* `src/`
|
15 |
* `ctp_slack_bot/`
|
16 |
* `api/`: FastAPI application structure
|
|
|
17 |
* `core/`: fundamental components like configuration (using pydantic), logging setup (loguru), and custom exceptions
|
18 |
* `db/`: database connection
|
19 |
* `repositories/`: repository pattern implementation
|
@@ -23,7 +24,9 @@
|
|
23 |
* `utils/`: reusable utilities
|
24 |
* `tests/`: unit tests
|
25 |
* `scripts/`: utility scripts for development, deployment, etc.
|
|
|
26 |
* `notebooks/`: Jupyter notebooks for exploration and model development
|
|
|
27 |
|
28 |
## How to Run the Application
|
29 |
|
@@ -41,6 +44,8 @@ First, make sure you are set up with a Python virtual environment created by the
|
|
41 |
pip3 install -e .
|
42 |
```
|
43 |
|
|
|
|
|
44 |
If `localhost` port `8000` is free, running the following will make the application available on that port:
|
45 |
|
46 |
```sh
|
@@ -54,4 +59,6 @@ $ curl http://localhost:8000/health
|
|
54 |
{"status":"healthy"}
|
55 |
```
|
56 |
|
|
|
|
|
57 |
Uvicorn will restart the application automatically when any source files are changed.
|
|
|
14 |
* `src/`
|
15 |
* `ctp_slack_bot/`
|
16 |
* `api/`: FastAPI application structure
|
17 |
+
* `routes.py`: API endpoint definitions
|
18 |
* `core/`: fundamental components like configuration (using pydantic), logging setup (loguru), and custom exceptions
|
19 |
* `db/`: database connection
|
20 |
* `repositories/`: repository pattern implementation
|
|
|
24 |
* `utils/`: reusable utilities
|
25 |
* `tests/`: unit tests
|
26 |
* `scripts/`: utility scripts for development, deployment, etc.
|
27 |
+
* `run-dev.sh`: script to run the application locally
|
28 |
* `notebooks/`: Jupyter notebooks for exploration and model development
|
29 |
+
* `.env`: local environment variables for development purposes
|
30 |
|
31 |
## How to Run the Application
|
32 |
|
|
|
44 |
pip3 install -e .
|
45 |
```
|
46 |
|
47 |
+
Make a copy of `.env.template` as `.env` and define the environment variables. (You can also define them by other means, but this has the least friction.) This file should not be committed and is excluded by `.gitignore`!
|
48 |
+
|
49 |
If `localhost` port `8000` is free, running the following will make the application available on that port:
|
50 |
|
51 |
```sh
|
|
|
59 |
{"status":"healthy"}
|
60 |
```
|
61 |
|
62 |
+
In debug mode (`DEBUG=true`), [http://localhost:8000/env](http://localhost:8000/env) will pretty-print the non-sensitive environment variables as JSON.
|
63 |
+
|
64 |
Uvicorn will restart the application automatically when any source files are changed.
|
pyproject.toml
CHANGED
@@ -43,6 +43,7 @@ dev = [
|
|
43 |
"pytest>=7.3.1",
|
44 |
"pytest-cov>=4.1.0",
|
45 |
"mypy>=1.3.0",
|
|
|
46 |
"black>=23.3.0",
|
47 |
"isort>=5.12.0",
|
48 |
"ruff>=0.0.270",
|
|
|
43 |
"pytest>=7.3.1",
|
44 |
"pytest-cov>=4.1.0",
|
45 |
"mypy>=1.3.0",
|
46 |
+
"types-pytz>=2025.2",
|
47 |
"black>=23.3.0",
|
48 |
"isort>=5.12.0",
|
49 |
"ruff>=0.0.270",
|
src/ctp_slack_bot/api/main.py
CHANGED
@@ -1,23 +1,23 @@
|
|
1 |
-
import logging
|
2 |
from contextlib import asynccontextmanager
|
3 |
-
|
4 |
-
from fastapi import FastAPI
|
5 |
from loguru import logger
|
|
|
6 |
|
7 |
from ctp_slack_bot.api.routes import router
|
8 |
-
from ctp_slack_bot.core.config import settings
|
9 |
from ctp_slack_bot.core.logging import setup_logging
|
|
|
10 |
from ctp_slack_bot.tasks.scheduler import start_scheduler, stop_scheduler
|
11 |
|
12 |
|
13 |
@asynccontextmanager
|
14 |
-
async def lifespan(app: FastAPI):
|
15 |
"""
|
16 |
Lifespan context manager for FastAPI application.
|
17 |
Handles startup and shutdown events.
|
18 |
"""
|
19 |
# Setup logging
|
20 |
-
|
21 |
logger.info("Starting application")
|
22 |
|
23 |
# Start scheduler
|
@@ -42,11 +42,19 @@ app = FastAPI(
|
|
42 |
# Include routers
|
43 |
app.include_router(router)
|
44 |
|
45 |
-
|
46 |
@app.get("/health")
|
47 |
-
async def
|
48 |
-
"""Health check
|
49 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
|
51 |
|
52 |
if __name__ == "__main__":
|
@@ -54,7 +62,7 @@ if __name__ == "__main__":
|
|
54 |
|
55 |
uvicorn.run(
|
56 |
"main:app",
|
57 |
-
host=
|
58 |
-
port=
|
59 |
-
reload=
|
60 |
)
|
|
|
|
|
1 |
from contextlib import asynccontextmanager
|
2 |
+
from fastapi import FastAPI, HTTPException
|
|
|
3 |
from loguru import logger
|
4 |
+
from typing import AsyncGenerator, Never
|
5 |
|
6 |
from ctp_slack_bot.api.routes import router
|
7 |
+
from ctp_slack_bot.core.config import Settings, settings
|
8 |
from ctp_slack_bot.core.logging import setup_logging
|
9 |
+
from ctp_slack_bot.core.response_rendering import PrettyJSONResponse
|
10 |
from ctp_slack_bot.tasks.scheduler import start_scheduler, stop_scheduler
|
11 |
|
12 |
|
13 |
@asynccontextmanager
|
14 |
+
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
15 |
"""
|
16 |
Lifespan context manager for FastAPI application.
|
17 |
Handles startup and shutdown events.
|
18 |
"""
|
19 |
# Setup logging
|
20 |
+
setup_logging()
|
21 |
logger.info("Starting application")
|
22 |
|
23 |
# Start scheduler
|
|
|
42 |
# Include routers
|
43 |
app.include_router(router)
|
44 |
|
|
|
45 |
@app.get("/health")
|
46 |
+
async def health() -> dict[str, str]:
|
47 |
+
"""Health check"""
|
48 |
+
return {
|
49 |
+
"status": "healthy"
|
50 |
+
}
|
51 |
+
|
52 |
+
@app.get("/env", response_class=PrettyJSONResponse)
|
53 |
+
async def env() -> Settings:
|
54 |
+
"""Server-internal environment variables"""
|
55 |
+
if not settings.DEBUG:
|
56 |
+
raise HTTPException(status_code=404)
|
57 |
+
return settings
|
58 |
|
59 |
|
60 |
if __name__ == "__main__":
|
|
|
62 |
|
63 |
uvicorn.run(
|
64 |
"main:app",
|
65 |
+
host=settings.API_HOST,
|
66 |
+
port=settings.API_PORT,
|
67 |
+
reload=settings.DEBUG
|
68 |
)
|
src/ctp_slack_bot/core/config.py
CHANGED
@@ -1,57 +1,54 @@
|
|
1 |
from functools import lru_cache
|
2 |
from typing import Literal, Optional
|
3 |
|
4 |
-
from pydantic import Field,
|
5 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
6 |
|
7 |
|
8 |
-
class Settings(BaseSettings):
|
9 |
"""
|
10 |
Application settings loaded from environment variables.
|
11 |
"""
|
12 |
-
#
|
13 |
-
API_HOST: str = "0.0.0.0"
|
14 |
-
API_PORT: int = 8000
|
15 |
DEBUG: bool = False
|
16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
# Vectorization Configuration
|
18 |
-
EMBEDDING_MODEL: str
|
19 |
-
VECTOR_DIMENSION:
|
20 |
-
CHUNK_SIZE:
|
21 |
-
CHUNK_OVERLAP:
|
22 |
-
TOP_K_MATCHES:
|
23 |
|
24 |
# MongoDB Configuration
|
25 |
-
MONGODB_URI:
|
26 |
-
|
27 |
-
|
28 |
-
# Slack Configuration
|
29 |
-
SLACK_BOT_TOKEN: Optional[SecretStr] = None # TODO: Remove optionality
|
30 |
-
SLACK_SIGNING_SECRET: Optional[SecretStr] = None # TODO: Remove optionality
|
31 |
-
SLACK_APP_TOKEN: Optional[SecretStr] = None
|
32 |
-
|
33 |
# Hugging Face Configuration
|
34 |
HF_API_TOKEN: Optional[SecretStr] = None
|
35 |
|
36 |
# OpenAI Configuration
|
37 |
OPENAI_API_KEY: Optional[SecretStr] = None
|
|
|
|
|
|
|
|
|
38 |
|
39 |
-
|
40 |
-
|
41 |
-
# Logging Configuration
|
42 |
-
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
43 |
-
LOG_FORMAT: Literal["text", "json"] = "json"
|
44 |
-
|
45 |
-
# APScheduler Configuration
|
46 |
-
SCHEDULER_TIMEZONE: str = "UTC"
|
47 |
-
|
48 |
-
@validator("MONGODB_URI")
|
49 |
-
def validate_mongodb_uri(cls, v):
|
50 |
-
"""Validate MongoDB URI format"""
|
51 |
-
#if not v.get_secret_value().startswith("mongodb"):
|
52 |
-
# raise ValueError("MONGODB_URI must be a valid MongoDB connection string")
|
53 |
-
return v
|
54 |
-
|
55 |
model_config = SettingsConfigDict(
|
56 |
env_file=".env",
|
57 |
env_file_encoding="utf-8",
|
@@ -64,7 +61,7 @@ def get_settings() -> Settings:
|
|
64 |
"""
|
65 |
Get cached settings instance.
|
66 |
"""
|
67 |
-
return Settings()
|
68 |
|
69 |
|
70 |
settings = get_settings()
|
|
|
1 |
from functools import lru_cache
|
2 |
from typing import Literal, Optional
|
3 |
|
4 |
+
from pydantic import Field, MongoDsn, NonNegativeFloat, NonNegativeInt, PositiveInt, SecretStr
|
5 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
6 |
|
7 |
|
8 |
+
class Settings(BaseSettings): # TODO: Strong guarantees of validity, because garbage in = garbage out, and settings flow into all the nooks and crannies
|
9 |
"""
|
10 |
Application settings loaded from environment variables.
|
11 |
"""
|
12 |
+
# Application Configuration
|
|
|
|
|
13 |
DEBUG: bool = False
|
14 |
|
15 |
+
# Logging Configuration
|
16 |
+
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(default_factory=lambda data: "DEBUG" if data.get("DEBUG", False) else "INFO")
|
17 |
+
LOG_FORMAT: Literal["text", "json"] = "json"
|
18 |
+
|
19 |
+
# APScheduler Configuration
|
20 |
+
SCHEDULER_TIMEZONE: str = "UTC"
|
21 |
+
|
22 |
+
# API Configuration
|
23 |
+
API_HOST: str
|
24 |
+
API_PORT: PositiveInt
|
25 |
+
|
26 |
+
# Slack Configuration
|
27 |
+
SLACK_BOT_TOKEN: SecretStr
|
28 |
+
SLACK_SIGNING_SECRET: SecretStr
|
29 |
+
SLACK_APP_TOKEN: SecretStr
|
30 |
+
|
31 |
# Vectorization Configuration
|
32 |
+
EMBEDDING_MODEL: str
|
33 |
+
VECTOR_DIMENSION: PositiveInt
|
34 |
+
CHUNK_SIZE: PositiveInt
|
35 |
+
CHUNK_OVERLAP: NonNegativeInt
|
36 |
+
TOP_K_MATCHES: PositiveInt
|
37 |
|
38 |
# MongoDB Configuration
|
39 |
+
MONGODB_URI: SecretStr # TODO: Contemplate switching to MongoDsn type for the main URL, and separate out the credentials to SecretStr variables.
|
40 |
+
MONGODB_NAME: str
|
41 |
+
|
|
|
|
|
|
|
|
|
|
|
42 |
# Hugging Face Configuration
|
43 |
HF_API_TOKEN: Optional[SecretStr] = None
|
44 |
|
45 |
# OpenAI Configuration
|
46 |
OPENAI_API_KEY: Optional[SecretStr] = None
|
47 |
+
CHAT_MODEL: str
|
48 |
+
MAX_TOKENS: PositiveInt
|
49 |
+
TEMPERATURE: NonNegativeFloat
|
50 |
+
SYSTEM_PROMPT: str
|
51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
model_config = SettingsConfigDict(
|
53 |
env_file=".env",
|
54 |
env_file_encoding="utf-8",
|
|
|
61 |
"""
|
62 |
Get cached settings instance.
|
63 |
"""
|
64 |
+
return Settings() # type: ignore
|
65 |
|
66 |
|
67 |
settings = get_settings()
|
src/ctp_slack_bot/core/response_rendering.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from json import dumps
|
2 |
+
from starlette.responses import JSONResponse
|
3 |
+
from typing import Any, Self
|
4 |
+
|
5 |
+
class PrettyJSONResponse(JSONResponse):
|
6 |
+
def render(self: Self, content: Any) -> bytes:
|
7 |
+
return dumps(
|
8 |
+
content,
|
9 |
+
ensure_ascii=False,
|
10 |
+
allow_nan=False,
|
11 |
+
indent=4,
|
12 |
+
separators=(", ", ": "),
|
13 |
+
).encode("utf-8")
|
src/ctp_slack_bot/db/MongoDB.py
ADDED
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
2 |
+
from pymongo import IndexModel, ASCENDING
|
3 |
+
import logging
|
4 |
+
from typing import Optional
|
5 |
+
|
6 |
+
from ctp_slack_bot.core.config import settings
|
7 |
+
|
8 |
+
logger = logging.getLogger(__name__)
|
9 |
+
|
10 |
+
class MongoDB:
|
11 |
+
"""
|
12 |
+
MongoDB connection and initialization class.
|
13 |
+
Handles connection to MongoDB, database selection, and index creation.
|
14 |
+
"""
|
15 |
+
def __init__(self):
|
16 |
+
self.client: Optional[AsyncIOMotorClient] = None
|
17 |
+
self.db = None
|
18 |
+
self.vector_collection = None
|
19 |
+
self.initialized = False
|
20 |
+
|
21 |
+
async def connect(self):
|
22 |
+
"""
|
23 |
+
Connect to MongoDB using connection string from settings.
|
24 |
+
"""
|
25 |
+
if self.client is not None:
|
26 |
+
return
|
27 |
+
|
28 |
+
if not settings.MONGODB_URI:
|
29 |
+
raise ValueError("MONGODB_URI is not set in environment variables")
|
30 |
+
|
31 |
+
try:
|
32 |
+
# Create MongoDB connection
|
33 |
+
self.client = AsyncIOMotorClient(settings.MONGODB_URI.get_secret_value())
|
34 |
+
self.db = self.client[settings.MONGODB_DB_NAME]
|
35 |
+
self.vector_collection = self.db["vector_store"]
|
36 |
+
logger.info(f"Connected to MongoDB: {settings.MONGODB_DB_NAME}")
|
37 |
+
except Exception as e:
|
38 |
+
logger.error(f"Error connecting to MongoDB: {str(e)}")
|
39 |
+
raise
|
40 |
+
|
41 |
+
async def initialize(self):
|
42 |
+
"""
|
43 |
+
Initialize MongoDB with required collections and indexes.
|
44 |
+
"""
|
45 |
+
if self.initialized:
|
46 |
+
return
|
47 |
+
|
48 |
+
if not self.client:
|
49 |
+
await self.connect()
|
50 |
+
|
51 |
+
try:
|
52 |
+
# Create vector index for similarity search
|
53 |
+
await self.create_vector_index()
|
54 |
+
self.initialized = True
|
55 |
+
logger.info("MongoDB initialized successfully")
|
56 |
+
except Exception as e:
|
57 |
+
logger.error(f"Error initializing MongoDB: {str(e)}")
|
58 |
+
raise
|
59 |
+
|
60 |
+
async def create_vector_index(self):
|
61 |
+
"""
|
62 |
+
Create vector index for similarity search using MongoDB Atlas Vector Search.
|
63 |
+
"""
|
64 |
+
try:
|
65 |
+
# Check if index already exists
|
66 |
+
existing_indexes = await self.vector_collection.list_indexes().to_list(length=None)
|
67 |
+
index_names = [index.get('name') for index in existing_indexes]
|
68 |
+
|
69 |
+
if "vector_index" not in index_names:
|
70 |
+
# Create vector search index
|
71 |
+
index_definition = {
|
72 |
+
"mappings": {
|
73 |
+
"dynamic": True,
|
74 |
+
"fields": {
|
75 |
+
"embedding": {
|
76 |
+
"dimensions": settings.VECTOR_DIMENSION,
|
77 |
+
"similarity": "cosine",
|
78 |
+
"type": "knnVector"
|
79 |
+
}
|
80 |
+
}
|
81 |
+
}
|
82 |
+
}
|
83 |
+
|
84 |
+
# Create the index
|
85 |
+
await self.db.command({
|
86 |
+
"createIndexes": self.vector_collection.name,
|
87 |
+
"indexes": [
|
88 |
+
{
|
89 |
+
"name": "vector_index",
|
90 |
+
"key": {"embedding": "vector"},
|
91 |
+
"weights": {"embedding": 1},
|
92 |
+
"vectorSearchOptions": index_definition
|
93 |
+
}
|
94 |
+
]
|
95 |
+
})
|
96 |
+
|
97 |
+
# Create additional metadata indexes for filtering
|
98 |
+
await self.vector_collection.create_index([("metadata.source", ASCENDING)])
|
99 |
+
await self.vector_collection.create_index([("metadata.timestamp", ASCENDING)])
|
100 |
+
|
101 |
+
logger.info("Vector search index created")
|
102 |
+
else:
|
103 |
+
logger.info("Vector search index already exists")
|
104 |
+
|
105 |
+
except Exception as e:
|
106 |
+
logger.error(f"Error creating vector index: {str(e)}")
|
107 |
+
raise
|
108 |
+
|
109 |
+
async def close(self):
|
110 |
+
"""
|
111 |
+
Close MongoDB connection.
|
112 |
+
"""
|
113 |
+
if self.client:
|
114 |
+
self.client.close()
|
115 |
+
self.client = None
|
116 |
+
self.db = None
|
117 |
+
self.vector_collection = None
|
118 |
+
self.initialized = False
|
119 |
+
logger.info("MongoDB connection closed")
|
120 |
+
|
121 |
+
# Create a singleton instance
|
122 |
+
mongodb = MongoDB()
|
src/ctp_slack_bot/models/VectorQuery.py
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field, validator
|
2 |
+
from typing import Optional, List, Dict, Any
|
3 |
+
from ctp_slack_bot.core.config import settings
|
4 |
+
|
5 |
+
class VectorQuery(BaseModel):
|
6 |
+
"""Model for vector database similarity search queries.
|
7 |
+
|
8 |
+
Attributes:
|
9 |
+
query_text: The text to be vectorized and used for similarity search
|
10 |
+
k: Number of similar documents to retrieve
|
11 |
+
score_threshold: Minimum similarity score threshold for inclusion in results
|
12 |
+
filter_metadata: Optional filters for metadata fields
|
13 |
+
"""
|
14 |
+
query_text: str
|
15 |
+
k: int = Field(default=settings.TOP_K_MATCHES)
|
16 |
+
score_threshold: float = Field(default=0.7)
|
17 |
+
filter_metadata: Optional[Dict[str, Any]] = None
|
src/ctp_slack_bot/models/content.py
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field
|
2 |
+
from typing import Optional, List, Dict, Any
|
3 |
+
from ctp_slack_bot.models.slack import SlackMessage
|
4 |
+
|
5 |
+
class RetreivedContext(BaseModel):
|
6 |
+
"""Represents a the context of a question from Slack returned from the Vector Store Database.
|
7 |
+
|
8 |
+
contextual_text: The text that is relevant to the question.
|
9 |
+
metadata_source: The source of the contextual text.
|
10 |
+
similarity_score: The similarity score of the contextual text to the question.
|
11 |
+
|
12 |
+
in_reation_to_question: OPTINAL: The question that the contextual text is related to.
|
13 |
+
"""
|
14 |
+
contextual_text: str
|
15 |
+
metadata_source: str
|
16 |
+
similarity_score: float
|
17 |
+
|
18 |
+
said_by: str = Optional[None]
|
19 |
+
in_reation_to_question: str = Optional[None]
|
src/ctp_slack_bot/services/AnswerQuestionService.py
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, validator
|
2 |
+
from typing import List, Optional, Tuple
|
3 |
+
from ctp_slack_bot.core.config import settings
|
4 |
+
import numpy as np
|
5 |
+
from openai import OpenAI
|
6 |
+
from ctp_slack_bot.models.slack import SlackMessage
|
7 |
+
from ctp_slack_bot.models.content import RetreivedContext
|
8 |
+
|
9 |
+
class GenerateAnswer():
|
10 |
+
"""
|
11 |
+
Service for language model operations.
|
12 |
+
"""
|
13 |
+
def __init__(self):
|
14 |
+
self.client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
15 |
+
|
16 |
+
def generate_answer(self, question: SlackMessage, context: List[RetreivedContext]) -> str:
|
17 |
+
"""Generate a response using OpenAI's API with retrieved context.
|
18 |
+
|
19 |
+
Args:
|
20 |
+
question (str): The user's question
|
21 |
+
context (List[RetreivedContext]): List of RetreivedContext
|
22 |
+
|
23 |
+
Returns:
|
24 |
+
str: Generated answer
|
25 |
+
"""
|
26 |
+
# Prepare context string from retrieved chunks
|
27 |
+
context_str = ""
|
28 |
+
for c in context:
|
29 |
+
context_str += f"{c.contextual_text}\n"
|
30 |
+
|
31 |
+
|
32 |
+
# logger.info(f"Generating response for question: {question}")
|
33 |
+
# logger.info(f"Using {len(context)} context chunks")
|
34 |
+
|
35 |
+
# Create messages for the chat completion
|
36 |
+
messages = [
|
37 |
+
{"role": "system", "content": settings.SYSTEM_PROMPT},
|
38 |
+
{"role": "user", "content":
|
39 |
+
f"""Student Auestion: {question.text}
|
40 |
+
Context from class materials and transcripts: {context_str}
|
41 |
+
Please answer the Student Auestion based on the Context from class materials and transcripts. If the context doesn't contain relevant information, acknowledge that and suggest asking the professor."""}
|
42 |
+
]
|
43 |
+
|
44 |
+
# Generate response
|
45 |
+
response = self.client.chat.completions.create(
|
46 |
+
model=settings.CHAT_MODEL,
|
47 |
+
messages=messages,
|
48 |
+
max_tokens=settings.MAX_TOKENS,
|
49 |
+
temperature=settings.TEMPERATURE
|
50 |
+
)
|
51 |
+
|
52 |
+
return response.choices[0].message.content
|
53 |
+
|
54 |
+
|
55 |
+
|
56 |
+
### REMOVE BELOW, PUT SOMEWHERE IN TESTS BUT IDK WHERE YET
|
57 |
+
# sm = SlackMessage(text="What is the capital of France?", channel_id="123", user_id="456", timestamp="789")
|
58 |
+
# context = [RetreivedContext(contextual_text="The capital of France is Paris", metadata_source="class materials", similarity_score=0.95)]
|
59 |
+
# a = GenerateAnswer()
|
60 |
+
# a.generate_answer(sm, context)
|
src/ctp_slack_bot/services/ContextRetrievalService.py
ADDED
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from typing import List, Dict, Any, Optional
|
3 |
+
|
4 |
+
from ctp_slack_bot.models.slack import SlackMessage
|
5 |
+
from ctp_slack_bot.models.content import RetreivedContext
|
6 |
+
from ctp_slack_bot.models.VectorQuery import VectorQuery
|
7 |
+
from ctp_slack_bot.services.VectorizationService import VectorizationService
|
8 |
+
from ctp_slack_bot.services.VectorDatabaseService import VectorDatabaseService
|
9 |
+
from ctp_slack_bot.core.config import settings
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
+
|
13 |
+
class ContextRetrievalService:
|
14 |
+
"""
|
15 |
+
Service for retrieving relevant context from the vector database based on user questions.
|
16 |
+
"""
|
17 |
+
|
18 |
+
def __init__(self):
|
19 |
+
self.vectorization_service = VectorizationService()
|
20 |
+
self.vector_db_service = VectorDatabaseService()
|
21 |
+
|
22 |
+
async def initialize(self):
|
23 |
+
"""
|
24 |
+
Initialize the required services.
|
25 |
+
"""
|
26 |
+
await self.vector_db_service.initialize()
|
27 |
+
|
28 |
+
async def get_context(self, message: SlackMessage) -> List[RetreivedContext]:
|
29 |
+
"""
|
30 |
+
Retrieve relevant context for a given Slack message.
|
31 |
+
|
32 |
+
This function:
|
33 |
+
1. Extracts the question text from the message
|
34 |
+
2. Vectorizes the question using VectorizationService
|
35 |
+
3. Queries VectorDatabaseService for similar context
|
36 |
+
4. Returns the relevant context as a list of RetreivedContext objects
|
37 |
+
|
38 |
+
Args:
|
39 |
+
message: The SlackMessage containing the user's question
|
40 |
+
|
41 |
+
Returns:
|
42 |
+
List[RetreivedContext]: List of retrieved context items with similarity scores
|
43 |
+
"""
|
44 |
+
if not message.is_question:
|
45 |
+
logger.debug(f"Message {message.key} is not a question, skipping context retrieval")
|
46 |
+
return []
|
47 |
+
|
48 |
+
try:
|
49 |
+
# Vectorize the message text
|
50 |
+
embeddings = self.vectorization_service.get_embeddings([message.text])
|
51 |
+
if embeddings is None or len(embeddings) == 0:
|
52 |
+
logger.error(f"Failed to generate embedding for message: {message.key}")
|
53 |
+
return []
|
54 |
+
|
55 |
+
query_embedding = embeddings[0].tolist()
|
56 |
+
|
57 |
+
# Create vector query
|
58 |
+
vector_query = VectorQuery(
|
59 |
+
query_text=message.text,
|
60 |
+
k=settings.TOP_K_MATCHES,
|
61 |
+
score_threshold=0.7 # Minimum similarity threshold
|
62 |
+
)
|
63 |
+
|
64 |
+
# Search for similar content in vector database
|
65 |
+
context_results = await self.vector_db_service.search_by_similarity(
|
66 |
+
query=vector_query,
|
67 |
+
query_embedding=query_embedding
|
68 |
+
)
|
69 |
+
|
70 |
+
logger.info(f"Retrieved {len(context_results)} context items for message: {message.key}")
|
71 |
+
return context_results
|
72 |
+
|
73 |
+
except Exception as e:
|
74 |
+
logger.error(f"Error retrieving context for message {message.key}: {str(e)}")
|
75 |
+
return []
|
76 |
+
|
src/ctp_slack_bot/services/VectorDatabaseService.py
ADDED
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from typing import List, Dict, Any, Optional
|
3 |
+
# import numpy as np
|
4 |
+
|
5 |
+
from ctp_slack_bot.db.MongoDB import mongodb
|
6 |
+
from ctp_slack_bot.models.VectorQuery import VectorQuery
|
7 |
+
from ctp_slack_bot.models.content import RetreivedContext
|
8 |
+
|
9 |
+
logger = logging.getLogger(__name__)
|
10 |
+
|
11 |
+
class VectorDatabaseService:
|
12 |
+
"""
|
13 |
+
Service for storing and retrieving vector embeddings from MongoDB.
|
14 |
+
"""
|
15 |
+
|
16 |
+
async def initialize(self):
|
17 |
+
"""
|
18 |
+
Initialize the database connection.
|
19 |
+
"""
|
20 |
+
await mongodb.initialize()
|
21 |
+
|
22 |
+
async def store(self, text: str, embedding: List[float], metadata: Dict[str, Any]) -> str:
|
23 |
+
"""
|
24 |
+
Store text and its embedding vector in the database.
|
25 |
+
|
26 |
+
Args:
|
27 |
+
text: The text content to store
|
28 |
+
embedding: The vector embedding of the text
|
29 |
+
metadata: Additional metadata about the text (source, timestamp, etc.)
|
30 |
+
|
31 |
+
Returns:
|
32 |
+
str: The ID of the stored document
|
33 |
+
"""
|
34 |
+
if not mongodb.initialized:
|
35 |
+
await mongodb.initialize()
|
36 |
+
|
37 |
+
try:
|
38 |
+
# Create document to store
|
39 |
+
document = {
|
40 |
+
"text": text,
|
41 |
+
"embedding": embedding,
|
42 |
+
"metadata": metadata
|
43 |
+
}
|
44 |
+
|
45 |
+
# Insert into collection
|
46 |
+
result = await mongodb.vector_collection.insert_one(document)
|
47 |
+
logger.debug(f"Stored document with ID: {result.inserted_id}")
|
48 |
+
|
49 |
+
return str(result.inserted_id)
|
50 |
+
except Exception as e:
|
51 |
+
logger.error(f"Error storing embedding: {str(e)}")
|
52 |
+
raise
|
53 |
+
|
54 |
+
async def search_by_similarity(self, query: VectorQuery, query_embedding: List[float]) -> List[RetreivedContext]:
|
55 |
+
"""
|
56 |
+
Query the vector database for similar documents.
|
57 |
+
|
58 |
+
Args:
|
59 |
+
query: VectorQuery object with search parameters
|
60 |
+
query_embedding: The vector embedding of the query text
|
61 |
+
|
62 |
+
Returns:
|
63 |
+
List[RetreivedContext]: List of similar documents with similarity scores
|
64 |
+
"""
|
65 |
+
if not mongodb.initialized:
|
66 |
+
await mongodb.initialize()
|
67 |
+
|
68 |
+
try:
|
69 |
+
# Build aggregation pipeline for vector search
|
70 |
+
pipeline = [
|
71 |
+
{
|
72 |
+
"$search": {
|
73 |
+
"index": "vector_index",
|
74 |
+
"knnBeta": {
|
75 |
+
"vector": query_embedding,
|
76 |
+
"path": "embedding",
|
77 |
+
"k": query.k
|
78 |
+
}
|
79 |
+
}
|
80 |
+
},
|
81 |
+
{
|
82 |
+
"$project": {
|
83 |
+
"_id": 0,
|
84 |
+
"text": 1,
|
85 |
+
"metadata": 1,
|
86 |
+
"score": {"$meta": "searchScore"}
|
87 |
+
}
|
88 |
+
}
|
89 |
+
]
|
90 |
+
|
91 |
+
# Add metadata filters if provided
|
92 |
+
if query.filter_metadata:
|
93 |
+
metadata_filter = {f"metadata.{k}": v for k, v in query.filter_metadata.items()}
|
94 |
+
pipeline.insert(1, {"$match": metadata_filter})
|
95 |
+
|
96 |
+
# Execute the pipeline
|
97 |
+
results = await mongodb.vector_collection.aggregate(pipeline).to_list(length=query.k)
|
98 |
+
|
99 |
+
# Convert to RetreivedContext objects directly
|
100 |
+
context_results = []
|
101 |
+
for result in results:
|
102 |
+
# Normalize score to [0,1] range
|
103 |
+
normalized_score = result.get("score", 0)
|
104 |
+
|
105 |
+
# Skip if below threshold
|
106 |
+
if normalized_score < query.score_threshold:
|
107 |
+
continue
|
108 |
+
|
109 |
+
context_results.append(
|
110 |
+
RetreivedContext(
|
111 |
+
contextual_text=result["text"],
|
112 |
+
metadata_source=result["metadata"].get("source", "unknown"),
|
113 |
+
similarity_score=normalized_score,
|
114 |
+
said_by=result["metadata"].get("speaker", None),
|
115 |
+
in_reation_to_question=result["metadata"].get("related_question", None)
|
116 |
+
)
|
117 |
+
)
|
118 |
+
|
119 |
+
logger.debug(f"Found {len(context_results)} similar documents")
|
120 |
+
return context_results
|
121 |
+
|
122 |
+
except Exception as e:
|
123 |
+
logger.error(f"Error in similarity search: {str(e)}")
|
124 |
+
raise
|