Merge pull request #1 from ukzada/main
Browse filesInitial commit: GitHub Profile AI Reviewer
- .env.example +4 -0
- .gitignore +1 -1
- Dockerfile +9 -0
- app/__init__.py +1 -0
- app/ai/__init__.py +1 -0
- app/ai/embeddings.py +56 -0
- app/ai/scoring.py +75 -0
- app/clients/__init__.py +1 -0
- app/clients/github_graphql.py +42 -0
- app/clients/queries.py +50 -0
- app/clients/sample_user.json +24 -0
- app/clients/streak.py +28 -0
- app/core/__init__.py +1 -0
- app/core/config.py +23 -0
- app/core/logging.py +7 -0
- app/graph/__init__.py +1 -0
- app/graph/state.py +22 -0
- app/graph/workflow.py +101 -0
- app/main.py +36 -0
- app/schemas.py +33 -0
- app/service.py +13 -0
- docker-compose.yml +22 -0
- main.py +24 -0
- requirements.txt +9 -0
.env.example
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
GITHUB_TOKEN=
|
| 2 |
+
LANGSMITH_API_KEY=
|
| 3 |
+
APP_HOST=0.0.0.0
|
| 4 |
+
APP_PORT=8000
|
.gitignore
CHANGED
|
@@ -29,4 +29,4 @@ Thumbs.db
|
|
| 29 |
.txtconfig
|
| 30 |
.txtignore
|
| 31 |
PolyMentor.txt
|
| 32 |
-
venv312/
|
|
|
|
| 29 |
.txtconfig
|
| 30 |
.txtignore
|
| 31 |
PolyMentor.txt
|
| 32 |
+
venv312/
|
Dockerfile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM pytorch/pytorch:2.7.1-cuda12.6-cudnn9-runtime
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
COPY requirements.txt ./
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
COPY . .
|
| 8 |
+
EXPOSE 8000
|
| 9 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/ai/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/ai/embeddings.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Iterable
|
| 4 |
+
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
import torch
|
| 9 |
+
from transformers import AutoModel, AutoTokenizer
|
| 10 |
+
except Exception:
|
| 11 |
+
torch = None
|
| 12 |
+
AutoModel = None
|
| 13 |
+
AutoTokenizer = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CodeEmbeddingService:
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
self._dim = settings.embedding_dim
|
| 19 |
+
self._ready = False
|
| 20 |
+
self._tokenizer = None
|
| 21 |
+
self._model = None
|
| 22 |
+
|
| 23 |
+
if AutoModel is not None and AutoTokenizer is not None:
|
| 24 |
+
try:
|
| 25 |
+
self._tokenizer = AutoTokenizer.from_pretrained(settings.codebert_model)
|
| 26 |
+
self._model = AutoModel.from_pretrained(settings.codebert_model)
|
| 27 |
+
self._model.eval()
|
| 28 |
+
self._ready = True
|
| 29 |
+
except Exception:
|
| 30 |
+
self._ready = False
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def ready(self) -> bool:
|
| 34 |
+
return self._ready
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def embedding_dim(self) -> int:
|
| 38 |
+
return self._dim
|
| 39 |
+
|
| 40 |
+
def embed_repository_signals(self, snippets: Iterable[str]) -> list[float]:
|
| 41 |
+
text = "\n".join(snippets).strip()
|
| 42 |
+
if not text:
|
| 43 |
+
return [0.0] * self._dim
|
| 44 |
+
|
| 45 |
+
if self._ready and torch is not None and self._tokenizer is not None and self._model is not None:
|
| 46 |
+
tokens = self._tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
|
| 47 |
+
with torch.no_grad():
|
| 48 |
+
outputs = self._model(**tokens)
|
| 49 |
+
cls = outputs.last_hidden_state[:, 0, :].squeeze(0)
|
| 50 |
+
return cls.tolist()
|
| 51 |
+
|
| 52 |
+
vector = [0.0] * self._dim
|
| 53 |
+
for i, ch in enumerate(text):
|
| 54 |
+
vector[i % self._dim] += (ord(ch) % 31) / 31.0
|
| 55 |
+
norm = sum(abs(v) for v in vector) or 1.0
|
| 56 |
+
return [v / norm for v in vector]
|
app/ai/scoring.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
try:
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
except Exception:
|
| 9 |
+
torch = None
|
| 10 |
+
nn = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
if nn is not None:
|
| 14 |
+
class DeveloperScoringModel(nn.Module):
|
| 15 |
+
def __init__(self, input_dim: int = 768) -> None:
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.encoder = nn.Sequential(
|
| 18 |
+
nn.Linear(input_dim, 256),
|
| 19 |
+
nn.ReLU(),
|
| 20 |
+
nn.Dropout(0.2),
|
| 21 |
+
nn.Linear(256, 128),
|
| 22 |
+
nn.ReLU(),
|
| 23 |
+
)
|
| 24 |
+
self.level_head = nn.Linear(128, 4)
|
| 25 |
+
self.hiring_head = nn.Linear(128, 1)
|
| 26 |
+
|
| 27 |
+
def forward(self, x):
|
| 28 |
+
feat = self.encoder(x)
|
| 29 |
+
level_logits = self.level_head(feat)
|
| 30 |
+
hiring = torch.sigmoid(self.hiring_head(feat)) * 100
|
| 31 |
+
return level_logits, hiring
|
| 32 |
+
else:
|
| 33 |
+
class DeveloperScoringModel: # type: ignore[no-redef]
|
| 34 |
+
def __init__(self, input_dim: int = 768) -> None:
|
| 35 |
+
self.input_dim = input_dim
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class ScoreOutput:
|
| 40 |
+
level: str
|
| 41 |
+
confidence: float
|
| 42 |
+
hiring_score: int
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ScoringEngine:
|
| 46 |
+
LEVELS = ["Junior", "Mid", "Senior", "Staff / Principal"]
|
| 47 |
+
|
| 48 |
+
def __init__(self, input_dim: int = 768) -> None:
|
| 49 |
+
self._model = DeveloperScoringModel(input_dim=input_dim)
|
| 50 |
+
|
| 51 |
+
def infer(self, embedding: list[float], activity_score: float, consistency_score: float) -> ScoreOutput:
|
| 52 |
+
if torch is None or nn is None or not isinstance(self._model, nn.Module):
|
| 53 |
+
return self._heuristic(activity_score, consistency_score)
|
| 54 |
+
|
| 55 |
+
x = torch.tensor([embedding], dtype=torch.float32)
|
| 56 |
+
self._model.eval()
|
| 57 |
+
with torch.no_grad():
|
| 58 |
+
level_logits, hiring = self._model(x)
|
| 59 |
+
|
| 60 |
+
probs = torch.softmax(level_logits[0], dim=-1)
|
| 61 |
+
level_idx = int(torch.argmax(probs).item())
|
| 62 |
+
base_hiring = int(round(float(hiring[0].item())))
|
| 63 |
+
blended_hiring = int(max(0, min(100, 0.6 * base_hiring + 0.4 * activity_score)))
|
| 64 |
+
confidence = float(probs[level_idx].item())
|
| 65 |
+
return ScoreOutput(level=self.LEVELS[level_idx], confidence=round(confidence, 2), hiring_score=blended_hiring)
|
| 66 |
+
|
| 67 |
+
def _heuristic(self, activity_score: float, consistency_score: float) -> ScoreOutput:
|
| 68 |
+
hiring = int(max(0, min(100, round(0.7 * activity_score + 0.3 * consistency_score))))
|
| 69 |
+
if hiring >= 90:
|
| 70 |
+
return ScoreOutput("Staff / Principal", 0.95, hiring)
|
| 71 |
+
if hiring >= 75:
|
| 72 |
+
return ScoreOutput("Senior", 0.90, hiring)
|
| 73 |
+
if hiring >= 55:
|
| 74 |
+
return ScoreOutput("Mid", 0.86, hiring)
|
| 75 |
+
return ScoreOutput("Junior", 0.82, hiring)
|
app/clients/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/clients/github_graphql.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
from app.clients.queries import ANALYZE_PROFILE_QUERY
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GitHubAPIError(RuntimeError):
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class GitHubGraphQLClient:
|
| 18 |
+
async def analyze_user(self, username: str) -> dict[str, Any]:
|
| 19 |
+
# If token missing, use local sample so app can run offline for testing.
|
| 20 |
+
if not settings.github_token:
|
| 21 |
+
sample_path = Path(__file__).with_name("sample_user.json")
|
| 22 |
+
if sample_path.exists():
|
| 23 |
+
with sample_path.open("r", encoding="utf-8") as fh:
|
| 24 |
+
return json.load(fh)
|
| 25 |
+
raise GitHubAPIError("Missing GITHUB_TOKEN in environment and no sample_user.json available.")
|
| 26 |
+
|
| 27 |
+
headers = {"Authorization": f"Bearer {settings.github_token}", "Accept": "application/vnd.github+json"}
|
| 28 |
+
payload = {"query": ANALYZE_PROFILE_QUERY, "variables": {"username": username}}
|
| 29 |
+
|
| 30 |
+
async with httpx.AsyncClient(timeout=settings.request_timeout_seconds) as client:
|
| 31 |
+
response = await client.post(settings.github_api_url, headers=headers, json=payload)
|
| 32 |
+
|
| 33 |
+
if response.status_code >= 400:
|
| 34 |
+
raise GitHubAPIError(f"GitHub API status={response.status_code}: {response.text}")
|
| 35 |
+
|
| 36 |
+
data = response.json()
|
| 37 |
+
if data.get("errors"):
|
| 38 |
+
raise GitHubAPIError(str(data["errors"]))
|
| 39 |
+
if data.get("data", {}).get("user") is None:
|
| 40 |
+
raise GitHubAPIError(f"GitHub user '{username}' not found.")
|
| 41 |
+
|
| 42 |
+
return data
|
app/clients/queries.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
ANALYZE_PROFILE_QUERY = """
|
| 4 |
+
query AnalyzeProfile($username: String!) {
|
| 5 |
+
user(login: $username) {
|
| 6 |
+
login
|
| 7 |
+
name
|
| 8 |
+
bio
|
| 9 |
+
createdAt
|
| 10 |
+
followers { totalCount }
|
| 11 |
+
contributionsCollection {
|
| 12 |
+
totalCommitContributions
|
| 13 |
+
totalPullRequestContributions
|
| 14 |
+
contributionCalendar {
|
| 15 |
+
totalContributions
|
| 16 |
+
weeks {
|
| 17 |
+
contributionDays {
|
| 18 |
+
contributionCount
|
| 19 |
+
date
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
repositories(first: 20, orderBy: { field: STARGAZERS, direction: DESC }) {
|
| 25 |
+
nodes {
|
| 26 |
+
name
|
| 27 |
+
nameWithOwner
|
| 28 |
+
description
|
| 29 |
+
stargazerCount
|
| 30 |
+
forkCount
|
| 31 |
+
primaryLanguage { name }
|
| 32 |
+
languages(first: 5) {
|
| 33 |
+
edges {
|
| 34 |
+
size
|
| 35 |
+
node { name }
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
defaultBranchRef {
|
| 39 |
+
target {
|
| 40 |
+
... on Commit {
|
| 41 |
+
history(first: 1) { totalCount }
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
pullRequests(first: 50, states: MERGED) { totalCount }
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
""".strip()
|
app/clients/sample_user.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"data": {
|
| 3 |
+
"user": {
|
| 4 |
+
"repositories": {
|
| 5 |
+
"nodes": [
|
| 6 |
+
{
|
| 7 |
+
"name": "demo-repo",
|
| 8 |
+
"description": "Example repo",
|
| 9 |
+
"primaryLanguage": {"name": "Python"},
|
| 10 |
+
"defaultBranchRef": {"target": {"history": {"totalCount": 42}}},
|
| 11 |
+
"languages": {"edges": [{"node": {"name": "Python"}, "size": 1000}]}
|
| 12 |
+
}
|
| 13 |
+
]
|
| 14 |
+
},
|
| 15 |
+
"pullRequests": {"totalCount": 5},
|
| 16 |
+
"contributionsCollection": {
|
| 17 |
+
"contributionCalendar": {"totalContributions": 120, "weeks": [
|
| 18 |
+
{"contributionDays": [{"contributionCount": 0}, {"contributionCount": 1}, {"contributionCount": 1}, {"contributionCount": 0}, {"contributionCount": 2}, {"contributionCount": 3}, {"contributionCount": 0}]},
|
| 19 |
+
{"contributionDays": [{"contributionCount": 1}, {"contributionCount": 1}, {"contributionCount": 1}, {"contributionCount": 1}, {"contributionCount": 1}, {"contributionCount": 1}, {"contributionCount": 1}]}
|
| 20 |
+
]}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
}
|
app/clients/streak.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.schemas import StreakData
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def compute_streak_from_calendar(weeks: list[dict]) -> StreakData:
|
| 7 |
+
daily: list[int] = []
|
| 8 |
+
for week in weeks:
|
| 9 |
+
for day in week.get("contributionDays", []):
|
| 10 |
+
daily.append(int(day.get("contributionCount", 0)))
|
| 11 |
+
|
| 12 |
+
current = 0
|
| 13 |
+
for v in reversed(daily):
|
| 14 |
+
if v > 0:
|
| 15 |
+
current += 1
|
| 16 |
+
else:
|
| 17 |
+
break
|
| 18 |
+
|
| 19 |
+
longest = 0
|
| 20 |
+
run = 0
|
| 21 |
+
for v in daily:
|
| 22 |
+
if v > 0:
|
| 23 |
+
run += 1
|
| 24 |
+
longest = max(longest, run)
|
| 25 |
+
else:
|
| 26 |
+
run = 0
|
| 27 |
+
|
| 28 |
+
return StreakData(current_streak=current, longest_streak=longest)
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/core/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import Field
|
| 4 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Settings(BaseSettings):
|
| 8 |
+
app_name: str = "GitHub Profile AI Reviewer"
|
| 9 |
+
app_version: str = "1.0.0"
|
| 10 |
+
app_host: str = "0.0.0.0"
|
| 11 |
+
app_port: int = 8000
|
| 12 |
+
|
| 13 |
+
github_token: str = Field(default="", alias="GITHUB_TOKEN")
|
| 14 |
+
github_api_url: str = "https://api.github.com/graphql"
|
| 15 |
+
|
| 16 |
+
codebert_model: str = "microsoft/codebert-base"
|
| 17 |
+
embedding_dim: int = 768
|
| 18 |
+
request_timeout_seconds: float = 30.0
|
| 19 |
+
|
| 20 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
settings = Settings()
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def setup_logging() -> None:
|
| 7 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(name)s | %(message)s")
|
app/graph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/graph/state.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, TypedDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ProfileState(TypedDict, total=False):
|
| 7 |
+
username: str
|
| 8 |
+
graphql_data: dict[str, Any]
|
| 9 |
+
language_embeddings: list[float]
|
| 10 |
+
feature_vector: list[float]
|
| 11 |
+
strongest_language: str
|
| 12 |
+
language_breakdown: dict[str, int]
|
| 13 |
+
total_commits: int
|
| 14 |
+
merged_prs: int
|
| 15 |
+
total_contributions: int
|
| 16 |
+
consistency_score: int
|
| 17 |
+
current_streak: int
|
| 18 |
+
longest_streak: int
|
| 19 |
+
developer_level: str
|
| 20 |
+
confidence: float
|
| 21 |
+
hiring_score: int
|
| 22 |
+
final_report: dict[str, Any]
|
app/graph/workflow.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from app.ai.embeddings import CodeEmbeddingService
|
| 7 |
+
from app.ai.scoring import ScoringEngine
|
| 8 |
+
from app.clients.github_graphql import GitHubGraphQLClient
|
| 9 |
+
from app.clients.streak import compute_streak_from_calendar
|
| 10 |
+
from app.graph.state import ProfileState
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _extract_features(user: dict[str, Any]) -> tuple[dict[str, int], int, int, int, list[str]]:
|
| 14 |
+
repositories = user.get("repositories", {}).get("nodes", [])
|
| 15 |
+
language_sizes: dict[str, int] = defaultdict(int)
|
| 16 |
+
repo_snippets: list[str] = []
|
| 17 |
+
total_commits = 0
|
| 18 |
+
|
| 19 |
+
for repo in repositories:
|
| 20 |
+
name = repo.get("name") or ""
|
| 21 |
+
desc = repo.get("description") or ""
|
| 22 |
+
primary = (repo.get("primaryLanguage") or {}).get("name") or ""
|
| 23 |
+
repo_snippets.append(f"repo:{name} lang:{primary} desc:{desc}")
|
| 24 |
+
|
| 25 |
+
default_branch = repo.get("defaultBranchRef") or {}
|
| 26 |
+
history = (default_branch.get("target") or {}).get("history", {})
|
| 27 |
+
total_commits += int(history.get("totalCount", 0))
|
| 28 |
+
|
| 29 |
+
for edge in repo.get("languages", {}).get("edges", []):
|
| 30 |
+
lang_name = edge.get("node", {}).get("name") or "Unknown"
|
| 31 |
+
language_sizes[lang_name] += int(edge.get("size", 0))
|
| 32 |
+
|
| 33 |
+
merged_prs = int(user.get("pullRequests", {}).get("totalCount", 0))
|
| 34 |
+
total_contributions = int(user.get("contributionsCollection", {}).get("contributionCalendar", {}).get("totalContributions", 0))
|
| 35 |
+
return dict(language_sizes), total_commits, merged_prs, total_contributions, repo_snippets
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _normalize_activity(total_contributions: int, total_commits: int, merged_prs: int) -> int:
|
| 39 |
+
a = min(45, total_contributions // 20)
|
| 40 |
+
b = min(30, total_commits // 40)
|
| 41 |
+
c = min(25, merged_prs // 4)
|
| 42 |
+
return int(max(0, min(100, a + b + c)))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _language_breakdown(language_sizes: dict[str, int]) -> tuple[str, dict[str, int]]:
|
| 46 |
+
if not language_sizes:
|
| 47 |
+
return "Unknown", {}
|
| 48 |
+
strongest = max(language_sizes.items(), key=lambda x: x[1])[0]
|
| 49 |
+
total = sum(language_sizes.values()) or 1
|
| 50 |
+
breakdown = {lang: int((size / total) * 100) for lang, size in sorted(language_sizes.items(), key=lambda x: x[1], reverse=True)}
|
| 51 |
+
return strongest, breakdown
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AnalyzerWorkflow:
|
| 55 |
+
def __init__(self) -> None:
|
| 56 |
+
self._github = GitHubGraphQLClient()
|
| 57 |
+
self._embedder = CodeEmbeddingService()
|
| 58 |
+
self._scorer = ScoringEngine(input_dim=self._embedder.embedding_dim)
|
| 59 |
+
|
| 60 |
+
async def run(self, username: str) -> ProfileState:
|
| 61 |
+
state: ProfileState = {"username": username}
|
| 62 |
+
raw = await self._github.analyze_user(username)
|
| 63 |
+
state["graphql_data"] = raw
|
| 64 |
+
|
| 65 |
+
user = raw["data"]["user"]
|
| 66 |
+
lang_sizes, total_commits, merged_prs, total_contributions, snippets = _extract_features(user)
|
| 67 |
+
strongest_language, breakdown = _language_breakdown(lang_sizes)
|
| 68 |
+
|
| 69 |
+
weeks = user.get("contributionsCollection", {}).get("contributionCalendar", {}).get("weeks", [])
|
| 70 |
+
streak = compute_streak_from_calendar(weeks)
|
| 71 |
+
consistency_score = int((streak.current_streak / streak.longest_streak) * 100) if streak.longest_streak > 0 else 0
|
| 72 |
+
|
| 73 |
+
embedding = self._embedder.embed_repository_signals(snippets)
|
| 74 |
+
activity_score = _normalize_activity(total_contributions, total_commits, merged_prs)
|
| 75 |
+
scored = self._scorer.infer(embedding, activity_score, consistency_score)
|
| 76 |
+
|
| 77 |
+
state["final_report"] = {
|
| 78 |
+
"username": username,
|
| 79 |
+
"developer_level": scored.level,
|
| 80 |
+
"confidence": scored.confidence,
|
| 81 |
+
"strongest_language": strongest_language,
|
| 82 |
+
"language_breakdown": breakdown,
|
| 83 |
+
"hiring_readiness_score": scored.hiring_score,
|
| 84 |
+
"consistency_score": consistency_score,
|
| 85 |
+
"graphql_signals": {
|
| 86 |
+
"total_commits": total_commits,
|
| 87 |
+
"merged_prs": merged_prs,
|
| 88 |
+
"total_contributions": total_contributions,
|
| 89 |
+
},
|
| 90 |
+
"streak_data": {
|
| 91 |
+
"current_streak": streak.current_streak,
|
| 92 |
+
"longest_streak": streak.longest_streak,
|
| 93 |
+
},
|
| 94 |
+
"model_info": {
|
| 95 |
+
"embedding_model": "microsoft/codebert-base",
|
| 96 |
+
"scoring_model": "DeveloperScoringModel",
|
| 97 |
+
"embedding_dim": self._embedder.embedding_dim,
|
| 98 |
+
"embedding_backend": "transformers" if self._embedder.ready else "deterministic-fallback",
|
| 99 |
+
},
|
| 100 |
+
}
|
| 101 |
+
return state
|
app/main.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, HTTPException
|
| 4 |
+
|
| 5 |
+
from app.clients.github_graphql import GitHubAPIError
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.core.logging import setup_logging
|
| 8 |
+
from app.schemas import AnalyzeRequest, AnalyzeResponse
|
| 9 |
+
from app.service import AnalyzerService
|
| 10 |
+
|
| 11 |
+
setup_logging()
|
| 12 |
+
|
| 13 |
+
app = FastAPI(title=settings.app_name, version=settings.app_version)
|
| 14 |
+
service: AnalyzerService | None = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_service() -> AnalyzerService:
|
| 18 |
+
global service
|
| 19 |
+
if service is None:
|
| 20 |
+
service = AnalyzerService()
|
| 21 |
+
return service
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@app.get("/health")
|
| 25 |
+
async def health() -> dict[str, str]:
|
| 26 |
+
return {"status": "ok"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@app.post("/analyze", response_model=AnalyzeResponse)
|
| 30 |
+
async def analyze(payload: AnalyzeRequest) -> AnalyzeResponse:
|
| 31 |
+
try:
|
| 32 |
+
return await get_service().analyze(payload.username)
|
| 33 |
+
except GitHubAPIError as exc:
|
| 34 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 35 |
+
except Exception as exc:
|
| 36 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
app/schemas.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class AnalyzeRequest(BaseModel):
|
| 9 |
+
username: str = Field(min_length=1, max_length=39, pattern=r"^[A-Za-z0-9-]+$")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class GraphQLSignals(BaseModel):
|
| 13 |
+
total_commits: int
|
| 14 |
+
merged_prs: int
|
| 15 |
+
total_contributions: int
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class StreakData(BaseModel):
|
| 19 |
+
current_streak: int
|
| 20 |
+
longest_streak: int
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AnalyzeResponse(BaseModel):
|
| 24 |
+
username: str
|
| 25 |
+
developer_level: str
|
| 26 |
+
confidence: float
|
| 27 |
+
strongest_language: str
|
| 28 |
+
language_breakdown: dict[str, int]
|
| 29 |
+
hiring_readiness_score: int
|
| 30 |
+
consistency_score: int
|
| 31 |
+
graphql_signals: GraphQLSignals
|
| 32 |
+
streak_data: StreakData
|
| 33 |
+
model_info: dict[str, Any]
|
app/service.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from app.graph.workflow import AnalyzerWorkflow
|
| 4 |
+
from app.schemas import AnalyzeResponse
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class AnalyzerService:
|
| 8 |
+
def __init__(self) -> None:
|
| 9 |
+
self._workflow = AnalyzerWorkflow()
|
| 10 |
+
|
| 11 |
+
async def analyze(self, username: str) -> AnalyzeResponse:
|
| 12 |
+
state = await self._workflow.run(username)
|
| 13 |
+
return AnalyzeResponse.model_validate(state["final_report"])
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: "3.9"
|
| 2 |
+
services:
|
| 3 |
+
api:
|
| 4 |
+
build: .
|
| 5 |
+
ports:
|
| 6 |
+
- "8000:8000"
|
| 7 |
+
environment:
|
| 8 |
+
- GITHUB_TOKEN=${GITHUB_TOKEN}
|
| 9 |
+
- LANGSMITH_API_KEY=${LANGSMITH_API_KEY}
|
| 10 |
+
volumes:
|
| 11 |
+
- hf_cache:/root/.cache/huggingface
|
| 12 |
+
deploy:
|
| 13 |
+
resources:
|
| 14 |
+
reservations:
|
| 15 |
+
devices:
|
| 16 |
+
- capabilities: [gpu]
|
| 17 |
+
redis:
|
| 18 |
+
image: redis:7-alpine
|
| 19 |
+
ports:
|
| 20 |
+
- "6379:6379"
|
| 21 |
+
volumes:
|
| 22 |
+
hf_cache:
|
main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from app.service import AnalyzerService
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def run(username: str) -> None:
|
| 11 |
+
service = AnalyzerService()
|
| 12 |
+
result = await service.analyze(username)
|
| 13 |
+
print(json.dumps(result.model_dump(), indent=2))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
parser = argparse.ArgumentParser(description="GitHub Profile AI Reviewer CLI")
|
| 18 |
+
parser.add_argument("--username", required=True, help="GitHub username")
|
| 19 |
+
args = parser.parse_args()
|
| 20 |
+
asyncio.run(run(args.username))
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if __name__ == "__main__":
|
| 24 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.12
|
| 2 |
+
uvicorn[standard]==0.34.3
|
| 3 |
+
httpx==0.28.1
|
| 4 |
+
pydantic==2.11.5
|
| 5 |
+
pydantic-settings==2.9.1
|
| 6 |
+
python-dotenv==1.1.0
|
| 7 |
+
langgraph==0.4.8
|
| 8 |
+
transformers==4.52.4
|
| 9 |
+
torch==2.7.1
|