""" team_context.py — Turn a GitHub repo URL into TWO artifacts in a single Gemini call: team_context — Corpus-format prompt (bullets, [Category] tags, iteration_chain_keys) that matches the distribution v1.4 was trained on. Used at scoring time. domain_summary — 2-4 natural-language sentences describing the team's domain and technical focus. Used as the embedding query for candidate pre-filtering. The two artifacts serve different downstream tasks: - Scoring wants the exact training distribution. - Embedding retrieval wants a clean topical summary that aligns with arxiv abstracts (which are also natural-language descriptions of research topics, not bullet-formatted commit logs). Using the corpus-format prompt for embedding retrieval was an early architectural shortcut. The "Recently shipped by the team", dates, brackets, and trailing "Generate a paper suggestion" line are pure noise for cosine similarity — they pull the query vector toward generic "experiment-history" similarity instead of the team's actual domain. """ from __future__ import annotations import json import os import re import urllib.parse import urllib.request from dataclasses import dataclass from datetime import datetime from typing import Any import llm_api # Separator that splits the two sections in Gemini's response. Chosen to # be unique and unlikely to appear in any reasonable model output. _SECTION_SEP = "---DOMAIN-SUMMARY---" @dataclass class TeamContextResult: team_context: str # corpus-format prompt — fed to v1.4 LoRA at scoring domain_summary: str # natural-language topical summary — embedded for retrieval def is_stub(self) -> bool: """True when we fell back to description-only (no real commits).""" return "limited history available" in self.team_context GH_API = "https://api.github.com" # Few-shot example in the training distribution — used to coach Gemini # into the exact prompt format the model was trained on. Without this, # Gemini's free-form summary may drift in style and hurt scoring # calibration. FEW_SHOT_EXAMPLE = """\ Recently shipped by amrit110/oli (sample of their experiment history): - 2025-04-15 — Initial implementation with multi-model support [Research; iteration_chain_key: anthropic_model_version, iter #1] Initial version of 'oli', a terminal-based AI coding assistant with a Rust backend and React/Ink frontend. Establishes support for multiple model providers including Anthropic (Claude 3.7 Sonnet), OpenAI (GPT-4o), Google (Gemini), and local models via Ollama, as described in the README. - 2025-05-13 — Refactor agent executor [General] Refactors the core agent executor for improved logic, clarity, and testability. This change enhances the maintainability of the agent's decision-making loop.""" def parse_repo_url(url: str) -> tuple[str, str] | None: """Return (owner, name) from a GitHub URL or repo slug. Accepts (whitespace tolerated): https://github.com/owner/repo http://github.com/owner/repo github.com/owner/repo www.github.com/owner/repo git@github.com:owner/repo.git (SSH URL) owner/repo (bare slug) https://github.com/owner/repo/tree/main (any subpath gets ignored) Any of the above with .git suffix or trailing / """ s = (url or "").strip() if not s: return None # SSH URL: git@github.com:owner/repo.git m = re.match(r"^git@github\.com:([\w.-]+)/([\w.-]+?)(?:\.git)?/?$", s) if m: return m.group(1), m.group(2) # Strip optional scheme + www + github.com prefix s = re.sub(r"^(https?://)?(www\.)?github\.com/", "", s) # Take the first two path segments (so /tree/main, /pull/123, etc. # at the end of a URL paste don't break us). Strip .git suffix. m = re.match(r"^([\w.-]+)/([\w.-]+?)(?:\.git)?(?:/.*)?$", s) return (m.group(1), m.group(2)) if m else None def _gh_get(path: str) -> Any: """Unauthenticated GitHub API GET. 60 req/hr/IP — caching at request level keeps us safely within that for demo traffic.""" req = urllib.request.Request( f"{GH_API}{path}", headers={"User-Agent": "mhpd-paper-recommender", "Accept": "application/vnd.github+json"}, ) with urllib.request.urlopen(req, timeout=10) as r: return json.loads(r.read()) def fetch_github_commits(owner: str, name: str, n: int = 100) -> list[dict]: """Pull the latest N commits. Each result includes title, body, date, and author. Pre-filters out obvious dependabot/renovate/CI noise before handing to Gemini (cheaper than letting Gemini filter the full list). Default n=100 (up from 30) because some repos have long runs of dependabot/renovate commits — fetching too few leaves us with zero meaningful commits after filtering. 100 is a reasonable upper bound that still fits in one paginated API call.""" raw = _gh_get(f"/repos/{owner}/{name}/commits?per_page={n}") if not isinstance(raw, list): return [] out: list[dict] = [] skip_authors = {"dependabot[bot]", "renovate[bot]", "github-actions[bot]"} skip_title_prefixes = ("Bump ", "Update ", "chore(deps)", "chore: bump") for c in raw: commit = c.get("commit") or {} msg = commit.get("message") or "" title = msg.split("\n", 1)[0] body = msg.split("\n", 1)[1].strip() if "\n" in msg else "" # Drop bot commits author_user = (c.get("author") or {}).get("login") or "" if author_user in skip_authors: continue # Drop obvious dependency-bump titles if any(title.startswith(p) for p in skip_title_prefixes): continue # Drop merge commits (they aggregate; the actual feature commits # contain the real signal) if title.lower().startswith("merge "): continue out.append({ "date": (commit.get("author") or {}).get("date", "")[:10], "title": title, "body": body[:600], # cap to keep Gemini prompt small "author": author_user or (commit.get("author") or {}).get("name", "unknown"), }) return out def fetch_repo_meta(owner: str, name: str) -> dict: """Pull description + topics + stars — useful for Gemini context.""" try: d = _gh_get(f"/repos/{owner}/{name}") return { "description": d.get("description") or "", "topics": d.get("topics") or [], "stars": d.get("stargazers_count", 0), "language": d.get("language") or "", } except Exception: return {"description": "", "topics": [], "stars": 0, "language": ""} def extract_team_context( owner: str, name: str, commits: list[dict], meta: dict, n_events: int = 4 ) -> TeamContextResult: """Hand the filtered commits to Gemini Flash and ask for BOTH the corpus-style team-context AND a clean domain summary in one call. Returns TeamContextResult with both artifacts populated, even in the stub/heuristic fallback paths (with degraded quality).""" if not commits: return _stub_result(owner, name, meta) if not os.environ.get("GEMINI_API_KEY"): return _heuristic_result(owner, name, commits[:n_events], meta) sample_lines = [] for c in commits[:12]: # give Gemini more context than it needs sample_lines.append(f"- {c['date']} — {c['title']}") if c["body"]: sample_lines.append(f" {c['body'][:400]}") raw_history = "\n".join(sample_lines) instruction = f"""\ You are formatting a research team's GitHub commit history for a paper-recommendation pipeline. Produce TWO sections, separated by the literal marker line `{_SECTION_SEP}`. SECTION 1 — Experiment history (for the scoring model): A structured prompt that exactly matches the EXAMPLE FORMAT below. Pick the {n_events} most meaningful commits (skip dependency bumps, version bumps, CI changes, formatting). For each chosen commit, write one tight paragraph (1-3 sentences) describing the experiment in research terms. Use one of these category tags: [Research], [General], [Evaluation], [Bugfix], [Model Finetune], [Data Curation], [Refactor]. If a commit looks like part of an ongoing initiative (multiple commits on the same theme), include `[Category; iteration_chain_key: short_chain_name, iter #N]` to mark the chain — otherwise use the bare `[Category]` form. End SECTION 1 with a single sentence that prompts paper suggestion. SECTION 2 — Domain summary (for embedding-based retrieval): 2-4 sentences of clean natural language describing what the team works on, their technical focus areas, and the kinds of research papers they'd benefit from reading. NO bullets, NO bracketed tags, NO dates, NO meta-commentary about the format. Imagine you're writing an abstract that a search engine would index — pure topical content. The text should resemble the style and vocabulary of arxiv abstracts. Repository: {owner}/{name} Description: {meta.get('description') or '(none)'} Topics: {", ".join(meta.get('topics') or []) or '(none)'} Primary language: {meta.get('language') or '(unknown)'} Raw commit history (most recent first): {raw_history} EXAMPLE FORMAT FOR SECTION 1 (do not copy content, only structure): {FEW_SHOT_EXAMPLE} Now output BOTH sections for {owner}/{name}. Begin with SECTION 1, then the separator on its own line, then SECTION 2:""" try: raw = llm_api.gemini_call(instruction, max_output_tokens=1200, temperature=0.2) except Exception as e: print(f"[team_context] Gemini call failed ({e}), falling back to heuristic.") return _heuristic_result(owner, name, commits[:n_events], meta) parts = raw.split(_SECTION_SEP, 1) if len(parts) != 2: # Gemini didn't follow the format. Use the full output as the # team_context and derive a domain summary from description + topics. print(f"[team_context] Section separator not found; using fallback domain summary.") return TeamContextResult( team_context=_strip_section_header(raw.strip()), domain_summary=_domain_summary_from_meta(owner, name, meta), ) return TeamContextResult( team_context=_strip_section_header(parts[0].strip()), domain_summary=_strip_section_header(parts[1].strip()), ) def _strip_section_header(text: str) -> str: """Gemini sometimes echoes the section header ("SECTION 1 — ...") at the top of each section. Strip it so downstream consumers see only the actual content. Conservative — only matches the literal patterns we used in the prompt.""" lines = text.split("\n") while lines and ( lines[0].startswith("SECTION 1") or lines[0].startswith("SECTION 2") or not lines[0].strip() ): lines.pop(0) return "\n".join(lines).strip() def _domain_summary_from_meta(owner: str, name: str, meta: dict) -> str: """Build a topical summary from repo description + topics. Used when Gemini is unavailable or doesn't produce the structured output.""" desc = (meta.get("description") or "").strip() topics = meta.get("topics") or [] language = meta.get("language") or "" bits = [] if desc: bits.append(desc) if topics: bits.append(f"The project focuses on {', '.join(topics)}.") if language and not any(language.lower() in b.lower() for b in bits): bits.append(f"Primarily written in {language}.") if not bits: return f"{owner}/{name} is a software project without published metadata." return " ".join(bits) def _heuristic_result(owner: str, name: str, commits: list[dict], meta: dict) -> TeamContextResult: """No-Gemini fallback: rule-based team_context + meta-derived summary.""" return TeamContextResult( team_context=_heuristic_team_context(owner, name, commits), domain_summary=_domain_summary_from_meta(owner, name, meta), ) def _stub_result(owner: str, name: str, meta: dict) -> TeamContextResult: """Fallback for repos with no usable commit history.""" return TeamContextResult( team_context=_stub_team_context(owner, name, meta), domain_summary=_domain_summary_from_meta(owner, name, meta), ) def _heuristic_team_context(owner: str, name: str, commits: list[dict]) -> str: """No-LLM fallback. Formats raw commits into the corpus structure with category guessed from keywords. Worse quality than Gemini — used only when GEMINI_API_KEY is unset or the Gemini call fails.""" lines = [f"Recently shipped by {owner}/{name} (sample of their experiment history):"] for c in commits: title_lower = c["title"].lower() if any(k in title_lower for k in ("research", "new model", "experiment", "introduce")): cat = "[Research]" elif any(k in title_lower for k in ("bug", "fix")): cat = "[Bugfix]" elif any(k in title_lower for k in ("benchmark", "eval")): cat = "[Evaluation]" elif any(k in title_lower for k in ("refactor",)): cat = "[Refactor]" else: cat = "[General]" lines.append(f"- {c['date']} — {c['title']} {cat}") if c["body"]: summary = c["body"].split("\n", 1)[0][:250] lines.append(f" {summary}") lines.append("") lines.append(f"Generate a paper suggestion for the {owner}/{name} team's reading list.") return "\n".join(lines) def _stub_team_context(owner: str, name: str, meta: dict) -> str: """Minimal context when commit history is unavailable (private repo, empty repo, rate-limited). The model will still produce a recommendation based on description + topics alone, but with much weaker calibration.""" desc = meta.get("description") or "(no description)" topics = ", ".join(meta.get("topics") or []) or "(no topics)" return ( f"Recently shipped by {owner}/{name} (limited history available):\n" f"- Repository description: {desc}\n" f"- Topics: {topics}\n\n" f"Generate a paper suggestion for the {owner}/{name} team's reading list." ) def build_team_context(repo_url: str, n_events: int = 4) -> TeamContextResult: """End-to-end: URL → TeamContextResult(team_context, domain_summary). The orchestrator uses `.team_context` as the scoring-time prompt and `.domain_summary` as the embedding query for candidate retrieval.""" parsed = parse_repo_url(repo_url) if not parsed: raise ValueError(f"Not a github.com repo URL: {repo_url!r}") owner, name = parsed commits = fetch_github_commits(owner, name, n=100) meta = fetch_repo_meta(owner, name) return extract_team_context(owner, name, commits, meta, n_events=n_events)