| \n", +# " | commit_id | \n", +# "project | \n", +# "buggy | \n", +# "fix | \n", +# "year | \n", +# "author_date | \n", +# "la | \n", +# "ld | \n", +# "nf | \n", +# "nd | \n", +# "ns | \n", +# "ent | \n", +# "ndev | \n", +# "age | \n", +# "nuc | \n", +# "aexp | \n", +# "arexp | \n", +# "asexp | \n", +# "
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | \n", +# "06a456ec1670fb26a86f8a18f5b55017ac961f39 | \n", +# "apache/groovy | \n", +# "True | \n", +# "False | \n", +# "2003 | \n", +# "1064916954 | \n", +# "223 | \n", +# "33 | \n", +# "7 | \n", +# "5 | \n", +# "2 | \n", +# "2.213687 | \n", +# "0.142857 | \n", +# "1.857143 | \n", +# "12.142857 | \n", +# "104 | \n", +# "104.0 | \n", +# "0.000606 | \n", +# "
| 1 | \n", +# "79f50a10e7688279664146bb3c18837dada370a4 | \n", +# "apache/groovy | \n", +# "False | \n", +# "False | \n", +# "2003 | \n", +# "1071086882 | \n", +# "138 | \n", +# "10 | \n", +# "9 | \n", +# "3 | \n", +# "1 | \n", +# "1.692477 | \n", +# "0.333333 | \n", +# "32.333333 | \n", +# "11.777778 | \n", +# "287 | \n", +# "287.0 | \n", +# "228.000000 | \n", +# "
| 2 | \n", +# "42437917f185f8efa3d947fae3bb08b584504650 | \n", +# "apache/groovy | \n", +# "False | \n", +# "False | \n", +# "2003 | \n", +# "1072801387 | \n", +# "69 | \n", +# "20 | \n", +# "2 | \n", +# "2 | \n", +# "1 | \n", +# "0.599911 | \n", +# "1.500000 | \n", +# "9.500000 | \n", +# "16.500000 | \n", +# "366 | \n", +# "366.0 | \n", +# "281.000000 | \n", +# "
| 3 | \n", +# "b6616e5fb6d537229d307cf8647ef483468175a6 | \n", +# "apache/groovy | \n", +# "True | \n", +# "False | \n", +# "2003 | \n", +# "1072694298 | \n", +# "52 | \n", +# "2 | \n", +# "2 | \n", +# "2 | \n", +# "1 | \n", +# "0.380947 | \n", +# "1.000000 | \n", +# "6.000000 | \n", +# "14.000000 | \n", +# "364 | \n", +# "364.0 | \n", +# "279.000000 | \n", +# "
| 4 | \n", +# "5d0abaebf0bd4b5d98f9ef173f23b3be5e6fb37f | \n", +# "apache/groovy | \n", +# "True | \n", +# "False | \n", +# "2003 | \n", +# "1070542250 | \n", +# "256 | \n", +# "348 | \n", +# "8 | \n", +# "4 | \n", +# "1 | \n", +# "0.489213 | \n", +# "0.375000 | \n", +# "13.625000 | \n", +# "20.000000 | \n", +# "253 | \n", +# "253.0 | \n", +# "199.000000 | \n", +# "
| \n", +# " | buggy | \n", +# "la | \n", +# "ld | \n", +# "nf | \n", +# "nd | \n", +# "ns | \n", +# "ent | \n", +# "ndev | \n", +# "age | \n", +# "nuc | \n", +# "aexp | \n", +# "arexp | \n", +# "asexp | \n", +# "
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | \n", +# "True | \n", +# "223 | \n", +# "33 | \n", +# "7 | \n", +# "5 | \n", +# "2 | \n", +# "2.213687 | \n", +# "0.142857 | \n", +# "1.857143 | \n", +# "12.142857 | \n", +# "104 | \n", +# "104.000000 | \n", +# "0.000606 | \n", +# "
| 1 | \n", +# "False | \n", +# "138 | \n", +# "10 | \n", +# "9 | \n", +# "3 | \n", +# "1 | \n", +# "1.692477 | \n", +# "0.333333 | \n", +# "32.333333 | \n", +# "11.777778 | \n", +# "287 | \n", +# "287.000000 | \n", +# "228.000000 | \n", +# "
| 2 | \n", +# "False | \n", +# "69 | \n", +# "20 | \n", +# "2 | \n", +# "2 | \n", +# "1 | \n", +# "0.599911 | \n", +# "1.500000 | \n", +# "9.500000 | \n", +# "16.500000 | \n", +# "366 | \n", +# "366.000000 | \n", +# "281.000000 | \n", +# "
| 3 | \n", +# "True | \n", +# "52 | \n", +# "2 | \n", +# "2 | \n", +# "2 | \n", +# "1 | \n", +# "0.380947 | \n", +# "1.000000 | \n", +# "6.000000 | \n", +# "14.000000 | \n", +# "364 | \n", +# "364.000000 | \n", +# "279.000000 | \n", +# "
| 4 | \n", +# "True | \n", +# "256 | \n", +# "348 | \n", +# "8 | \n", +# "4 | \n", +# "1 | \n", +# "0.489213 | \n", +# "0.375000 | \n", +# "13.625000 | \n", +# "20.000000 | \n", +# "253 | \n", +# "253.000000 | \n", +# "199.000000 | \n", +# "
| ... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "
| 44829 | \n", +# "False | \n", +# "1 | \n", +# "0 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "0.000000 | \n", +# "5.000000 | \n", +# "1.000000 | \n", +# "12.000000 | \n", +# "1 | \n", +# "1.000000 | \n", +# "0.000000 | \n", +# "
| 44830 | \n", +# "False | \n", +# "14 | \n", +# "0 | \n", +# "2 | \n", +# "1 | \n", +# "1 | \n", +# "1.000000 | \n", +# "1.500000 | \n", +# "92.500000 | \n", +# "2.000000 | \n", +# "2 | \n", +# "2.000000 | \n", +# "0.000000 | \n", +# "
| 44831 | \n", +# "False | \n", +# "50 | \n", +# "0 | \n", +# "2 | \n", +# "2 | \n", +# "1 | \n", +# "0.855451 | \n", +# "2.000000 | \n", +# "3.500000 | \n", +# "8.000000 | \n", +# "3 | \n", +# "3.000000 | \n", +# "1.000000 | \n", +# "
| 44832 | \n", +# "False | \n", +# "4 | \n", +# "0 | \n", +# "3 | \n", +# "1 | \n", +# "1 | \n", +# "1.500000 | \n", +# "3.000000 | \n", +# "15.666667 | \n", +# "19.000000 | \n", +# "4 | \n", +# "4.000000 | \n", +# "0.000000 | \n", +# "
| 44833 | \n", +# "False | \n", +# "8 | \n", +# "29 | \n", +# "9 | \n", +# "3 | \n", +# "1 | \n", +# "2.533868 | \n", +# "0.333333 | \n", +# "100.000000 | \n", +# "0.777778 | \n", +# "10388 | \n", +# "4081.682143 | \n", +# "5238.000000 | \n", +# "
44834 rows × 13 columns
\n", +# "LogisticRegression()In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
| \n", +# " | commit_id | \n", +# "project | \n", +# "buggy | \n", +# "fix | \n", +# "year | \n", +# "author_date | \n", +# "la | \n", +# "ld | \n", +# "nf | \n", +# "nd | \n", +# "ns | \n", +# "ent | \n", +# "ndev | \n", +# "age | \n", +# "nuc | \n", +# "aexp | \n", +# "arexp | \n", +# "asexp | \n", +# "
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | \n", +# "999baceff36165d950a61dd9cc4342f252e64837 | \n", +# "apache/flink | \n", +# "True | \n", +# "False | \n", +# "2017 | \n", +# "1488367395 | \n", +# "1439 | \n", +# "87 | \n", +# "80 | \n", +# "4 | \n", +# "1 | \n", +# "5.746825 | \n", +# "0.312500 | \n", +# "50.625000 | \n", +# "4.237500 | \n", +# "223 | \n", +# "178.833333 | \n", +# "21.000000 | \n", +# "
| 1 | \n", +# "2c799fb70aa47f9109714dc410862af24b4a0321 | \n", +# "apache/hbase | \n", +# "False | \n", +# "False | \n", +# "2017 | \n", +# "1486008708 | \n", +# "983 | \n", +# "4378 | \n", +# "16 | \n", +# "8 | \n", +# "4 | \n", +# "0.727304 | \n", +# "5.250000 | \n", +# "0.000000 | \n", +# "64.750000 | \n", +# "105 | \n", +# "60.833333 | \n", +# "0.109456 | \n", +# "
| 2 | \n", +# "22ac8c00db676ccc01d1a44ec206ee17790b46b0 | \n", +# "apache/camel | \n", +# "False | \n", +# "False | \n", +# "2017 | \n", +# "1511424716 | \n", +# "8 | \n", +# "16 | \n", +# "4 | \n", +# "4 | \n", +# "2 | \n", +# "1.614005 | \n", +# "3.500000 | \n", +# "7.000000 | \n", +# "18.000000 | \n", +# "13353 | \n", +# "4421.309524 | \n", +# "0.992185 | \n", +# "
| 3 | \n", +# "a9d3412b4ce40f5ab5a18756ede7e0606b653171 | \n", +# "apache/hadoop | \n", +# "False | \n", +# "False | \n", +# "2017 | \n", +# "1498508667 | \n", +# "3 | \n", +# "2 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "0.000000 | \n", +# "15.000000 | \n", +# "19.000000 | \n", +# "25.000000 | \n", +# "44 | \n", +# "20.750000 | \n", +# "15.000000 | \n", +# "
| 4 | \n", +# "6cf958c2fa7dab4a028039c7c6e8f23456a5d7e5 | \n", +# "apache/groovy | \n", +# "False | \n", +# "False | \n", +# "2017 | \n", +# "1505541139 | \n", +# "9 | \n", +# "9 | \n", +# "6 | \n", +# "4 | \n", +# "1 | \n", +# "2.419382 | \n", +# "5.000000 | \n", +# "335.166667 | \n", +# "118.000000 | \n", +# "364 | \n", +# "258.166667 | \n", +# "244.000000 | \n", +# "
| ... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "... | \n", +# "
| 7521 | \n", +# "f89ffc3ae18d2ff25fe7250c63ee850372b35094 | \n", +# "apache/camel | \n", +# "False | \n", +# "False | \n", +# "2019 | \n", +# "1548057494 | \n", +# "18 | \n", +# "15 | \n", +# "11 | \n", +# "4 | \n", +# "1 | \n", +# "3.359253 | \n", +# "2.181818 | \n", +# "67.363636 | \n", +# "42.000000 | \n", +# "4767 | \n", +# "2909.000000 | \n", +# "1681.000000 | \n", +# "
| 7522 | \n", +# "bc0cb5bb0571e0044c5958142c519e9d2b7b610f | \n", +# "apache/camel | \n", +# "False | \n", +# "False | \n", +# "2019 | \n", +# "1554966471 | \n", +# "2 | \n", +# "2 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "0.000000 | \n", +# "2.000000 | \n", +# "0.000000 | \n", +# "3.000000 | \n", +# "5747 | \n", +# "3610.583333 | \n", +# "2275.000000 | \n", +# "
| 7523 | \n", +# "ee002068b75f5acaf909d7d82a6d212a879cdeb1 | \n", +# "apache/camel | \n", +# "False | \n", +# "False | \n", +# "2019 | \n", +# "1565342979 | \n", +# "2 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "0.000000 | \n", +# "4.000000 | \n", +# "115.000000 | \n", +# "5.000000 | \n", +# "73 | \n", +# "36.916667 | \n", +# "7.000000 | \n", +# "
| 7524 | \n", +# "c3c48f30f7450c3a04950805bba7fb1536db7c9d | \n", +# "apache/hbase | \n", +# "False | \n", +# "False | \n", +# "2019 | \n", +# "1555858691 | \n", +# "73 | \n", +# "17 | \n", +# "9 | \n", +# "2 | \n", +# "4 | \n", +# "2.791749 | \n", +# "6.666667 | \n", +# "68.111111 | \n", +# "36.777778 | \n", +# "516 | \n", +# "312.850000 | \n", +# "0.145894 | \n", +# "
| 7525 | \n", +# "36b52fc525876c17f55faff87bed66e91c3cc1e2 | \n", +# "apache/camel | \n", +# "False | \n", +# "False | \n", +# "2019 | \n", +# "1569053508 | \n", +# "0 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "1 | \n", +# "0.000000 | \n", +# "20.000000 | \n", +# "0.000000 | \n", +# "67.000000 | \n", +# "15351 | \n", +# "4099.212951 | \n", +# "7591.000000 | \n", +# "
7526 rows × 18 columns
\n", +# " and returns the full JSON
- response which includes { accessToken, cloudId, Projects }.
- """
- url = f"{self.base_url}/Jira/callback"
- response = requests.get(url, params={"code": code}, timeout=self.timeout)
- response.raise_for_status()
- return response.json()
-
- def get_current_user(self, token: str) -> dict:
- """
- Calls GET /api/Jira/me?token= and returns the user object.
- """
- url = f"{self.base_url}/Jira/me"
- response = requests.get(url, params={"token": token}, timeout=self.timeout)
- response.raise_for_status()
- return response.json()
-
- # ── Issues ────────────────────────────────────────────────────────────────
-
- def get_assigned_tickets(
- self,
- token: str,
- cloud_id: str,
- project_key: str,
- assignee_name: str,
- ) -> list[dict]:
- """
- Calls POST /api/Jira/assigned-tickets and returns the list of issues.
-
- Matches the JiraRequestDto expected by the controller:
- { Token, CloudId, ProjectKey, AssigneeName }
- """
- url = f"{self.base_url}/Jira/assigned-tickets"
- payload = {
- "token": token,
- "cloudId": cloud_id,
- "projectKey": project_key,
- "assigneeName": assignee_name,
- }
- response = requests.post(url, json=payload, timeout=self.timeout)
- if not response.ok:
- print(f"⚠️ Jira API Error Response Body: {response.text}")
- response.raise_for_status()
- return response.json()
-
-
-# ─── Default client (uses env config) ────────────────────────────────────────
-
-_client = JiraClient()
-
-
-# ─── Public helpers ───────────────────────────────────────────────────────────
-
-# Use this to map GitHub usernames to Jira display names/Account IDs if they differ.
-GITHUB_TO_JIRA_MAPPING = {
- # "github_username": "Jira Display Name",
- "your_github_username": "Your Jira Name",
-}
-
-def _resolve_jira_name(github_username: str) -> str:
- """Resolves a GitHub username to a Jira username using the local mapping."""
- # If the user is in the mapping, use the mapped name. Otherwise, fallback to github username.
- return GITHUB_TO_JIRA_MAPPING.get(github_username, github_username)
-
-# This API should recieve data from frontend and then return the tickets
-def fetch_jira_tickets(
- username: str,
- jira_username: str = None,
- token: str = None,
- cloud_id: str = None,
- project_key: str = None,
-) -> list[dict]:
- # ─── DEBUG SECTION ───
- print(f"🔍 DEBUG: Resolving Jira identity for {username}")
-
- # This data should be passed from frontend or backend
- target_user = jira_username or os.getenv("JIRA_USERNAME", username)
- resolved_token = token or os.getenv("JIRA_API_TOKEN")
- resolved_cloud_id = cloud_id or os.getenv("JIRA_CLOUD_ID")
- resolved_project_key = project_key or os.getenv("JIRA_PROJECT_KEY")
-
- print(f" -> Target User: {target_user}")
- print(f" -> Token exists: {bool(resolved_token)}")
- print(f" -> Cloud ID exists: {bool(resolved_cloud_id)}")
- print(f" -> Project Key exists: {bool(resolved_project_key)}")
-
- # Check for None values - C# will reject these with a 400 error!
- if not all([resolved_token, resolved_cloud_id, resolved_project_key]):
- print("❌ ERROR: Missing required Jira credentials in .env or API request.")
- return []
-
- try:
- tickets = _client.get_assigned_tickets(
- token=resolved_token,
- cloud_id=resolved_cloud_id,
- project_key=resolved_project_key,
- assignee_name=target_user,
- )
- return tickets
- except Exception as exc:
- print(f"⚠️ Error: {exc}")
- return []
-
-
-def get_current_user(token: str = None) -> dict:
- """
- Returns the Jira user profile for the supplied (or env-based) token
- by calling GET /api/Jira/me.
- """
- resolved_token = token or os.getenv("JIRA_API_TOKEN", "")
- try:
- user = _client.get_current_user(resolved_token)
- return user
- except requests.HTTPError as http_err:
- print(f"⚠️ HTTP error fetching current Jira user: {http_err}")
- except requests.RequestException as req_err:
- print(f"⚠️ Network error fetching current Jira user: {req_err}")
- except Exception as exc:
- print(f"⚠️ Unexpected error fetching current Jira user: {exc}")
- return {}
-
-
-# ─── LLM Analysis ─────────────────────────────────────────────────────────────
-
-def analyze_jira_context(
- username: str,
- jira_username: str = None,
- token: str = None,
- cloud_id: str = None,
- project_key: str = None,
-) -> dict:
- """
- Fetches Jira tickets for *username* and uses the configured LLM to
- determine the developer's current domain and skills.
-
- Returns a dict with keys: domain, recent_skills, summary.
- """
- tickets = fetch_jira_tickets(
- username,
- jira_username=jira_username,
- token=token,
- cloud_id=cloud_id,
- project_key=project_key,
- )
- return analyze_jira_tickets(username, tickets)
-
-
-def analyze_jira_tickets(username: str, tickets: list[dict]) -> dict:
- """
- Uses the configured LLM to determine the developer's current domain and skills
- from the provided list of tickets.
- """
-
-
- if not tickets:
- return {"domain": "Unknown", "recent_skills": [], "summary": "No Jira tickets found."}
-
- # Build a text representation for the LLM (cap at 10 tickets)
- ticket_texts: list[str] = []
- for i, ticket in enumerate(tickets[:10]):
- title = ticket.get("title") or ticket.get("summary", "")
- description = ticket.get("description", "")
- if not title and not description:
- continue
- ticket_texts.append(f"Ticket {i + 1}: TITLE: {title}\nDESCRIPTION: {description}")
-
- if not ticket_texts:
- return {"domain": "Unknown", "recent_skills": [], "summary": "No valid Jira ticket content found."}
-
- combined_tickets = "\n\n".join(ticket_texts)
- prompt = JIRA_ANALYSIS_PROMPT.format(username=username, combined_tickets=combined_tickets)
-
- result = generate_with_resilience(prompt, purpose="jira_analysis")
- if not result.get("ok"):
- print(f"⚠️ Jira analysis fallback for '{username}'. reason={result.get('reason')}")
- return {"domain": "Unknown", "recent_skills": [], "summary": "Analysis failed after retries."}
-
- raw_text = result.get("text", "")
- try:
- json_match = re.search(r"\{.*\}", raw_text, re.DOTALL)
- json_str = json_match.group() if json_match else raw_text
- parsed_data = json.loads(json_str)
- validated_output = JiraAnalysisOutput(**parsed_data)
- return validated_output.model_dump()
- except json.JSONDecodeError as decode_error:
- print(f"⚠️ Jira JSON decode error: {decode_error}")
- except ValidationError as validation_error:
- print(f"⚠️ Jira validation failed: {validation_error}")
- except Exception as exc:
- print(f"⚠️ Jira analysis parse error: {exc}")
-
- return {"domain": "Unknown", "recent_skills": [], "summary": "Analysis failed after retries."}
-
-
-# ─── Smoke test ───────────────────────────────────────────────────────────────
-
-if __name__ == "__main__":
- result = analyze_jira_context("test_user")
- print("\n--- Jira Analysis Output ---")
- print(json.dumps(result, indent=2))
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/Process/llm.py b/codience/src/Reviewer_Recommender/Process/llm.py
deleted file mode 100644
index 259dc37d..00000000
--- a/codience/src/Reviewer_Recommender/Process/llm.py
+++ /dev/null
@@ -1,269 +0,0 @@
-import os
-import random
-import re
-import time
-import importlib
-from threading import Lock
-from google import genai
-from dotenv import load_dotenv
-
-load_dotenv()
-
-LLM_PROVIDER = os.getenv("LLM_PROVIDER", "groq_primary_gemini_fallback").strip().lower()
-
-GEMINI_MODEL_PRIMARY = os.getenv("LLM_MODEL_PRIMARY", "gemini-3.1-flash-lite-preview")
-GEMINI_MODEL_FALLBACKS = [m.strip() for m in os.getenv("LLM_MODEL_FALLBACKS", "").split(",") if m.strip()]
-
-GROQ_MODEL_PRIMARY = os.getenv("GROQ_MODEL_PRIMARY", "llama-3.3-70b-versatile")
-GROQ_MODEL_FALLBACKS = [m.strip() for m in os.getenv("GROQ_MODEL_FALLBACKS", "").split(",") if m.strip()]
-
-LLM_MAX_RETRIES = int(os.getenv("LLM_MAX_RETRIES", "2"))
-LLM_BACKOFF_BASE_SEC = float(os.getenv("LLM_BACKOFF_BASE_SEC", "1.5"))
-LLM_BACKOFF_MAX_SEC = float(os.getenv("LLM_BACKOFF_MAX_SEC", "8"))
-LLM_MAX_TOTAL_WAIT_SEC = float(os.getenv("LLM_MAX_TOTAL_WAIT_SEC", "20"))
-LLM_CIRCUIT_FAIL_THRESHOLD = int(os.getenv("LLM_CIRCUIT_FAIL_THRESHOLD", "4"))
-LLM_CIRCUIT_COOLDOWN_SEC = float(os.getenv("LLM_CIRCUIT_COOLDOWN_SEC", "30"))
-
-_clients = {}
-_lock = Lock()
-_circuit_failures = 0
-_circuit_open_until = 0.0
-
-
-class ProviderConfigurationError(Exception):
- pass
-
-
-class ProviderUnavailableError(Exception):
- pass
-
-
-def _parse_model_list(value):
- return [m.strip() for m in value.split(",") if m.strip()]
-
-
-def _purpose_env_key(purpose):
- suffix = re.sub(r"[^a-zA-Z0-9]+", "_", (purpose or "general")).upper().strip("_")
- if not suffix:
- suffix = "GENERAL"
- return suffix
-
-
-def _provider_sequence():
- if LLM_PROVIDER in ("groq", "groq_only"):
- return ["groq"]
- if LLM_PROVIDER in ("gemini", "gemini_only"):
- return ["gemini"]
- if LLM_PROVIDER == "gemini_primary_groq_fallback":
- return ["gemini", "groq"]
- if LLM_PROVIDER == "groq_primary_gemini_fallback":
- return ["groq", "gemini"]
-
- parsed = [p.strip().lower() for p in LLM_PROVIDER.split(",") if p.strip()]
- valid = [p for p in parsed if p in ("groq", "gemini")]
- return valid or ["groq", "gemini"]
-
-
-def _provider_api_key(provider):
- if provider == "groq":
- return os.getenv("GROQ_API_KEY", "").strip()
- if provider == "gemini":
- return os.getenv("GEMINI_API_KEY", "").strip()
- return ""
-
-
-def _models_for_provider(purpose, provider, allow_fallback=True):
- suffix = _purpose_env_key(purpose)
- provider_key = f"LLM_MODELS_{provider.upper()}_{suffix}"
- generic_key = f"LLM_MODELS_{suffix}"
-
- provider_models = _parse_model_list(os.getenv(provider_key, ""))
- if provider_models:
- return list(dict.fromkeys(provider_models))
-
- generic_models = _parse_model_list(os.getenv(generic_key, ""))
- if generic_models:
- return list(dict.fromkeys(generic_models))
-
- if provider == "groq":
- models = [GROQ_MODEL_PRIMARY]
- if allow_fallback:
- models.extend(GROQ_MODEL_FALLBACKS)
- return list(dict.fromkeys([m for m in models if m]))
-
- if provider == "gemini":
- models = [GEMINI_MODEL_PRIMARY]
- if allow_fallback:
- models.extend(GEMINI_MODEL_FALLBACKS)
- return list(dict.fromkeys([m for m in models if m]))
-
- return []
-
-
-def _get_provider_client(provider):
- global _clients
- if provider in _clients:
- return _clients[provider]
-
- api_key = _provider_api_key(provider)
- if not api_key:
- raise ProviderConfigurationError(f"Missing API key for provider={provider}")
-
- if provider == "gemini":
- _clients[provider] = genai.Client(api_key=api_key)
- return _clients[provider]
-
- if provider == "groq":
- try:
- groq_module = importlib.import_module("groq")
- Groq = getattr(groq_module, "Groq")
- except Exception as exc:
- raise ProviderUnavailableError(f"Groq SDK unavailable: {exc}")
- _clients[provider] = Groq(api_key=api_key)
- return _clients[provider]
-
- raise ProviderConfigurationError(f"Unsupported provider={provider}")
-
-
-def _invoke_model(provider, model_name, prompt):
- client = _get_provider_client(provider)
-
- if provider == "gemini":
- response = client.models.generate_content(model=model_name, contents=prompt)
- return (response.text or "").strip()
-
- if provider == "groq":
- response = client.chat.completions.create(
- model=model_name,
- messages=[{"role": "user", "content": prompt}],
- temperature=0,
- )
- try:
- content = response.choices[0].message.content
- return (content or "").strip()
- except Exception:
- return ""
-
- raise ProviderConfigurationError(f"Unsupported provider={provider}")
-
-
-def _is_retryable_error(exc):
- msg = str(exc)
- retry_markers = [
- "429",
- "503",
- "500",
- "RESOURCE_EXHAUSTED",
- "UNAVAILABLE",
- "DEADLINE_EXCEEDED",
- "rate limit",
- "too many requests",
- "internal server error",
- "overloaded",
- "timeout",
- "temporarily unavailable",
- ]
- lowered = msg.lower()
- return any(marker.lower() in lowered for marker in retry_markers)
-
-
-def _mark_failure_and_maybe_open_circuit():
- global _circuit_failures, _circuit_open_until
- with _lock:
- _circuit_failures += 1
- if _circuit_failures >= LLM_CIRCUIT_FAIL_THRESHOLD:
- _circuit_open_until = time.time() + LLM_CIRCUIT_COOLDOWN_SEC
-
-
-def _reset_circuit_on_success():
- global _circuit_failures, _circuit_open_until
- with _lock:
- _circuit_failures = 0
- _circuit_open_until = 0.0
-
-
-def _is_circuit_open():
- return time.time() < _circuit_open_until
-
-
-def generate_with_resilience(prompt, purpose="general", allow_fallback=True, max_retries=None, model_candidates=None):
- if _is_circuit_open():
- return {
- "ok": False,
- "text": "",
- "reason": "CIRCUIT_OPEN",
- "attempts": 0,
- "model": "",
- }
-
- retries = LLM_MAX_RETRIES if max_retries is None else max(0, max_retries)
- forced_models = list(dict.fromkeys([m.strip() for m in (model_candidates or []) if str(m).strip()]))
- providers = _provider_sequence()
- start_time = time.time()
- attempts = 0
- last_error = ""
-
- for attempt_index in range(retries + 1):
- for provider in providers:
- if not _provider_api_key(provider):
- last_error = f"Missing API key for provider={provider}"
- continue
-
- models = forced_models or _models_for_provider(purpose, provider, allow_fallback=allow_fallback)
- if not models:
- last_error = f"No models configured for provider={provider}, purpose={purpose}"
- continue
-
- provider_blocked = False
- for model_name in models:
- attempts += 1
- try:
- text = _invoke_model(provider, model_name, prompt)
- _reset_circuit_on_success()
- return {
- "ok": bool(text),
- "text": text,
- "reason": "OK" if text else "EMPTY_RESPONSE",
- "attempts": attempts,
- "model": model_name,
- "provider": provider,
- }
- except (ProviderConfigurationError, ProviderUnavailableError) as exc:
- last_error = str(exc)
- provider_blocked = True
- continue
- except Exception as exc:
- last_error = str(exc)
- if _is_retryable_error(exc):
- _mark_failure_and_maybe_open_circuit()
- continue
-
- if provider_blocked:
- continue
-
- if attempt_index < retries:
- elapsed = time.time() - start_time
- if elapsed >= LLM_MAX_TOTAL_WAIT_SEC:
- break
- delay = min(LLM_BACKOFF_MAX_SEC, LLM_BACKOFF_BASE_SEC * (2 ** attempt_index))
- delay += random.uniform(0, 0.5)
- remaining_budget = max(0.0, LLM_MAX_TOTAL_WAIT_SEC - elapsed)
- if remaining_budget <= 0:
- break
- time.sleep(min(delay, remaining_budget))
-
- reason = "RETRY_EXHAUSTED"
- if _is_circuit_open():
- reason = "CIRCUIT_OPEN"
- elif last_error:
- reason = f"RETRY_EXHAUSTED:{last_error}"
-
- print(f"⚠️ LLM {purpose} failed. reason={reason}, attempts={attempts}")
- return {
- "ok": False,
- "text": "",
- "reason": reason,
- "attempts": attempts,
- "model": "",
- "provider": "",
- }
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/Process/main.py b/codience/src/Reviewer_Recommender/Process/main.py
deleted file mode 100644
index 26895476..00000000
--- a/codience/src/Reviewer_Recommender/Process/main.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from codience.src.Reviewer_Recommender.Process.analysis_PR import extract_pr_skills
-from codience.src.Reviewer_Recommender.Data.searching_into_vectordb import search_vector_db
-from codience.src.Reviewer_Recommender.Process.tests_prs import test_prs
-
-
-def main():
- for mock_pr_data in test_prs:
- print(f"\nProcessing PR: {mock_pr_data['title']}")
- extraction_result = extract_pr_skills(mock_pr_data)
- print(f"Extracted Skills: {extraction_result['required_skills']}")
- print(f"RAG Query: {extraction_result['rag_query']}")
- skills_to_search = extraction_result['rag_query']
-
-
- role_matches = search_vector_db(skills_to_search, k=20)
-
- # 3. Final Step: Rank your reviewers based on these roles
- # 4. Check the results
- if not role_matches:
- print("⚠️ No direct match found. System might need to fallback to general roles.")
- else:
- for i, match in enumerate(role_matches):
- # Extract the role title from the page_content string
- content = match.page_content
- role_title = content.split('|')[0].replace("rag_content: Role:", "").strip()
-
- print(f"Rank {i+1}: {role_title}")
- # Show more details for verification
- print(f" Matches: {content.split('|')[1][:100]}...")
-
-if __name__ == "__main__":
- main()
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/Process/prompts.py b/codience/src/Reviewer_Recommender/Process/prompts.py
deleted file mode 100644
index 2eca809e..00000000
--- a/codience/src/Reviewer_Recommender/Process/prompts.py
+++ /dev/null
@@ -1,108 +0,0 @@
-SKILL_EXTRACTION_PROMPT = """
-You are a Senior Technical Architect. Your task is to analyze a Pull Request and identify the specific technical skills and programming languages required to audit this code.
-
-PR DATA:
-Title: {title}
-Description: {description}
-Code Changes (Diff):
-{diff}
-
-INSTRUCTIONS:
-1. **Identify Atomic Technical Skills**: Be specific to libraries and logic (e.g., "FastAPI: Dependency Injection", "OpenCV: Image Thresholding").
-2. **Identify Raw Programming Languages**: List the base programming languages involved (e.g., "Python", "Java", "C#", "SQL").
-3. **Format**: Extract skills as 'Technology: Specific Feature'.
-4. **Prioritize**: Highlight high-risk logic like security or database migrations.
-
-OUTPUT FORMAT (JSON ONLY):
-{{
- "required_skills": ["Skill A", "Skill B"],
- "detected_languages": ["Language A", "Language B"],
- "rag_query": "A Senior Engineer with expertise in [Key Skills] and [Languages]."
-}}
-"""
-
-JIRA_ANALYSIS_PROMPT = """
-You are an AI tasked with analyzing a developer's recently assigned Jira tickets to understand their current technical domain and skills.
-
-DEVELOPER USERNAME: {username}
-
-RECENT TICKETS:
-{combined_tickets}
-
-Task:
-Based on the tickets, identify the primary domain the developer is working on (e.g., Authentication, Database Migration, Frontend UI, CI/CD) and the specific technical skills or tools that are apparent from the ticket contents.
-
-Return the response as a valid JSON object strictly matching this schema:
-{{
- "domain": "string (the current primary work domain)",
- "recent_skills": ["skill_1", "skill_2"],
- "summary": "string (1-2 sentence summary of their recent focus)"
-}}
-"""
-
-SCORER_PROMPT = """
-You are an expert tech lead tasked with assigning the best reviewers for a Pull Request.
-
-PULL REQUEST REQUIREMENTS:
-- Required Skills: {pr_skills}
-- Analysis Summary: {pr_analysis_summary}
-
-VECTOR DATABASE RECOMMENDATIONS (Historical Best Roles):
-{rag_context}
-
-CANDIDATES PROFILES:
-{candidates_text}
-
-Task:
-Rank ALL candidates based on how well their historical commit skills and current Jira domain match the PR requirements.
-Assign each a 'confidence_score' from 0 to 100, where 100 is an absolutely perfect match and 0 is no relevance at all.
-Also provide a short 1-sentence 'justification' for why they received this score.
-
-Output strictly in the following JSON array format:
-[
- {{
- "name": "Candidate Name",
- "confidence_score": 85,
- "justification": "Has strong recent Jira activity in the required domain and commit history matches perfectly."
- }}
-]
-"""
-
-FILE_DIFF_SUMMARY_PROMPT = """
-You are a Senior Technical Architect analyzing a single file's changes.
-
-FILE NAME: {filename}
-DIFF:
-{patch}
-
-Task:
-Summarize the technical changes made in this file in 1-2 sentences.
-Highlight any specific programming languages, frameworks, or libraries that are evident.
-"""
-
-COMMIT_CHUNK_SUMMARY_PROMPT = """
-You are analyzing a developer's commit to understand their technical skills.
-
-COMMIT MESSAGE: {commit_message}
-
-FILE SUMMARIES:
-{file_summaries}
-
-Task:
-Summarize the technical skills, languages, and frameworks demonstrated in this specific commit based on the files changed.
-Keep it extremely concise (1-2 sentences).
-"""
-
-DEVELOPER_PROFILE_REDUCE_PROMPT = """
-You are building a technical profile for a developer based on summaries of their recent commits.
-
-DEVELOPER: {author}
-
-COMMIT SUMMARIES:
-{commit_summaries}
-
-Task:
-Identify the unique programming languages and technical skills this developer possesses.
-Return a valid JSON array of strings representing their skills.
-Example: ["Python", "React", "AWS", "SQL"]
-"""
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/Process/scorer_agent.py b/codience/src/Reviewer_Recommender/Process/scorer_agent.py
deleted file mode 100644
index 343da228..00000000
--- a/codience/src/Reviewer_Recommender/Process/scorer_agent.py
+++ /dev/null
@@ -1,89 +0,0 @@
-import json
-import re
-from typing import List, Dict, Any
-from pydantic import BaseModel, ValidationError
-from codience.src.Reviewer_Recommender.Process.llm import generate_with_resilience
-from codience.src.Reviewer_Recommender.Process.prompts import SCORER_PROMPT
-
-
-
-class CandidateScore(BaseModel):
- name: str
- confidence_score: int
- justification: str
-
-def calculate_match_scores(pr_analysis: Dict[str, Any], rag_roles: List[Dict[str, Any]], candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
- """
- Evaluates a list of candidates against PR requirements and their Jira/Commit context.
- Returns a ranked list with a 0 to 100 confidence score.
-
- pr_analysis: output from extract_pr_skills
- rag_roles: list of recommended roles from search_vector_db (if available)
- candidates: list of dicts:
- [{"name": "DevA", "commit_skills": ["Python"], "jira_context": {"domain": "...", "recent_skills": []}}]
- """
-
- if not candidates:
- return []
-
- # Format candidates for the prompt
- candidates_text = ""
- for i, c in enumerate(candidates):
- c_name = c["name"]
- c_commits = ", ".join(c.get("commit_skills", []))
- c_jira_domain = c.get("jira_context", {}).get("domain", "Unknown")
- c_jira_skills = ", ".join(c.get("jira_context", {}).get("recent_skills", []))
- c_raw_skills = ", ".join(c.get("raw_skills", [])) if isinstance(c.get("raw_skills"), list) else c.get("raw_skills", "None")
-
- candidates_text += f"\nCandidate {i+1}: {c_name}\n"
- candidates_text += f" - Commit History Skills: {c_commits}\n"
- candidates_text += f" - Current Jira Domain: {c_jira_domain}\n"
- candidates_text += f" - Recent Jira Skills: {c_jira_skills}\n"
- candidates_text += f" - Explicitly Provided Skills: {c_raw_skills}\n"
-
- # Format PR and RAG context
- pr_skills = ", ".join(pr_analysis.get("required_skills", pr_analysis.get("detected_languages", [])))
- rag_context = "\n".join([f"- {r.page_content}" for r in rag_roles]) if rag_roles else "No specific role found in Vector DB."
-
- prompt = SCORER_PROMPT.format(
- pr_skills=pr_skills,
- pr_analysis_summary=pr_analysis.get("summary", ""),
- rag_context=rag_context,
- candidates_text=candidates_text
- )
-
- result = generate_with_resilience(prompt, purpose="candidate_scoring")
- if result.get("ok"):
- raw_text = result.get("text", "")
- try:
- json_match = re.search(r'\[.*\]', raw_text, re.DOTALL)
- json_str = json_match.group() if json_match else raw_text
- parsed_data = json.loads(json_str)
- validated_results = []
- for item in parsed_data:
- validated_results.append(CandidateScore(**item).model_dump())
-
- return sorted(validated_results, key=lambda x: x.get("confidence_score", 0), reverse=True)
- except json.JSONDecodeError as decode_error:
- print(f"⚠️ Scorer JSON decode error: {decode_error}")
- except ValidationError as validation_error:
- print(f"⚠️ Scorer validation failed: {validation_error}")
- except Exception as e:
- print(f"⚠️ Scorer parse error: {e}")
- else:
- print(f"⚠️ Scorer LLM fallback. reason={result.get('reason')}")
-
- # Fallback heuristic if all retries fail
- print("⚠️ Heuristic scorer fallback used.")
- fallback_results = []
- req_set = set(pr_analysis.get("detected_languages", []))
- for c in candidates:
- c_skills = set(c.get("commit_skills", []))
- match_count = len(req_set.intersection(c_skills))
- score = min(100, int((match_count / max(len(req_set), 1)) * 100))
- fallback_results.append({
- "name": c["name"],
- "confidence_score": score,
- "justification": "Fallback heuristic used due to LLM error."
- })
- return sorted(fallback_results, key=lambda x: x["confidence_score"], reverse=True)
diff --git a/codience/src/Reviewer_Recommender/Process/tests_prs.py b/codience/src/Reviewer_Recommender/Process/tests_prs.py
deleted file mode 100644
index 15c924fc..00000000
--- a/codience/src/Reviewer_Recommender/Process/tests_prs.py
+++ /dev/null
@@ -1,65 +0,0 @@
-test_prs = [
- # flutter
- {
- "title": "Feature: Implement Biometric Authentication Flow",
- "description": "Added FaceID and Fingerprint support using the local_auth package. Updated the login screen UI to include a biometric toggle.",
- "diff": """
- --- a/lib/screens/login_screen.dart
- +++ b/lib/screens/login_screen.dart
- + Future _authenticateWithBiometrics() async {
- + bool authenticated = await auth.authenticate(localizedReason: 'Scan to login');
- + }
- """
- },
- # .NET
- {
- "title": "Fix: Optimized SQL Query for User Dashboard",
- "description": "I added an index to the User table and refactored the Entity Framework query to avoid N+1 issues by using Eager Loading.",
- "diff": """
- --- a/Data/UserRepository.cs
- +++ b/Data/UserRepository.cs
- - var users = context.Users.ToList();
- + var users = context.Users.Include(u => u.Posts).AsNoTracking().ToList();
- """
- },
- # React
- {
- "title": "Feat: Migrate Component State to Redux Toolkit",
- "description": "Replacing local useState hooks with a centralized Redux slice to handle global authentication state and improve data consistency.",
- "diff": """
- --- a/src/components/Header.tsx
- +++ b/src/components/Header.tsx
- - const [user, setUser] = useState(null);
- + const user = useSelector((state: RootState) => state.auth.user);
- + const dispatch = useDispatch();
- """
- },
- # DevOps
- {
- "title": "Chore: Multi-stage Docker Build for Production",
- "description": "Optimized image size by implementing a multi-stage build process and moving to alpine-based images for the Nginx frontend.",
- "diff": """
- --- a/Dockerfile
- +++ b/Dockerfile
- + FROM node:20-alpine AS builder
- + WORKDIR /app
- + COPY . .
- + RUN npm run build
- + FROM nginx:alpine
- + COPY --from=builder /app/dist /usr/share/nginx/html
- """
- },
- # Python Backend
- {
- "title": "Feature: Integrate FastAPI Background Tasks",
- "description": "Implemented background worker tasks for processing heavy image uploads without blocking the main request-response cycle.",
- "diff": """
- --- a/app/main.py
- +++ b/app/main.py
- + @app.post("/upload/")
- + async def create_upload_file(background_tasks: BackgroundTasks, file: UploadFile):
- + background_tasks.add_task(process_image_in_background, file.filename)
- + return {"message": "Upload started"}
- """
- }
-]
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/requirements.txt b/codience/src/Reviewer_Recommender/requirements.txt
index ca95d535..d0108215 100644
--- a/codience/src/Reviewer_Recommender/requirements.txt
+++ b/codience/src/Reviewer_Recommender/requirements.txt
@@ -1,16 +1,20 @@
-datasets
-pandas
-langchain
-langchain-huggingface # New
-mistralai # NEW
-openai # New
-redis # NEW
-langchain-community
-sentence-transformers
chromadb
+datasets
+fastapi
google-genai>=0.7.0
groq>=0.11.0
+langchain
+langchain-community
+langchain-huggingface
+langchain-text-splitters
+mistralai
+nomic
+numpy
+openai
+pandas
+pydantic
python-dotenv
-fastapi
-uvicorn[standard]
-requests
\ No newline at end of file
+redis
+requests
+sentence-transformers
+uvicorn[standard]
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/tests/api/test_app.py b/codience/src/Reviewer_Recommender/tests/api/test_app.py
new file mode 100644
index 00000000..bdf8ee93
--- /dev/null
+++ b/codience/src/Reviewer_Recommender/tests/api/test_app.py
@@ -0,0 +1,206 @@
+import pytest
+from fastapi.testclient import TestClient
+from unittest.mock import patch, MagicMock
+from codience.src.app import app
+
+client = TestClient(app)
+
+# ==========================================
+# /api/analyze/jira-tickets Tests
+# ==========================================
+
+@patch("codience.src.app.analyze_jira_tickets")
+def test_analyze_jira_tickets_success(mock_analyze):
+ mock_analyze.return_value = {"PROJ-123": {"summary": "Test ticket", "components": []}}
+ response = client.post("/api/analyze/jira-tickets", json={
+ "username": "testuser",
+ "tickets": ["PROJ-123"]
+ })
+ assert response.status_code == 200
+ assert response.json() == {"PROJ-123": {"summary": "Test ticket", "components": []}}
+
+def test_analyze_jira_tickets_validation_error():
+ # Missing required field 'username'
+ response = client.post("/api/analyze/jira-tickets", json={
+ "tickets": ["PROJ-123"]
+ })
+ assert response.status_code == 422
+
+@patch("codience.src.app.analyze_jira_tickets")
+def test_analyze_jira_tickets_empty_list(mock_analyze):
+ mock_analyze.return_value = {}
+ response = client.post("/api/analyze/jira-tickets", json={
+ "username": "testuser",
+ "tickets": []
+ })
+ assert response.status_code == 200
+ assert response.json() == {}
+
+# ==========================================
+# /api/analyze/commit-history Tests
+# ==========================================
+
+@patch("codience.src.app.map_commits_to_skills")
+def test_analyze_commit_history_success(mock_map_commits):
+ mock_map_commits.return_value = {"testauthor": {"Python", "FastAPI"}}
+ response = client.post("/api/analyze/commit-history", json={
+ "author": "testauthor",
+ "commits": ["sha123"]
+ })
+ assert response.status_code == 200
+ assert set(response.json()["skills"]) == {"Python", "FastAPI"}
+
+def test_analyze_commit_history_validation_error():
+ # Missing 'commits' field
+ response = client.post("/api/analyze/commit-history", json={
+ "author": "testauthor"
+ })
+ assert response.status_code == 422
+
+@patch("codience.src.app.map_commits_to_skills")
+def test_analyze_commit_history_empty_list(mock_map_commits):
+ mock_map_commits.return_value = {}
+ response = client.post("/api/analyze/commit-history", json={
+ "author": "testauthor",
+ "commits": []
+ })
+ assert response.status_code == 200
+ assert response.json()["skills"] == []
+
+# ==========================================
+# /api/recommend/reviewer Tests
+# ==========================================
+
+@patch("codience.src.app.get_composite_recommendations")
+def test_recommend_reviewer_success(mock_get_composite):
+ mock_get_composite.return_value = [
+ {"name": "reviewer1", "confidence_score": 90, "justification": "Good"},
+ {"name": "reviewer2", "confidence_score": 80, "justification": "Okay"}
+ ]
+ response = client.post("/api/recommend/reviewer", json={
+ "pr_data": {"files": [], "commits": []},
+ "candidates": [{"name": "reviewer1"}, {"name": "reviewer2"}],
+ "options": {}
+ })
+ assert response.status_code == 200
+ assert "recommended_reviewers" in response.json()
+ assert len(response.json()["recommended_reviewers"]) == 2
+
+def test_recommend_reviewer_validation_error():
+ # Missing 'pr_data' field
+ response = client.post("/api/recommend/reviewer", json={
+ "candidates": [{"name": "reviewer1"}],
+ "options": {}
+ })
+ assert response.status_code == 422
+
+# ==========================================
+# /api/recommend-reviewers Tests
+# ==========================================
+
+@patch("codience.src.app.get_pr_data_or_raise")
+@patch("codience.src.app._build_engine")
+def test_recommend_reviewers_default_engine(mock_build_engine, mock_get_pr_data):
+ mock_get_pr_data.return_value = {"diff": "some diff"}
+
+ mock_engine = MagicMock()
+ mock_engine.recommend_v2.return_value = {
+ "recommended_reviewers": [{"name": "dev1", "confidence_score": 95, "justification": "Expert"}]
+ }
+ mock_build_engine.return_value = mock_engine
+
+ response = client.post("/api/recommend-reviewers", json={
+ "owner": "org",
+ "repo": "repo",
+ "pr_number": 1
+ })
+
+ assert response.status_code == 200
+ assert len(response.json()["recommended_reviewers"]) == 1
+ mock_build_engine.assert_called_once()
+ mock_engine.recommend_v2.assert_called_once()
+
+@patch("codience.src.app.get_pr_data_or_raise")
+@patch("codience.src.app._build_engine_for_required")
+def test_recommend_reviewers_required_engine(mock_build_required, mock_get_pr_data):
+ mock_get_pr_data.return_value = {"diff": "some diff"}
+
+ mock_engine = MagicMock()
+ mock_engine.recommend_v2.return_value = {
+ "recommended_reviewers": [{"name": "req_dev", "confidence_score": 85, "justification": "Required"}]
+ }
+ mock_build_required.return_value = mock_engine
+
+ response = client.post("/api/recommend-reviewers", json={
+ "owner": "org",
+ "repo": "repo",
+ "pr_number": 1,
+ "required_reviewers": ["req_dev"]
+ })
+
+ assert response.status_code == 200
+ assert response.json()["recommended_reviewers"][0]["name"] == "req_dev"
+ mock_build_required.assert_called_once()
+
+def test_recommend_reviewers_validation_error():
+ # Missing repo, owner, pr_number
+ response = client.post("/api/recommend-reviewers", json={})
+ assert response.status_code == 422
+
+# ==========================================
+# /api/orchestrator Tests
+# ==========================================
+
+@patch("codience.src.app.get_pr_data_or_raise")
+@patch("codience.src.app.ReviewerRecommender")
+@patch("codience.src.app._profile_single_user")
+@patch("codience.src.app.get_composite_recommendations")
+def test_orchestrator_success(mock_get_composite, mock_profile, mock_recommender, mock_get_pr_data):
+ mock_get_pr_data.return_value = {"diff": "diff data"}
+ mock_profile.side_effect = [{"name": "userA"}, {"name": "userB"}]
+ mock_get_composite.return_value = [
+ {"name": "userA", "confidence_score": 90, "justification": "Match"},
+ {"name": "userB", "confidence_score": 80, "justification": "Match"}
+ ]
+
+ response = client.post("/api/orchestrator", json={
+ "owner": "org",
+ "repo": "repo",
+ "pr_number": 123,
+ "users": [
+ {"github_username": "userA"},
+ {"github_username": "userB"}
+ ]
+ })
+
+ assert response.status_code == 200
+ assert len(response.json()["recommended_reviewers"]) == 2
+ assert mock_profile.call_count == 2
+ mock_get_composite.assert_called_once()
+
+@patch("codience.src.app.get_pr_data_or_raise")
+@patch("codience.src.app.ReviewerRecommender")
+@patch("codience.src.app.get_composite_recommendations")
+def test_orchestrator_empty_users(mock_get_composite, mock_recommender, mock_get_pr_data):
+ mock_get_pr_data.return_value = {"diff": "diff data"}
+ mock_get_composite.return_value = []
+
+ response = client.post("/api/orchestrator", json={
+ "owner": "org",
+ "repo": "repo",
+ "pr_number": 123,
+ "users": []
+ })
+
+ assert response.status_code == 200
+ assert response.json()["recommended_reviewers"] == []
+ mock_get_composite.assert_called_once()
+
+def test_orchestrator_validation_error():
+ # Missing 'users' field
+ response = client.post("/api/orchestrator", json={
+ "owner": "org",
+ "repo": "repo",
+ "pr_number": 123
+ })
+ assert response.status_code == 422
diff --git a/codience/src/Reviewer_Recommender/tests/conftest.py b/codience/src/Reviewer_Recommender/tests/conftest.py
new file mode 100644
index 00000000..ebfbdc5d
--- /dev/null
+++ b/codience/src/Reviewer_Recommender/tests/conftest.py
@@ -0,0 +1,42 @@
+import pytest
+
+@pytest.fixture
+def mock_pr_data():
+ return {
+ "title": "Add authentication middleware",
+ "description": "Implemented JWT based authentication for the API.",
+ "files": [
+ {"filename": "src/auth.py", "patch": "+ def verify_token(token):\n+ pass"},
+ {"filename": "src/main.py", "patch": "+ from auth import verify_token"}
+ ]
+ }
+
+@pytest.fixture
+def mock_commits():
+ return [
+ {
+ "author": {"login": "dev1"},
+ "commit": {"author": {"name": "dev1", "date": "2023-10-01T12:00:00Z"}},
+ "files": [{"filename": "src/auth.py"}],
+ "sha": "abc1234"
+ },
+ {
+ "author": {"login": "dev1"},
+ "commit": {"author": {"name": "dev1", "date": "2023-10-05T12:00:00Z"}},
+ "files": [{"filename": "src/utils.py"}],
+ "sha": "def5678"
+ },
+ {
+ "author": {"login": "dev2"},
+ "commit": {"author": {"name": "dev2", "date": "2023-09-15T12:00:00Z"}},
+ "files": [{"filename": "src/database.py"}],
+ "sha": "ghi9012"
+ }
+ ]
+
+@pytest.fixture
+def mock_history_profiles():
+ return {
+ "dev1": {"Python", "JWT", "FastAPI"},
+ "dev2": {"Python", "SQL", "Database Migration"}
+ }
diff --git a/codience/src/Reviewer_Recommender/tests/integration/test_integration.py b/codience/src/Reviewer_Recommender/tests/integration/test_integration.py
new file mode 100644
index 00000000..284ed540
--- /dev/null
+++ b/codience/src/Reviewer_Recommender/tests/integration/test_integration.py
@@ -0,0 +1,23 @@
+import pytest
+from codience.src.Reviewer_Recommender.PRNew.Reviewer_Engine import ReviewerRecommender
+
+def test_engine_initialization():
+ engine = ReviewerRecommender(owner="test_owner", repo="test_repo")
+ assert engine.owner == "test_owner"
+ assert engine.repo == "test_repo"
+
+def test_engine_recommend_v2_empty_pr():
+ engine = ReviewerRecommender(owner="test_owner", repo="test_repo")
+ pr_data = {
+ "files": [],
+ "commits": []
+ }
+
+ result = engine.recommend_v2(
+ pr_data=pr_data,
+ required_reviewers=[],
+ options={},
+ )
+
+ assert "recommended_reviewers" in result
+ assert isinstance(result["recommended_reviewers"], list)
diff --git a/codience/src/Reviewer_Recommender/tests/unit/test_engine_math.py b/codience/src/Reviewer_Recommender/tests/unit/test_engine_math.py
new file mode 100644
index 00000000..c1dc9cc3
--- /dev/null
+++ b/codience/src/Reviewer_Recommender/tests/unit/test_engine_math.py
@@ -0,0 +1,78 @@
+import pytest
+import sys
+from pathlib import Path
+from datetime import datetime, timezone, timedelta
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[5]))
+
+from codience.src.Reviewer_Recommender.PRNew.Reviewer_Engine import ReviewerRecommender
+
+@pytest.fixture
+def engine():
+ # using mocks reviewerw
+ return ReviewerRecommender(owner="mock", repo="mock")
+
+def test_parse_commit_datetime(engine):
+ date_str = "2023-10-01T12:00:00Z"
+ parsed = engine._parse_commit_datetime(date_str)
+ assert parsed is not None
+ assert parsed.year == 2023
+ assert parsed.month == 10
+
+ assert engine._parse_commit_datetime("invalid-date") is None
+ assert engine._parse_commit_datetime(None) is None
+
+def test_build_contributor_stats(engine):
+ # We dynamically generate dates relative to "now" to test recency scoring
+ now = datetime.now(timezone.utc)
+ date_45_days_ago = (now - timedelta(days=45)).isoformat()
+ date_180_days_ago = (now - timedelta(days=180)).isoformat()
+
+ custom_commits = [
+ {
+ "author": {"login": "dev_recent"},
+ "commit": {"author": {"name": "dev_recent", "date": date_45_days_ago}},
+ "files": [{"filename": "src/auth.py"}],
+ "sha": "abc1"
+ },
+ {
+ "author": {"login": "dev_old"},
+ "commit": {"author": {"name": "dev_old", "date": date_180_days_ago}},
+ "files": [{"filename": "src/utils.py"}],
+ "sha": "def2"
+ }
+ ]
+
+ stats = engine._build_contributor_stats(custom_commits)
+
+ assert "dev_recent" in stats
+ assert "dev_old" in stats
+
+ # dev_recent was 45 days ago. Score formula: 1.0 - (min(recency_days, 90) / 90.0)
+ # 1.0 - (45/90) = 0.5
+ assert stats["dev_recent"]["recency_score"] == 0.5
+
+ # dev_old was 180 days ago (exceeds 90 days threshold). Score should be 0.0
+ assert stats["dev_old"]["recency_score"] == 0.0
+
+def test_limit_commits_per_reviewer(engine, mock_commits):
+ # Limit to 1 commit per reviewer
+ limited = engine._limit_commits_per_reviewer(mock_commits, 1)
+
+ # Should only have 1 for dev1, 1 for dev2 = 2 total
+ assert len(limited) == 2
+
+ dev1_commits = [c for c in limited if c["author"]["login"] == "dev1"]
+ assert len(dev1_commits) == 1
+
+def test_normalize_required_reviewers(engine):
+ required = ["dev1", {"username": "dev2", "raw_skills": ["Python"]}]
+
+ normalized = engine._normalize_required_reviewers(required)
+
+ assert "dev1" in normalized
+ assert normalized["dev1"]["username"] == "dev1"
+
+ assert "dev2" in normalized
+ assert normalized["dev2"]["username"] == "dev2"
+ assert "Python" in normalized["dev2"]["raw_skills"]
\ No newline at end of file
diff --git a/codience/src/Reviewer_Recommender/tests/unit/test_multiset_engine.py b/codience/src/Reviewer_Recommender/tests/unit/test_multiset_engine.py
new file mode 100644
index 00000000..9f1a898d
--- /dev/null
+++ b/codience/src/Reviewer_Recommender/tests/unit/test_multiset_engine.py
@@ -0,0 +1,98 @@
+import pytest
+from datetime import datetime, timezone, timedelta
+from collections import Counter
+from PRNew.multiset_engine import (
+ tokenise_path,
+ commit_multiset,
+ extinguish,
+ build_reviewer_profile,
+ tversky_similarity,
+ jaccard_similarity,
+ score_reviewer
+)
+
+def test_tokenise_path():
+ assert tokenise_path("src/main/java/package1/SomeClass.java") == ["src", "main", "java", "package1", "someclass", "java"]
+ assert tokenise_path("path-with-dashes/and_underscores.txt") == ["path", "with", "dashes", "and", "underscores", "txt"]
+ assert tokenise_path("CamelCaseIsNotSplit/file.ts") == ["camelcaseisnotsplit", "file", "ts"]
+
+def test_commit_multiset():
+ paths = ["src/file1.py", "src/file2.py"]
+ result = commit_multiset(paths)
+ assert result == Counter({"src": 2, "file1": 1, "py": 2, "file2": 1})
+
+def test_extinguish():
+ assert extinguish(0) == 1.0
+ assert extinguish(180, decay_factor=2.0, halflife_days=180) == 0.5
+ assert extinguish(360, decay_factor=2.0, halflife_days=180) == 0.25
+
+def test_build_reviewer_profile_no_decay():
+ commits = [
+ {"files": ["src/main.py", "src/utils.py"]},
+ {"files": ["src/main.py"]}
+ ]
+ profile = build_reviewer_profile(commits, use_decay=False)
+ assert profile == Counter({"src": 3, "main": 2, "py": 3, "utils": 1})
+
+def test_build_reviewer_profile_with_decay():
+ ref_date = datetime(2023, 1, 1, tzinfo=timezone.utc)
+ # Commit 1 is exactly 180 days old (halflife = 180, factor = 2.0 -> multiplier 0.5)
+ date_180_days_ago = (ref_date - timedelta(days=180)).isoformat()
+ # Commit 2 is today (0 days old -> multiplier 1.0)
+ date_today = ref_date.isoformat()
+
+ commits = [
+ {"files": ["src/old.py"], "date": date_180_days_ago},
+ {"files": ["src/new.py"], "date": date_today}
+ ]
+ profile = build_reviewer_profile(
+ commits, use_decay=True, decay_factor=2.0, halflife_days=180, reference_date=ref_date
+ )
+ # old.py elements get multiplied by 0.5
+ # new.py elements get multiplied by 1.0
+ assert profile["src"] == 1.5 # 0.5 from old, 1.0 from new
+ assert profile["old"] == 0.5
+ assert profile["new"] == 1.0
+ assert profile["py"] == 1.5
+
+def test_tversky_similarity():
+ profile = Counter({"src": 2, "file1": 1})
+ commit = Counter({"src": 1, "file2": 1})
+
+ # Inter: "src": min(2,1) = 1
+ # p_only: "src": max(0, 2-1)=1, "file1": max(0, 1-0)=1 -> total p_only = 2
+ # c_only: "src": max(0, 1-2)=0, "file2": max(0, 1-0)=1 -> total c_only = 1
+ # denom (alpha=0.5, beta=0.5) = 1 + 0.5*2 + 0.5*1 = 1 + 1 + 0.5 = 2.5
+ # score = 1 / 2.5 = 0.4
+
+ score = tversky_similarity(profile, commit, alpha=0.5, beta=0.5)
+ assert score == 0.4
+
+ # Empty cases
+ assert tversky_similarity(Counter(), commit) == 0.0
+ assert tversky_similarity(profile, Counter()) == 0.0
+
+def test_jaccard_similarity():
+ profile = Counter({"src": 2, "file1": 1})
+ commit = Counter({"src": 1, "file2": 1})
+
+ # Inter = 1
+ # Union = sum(profile+commit) - Inter = (2+1+1+1) - 1 = 4
+ # score = 1 / 4 = 0.25
+ score = jaccard_similarity(profile, commit)
+ assert score == 0.25
+
+def test_score_reviewer():
+ commits = [
+ {"files": ["src/main.py"], "date": datetime.now(timezone.utc).isoformat()}
+ ]
+ pr_files = ["src/main.py"]
+
+ res = score_reviewer(commits, pr_files, use_decay=False)
+ # Profile = {"src":1, "main":1, "py":1}
+ # Commit = {"src":1, "main":1, "py":1}
+ # Both identical, scores should be 1.0
+ assert res["tversky"] == 1.0
+ assert res["jaccard"] == 1.0
+ assert res["profile_size"] == 3
+ assert res["commit_size"] == 3
diff --git a/codience/src/__pycache__/app.cpython-312.pyc b/codience/src/__pycache__/app.cpython-312.pyc
index 7c6811af..49e77d94 100644
Binary files a/codience/src/__pycache__/app.cpython-312.pyc and b/codience/src/__pycache__/app.cpython-312.pyc differ
diff --git a/codience/src/app.py b/codience/src/app.py
index a5525b64..e0f8f43c 100644
--- a/codience/src/app.py
+++ b/codience/src/app.py
@@ -1,170 +1,55 @@
# Fast API for reviewer recommendation
import os
+
from typing import Optional, Any
-from fastapi import FastAPI, HTTPException
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
# pyrefly: ignore [missing-import]
-from pydantic import BaseModel, Field
-from codience.src.Reviewer_Recommender.Process.Engine_test import ReviewerRecommender, fetch_real_pr_data
-from codience.src.Reviewer_Recommender.Process.jira_agent import analyze_jira_tickets, fetch_jira_tickets
-from codience.src.Reviewer_Recommender.Process.Reviewer_Engine_Helper import analyze_user_commit_history
-from codience.src.Reviewer_Recommender.Process.analysis_PR import extract_pr_skills
-from codience.src.Reviewer_Recommender.Data.searching_into_vectordb import search_vector_db
-from codience.src.Reviewer_Recommender.Process.scorer_agent import calculate_match_scores
-
-app = FastAPI(title="Codience Reviewer Recommender API")
-
-
-# --- Models ------------------------------------------------------------------
-
-class BaseRepoRequest(BaseModel):
- owner: str = Field(..., description="GitHub repository owner or organization.")
- repo: str = Field(..., description="GitHub repository name.")
- pr_number: int = Field(..., description="Pull request number.")
-
-
-class RankingOptions(BaseModel):
- top_k: Optional[int] = Field(default=None, description="Max recommendations (1-20). Default 5.")
- prioritize_recent_activity: Optional[bool] = Field(default=None, description="Toggle recency prioritization.")
- commits_per_reviewer: Optional[int] = Field(default=None, description="Recent commits considered (1-100). Default 50.")
-
-
-class ReviewerRequest(BaseRepoRequest):
- pass
-
-
-class ReviewerRequestV2(BaseRepoRequest):
- required_reviewers: list[Any] = Field(default_factory=list, description="Explicit reviewers to consider (strings or dicts).")
- options: Optional[RankingOptions] = None
-
-class TicketListRequest(BaseModel):
- username: str
- tickets: list[Any]
-
-
-class CommitHistoryRequest(BaseModel):
- author: str
- commits: list[Any]
-
-
-class CandidateProfile(BaseModel):
- name: str
- jira_username: Optional[str] = None
- commit_skills: list[str] = []
- jira_context: dict = {}
- commit_count: int = 0
- tenure_days: int = 365
- recency_score: float = 0.0
- required_reviewer: bool = False
- raw_skills: list[str] = []
- prelim_score: float = 0.0
-
-
-class ReviewerMatchRequest(BaseModel):
- pr_data: dict
- candidates: list[CandidateProfile]
- options: Optional[RankingOptions] = None
-
-
-class OrchestratorUser(BaseModel):
- github_username: str
- jira_username: Optional[str] = None
- jira_token: Optional[str] = None
- jira_cloud_id: Optional[str] = None
- jira_project_key: Optional[str] = None
- raw_skills: list[str] = []
-
-
-class OrchestratorRequest(BaseRepoRequest):
- users: list[OrchestratorUser]
- commits_per_user: Optional[int] = 50
- options: Optional[RankingOptions] = None
-
-
-class ReviewerResponse(BaseModel):
- name: str
- confidence_score: int
- justification: str
+from codience.src.models import (
+ RecommendReviewersRequest,
+ TicketListRequest,
+ CommitHistoryRequest,
+ ReviewerMatchRequest,
+ OrchestratorRequest,
+ ReviewerResponse,
+)
+from codience.src.helpers import (
+ get_pr_data_or_raise,
+ format_reviewer_results,
+ _build_engine,
+ _build_engine_for_required,
+ _normalize_reviewers,
+ get_composite_recommendations,
+ _profile_single_user,
+)
+from codience.src.Reviewer_Recommender.PRNew.Reviewer_Engine import ReviewerRecommender
+from codience.src.Reviewer_Recommender.PRNew.jira_agent import analyze_jira_tickets
+from codience.src.Reviewer_Recommender.PRNew.commit_history_utils import map_commits_to_skills
+app = FastAPI(title="Codience Reviewer Recommender API")
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # Allow all origins
+ allow_credentials=True,
+ allow_methods=["*"], # Allow all HTTP methods
+ allow_headers=["*"], # Allow all headers
+)
# --- Helpers -----------------------------------------------------------------
+# Helper functions and business logic are imported from codience.src.helpers
-def get_pr_data_or_raise(owner: str, repo: str, pr_number: int) -> dict:
- """Centralized helper to fetch and validate PR data."""
- real_pr = fetch_real_pr_data(owner, repo, pr_number)
- if not real_pr:
- raise HTTPException(status_code=404, detail=f"Failed to fetch PR #{pr_number} data for {owner}/{repo}.")
- return real_pr
-
-def format_reviewer_results(results: list[dict], limit: int = 5) -> list[ReviewerResponse]:
- """Uniformly formats internal reviewer dicts for API response."""
- formatted = []
- for res in results[:limit]:
- formatted.append(ReviewerResponse(
- name=res.get("name", "Unknown"),
- confidence_score=int(res.get("confidence_score", 0)),
- justification=res.get("justification", "")
- ))
- return formatted
-
-
-def _build_engine(owner: str, repo: str, min_commits: Optional[int] = None) -> ReviewerRecommender:
- """Full init: indexes all repo contributors."""
- engine = ReviewerRecommender(owner, repo)
- max_commits = int(os.getenv("ENGINE_MAX_COMMITS", "1000"))
- if min_commits is not None:
- max_commits = max(max_commits, int(min_commits))
- max_llm_calls = int(os.getenv("ENGINE_MAX_LLM_CALLS", "30"))
- engine.initialize_system(max_commits=max_commits, max_llm_calls=max_llm_calls)
- return engine
-
-
-def _build_engine_for_required(owner: str, repo: str, required_reviewers: list, commits_per_reviewer: int = 50) -> ReviewerRecommender:
- """Lean init: only fetches history for the specified required reviewers."""
- engine = ReviewerRecommender(owner, repo)
- max_llm_calls = int(os.getenv("ENGINE_MAX_LLM_CALLS", "20"))
- engine.initialize_for_required_only(
- required_reviewers=required_reviewers,
- commits_per_reviewer=commits_per_reviewer,
- max_llm_calls=max_llm_calls,
- )
- return engine
-
-
-def _normalize_reviewers(reviewers: list[Any]) -> list[Any]:
- """Deduplicates and normalizes strings/dicts into a clean list of reviewer identifiers."""
- normalized = []
- seen = set()
- for r in reviewers:
- username = r.strip() if isinstance(r, str) else (r.get("username") or r.get("login") or "").strip()
- if username and username.lower() not in seen:
- seen.add(username.lower())
- normalized.append(r)
- return normalized
-
-
-@app.post("/api/recommend", response_model=dict[str, list[ReviewerResponse]])
-async def recommend(request: ReviewerRequest):
- engine = _build_engine(request.owner, request.repo)
- pr_data = get_pr_data_or_raise(request.owner, request.repo, request.pr_number)
-
- results = engine.recommend(pr_data)
- return {"reviewers": format_reviewer_results(results)}
-
-
-@app.post("/api/recommend/v2", response_model=dict[str, list[ReviewerResponse]])
-async def recommend_v2(request: ReviewerRequestV2):
+@app.post("/api/recommend-reviewers", response_model=dict[str, list[ReviewerResponse]])
+async def recommend_reviewers(request: RecommendReviewersRequest):
normalized_required = _normalize_reviewers(request.required_reviewers)
options = request.options.model_dump(exclude_none=True) if request.options else {}
- # Bound options
+ # Bound options
if "top_k" in options:
options["top_k"] = max(1, min(20, options["top_k"]))
- if "commits_per_reviewer" in options:
- options["commits_per_reviewer"] = max(1, min(100, options["commits_per_reviewer"]))
- commits_per_reviewer = options.get("commits_per_reviewer", ReviewerRecommender.DEFAULT_COMMITS_PER_REVIEWER)
+ commits_per_reviewer = ReviewerRecommender.DEFAULT_COMMITS_PER_REVIEWER
if normalized_required:
engine = _build_engine_for_required(
@@ -178,7 +63,14 @@ async def recommend_v2(request: ReviewerRequestV2):
engine = _build_engine(request.owner, request.repo, min_commits=min_commits)
pr_data = get_pr_data_or_raise(request.owner, request.repo, request.pr_number)
- result = engine.recommend_v2(pr_data, required_reviewers=normalized_required, options=options)
+ result = engine.recommend_v2(
+ pr_data,
+ required_reviewers=normalized_required,
+ options=options,
+ jira_token=request.jira_token,
+ jira_cloud_id=request.jira_cloud_id,
+ jira_project_key=request.jira_project_key
+ )
return {"recommended_reviewers": format_reviewer_results(result.get("recommended_reviewers", []))}
@@ -190,54 +82,11 @@ async def api_analyze_jira_tickets(request: TicketListRequest):
@app.post("/api/analyze/commit-history")
async def api_analyze_commit_history(request: CommitHistoryRequest):
- skills = analyze_user_commit_history(request.author, request.commits)
+ skills_set = map_commits_to_skills(request.commits, repo="unknown/unknown", specific_authors=[request.author]).get(request.author, set())
+ skills = list(skills_set)
return {"skills": skills}
-def get_composite_recommendations(pr_data: dict, candidates: list[dict], options: Optional[RankingOptions] = None) -> list[dict]:
- """
- Business logic for calculating composite scores based on AI analysis,
- preliminary scores, and recency signals.
- """
- analysis = extract_pr_skills(pr_data)
- required_languages = {lang.strip().title() for lang in analysis.get('detected_languages', [])}
- if "C#" in required_languages:
- required_languages.remove("C#")
- required_languages.add(".NET")
-
- rag_query = analysis.get('rag_query', '') or ', '.join(required_languages)
- try:
- rag_roles = search_vector_db(rag_query, k=10) if rag_query else []
- except Exception as e:
- print(f"⚠️ Vector DB Search Failed: {e}")
- rag_roles = []
-
- ai_rankings = calculate_match_scores(analysis, rag_roles, candidates)
- ai_by_name = {r.get("name", "").lower(): r for r in ai_rankings}
-
- final_candidates = []
- for c in candidates:
- ai_result = ai_by_name.get(c["name"].lower(), {})
- ai_score = float(ai_result.get("confidence_score", 0)) / 100.0
-
- # Scoring weights: 65% Preliminary (Commit/Jira), 35% AI Match (Matchmaker)
- final_score = int(round(100 * ((0.65 * c.get("prelim_score", 0)) + (0.35 * ai_score))))
-
- reasons = []
- if c.get("required_reviewer"):
- reasons.append("required_reviewer")
- if c.get("recency_score", 0) >= 0.5:
- reasons.append("recent_activity_priority")
-
- final_candidates.append({
- "name": c["name"],
- "confidence_score": max(0, min(100, final_score)),
- "justification": ai_result.get("justification", "Composite scoring based on skills and recent contributor activity."),
- "reasons": reasons,
- "required_reviewer": c.get("required_reviewer", False),
- })
-
- final_candidates.sort(key=lambda x: x["confidence_score"], reverse=True)
- return final_candidates
+# get_composite_recommendations moved to codience.src.helpers
@app.post("/api/recommend/reviewer", response_model=dict[str, list[ReviewerResponse]])
@@ -247,44 +96,7 @@ async def api_recommend_reviewer(request: ReviewerMatchRequest):
return {"recommended_reviewers": format_reviewer_results(results, limit=len(results))}
-def _profile_single_user(user: OrchestratorUser, engine: ReviewerRecommender, commits_per_user: int) -> dict:
- """Collects GitHub and Jira data for a single user to build their candidate profile."""
- github_username = user.github_username
-
- # 1. GitHub History
- author_commits = engine._fetch_commits_for_author(github_username, commits_per_user)
- commit_skills = analyze_user_commit_history(github_username, author_commits)
-
- # 2. Jira Context
- tickets = fetch_jira_tickets(
- username=github_username,
- jira_username=user.jira_username,
- token=user.jira_token,
- cloud_id=user.jira_cloud_id,
- project_key=user.jira_project_key
- )
- jira_context = analyze_jira_tickets(github_username, tickets)
-
- # 3. Stats & Scoring
- stats = engine._build_contributor_stats(author_commits).get(github_username, {})
- recency_score = stats.get("recency_score", 0.0)
-
- # Simple preliminary skill-match heuristic
- skill_score = 0.8 if commit_skills else 0.5
- prelim_score = (0.55 * skill_score) + (0.45 * recency_score)
-
- return {
- "name": github_username,
- "jira_username": user.jira_username,
- "commit_skills": commit_skills,
- "jira_context": jira_context,
- "commit_count": stats.get("commit_count", 0),
- "tenure_days": stats.get("tenure_days", 365),
- "recency_score": recency_score,
- "required_reviewer": True,
- "raw_skills": user.raw_skills,
- "prelim_score": prelim_score
- }
+# _profile_single_user moved to codience.src.helpers
@app.post("/api/orchestrator", response_model=dict[str, list[ReviewerResponse]])
diff --git a/codience/src/helpers.py b/codience/src/helpers.py
new file mode 100644
index 00000000..a0b648cc
--- /dev/null
+++ b/codience/src/helpers.py
@@ -0,0 +1,183 @@
+import os
+from typing import Optional, Any
+from fastapi import HTTPException
+
+# Models
+from codience.src.models import (
+ ReviewerResponse,
+ RankingOptions,
+ OrchestratorUser,
+)
+
+# External engine and agent imports
+from codience.src.Reviewer_Recommender.PRNew.Reviewer_Engine import ReviewerRecommender, fetch_real_pr_data
+from codience.src.Reviewer_Recommender.PRNew.jira_agent import analyze_jira_tickets, fetch_jira_tickets
+from codience.src.Reviewer_Recommender.PRNew.commit_history_utils import fetch_commit_history_for_author, map_commits_to_skills
+from codience.src.Reviewer_Recommender.PRNew.analysis_PR import extract_pr_skills
+from codience.src.Reviewer_Recommender.Data.searching_into_vectordb import search_vector_db
+from codience.src.Reviewer_Recommender.PRNew.scorer_agent import calculate_match_scores
+from codience.src.Reviewer_Recommender.Data.commit_diff_vectordb import search_similar_commits
+
+
+def get_pr_data_or_raise(owner: str, repo: str, pr_number: int) -> dict:
+ """Centralized helper to fetch and validate PR data."""
+ real_pr = fetch_real_pr_data(owner, repo, pr_number)
+ if not real_pr:
+ raise HTTPException(status_code=404, detail=f"Failed to fetch PR #{pr_number} data for {owner}/{repo}.")
+ return real_pr
+
+
+def format_reviewer_results(results: list[dict], limit: int = 5) -> list[ReviewerResponse]:
+ """Uniformly formats internal reviewer dicts for API response."""
+ formatted = []
+ for res in results[:limit]:
+ formatted.append(ReviewerResponse(
+ name=res.get("name", "Unknown"),
+ confidence_score=int(res.get("confidence_score", 0)),
+ justification=res.get("justification", "")
+ ))
+ return formatted
+
+
+def _build_engine(owner: str, repo: str, min_commits: Optional[int] = None) -> ReviewerRecommender:
+ """Full init: indexes all repo contributors."""
+ engine = ReviewerRecommender(owner, repo)
+ max_commits = int(os.getenv("ENGINE_MAX_COMMITS", "1000"))
+ if min_commits is not None:
+ max_commits = max(max_commits, int(min_commits))
+ engine.initialize_with_cache_check(max_developers=50, max_commits=max_commits)
+ return engine
+
+
+def _build_engine_for_required(owner: str, repo: str, required_reviewers: list, commits_per_reviewer: int = 50) -> ReviewerRecommender:
+ """Lean init: only fetches history for the specified required reviewers."""
+ engine = ReviewerRecommender(owner, repo)
+ engine.initialize_with_cache_check(
+ required_developers=required_reviewers,
+ commits_per_reviewer=commits_per_reviewer,
+ max_commits=500,
+ )
+ return engine
+
+# normalize the required reviewers
+def _normalize_reviewers(reviewers: list[Any]) -> list[Any]:
+ normalized = []
+ seen = set()
+ for r in reviewers:
+ username = r.strip() if isinstance(r, str) else (r.get("username") or r.get("login") or "").strip()
+ if username and username.lower() not in seen:
+ seen.add(username.lower())
+ normalized.append(r)
+ return normalized
+
+
+def get_composite_recommendations(pr_data: dict, candidates: list[dict], options: Optional[RankingOptions] = None) -> list[dict]:
+ """
+ Business logic for calculating composite scores based on AI analysis,
+ preliminary scores, and recency signals.
+ """
+ analysis = extract_pr_skills(pr_data)
+ required_languages = {lang.strip().title() for lang in analysis.get('detected_languages', [])}
+ if "C#" in required_languages:
+ required_languages.remove("C#")
+ required_languages.add(".NET")
+
+ rag_query = analysis.get('rag_query', '') or ', '.join(required_languages)
+ try:
+ rag_roles = search_vector_db(rag_query, k=10) if rag_query else []
+ except Exception as e:
+ print(f"⚠️ Vector DB Search Failed: {e}")
+ rag_roles = []
+
+ pr_patch_text = "\n".join([f.get("patch", "") for f in pr_data.get("files", []) if f.get("patch")])
+ rag_commits = []
+ if pr_patch_text:
+ try:
+ rag_commits = search_similar_commits(pr_patch_text, k=15)
+ except Exception as e:
+ print(f"⚠️ Code Diff Vector DB Search Failed: {e}")
+
+ author_rag_matches = {}
+ for res in rag_commits:
+ author = res.metadata.get("author")
+ if author:
+ author_rag_matches[author.lower()] = author_rag_matches.get(author.lower(), 0) + 1
+
+ for c in candidates:
+ rag_match_count = author_rag_matches.get(c["name"].lower(), 0)
+ max_rag = max(author_rag_matches.values()) if author_rag_matches else 1
+ rag_match_score = rag_match_count / max_rag if max_rag > 0 else 0.0
+
+ c["prelim_score"] = (0.7 * c.get("prelim_score", 0)) + (0.3 * rag_match_score)
+
+ matched_diffs = [res.page_content for res in rag_commits if res.metadata.get("author", "").lower() == c["name"].lower()]
+ c["rag_code_matches"] = matched_diffs[:3]
+
+ ai_rankings = calculate_match_scores(analysis, rag_roles, candidates)
+ ai_by_name = {r.get("name", "").lower(): r for r in ai_rankings}
+
+ final_candidates = []
+ for c in candidates:
+ ai_result = ai_by_name.get(c["name"].lower(), {})
+ ai_score = float(ai_result.get("confidence_score", 0)) / 100.0
+
+ # Scoring weights: 65% Preliminary (Commit/Jira), 35% AI Match (Matchmaker)
+ final_score = int(round(100 * ((0.65 * c.get("prelim_score", 0)) + (0.35 * ai_score))))
+
+ reasons = []
+ if c.get("required_reviewer"):
+ reasons.append("required_reviewer")
+ if c.get("recency_score", 0) >= 0.5:
+ reasons.append("recent_activity_priority")
+
+ final_candidates.append({
+ "name": c["name"],
+ "confidence_score": max(0, min(100, final_score)),
+ "justification": ai_result.get("justification", "Composite scoring based on skills and recent contributor activity."),
+ "reasons": reasons,
+ "required_reviewer": c.get("required_reviewer", False),
+ })
+
+ final_candidates.sort(key=lambda x: x["confidence_score"], reverse=True)
+ return final_candidates
+
+
+def _profile_single_user(user: OrchestratorUser, engine: ReviewerRecommender, commits_per_user: int) -> dict:
+ """Collects GitHub and Jira data for a single user to build their candidate profile."""
+ github_username = user.github_username
+
+ # 1. GitHub History
+ author_commits = fetch_commit_history_for_author(github_username, engine.owner, engine.repo, limit=commits_per_user)
+ commit_skills_set = map_commits_to_skills(author_commits, repo=f"{engine.owner}/{engine.repo}", specific_authors=[github_username]).get(github_username, set())
+ commit_skills = list(commit_skills_set)
+
+ # 2. Jira Context
+ tickets = fetch_jira_tickets(
+ username=github_username,
+ jira_username=user.jira_username,
+ token=user.jira_token,
+ cloud_id=user.jira_cloud_id,
+ project_key=user.jira_project_key
+ )
+ jira_context = analyze_jira_tickets(github_username, tickets)
+
+ # 3. Stats & Scoring
+ stats = engine._build_contributor_stats(author_commits).get(github_username, {})
+ recency_score = stats.get("recency_score", 0.0)
+
+ # Simple preliminary skill-match heuristic
+ skill_score = 0.8 if commit_skills else 0.5
+ prelim_score = (0.55 * skill_score) + (0.45 * recency_score)
+
+ return {
+ "name": github_username,
+ "jira_username": user.jira_username,
+ "commit_skills": commit_skills,
+ "jira_context": jira_context,
+ "commit_count": stats.get("commit_count", 0),
+ "tenure_days": stats.get("tenure_days", 365),
+ "recency_score": recency_score,
+ "required_reviewer": True,
+ "raw_skills": user.raw_skills,
+ "prelim_score": prelim_score
+ }
diff --git a/codience/src/models.py b/codience/src/models.py
new file mode 100644
index 00000000..a44a5c49
--- /dev/null
+++ b/codience/src/models.py
@@ -0,0 +1,69 @@
+from typing import Optional, Any
+from pydantic import BaseModel, Field
+
+class BaseRepoRequest(BaseModel):
+ owner: str = Field(..., description="GitHub repository owner or organization.")
+ repo: str = Field(..., description="GitHub repository name.")
+ pr_number: int = Field(..., description="Pull request number.")
+
+
+class RankingOptions(BaseModel):
+ top_k: Optional[int] = Field(default=None, description="Max recommendations (1-20). Default 5.")
+
+
+class RecommendReviewersRequest(BaseRepoRequest):
+ required_reviewers: list[Any] = Field(default_factory=list, description="Explicit reviewers to consider (strings or dicts).")
+ options: Optional[RankingOptions] = None
+ jira_token: Optional[str] = None
+ jira_cloud_id: Optional[str] = None
+ jira_project_key: Optional[str] = None
+
+
+class TicketListRequest(BaseModel):
+ username: str
+ tickets: list[Any]
+
+
+class CommitHistoryRequest(BaseModel):
+ author: str
+ commits: list[Any]
+
+
+class CandidateProfile(BaseModel):
+ name: str
+ jira_username: Optional[str] = None
+ commit_skills: list[str] = []
+ jira_context: dict = {}
+ commit_count: int = 0
+ tenure_days: int = 365
+ recency_score: float = 0.0
+ required_reviewer: bool = False
+ raw_skills: list[str] = []
+ prelim_score: float = 0.0
+
+
+class ReviewerMatchRequest(BaseModel):
+ pr_data: dict
+ candidates: list[CandidateProfile]
+ options: Optional[RankingOptions] = None
+
+
+class OrchestratorUser(BaseModel):
+ github_username: str
+ jira_username: Optional[str] = None
+ jira_token: Optional[str] = None
+ jira_cloud_id: Optional[str] = None
+ jira_project_key: Optional[str] = None
+ raw_skills: list[str] = []
+
+
+class OrchestratorRequest(BaseRepoRequest):
+ users: list[OrchestratorUser]
+ commits_per_user: Optional[int] = 50
+ options: Optional[RankingOptions] = None
+
+
+class ReviewerResponse(BaseModel):
+ name: str
+ confidence_score: int
+ justification: str
diff --git a/codience/src/test_and_evaluate.py b/codience/src/test_and_evaluate.py
new file mode 100644
index 00000000..67b6f3c2
--- /dev/null
+++ b/codience/src/test_and_evaluate.py
@@ -0,0 +1,211 @@
+import unittest
+from unittest.mock import patch, MagicMock
+from fastapi.testclient import TestClient
+import numpy as np
+import sys
+
+# Import the FastAPI application
+from codience.src.app import app
+from codience.src.models import RecommendReviewersRequest
+
+client = TestClient(app)
+
+# =====================================================================
+# PART 1: API UNIT TESTS (FastAPI TestClient)
+# =====================================================================
+
+class TestEvaluatorAgent(unittest.TestCase):
+ @patch("codience.src.Reviewer_Recommender.PRNew.evaluator_agent.generate_with_resilience")
+ def test_evaluate_recommendations_accepted(self, mock_generate):
+ from codience.src.Reviewer_Recommender.PRNew.evaluator_agent import evaluate_recommendations
+
+ # Mock LLM returning an accepted response
+ mock_generate.return_value = {
+ "ok": True,
+ "text": '{"accepted": true, "feedback": "Looks good"}'
+ }
+
+ pr_data = {"title": "Add eval layer", "description": "Adds LLM judge"}
+ recommendations = [{"name": "MalakHisham121", "confidence_score": 90}]
+
+ result = evaluate_recommendations(pr_data, recommendations)
+ self.assertTrue(result["accepted"])
+ self.assertEqual(result["feedback"], "Looks good")
+
+ @patch("codience.src.Reviewer_Recommender.PRNew.evaluator_agent.generate_with_resilience")
+ def test_evaluate_recommendations_rejected(self, mock_generate):
+ from codience.src.Reviewer_Recommender.PRNew.evaluator_agent import evaluate_recommendations
+
+ # Mock LLM returning a rejected response with feedback
+ mock_generate.return_value = {
+ "ok": True,
+ "text": '{"accepted": false, "feedback": "Candidate lacks Python skills."}'
+ }
+
+ pr_data = {"title": "Python script", "description": "Needs Python"}
+ recommendations = [{"name": "JavaDev", "confidence_score": 80}]
+
+ result = evaluate_recommendations(pr_data, recommendations)
+ self.assertFalse(result["accepted"])
+ self.assertEqual(result["feedback"], "Candidate lacks Python skills.")
+
+
+class TestReviewerRecommenderAPI(unittest.TestCase):
+
+ @patch("codience.src.helpers.fetch_real_pr_data")
+ @patch("codience.src.helpers.ReviewerRecommender")
+ def test_recommend_reviewers_endpoint(self, mock_recommender_class, mock_fetch_pr):
+ """Test the main /api/recommend-reviewers endpoint with mocked dependencies."""
+ # 1. Mock PR Data
+ mock_fetch_pr.return_value = {
+ "title": "Fix memory leak in trainer",
+ "description": "Optimized memory footprint",
+ "files": [{"filename": "src/trainer.py", "patch": "+ def optimize(): pass"}]
+ }
+
+ # 2. Mock Recommender Engine behavior
+ mock_engine = MagicMock()
+ mock_recommender_class.return_value = mock_engine
+ mock_engine.recommend_v2.return_value = {
+ "recommended_reviewers": [
+ {"name": "ArthurZucker", "confidence_score": 90, "justification": "Expert in training loop"},
+ {"name": "younesbelkada", "confidence_score": 75, "justification": "Maintained trainer.py recently"}
+ ]
+ }
+
+ # 3. Call endpoint
+ payload = {
+ "owner": "huggingface",
+ "repo": "transformers",
+ "pr_number": 42,
+ "required_reviewers": [],
+ "options": {"top_k": 5}
+ }
+ response = client.post("/api/recommend-reviewers", json=payload)
+
+ # 4. Assertions
+ self.assertEqual(response.status_code, 200)
+ json_data = response.json()
+ self.assertIn("recommended_reviewers", json_data)
+ self.assertEqual(len(json_data["recommended_reviewers"]), 2)
+ self.assertEqual(json_data["recommended_reviewers"][0]["name"], "ArthurZucker")
+ self.assertEqual(json_data["recommended_reviewers"][0]["confidence_score"], 90)
+
+ @patch("codience.src.app.analyze_jira_tickets")
+ def test_analyze_jira_tickets_endpoint(self, mock_analyze):
+ """Test the JIRA tickets analysis API endpoint."""
+ mock_analyze.return_value = {"skills": ["Authentication", "FastAPI"], "resolved_count": 5}
+
+ payload = {
+ "username": "dev_user",
+ "tickets": [{"key": "PROJ-101", "summary": "Add OAuth2 integration"}]
+ }
+ response = client.post("/api/analyze/jira-tickets", json=payload)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["resolved_count"], 5)
+
+
+# =====================================================================
+# PART 2: RECOMMENDATION SYSTEM EVALUATION (Metrics)
+# =====================================================================
+
+def evaluate_recommendations(predictions: list[list[str]], ground_truth: list[list[str]], k_values=[1, 3, 5]):
+ """
+ Evaluates recommendations using standard IR metrics:
+ - Hit Rate@k (HR@k)
+ - Mean Reciprocal Rank (MRR)
+ - Precision@k
+ - Recall@k
+ """
+ results = {}
+ n = len(predictions)
+ if n == 0:
+ return results
+
+ # 1. Mean Reciprocal Rank (MRR)
+ rr_list = []
+ for pred, gt in zip(predictions, ground_truth):
+ gt_set = set(gt)
+ rank = -1
+ for i, p in enumerate(pred):
+ if p in gt_set:
+ rank = i + 1
+ break
+ rr = 1.0 / rank if rank > 0 else 0.0
+ rr_list.append(rr)
+ results["MRR"] = float(np.mean(rr_list))
+
+ # 2. Hit Rate, Precision, Recall at different K values
+ for k in k_values:
+ hits = 0
+ precision_list = []
+ recall_list = []
+
+ for pred, gt in zip(predictions, ground_truth):
+ pred_k = pred[:k]
+ gt_set = set(gt)
+
+ # Intersection of recommended and actual reviewers
+ correct_recs = [p for p in pred_k if p in gt_set]
+
+ # Hit Rate: at least one correct recommendation
+ if len(correct_recs) > 0:
+ hits += 1
+
+ # Precision@k = correct / recommended
+ precision = len(correct_recs) / k
+ precision_list.append(precision)
+
+ # Recall@k = correct / total actual
+ recall = len(correct_recs) / len(gt_set) if len(gt_set) > 0 else 0.0
+ recall_list.append(recall)
+
+ results[f"HitRate@{k}"] = hits / n
+ results[f"Precision@{k}"] = float(np.mean(precision_list))
+ results[f"Recall@{k}"] = float(np.mean(recall_list))
+
+ return results
+
+
+def run_model_evaluation_demo():
+ """
+ Runs a simulation/evaluation matching a ground-truth dataset
+ representing real-world pull requests.
+ """
+ print("\n" + "="*60)
+ # Ground truth: Who actually reviewed these PRs in reality
+ ground_truth = [
+ ["ArthurZucker"],
+ ["younesbelkada", "ArthurZucker"],
+ ["younesbelkada"],
+ ["ArthurZucker"],
+ ["dev_c", "younesbelkada"]
+ ]
+
+ # Predictions made by the Recommender (ordered by confidence)
+ predictions = [
+ ["ArthurZucker", "younesbelkada", "dev_c"], # PR 1
+ ["ArthurZucker", "dev_c", "younesbelkada"], # PR 2
+ ["dev_c", "younesbelkada", "ArthurZucker"], # PR 3
+ ["younesbelkada", "dev_c", "ArthurZucker"], # PR 4
+ ["dev_c", "younesbelkada", "ArthurZucker"] # PR 5
+ ]
+
+ print("📊 Evaluating Recommender System Metrics...")
+ metrics = evaluate_recommendations(predictions, ground_truth, k_values=[1, 2, 3])
+
+ print("-" * 60)
+ for metric, score in metrics.items():
+ print(f"🔹 {metric:<15} : {score * 100:.2f}%" if "MRR" not in metric else f"🔹 {metric:<15} : {score:.4f}")
+ print("="*60 + "\n")
+
+
+if __name__ == "__main__":
+ print("🧪 Running API Unit Tests...")
+ # Run unittest suite programmatically
+ suite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+ unittest.TextTestRunner(verbosity=2).run(suite)
+
+ # Run evaluation metrics demonstration
+ run_model_evaluation_demo()