Musadiq7860 commited on
Commit
6e02ff7
·
1 Parent(s): 9e53e90

Deploy SkillBridge Backend

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