Spaces:
Sleeping
Sleeping
Commit ·
6e02ff7
1
Parent(s): 9e53e90
Deploy SkillBridge Backend
Browse files- .env.example +6 -0
- Dockerfile +17 -0
- db/__init__.py +0 -0
- db/supabase_client.py +18 -0
- main.py +59 -0
- requirements.txt +8 -0
- routes/__init__.py +0 -0
- routes/chat.py +47 -0
- routes/matches.py +90 -0
- routes/skills.py +120 -0
- services/__init__.py +0 -0
- services/ai_coach.py +54 -0
- services/embedder.py +11 -0
- services/matcher.py +68 -0
.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to .env and fill in your values
|
| 2 |
+
# DO NOT commit .env to version control
|
| 3 |
+
|
| 4 |
+
SUPABASE_URL=https://your-project-ref.supabase.co
|
| 5 |
+
SUPABASE_SERVICE_KEY=your-service-role-key-here
|
| 6 |
+
GROQ_API_KEY=your-groq-api-key-here
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install dependencies first (cached layer)
|
| 7 |
+
COPY requirements.txt .
|
| 8 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 9 |
+
|
| 10 |
+
# Copy application code
|
| 11 |
+
COPY . .
|
| 12 |
+
|
| 13 |
+
# HuggingFace Spaces requires port 7860
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
# Start the FastAPI server
|
| 17 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
db/__init__.py
ADDED
|
File without changes
|
db/supabase_client.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client, Client
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
SUPABASE_URL: str = os.environ["SUPABASE_URL"]
|
| 8 |
+
SUPABASE_SERVICE_KEY: str = os.environ["SUPABASE_SERVICE_KEY"]
|
| 9 |
+
|
| 10 |
+
_client: Client | None = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_supabase_client() -> Client:
|
| 14 |
+
"""Return a cached Supabase client using the service role key."""
|
| 15 |
+
global _client
|
| 16 |
+
if _client is None:
|
| 17 |
+
_client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
| 18 |
+
return _client
|
main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
|
| 7 |
+
from routes import skills, matches, chat
|
| 8 |
+
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@asynccontextmanager
|
| 14 |
+
async def lifespan(app: FastAPI):
|
| 15 |
+
"""
|
| 16 |
+
Load the sentence-transformers model once at startup.
|
| 17 |
+
Importing embedder triggers the module-level SentenceTransformer() call.
|
| 18 |
+
"""
|
| 19 |
+
logger.info("Loading sentence-transformers model...")
|
| 20 |
+
import services.embedder # noqa: F401 — side-effect import triggers model load
|
| 21 |
+
logger.info("Model ready. SkillBridge API is live.")
|
| 22 |
+
yield
|
| 23 |
+
logger.info("Shutting down.")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
app = FastAPI(
|
| 27 |
+
title="SkillBridge API",
|
| 28 |
+
version="1.0.0",
|
| 29 |
+
lifespan=lifespan,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# ── CORS ─────────────────────────────────────────────────────────────────────
|
| 33 |
+
app.add_middleware(
|
| 34 |
+
CORSMiddleware,
|
| 35 |
+
allow_origins=[
|
| 36 |
+
"http://localhost:3000",
|
| 37 |
+
"https://*.vercel.app",
|
| 38 |
+
],
|
| 39 |
+
allow_credentials=True,
|
| 40 |
+
allow_methods=["*"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# ── Routers ───────────────────────────────────────────────────────────────────
|
| 45 |
+
app.include_router(skills.router)
|
| 46 |
+
app.include_router(matches.router)
|
| 47 |
+
app.include_router(chat.router)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── Health check ──────────────────────────────────────────────────────────────
|
| 51 |
+
@app.get("/health", tags=["health"])
|
| 52 |
+
async def health() -> dict:
|
| 53 |
+
return {"status": "ok"}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── Entry point (used by Dockerfile CMD) ─────────────────────────────────────
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
import uvicorn
|
| 59 |
+
uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn==0.27.0
|
| 3 |
+
sentence-transformers==2.3.1
|
| 4 |
+
supabase==2.3.4
|
| 5 |
+
groq==0.4.2
|
| 6 |
+
python-dotenv==1.0.0
|
| 7 |
+
sse-starlette==1.8.2
|
| 8 |
+
pydantic==2.5.3
|
routes/__init__.py
ADDED
|
File without changes
|
routes/chat.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from fastapi.responses import Response
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from sse_starlette.sse import EventSourceResponse
|
| 5 |
+
|
| 6 |
+
from services.ai_coach import get_coach_response
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/api/v1/chat", tags=["chat"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ── Request model ────────────────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
class MessageItem(BaseModel):
|
| 14 |
+
sender_id: str
|
| 15 |
+
content: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class CoachRequest(BaseModel):
|
| 19 |
+
session_id: str
|
| 20 |
+
messages: list[MessageItem]
|
| 21 |
+
message_count: int
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── Endpoint ─────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
@router.post("/coach")
|
| 27 |
+
async def coach(payload: CoachRequest) -> Response:
|
| 28 |
+
"""
|
| 29 |
+
Stream the AI Coach response via Server-Sent Events.
|
| 30 |
+
Only fires when message_count is divisible by 4 (every 4th message).
|
| 31 |
+
Returns 204 No Content when the coach is not due to speak.
|
| 32 |
+
"""
|
| 33 |
+
if payload.message_count % 4 != 0 or payload.message_count == 0:
|
| 34 |
+
return Response(status_code=204)
|
| 35 |
+
|
| 36 |
+
messages_dicts: list[dict] = [
|
| 37 |
+
{"sender_id": m.sender_id, "content": m.content}
|
| 38 |
+
for m in payload.messages
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
async def event_generator():
|
| 42 |
+
async for chunk in get_coach_response(messages_dicts):
|
| 43 |
+
yield {"data": chunk}
|
| 44 |
+
# Signal stream completion to the client
|
| 45 |
+
yield {"data": "[DONE]"}
|
| 46 |
+
|
| 47 |
+
return EventSourceResponse(event_generator())
|
routes/matches.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
from db.supabase_client import get_supabase_client
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/api/v1/matches", tags=["matches"])
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# ── Response model ───────────────────────────────────────────────────────────
|
| 10 |
+
|
| 11 |
+
class MatchResponse(BaseModel):
|
| 12 |
+
id: str
|
| 13 |
+
score: float
|
| 14 |
+
status: str
|
| 15 |
+
skill_offered_title: str
|
| 16 |
+
skill_needed_title: str
|
| 17 |
+
other_user_name: str
|
| 18 |
+
other_user_id: str
|
| 19 |
+
chat_session_id: str
|
| 20 |
+
created_at: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ── Endpoint ─────────────────────────────────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
@router.get("/{user_id}", response_model=list[MatchResponse])
|
| 26 |
+
async def get_matches(user_id: str) -> list[MatchResponse]:
|
| 27 |
+
"""
|
| 28 |
+
Return all pending/accepted matches where the user is either teacher or
|
| 29 |
+
learner. Joins with profiles, skills, and chat_sessions so the frontend
|
| 30 |
+
has everything it needs to render the match card and navigate to the chat.
|
| 31 |
+
"""
|
| 32 |
+
client = get_supabase_client()
|
| 33 |
+
|
| 34 |
+
result = (
|
| 35 |
+
client.table("matches")
|
| 36 |
+
.select(
|
| 37 |
+
"id, score, status, created_at, "
|
| 38 |
+
"teacher_id, learner_id, "
|
| 39 |
+
"skills_offered(title), "
|
| 40 |
+
"skills_needed(title), "
|
| 41 |
+
"chat_sessions(id)"
|
| 42 |
+
)
|
| 43 |
+
.or_(f"teacher_id.eq.{user_id},learner_id.eq.{user_id}")
|
| 44 |
+
.neq("status", "rejected")
|
| 45 |
+
.order("created_at", desc=True)
|
| 46 |
+
.execute()
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
if not result.data:
|
| 50 |
+
return []
|
| 51 |
+
|
| 52 |
+
matches: list[MatchResponse] = []
|
| 53 |
+
for row in result.data:
|
| 54 |
+
# Determine which side the requesting user is on
|
| 55 |
+
is_teacher: bool = row["teacher_id"] == user_id
|
| 56 |
+
other_user_id: str = row["learner_id"] if is_teacher else row["teacher_id"]
|
| 57 |
+
|
| 58 |
+
# Fetch the other user's profile name
|
| 59 |
+
profile_result = (
|
| 60 |
+
client.table("profiles")
|
| 61 |
+
.select("name")
|
| 62 |
+
.eq("id", other_user_id)
|
| 63 |
+
.single()
|
| 64 |
+
.execute()
|
| 65 |
+
)
|
| 66 |
+
other_name: str = (
|
| 67 |
+
profile_result.data["name"] if profile_result.data else "Unknown"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# chat_sessions is a list (one-to-many relation) — take the first
|
| 71 |
+
chat_sessions = row.get("chat_sessions") or []
|
| 72 |
+
if not chat_sessions:
|
| 73 |
+
continue
|
| 74 |
+
chat_session_id: str = chat_sessions[0]["id"]
|
| 75 |
+
|
| 76 |
+
matches.append(
|
| 77 |
+
MatchResponse(
|
| 78 |
+
id=row["id"],
|
| 79 |
+
score=row["score"],
|
| 80 |
+
status=row["status"],
|
| 81 |
+
skill_offered_title=row["skills_offered"]["title"],
|
| 82 |
+
skill_needed_title=row["skills_needed"]["title"],
|
| 83 |
+
other_user_name=other_name,
|
| 84 |
+
other_user_id=other_user_id,
|
| 85 |
+
chat_session_id=chat_session_id,
|
| 86 |
+
created_at=row["created_at"],
|
| 87 |
+
)
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
return matches
|
routes/skills.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
from db.supabase_client import get_supabase_client
|
| 5 |
+
from services.embedder import encode_text
|
| 6 |
+
from services.matcher import find_matches, create_match_if_qualified
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/api/v1/skills", tags=["skills"])
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ── Request / Response models ────────────────────────────────────────────────
|
| 12 |
+
|
| 13 |
+
class SkillOfferRequest(BaseModel):
|
| 14 |
+
user_id: str
|
| 15 |
+
title: str
|
| 16 |
+
description: str = ""
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SkillNeedRequest(BaseModel):
|
| 20 |
+
user_id: str
|
| 21 |
+
title: str
|
| 22 |
+
description: str = ""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SkillResponse(BaseModel):
|
| 26 |
+
id: str
|
| 27 |
+
user_id: str
|
| 28 |
+
title: str
|
| 29 |
+
description: str
|
| 30 |
+
match: dict | None = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Endpoints ────────────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
@router.post("/offer", response_model=SkillResponse, status_code=201)
|
| 36 |
+
async def post_skill_offer(payload: SkillOfferRequest) -> SkillResponse:
|
| 37 |
+
"""
|
| 38 |
+
Encode the offered skill and store it with its embedding vector.
|
| 39 |
+
This makes it discoverable by future pgvector similarity searches.
|
| 40 |
+
"""
|
| 41 |
+
embedding: list[float] = encode_text(payload.title, payload.description)
|
| 42 |
+
client = get_supabase_client()
|
| 43 |
+
|
| 44 |
+
result = (
|
| 45 |
+
client.table("skills_offered")
|
| 46 |
+
.insert(
|
| 47 |
+
{
|
| 48 |
+
"user_id": payload.user_id,
|
| 49 |
+
"title": payload.title,
|
| 50 |
+
"description": payload.description,
|
| 51 |
+
"embedding": embedding,
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
.execute()
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if not result.data:
|
| 58 |
+
raise HTTPException(status_code=500, detail="Failed to store skill offer")
|
| 59 |
+
|
| 60 |
+
row: dict = result.data[0]
|
| 61 |
+
return SkillResponse(
|
| 62 |
+
id=row["id"],
|
| 63 |
+
user_id=row["user_id"],
|
| 64 |
+
title=row["title"],
|
| 65 |
+
description=row["description"] or "",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.post("/need", response_model=SkillResponse, status_code=201)
|
| 70 |
+
async def post_skill_need(payload: SkillNeedRequest) -> SkillResponse:
|
| 71 |
+
"""
|
| 72 |
+
Encode the needed skill, store it, then immediately run the ML matching loop:
|
| 73 |
+
1. pgvector cosine search against skills_offered
|
| 74 |
+
2. If best match score > 0.5 → create match + chat session
|
| 75 |
+
3. Return the new skill row plus match info (if any) so the frontend
|
| 76 |
+
can redirect the user directly to their chat room.
|
| 77 |
+
"""
|
| 78 |
+
embedding: list[float] = encode_text(payload.title, payload.description)
|
| 79 |
+
client = get_supabase_client()
|
| 80 |
+
|
| 81 |
+
result = (
|
| 82 |
+
client.table("skills_needed")
|
| 83 |
+
.insert(
|
| 84 |
+
{
|
| 85 |
+
"user_id": payload.user_id,
|
| 86 |
+
"title": payload.title,
|
| 87 |
+
"description": payload.description,
|
| 88 |
+
"embedding": embedding,
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
.execute()
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
if not result.data:
|
| 95 |
+
raise HTTPException(status_code=500, detail="Failed to store skill need")
|
| 96 |
+
|
| 97 |
+
row: dict = result.data[0]
|
| 98 |
+
|
| 99 |
+
# ── ML matching loop ─────────────────────────────────────────────────────
|
| 100 |
+
matches: list[dict] = await find_matches(
|
| 101 |
+
query_embedding=embedding,
|
| 102 |
+
exclude_user=payload.user_id,
|
| 103 |
+
match_count=3,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
best_match: dict | None = None
|
| 107 |
+
if matches:
|
| 108 |
+
best_match = await create_match_if_qualified(
|
| 109 |
+
teacher_skill=matches[0],
|
| 110 |
+
learner_skill_id=row["id"],
|
| 111 |
+
learner_id=payload.user_id,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return SkillResponse(
|
| 115 |
+
id=row["id"],
|
| 116 |
+
user_id=row["user_id"],
|
| 117 |
+
title=row["title"],
|
| 118 |
+
description=row["description"] or "",
|
| 119 |
+
match=best_match,
|
| 120 |
+
)
|
services/__init__.py
ADDED
|
File without changes
|
services/ai_coach.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import AsyncGenerator
|
| 3 |
+
from groq import AsyncGroq
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
# Initialized once at module level — never per request
|
| 9 |
+
_groq_client: AsyncGroq = AsyncGroq(api_key=os.environ["GROQ_API_KEY"])
|
| 10 |
+
|
| 11 |
+
_SYSTEM_PROMPT: str = (
|
| 12 |
+
"You are an AI Coach passively observing a live peer skill-sharing session "
|
| 13 |
+
"on SkillBridge between a teacher and a learner. You intervene occasionally to:\n"
|
| 14 |
+
"- Clarify concepts that seem unclear\n"
|
| 15 |
+
"- Provide concrete, real-world examples\n"
|
| 16 |
+
"- Suggest free learning resources (YouTube, freeCodeCamp, MDN, Khan Academy, etc.)\n\n"
|
| 17 |
+
"Keep responses concise (3–5 sentences). Be encouraging and practical. "
|
| 18 |
+
"Do not repeat what has already been said."
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def build_coach_prompt(messages: list[dict]) -> list[dict]:
|
| 23 |
+
"""
|
| 24 |
+
Format the recent chat history into Groq's message list format,
|
| 25 |
+
prefixed with the AI Coach system prompt.
|
| 26 |
+
"""
|
| 27 |
+
formatted: list[dict] = [{"role": "system", "content": _SYSTEM_PROMPT}]
|
| 28 |
+
for msg in messages:
|
| 29 |
+
sender: str = msg.get("sender_id", "user")
|
| 30 |
+
content: str = msg.get("content", "")
|
| 31 |
+
# Coach's own past messages map to 'assistant'; all others to 'user'
|
| 32 |
+
role: str = "assistant" if sender == "ai_coach" else "user"
|
| 33 |
+
formatted.append({"role": role, "content": f"[{sender}]: {content}"})
|
| 34 |
+
return formatted
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def get_coach_response(
|
| 38 |
+
messages: list[dict],
|
| 39 |
+
) -> AsyncGenerator[str, None]:
|
| 40 |
+
"""
|
| 41 |
+
Stream a Groq LLaMA 3.3 70B response for the AI Coach.
|
| 42 |
+
Always uses stream=True — never buffers the full response.
|
| 43 |
+
"""
|
| 44 |
+
prompt = build_coach_prompt(messages)
|
| 45 |
+
stream = await _groq_client.chat.completions.create(
|
| 46 |
+
model="llama-3.3-70b-versatile",
|
| 47 |
+
messages=prompt,
|
| 48 |
+
stream=True,
|
| 49 |
+
max_tokens=300,
|
| 50 |
+
)
|
| 51 |
+
async for chunk in stream:
|
| 52 |
+
content: str | None = chunk.choices[0].delta.content
|
| 53 |
+
if content:
|
| 54 |
+
yield content
|
services/embedder.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sentence_transformers import SentenceTransformer
|
| 2 |
+
|
| 3 |
+
# Loaded once at module import time — never reloaded per request
|
| 4 |
+
model: SentenceTransformer = SentenceTransformer("all-MiniLM-L6-v2")
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def encode_text(title: str, description: str = "") -> list[float]:
|
| 8 |
+
"""Encode a skill title + description into a 384-dim normalized vector."""
|
| 9 |
+
text: str = f"{title} {description}".strip()
|
| 10 |
+
embedding = model.encode(text, normalize_embeddings=True)
|
| 11 |
+
return embedding.tolist()
|
services/matcher.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from db.supabase_client import get_supabase_client
|
| 2 |
+
|
| 3 |
+
MATCH_THRESHOLD: float = 0.5
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def find_matches(
|
| 7 |
+
query_embedding: list[float],
|
| 8 |
+
exclude_user: str,
|
| 9 |
+
match_count: int = 3,
|
| 10 |
+
) -> list[dict]:
|
| 11 |
+
"""
|
| 12 |
+
Call the pgvector RPC function to find top matching skills_offered rows
|
| 13 |
+
for a given query embedding. All cosine similarity is computed in SQL.
|
| 14 |
+
"""
|
| 15 |
+
client = get_supabase_client()
|
| 16 |
+
result = (
|
| 17 |
+
client.rpc(
|
| 18 |
+
"match_skills",
|
| 19 |
+
{
|
| 20 |
+
"query_embedding": query_embedding,
|
| 21 |
+
"exclude_user": exclude_user,
|
| 22 |
+
"match_count": match_count,
|
| 23 |
+
},
|
| 24 |
+
)
|
| 25 |
+
.execute()
|
| 26 |
+
)
|
| 27 |
+
return result.data or []
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def create_match_if_qualified(
|
| 31 |
+
teacher_skill: dict,
|
| 32 |
+
learner_skill_id: str,
|
| 33 |
+
learner_id: str,
|
| 34 |
+
) -> dict | None:
|
| 35 |
+
"""
|
| 36 |
+
Insert a match + chat_session row only when similarity exceeds the threshold.
|
| 37 |
+
Returns the match dict with an added `chat_session_id` field, or None.
|
| 38 |
+
"""
|
| 39 |
+
if teacher_skill.get("similarity", 0) <= MATCH_THRESHOLD:
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
client = get_supabase_client()
|
| 43 |
+
|
| 44 |
+
match_result = (
|
| 45 |
+
client.table("matches")
|
| 46 |
+
.insert(
|
| 47 |
+
{
|
| 48 |
+
"teacher_id": teacher_skill["user_id"],
|
| 49 |
+
"learner_id": learner_id,
|
| 50 |
+
"skill_offered_id": teacher_skill["id"],
|
| 51 |
+
"skill_needed_id": learner_skill_id,
|
| 52 |
+
"score": teacher_skill["similarity"],
|
| 53 |
+
"status": "pending",
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
.execute()
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
match: dict = match_result.data[0]
|
| 60 |
+
|
| 61 |
+
session_result = (
|
| 62 |
+
client.table("chat_sessions")
|
| 63 |
+
.insert({"match_id": match["id"]})
|
| 64 |
+
.execute()
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
match["chat_session_id"] = session_result.data[0]["id"]
|
| 68 |
+
return match
|