pkheria commited on
Commit
cfdaeb1
Β·
1 Parent(s): ee5892f

backend done

Browse files
Files changed (2) hide show
  1. .DS_Store +0 -0
  2. app.py +241 -88
.DS_Store CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
 
app.py CHANGED
@@ -3,21 +3,27 @@ CommitLens β€” gradio.Server mode
3
  ================================
4
  - Serves custom index.html at GET /
5
  - Exposes process_repo via @app.api() for the JS frontend to call
6
- - Uses Mellum 2 for per-file summaries + final markdown report
 
 
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import logging
 
 
12
  import sys
13
  from pathlib import Path
14
 
 
15
  import torch
16
  from fastapi.responses import HTMLResponse
17
  from gradio import Server
 
18
  from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
19
 
20
- import spaces
21
  from commitlens import run_pipeline
22
 
23
  logging.basicConfig(
@@ -28,93 +34,233 @@ logging.basicConfig(
28
  log = logging.getLogger("commitlens")
29
 
30
  # ---------------------------------------------------------------------------
31
- # Model config
32
  # ---------------------------------------------------------------------------
33
 
34
- MODEL_REPO_ID = "JetBrains/Mellum2-12B-A2.5B-Thinking"
 
 
35
 
36
- SUMMARY_SYSTEM_PROMPT = (
37
- "You are an expert code reviewer. Analyze the following commit change context "
38
- "and provide a concise, clear summary of what changed in this file, why it might "
39
- "have changed, and any notable patterns or potential issues."
40
- )
41
 
42
- FINAL_SYSTEM_PROMPT = (
43
- "You are a technical documentation expert. Given the following per-file summaries "
44
- "of code changes from a commit, produce a well-formatted markdown document that "
45
- "provides a comprehensive overview of the commit. Include sections for:\n"
46
- "- Commit Overview\n"
47
- "- Summary of Changes (per file)\n"
48
- "- Key Takeaways / Impact\n\n"
49
- "Use clear markdown formatting (headings, code blocks, bullet lists)."
50
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
  # ---------------------------------------------------------------------------
53
- # Helpers
54
  # ---------------------------------------------------------------------------
55
 
56
- _model = None
57
- _tokenizer = None
58
 
59
 
60
- def _get_llm():
61
- global _model, _tokenizer
62
- if _model is None:
63
- log.info("Starting model load from %s ...", MODEL_REPO_ID)
64
- quantization_config = BitsAndBytesConfig(load_in_8bit=True)
65
-
66
- log.info("Loading tokenizer ...")
67
- _tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO_ID)
68
- log.info("Tokenizer loaded.")
69
-
70
- log.info("Loading model with 8-bit quantization ...")
71
- _model = AutoModelForCausalLM.from_pretrained(
72
- MODEL_REPO_ID,
73
- quantization_config=quantization_config,
74
- device_map="auto",
75
- torch_dtype=torch.bfloat16,
76
- )
77
- log.info("Model loaded successfully.")
78
- return _model, _tokenizer
79
 
80
 
81
  def _extract_filename(prompt: str) -> str:
82
  for line in prompt.splitlines():
83
  if line.startswith("Filename :"):
84
- name = line.split(":", 1)[1].strip()
85
- return name
86
  return "unknown"
87
 
88
 
89
- def _generate_response(system_prompt: str, user_prompt: str, max_tokens: int) -> str:
90
- model, tokenizer = _get_llm()
91
- messages = [
92
- {"role": "system", "content": system_prompt},
93
- {"role": "user", "content": user_prompt},
94
- ]
95
- formatted_prompt = tokenizer.apply_chat_template(
96
- messages, tokenize=False, add_generation_prompt=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  )
98
- inputs = tokenizer(formatted_prompt, return_tensors="pt").to("cuda")
99
- outputs = model.generate(
100
- **inputs,
101
- max_new_tokens=max_tokens,
102
- use_cache=True,
103
- do_sample=False,
104
- pad_token_id=tokenizer.eos_token_id,
105
  )
106
- response = tokenizer.decode(
107
- outputs[0][inputs.input_ids.shape[-1]:], skip_special_tokens=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  )
109
- return response.strip()
110
 
111
 
112
- def _summarize(prompt: str) -> str:
113
- return _generate_response(SUMMARY_SYSTEM_PROMPT, prompt, max_tokens=1024)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- def _final_md(combined: str) -> str:
117
- return _generate_response(FINAL_SYSTEM_PROMPT, combined, max_tokens=2048)
 
118
 
119
 
120
  # ---------------------------------------------------------------------------
@@ -126,48 +272,55 @@ app = Server()
126
 
127
  @app.get("/", response_class=HTMLResponse)
128
  async def homepage():
129
- """Serve the custom cinematic frontend."""
130
  html_path = Path(__file__).parent / "index.html"
131
  return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
132
 
133
 
134
  @app.api(name="process_repo")
135
- @spaces.GPU(duration=300)
136
  def process_repo(repo_url: str, token: str) -> dict:
137
  """
138
- Run the CommitLens pipeline and return structured results.
 
 
 
139
 
140
- Returns:
141
- {
142
- "files": [{"name": str, "summary": str}, ...],
143
- "report": str # final markdown
144
- }
145
  """
146
- log.info("Pipeline started for repo: %s", repo_url)
147
- prompts = run_pipeline(repo_url, token.strip() or None)
148
- log.info("CommitLens pipeline returned %d prompts.", len(prompts))
149
 
 
 
 
150
  if not prompts:
151
  raise ValueError("No source-code files changed in the latest commit.")
152
 
153
- file_results = []
154
- per_file_md_parts = []
155
 
156
- for i, prompt in enumerate(prompts):
157
- fname = _extract_filename(prompt)
158
- log.info("Processing file %d/%d: %s", i + 1, len(prompts), fname)
159
- summary = _summarize(prompt)
160
- file_results.append({"name": fname, "summary": summary})
161
- per_file_md_parts.append(f"## `{fname}`\n\n{summary}")
162
- log.info("Finished file %d/%d: %s", i + 1, len(prompts), fname)
 
 
163
 
164
- combined = "\n\n---\n\n".join(per_file_md_parts)
165
- final_report = _final_md(combined)
166
- log.info("Pipeline finished successfully.")
167
 
 
168
  return {"files": file_results, "report": final_report}
169
 
170
 
 
 
 
 
 
 
171
  if __name__ == "__main__":
172
- log.info("Starting CommitLens with gradio.Server ...")
173
  app.launch()
 
3
  ================================
4
  - Serves custom index.html at GET /
5
  - Exposes process_repo via @app.api() for the JS frontend to call
6
+ - Mellum 2 (6-bit, CPU-resident) handles per-file summaries via batched GPU inference
7
+ - Groq llama-70b handles the final report (fast, no GPU cost)
8
+ - <think>...</think> blocks stripped from all Mellum outputs
9
+ - Per-file output is tightly constrained to 3-5 bullet points max
10
  """
11
 
12
  from __future__ import annotations
13
 
14
  import logging
15
+ import os
16
+ import re
17
  import sys
18
  from pathlib import Path
19
 
20
+ import spaces
21
  import torch
22
  from fastapi.responses import HTMLResponse
23
  from gradio import Server
24
+ from groq import Groq
25
  from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
26
 
 
27
  from commitlens import run_pipeline
28
 
29
  logging.basicConfig(
 
34
  log = logging.getLogger("commitlens")
35
 
36
  # ---------------------------------------------------------------------------
37
+ # Config
38
  # ---------------------------------------------------------------------------
39
 
40
+ MODEL_REPO_ID = "JetBrains/Mellum2-12B-A2.5B-Thinking"
41
+ GROQ_MODEL = "llama-3.3-70b-versatile" # fast Groq-hosted 70B
42
+ BATCH_TOKEN_BUDGET = 7000 # estimated input tokens; above this β†’ sequential
43
 
44
+ # ---------------------------------------------------------------------------
45
+ # Prompts
46
+ # ---------------------------------------------------------------------------
 
 
47
 
48
+ # Tight, bullet-constrained prompt β†’ short output β†’ fewer tokens generated
49
+ SUMMARY_SYSTEM_PROMPT = """\
50
+ You are a senior code reviewer. Given a git diff for ONE file, output EXACTLY:
51
+ - 1 sentence: what changed (be specific, name functions/classes if relevant)
52
+ - 1 sentence: likely reason for the change
53
+ - Up to 3 bullet points of notable patterns, risks, or issues (skip if none)
54
+
55
+ Rules:
56
+ - Total response MUST be under 120 words
57
+ - No preamble, no "Sure!", no restating the filename
58
+ - No internal reasoning, no <think> blocks β€” final answer only
59
+ - Use plain text, no markdown headers
60
+ """
61
+
62
+ FINAL_SYSTEM_PROMPT = """\
63
+ You are a technical writer producing a commit review report.
64
+
65
+ Given per-file summaries, write a structured markdown report with these exact sections:
66
+
67
+ ## Commit Overview
68
+ One paragraph (3-5 sentences) summarising the overall intent of the commit.
69
+
70
+ ## Changes Per File
71
+ A sub-section per file (### `filename`) with 2-4 bullet points.
72
+
73
+ ## Key Takeaways
74
+ 3-5 bullets: cross-cutting concerns, risks, follow-up actions.
75
+
76
+ Rules:
77
+ - Total report MUST be under 400 words
78
+ - No filler phrases ("In conclusion", "It is worth noting")
79
+ - Output markdown only β€” no preamble, no explanation
80
+ """
81
 
82
  # ---------------------------------------------------------------------------
83
+ # Global model state β€” CPU-resident between requests
84
  # ---------------------------------------------------------------------------
85
 
86
+ _model: AutoModelForCausalLM | None = None
87
+ _tokenizer: AutoTokenizer | None = None
88
 
89
 
90
+ def _strip_thinking(text: str) -> str:
91
+ """Remove <think>...</think> blocks (multiline) produced by thinking models."""
92
+ return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
 
95
  def _extract_filename(prompt: str) -> str:
96
  for line in prompt.splitlines():
97
  if line.startswith("Filename :"):
98
+ return line.split(":", 1)[1].strip()
 
99
  return "unknown"
100
 
101
 
102
+ # ---------------------------------------------------------------------------
103
+ # Startup: load Mellum 2 in 6-bit NF4 into CPU RAM
104
+ # Runs ONCE before app.launch(), outside any @spaces.GPU context.
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def load_model_on_startup() -> None:
108
+ """
109
+ Load Mellum 2 into CPU RAM with 6-bit NF4 double quantization.
110
+ device_map='cpu' keeps weights off-GPU until a @spaces.GPU call fires,
111
+ satisfying ZeroGPU's requirement that GPU allocation only happens inside
112
+ decorated functions.
113
+ """
114
+ global _model, _tokenizer
115
+
116
+ log.info("=== STARTUP: loading tokenizer (%s) ===", MODEL_REPO_ID)
117
+ _tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO_ID)
118
+ if _tokenizer.pad_token_id is None:
119
+ _tokenizer.pad_token_id = _tokenizer.eos_token_id
120
+ log.info("Tokenizer ready. pad_token_id=%s", _tokenizer.pad_token_id)
121
+
122
+ log.info("=== STARTUP: loading model in 6-bit NF4 on CPU ===")
123
+ quant_cfg = BitsAndBytesConfig(
124
+ load_in_4bit=True,
125
+ bnb_4bit_use_double_quant=True, # NF4 + double quant β‰ˆ effective 6-bit
126
+ bnb_4bit_quant_type="nf4",
127
+ bnb_4bit_compute_dtype=torch.bfloat16,
128
  )
129
+ _model = AutoModelForCausalLM.from_pretrained(
130
+ MODEL_REPO_ID,
131
+ quantization_config=quant_cfg,
132
+ device_map="cpu",
133
+ torch_dtype=torch.bfloat16,
 
 
134
  )
135
+ _model.eval()
136
+ log.info("=== STARTUP: model ready on CPU ===")
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Mellum inference (called inside @spaces.GPU)
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def _build_mellum_prompt(user_content: str) -> str:
144
+ """Apply Mellum's chat template to a single user turn."""
145
+ return _tokenizer.apply_chat_template(
146
+ [
147
+ {"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
148
+ {"role": "user", "content": user_content},
149
+ ],
150
+ tokenize=False,
151
+ add_generation_prompt=True,
152
  )
 
153
 
154
 
155
+ def _generate_batch(prompts: list[str]) -> list[str]:
156
+ """
157
+ Single batched model.generate() call for all prompts.
158
+ Left-padding aligns sequences for parallel decode.
159
+ max_new_tokens is kept low (256) because SUMMARY_SYSTEM_PROMPT
160
+ instructs the model to stay under 120 words.
161
+ """
162
+ log.info("Batch inference: %d prompts", len(prompts))
163
+ _tokenizer.padding_side = "left"
164
+ enc = _tokenizer(
165
+ prompts,
166
+ return_tensors="pt",
167
+ padding=True,
168
+ truncation=True,
169
+ max_length=3072, # cap input so batch fits in VRAM
170
+ ).to("cuda")
171
+
172
+ log.info("Input shape: %s", enc.input_ids.shape)
173
+ with torch.no_grad():
174
+ out = _model.generate(
175
+ **enc,
176
+ max_new_tokens=512, # β‰ˆ 200 words β€” enough for our tight prompt
177
+ use_cache=True,
178
+ do_sample=False,
179
+ pad_token_id=_tokenizer.pad_token_id,
180
+ )
181
+
182
+ results = []
183
+ for seq in out:
184
+ new_tok = seq[enc.input_ids.shape[1]:]
185
+ text = _tokenizer.decode(new_tok, skip_special_tokens=True)
186
+ results.append(_strip_thinking(text))
187
+ return results
188
+
189
+
190
+ def _generate_sequential(prompts: list[str]) -> list[str]:
191
+ """Fallback single-prompt inference when batch would OOM."""
192
+ log.info("Sequential inference: %d prompts", len(prompts))
193
+ _tokenizer.padding_side = "right"
194
+ results = []
195
+ for i, prompt in enumerate(prompts):
196
+ log.info(" [%d/%d]", i + 1, len(prompts))
197
+ enc = _tokenizer(prompt, return_tensors="pt").to("cuda")
198
+ with torch.no_grad():
199
+ out = _model.generate(
200
+ **enc,
201
+ max_new_tokens=256,
202
+ use_cache=True,
203
+ do_sample=False,
204
+ pad_token_id=_tokenizer.pad_token_id,
205
+ )
206
+ text = _tokenizer.decode(out[0][enc.input_ids.shape[1]:], skip_special_tokens=True)
207
+ results.append(_strip_thinking(text))
208
+ return results
209
+
210
+
211
+ def _smart_generate(prompts: list[str]) -> list[str]:
212
+ """
213
+ Route to batch or sequential based on estimated token count.
214
+ Catches OOM and retries sequentially.
215
+ """
216
+ estimated_tokens = sum(len(p) for p in prompts) // 4
217
+ use_sequential = (len(prompts) == 1) or (estimated_tokens > BATCH_TOKEN_BUDGET)
218
+
219
+ if use_sequential:
220
+ log.info("Routing to sequential (est. %d tokens)", estimated_tokens)
221
+ return _generate_sequential(prompts)
222
+
223
+ try:
224
+ return _generate_batch(prompts)
225
+ except torch.cuda.OutOfMemoryError:
226
+ log.warning("Batch OOM β€” retrying sequentially")
227
+ torch.cuda.empty_cache()
228
+ return _generate_sequential(prompts)
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Groq final report (pure API call β€” no GPU needed)
233
+ # ---------------------------------------------------------------------------
234
+
235
+ def _generate_final_report_groq(per_file_summaries: list[dict]) -> str:
236
+ """
237
+ Send all per-file summaries to Groq llama-3.3-70b and get back
238
+ a structured markdown commit report. Fast (~2-4 s) and free of GPU cost.
239
+
240
+ Reads GROQ_API_KEY from environment (set as a HF Space secret).
241
+ """
242
+ groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])
243
+
244
+ # Format per-file summaries as a clean user message
245
+ user_content = "\n\n".join(
246
+ f"### `{f['name']}`\n{f['summary']}"
247
+ for f in per_file_summaries
248
+ )
249
 
250
+ log.info("Calling Groq %s for final report (%d files) ...", GROQ_MODEL, len(per_file_summaries))
251
+ response = groq_client.chat.completions.create(
252
+ model=GROQ_MODEL,
253
+ messages=[
254
+ {"role": "system", "content": FINAL_SYSTEM_PROMPT},
255
+ {"role": "user", "content": user_content},
256
+ ],
257
+ max_tokens=600, # 400-word cap + small buffer
258
+ temperature=0.2, # low temp for consistent, factual output
259
+ )
260
 
261
+ report = response.choices[0].message.content.strip()
262
+ log.info("Groq report received (%d chars)", len(report))
263
+ return report
264
 
265
 
266
  # ---------------------------------------------------------------------------
 
272
 
273
  @app.get("/", response_class=HTMLResponse)
274
  async def homepage():
 
275
  html_path = Path(__file__).parent / "index.html"
276
  return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
277
 
278
 
279
  @app.api(name="process_repo")
280
+ @spaces.GPU(duration=240) # reduced from 300 β€” summaries are now much shorter
281
  def process_repo(repo_url: str, token: str) -> dict:
282
  """
283
+ Full pipeline:
284
+ 1. run_pipeline() β†’ raw per-file prompts (CPU, fast)
285
+ 2. Mellum 2 batch β†’ per-file summaries (≀120 words) (GPU, batched)
286
+ 3. Groq 70B β†’ final markdown report (≀400 words) (API, ~3 s)
287
 
288
+ Returns: { "files": [{"name": str, "summary": str}], "report": str }
 
 
 
 
289
  """
290
+ log.info("=== process_repo: %s ===", repo_url)
 
 
291
 
292
+ # Step 1 β€” fetch diff and build prompts
293
+ prompts = run_pipeline(repo_url, token.strip() or None)
294
+ log.info("Got %d file prompts from pipeline", len(prompts))
295
  if not prompts:
296
  raise ValueError("No source-code files changed in the latest commit.")
297
 
298
+ fnames = [_extract_filename(p) for p in prompts]
 
299
 
300
+ # Step 2 β€” per-file summaries via Mellum 2 on GPU
301
+ mellum_prompts = [_build_mellum_prompt(p) for p in prompts]
302
+ summaries = _smart_generate(mellum_prompts)
303
+
304
+ file_results = [
305
+ {"name": n, "summary": s}
306
+ for n, s in zip(fnames, summaries)
307
+ ]
308
+ log.info("Per-file summaries done")
309
 
310
+ # Step 3 β€” final report via Groq (outside GPU, but still inside @spaces.GPU
311
+ # context β€” that's fine, the Groq call is pure HTTP and doesn't touch CUDA)
312
+ final_report = _generate_final_report_groq(file_results)
313
 
314
+ log.info("Pipeline complete β€” %d files", len(file_results))
315
  return {"files": file_results, "report": final_report}
316
 
317
 
318
+ # ---------------------------------------------------------------------------
319
+ # Boot
320
+ # ---------------------------------------------------------------------------
321
+
322
+ load_model_on_startup() # weights land in CPU RAM; GPU untouched until first request
323
+
324
  if __name__ == "__main__":
325
+ log.info("Starting CommitLens ...")
326
  app.launch()