Upload between-the-lines app

#1
Files changed (6) hide show
  1. README.md +33 -9
  2. app.py +394 -0
  3. btl/__init__.py +1 -0
  4. btl/model.py +158 -0
  5. btl/prompts.py +26 -0
  6. requirements.txt +11 -0
README.md CHANGED
@@ -1,15 +1,39 @@
1
  ---
2
- title: Between The Lines
3
- emoji: 👁
4
- colorFrom: purple
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: mit
12
- short_description: TBD
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: between-the-lines
3
+ emoji: 🟩
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: gradio
7
+ sdk_version: 5.50.0
 
8
  app_file: app.py
9
  pinned: false
10
+ tags:
11
+ - build-small-hackathon
12
+ - backyard-ai
13
+ - tiny-titan
14
+ - well-tuned
15
+ - llama-cpp
16
+ - code
17
  ---
18
 
19
+ # between-the-lines
20
+
21
+ A small-model code-reading assistant for Python files. It parses a single file deterministically, asks Mellum2 to add explanatory comments, and validates that the annotated output still parses to the same executable AST shape.
22
+
23
+ The app includes two modes:
24
+
25
+ - **Base Mellum2:** richer comments from the base instruct model through llama.cpp.
26
+ - **Fine-tuned LoRA:** a concise comment style trained on CodeSearchNet-derived Python examples with Modal.
27
+
28
+ The model never edits code directly. It only proposes comments, and the app rejects any annotated file whose semantic AST changes.
29
+
30
+ ## Hackathon Fit
31
+
32
+ - **Track:** Backyard AI
33
+ - **Interface:** Gradio Space
34
+ - **Model:** JetBrains Mellum2 12B-A2.5B Instruct, with a LoRA adapter for concise Python comments.
35
+ - **Training:** LoRA SFT on Modal using 10,000 train rows and 500 eval rows.
36
+ - **Correctness story:** the model proposes comments only; Python AST parsing and validation guard against semantic edits.
37
+ - **Deployment:** local/Space-hosted model inference, no external model API.
38
+
39
+ Demo video and social post links will be added before submission.
app.py ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import html
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import gradio as gr
8
+
9
+ from btl.model import ModelUnavailableError, generate_comment, generate_comment_with_llm, load_llm
10
+
11
+
12
+ CSS = """
13
+ :root {
14
+ --btl-bg: #f3f8f0;
15
+ --btl-panel: #ffffff;
16
+ --btl-ink: #111812;
17
+ --btl-muted: #5b6a5f;
18
+ --btl-line: #d8e4d6;
19
+ --btl-green: #1f6f43;
20
+ --btl-green-dark: #0d2818;
21
+ --btl-green-soft: #e3f1df;
22
+ --btl-code-bg: #07110b;
23
+ }
24
+
25
+ .gradio-container {
26
+ background:
27
+ radial-gradient(circle at 18% 0%, rgba(31, 111, 67, 0.16), transparent 28rem),
28
+ linear-gradient(135deg, rgba(7, 17, 11, 0.08), transparent 24rem),
29
+ linear-gradient(180deg, rgba(13, 40, 24, 0.08), transparent 260px),
30
+ var(--btl-bg) !important;
31
+ color: var(--btl-ink);
32
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
33
+ }
34
+
35
+ #btl-shell {
36
+ max-width: 1280px;
37
+ margin: 0 auto;
38
+ }
39
+
40
+ #btl-title {
41
+ padding: 22px 0 10px;
42
+ }
43
+
44
+ #btl-title h1 {
45
+ color: var(--btl-green-dark);
46
+ font-size: clamp(2rem, 5vw, 4.75rem);
47
+ line-height: 0.96;
48
+ letter-spacing: 0;
49
+ margin: 0;
50
+ }
51
+
52
+ #btl-title p {
53
+ color: var(--btl-muted);
54
+ font-size: 1.02rem;
55
+ max-width: 780px;
56
+ margin: 14px 0 0;
57
+ }
58
+
59
+ .btl-upload .wrap {
60
+ background: rgba(255, 255, 255, 0.76) !important;
61
+ }
62
+
63
+ #btl-controls {
64
+ align-items: end;
65
+ margin-bottom: 12px;
66
+ }
67
+
68
+ #btl-controls .form,
69
+ #btl-controls .block {
70
+ min-height: 72px !important;
71
+ }
72
+
73
+ #model_choice label,
74
+ #input_code label,
75
+ #output_code label,
76
+ #summary_box label,
77
+ #status_box label {
78
+ color: var(--btl-green-dark) !important;
79
+ font-weight: 700 !important;
80
+ }
81
+
82
+ .btl-card,
83
+ .form,
84
+ .block {
85
+ border-color: var(--btl-line) !important;
86
+ border-radius: 8px !important;
87
+ box-shadow: 0 10px 35px rgba(7, 17, 11, 0.05) !important;
88
+ }
89
+
90
+ button.primary,
91
+ .primary > button {
92
+ background: var(--btl-green-dark) !important;
93
+ border-color: var(--btl-green-dark) !important;
94
+ color: #f8fff8 !important;
95
+ border-radius: 8px !important;
96
+ }
97
+
98
+ button.secondary,
99
+ .secondary > button {
100
+ border-radius: 8px !important;
101
+ }
102
+
103
+ textarea,
104
+ pre,
105
+ code {
106
+ font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace !important;
107
+ }
108
+
109
+ #summary_box textarea {
110
+ background: #f7fbf4 !important;
111
+ color: var(--btl-green-dark) !important;
112
+ text-align: center !important;
113
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
114
+ font-weight: 650 !important;
115
+ line-height: 1.55 !important;
116
+ }
117
+
118
+ #status_box textarea {
119
+ background: #123923 !important;
120
+ color: #e7f8e8 !important;
121
+ border-color: #123923 !important;
122
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
123
+ line-height: 1.45 !important;
124
+ }
125
+
126
+ #input_code textarea,
127
+ #output_code textarea {
128
+ min-height: 520px !important;
129
+ line-height: 1.45 !important;
130
+ }
131
+
132
+ #input_code .cm-editor,
133
+ #output_code .cm-editor {
134
+ min-height: 520px !important;
135
+ }
136
+
137
+ #input_code .cm-scroller,
138
+ #output_code .cm-scroller {
139
+ min-height: 520px !important;
140
+ }
141
+
142
+ #output_code textarea {
143
+ background: var(--btl-code-bg) !important;
144
+ color: #dbf8df !important;
145
+ }
146
+
147
+ #output_code .cm-editor {
148
+ background: var(--btl-code-bg) !important;
149
+ }
150
+
151
+ #output_code .cm-content,
152
+ #output_code .cm-gutters {
153
+ background: var(--btl-code-bg) !important;
154
+ color: #dbf8df !important;
155
+ }
156
+ """
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class BlockInfo:
161
+ kind: str
162
+ name: str
163
+ lineno: int
164
+ source: str
165
+
166
+
167
+ def _parse_python(source: str) -> ast.Module:
168
+ return ast.parse(source)
169
+
170
+
171
+ def _strip_docstrings(node: ast.AST) -> ast.AST:
172
+ copied = ast.fix_missing_locations(ast.parse(ast.unparse(node)))
173
+ for child in ast.walk(copied):
174
+ if isinstance(child, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
175
+ if (
176
+ child.body
177
+ and isinstance(child.body[0], ast.Expr)
178
+ and isinstance(child.body[0].value, ast.Constant)
179
+ and isinstance(child.body[0].value.value, str)
180
+ ):
181
+ child.body = child.body[1:]
182
+ return copied
183
+
184
+
185
+ def _semantic_ast_dump(source: str) -> str:
186
+ tree = _parse_python(source)
187
+ stripped = _strip_docstrings(tree)
188
+ return ast.dump(stripped, include_attributes=False)
189
+
190
+
191
+ def _collect_blocks(tree: ast.Module, source: str) -> list[BlockInfo]:
192
+ blocks: list[BlockInfo] = []
193
+ for node in ast.walk(tree):
194
+ if isinstance(node, ast.ClassDef):
195
+ block_source = ast.get_source_segment(source, node) or ast.unparse(node)
196
+ blocks.append(BlockInfo("class", node.name, node.lineno, block_source))
197
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
198
+ kind = "async function" if isinstance(node, ast.AsyncFunctionDef) else "function"
199
+ block_source = ast.get_source_segment(source, node) or ast.unparse(node)
200
+ blocks.append(BlockInfo(kind, node.name, node.lineno, block_source))
201
+ return sorted(blocks, key=lambda item: item.lineno)
202
+
203
+
204
+ def _placeholder_summary(blocks: list[BlockInfo]) -> str:
205
+ if not blocks:
206
+ return "This Python file has no classes or functions to annotate yet."
207
+ names = ", ".join(f"{block.kind} `{block.name}`" for block in blocks[:8])
208
+ overflow = "" if len(blocks) <= 8 else f", plus {len(blocks) - 8} more"
209
+ return f"This file defines {names}{overflow}. Model-generated summaries will replace this deterministic placeholder."
210
+
211
+
212
+ def _insert_comments(source: str, comments: dict[int, str]) -> str:
213
+ lines = source.splitlines()
214
+ inserts: dict[int, list[str]] = {}
215
+ for lineno, comment_text in comments.items():
216
+ indent = len(lines[lineno - 1]) - len(lines[lineno - 1].lstrip())
217
+ inserts.setdefault(lineno - 1, []).append(" " * indent + comment_text)
218
+
219
+ annotated: list[str] = []
220
+ for index, line in enumerate(lines):
221
+ annotated.extend(inserts.get(index, []))
222
+ annotated.append(line)
223
+ return "\n".join(annotated) + ("\n" if source.endswith("\n") else "")
224
+
225
+
226
+ MODEL_LABELS = {
227
+ "Base Mellum2 (richer)": "base",
228
+ "Fine-tuned LoRA (concise)": "tuned",
229
+ }
230
+
231
+
232
+ def _generate_block_comments(blocks: list[BlockInfo], model_label: str) -> tuple[dict[int, str], list[str]]:
233
+ comments: dict[int, str] = {}
234
+ notes: list[str] = []
235
+ model_variant = MODEL_LABELS.get(model_label, "base")
236
+ llm = load_llm() if model_variant == "base" else None
237
+ for block in blocks:
238
+ try:
239
+ if model_variant == "base":
240
+ comments[block.lineno] = generate_comment_with_llm(llm, block.kind, block.name, block.source)
241
+ else:
242
+ comments[block.lineno] = generate_comment(block.kind, block.name, block.source, variant="tuned")
243
+ except Exception as exc:
244
+ comments[block.lineno] = f"# TODO(model): Could not explain {block.kind} `{block.name}`."
245
+ notes.append(f"{block.name}: model generation failed ({type(exc).__name__}).")
246
+ return comments, notes
247
+
248
+
249
+ def annotate_code(source: str, model_label: str) -> tuple[str, str, str]:
250
+ source = source.strip("\ufeff")
251
+ if not source.strip():
252
+ return "", "", "Paste a Python file to annotate."
253
+
254
+ try:
255
+ original_tree = _parse_python(source)
256
+ except SyntaxError as exc:
257
+ return "", "", f"Syntax error on line {exc.lineno}: {html.escape(exc.msg)}"
258
+
259
+ blocks = _collect_blocks(original_tree, source)
260
+ if not blocks:
261
+ return _placeholder_summary(blocks), source, "Parsed successfully. No classes or functions to annotate."
262
+
263
+ try:
264
+ comments, generation_notes = _generate_block_comments(blocks, model_label)
265
+ except ModelUnavailableError as exc:
266
+ return _placeholder_summary(blocks), source, f"Model unavailable: {html.escape(str(exc))}"
267
+
268
+ annotated = _insert_comments(source, comments)
269
+
270
+ try:
271
+ same_ast = _semantic_ast_dump(source) == _semantic_ast_dump(annotated)
272
+ except SyntaxError as exc:
273
+ return "", annotated, f"Generated annotation failed to parse on line {exc.lineno}: {html.escape(exc.msg)}"
274
+
275
+ status = (
276
+ f"Validated: parsed {len(blocks)} block(s), inserted {model_label} comments, semantic AST unchanged."
277
+ if same_ast
278
+ else "Rejected: annotation changed the semantic AST."
279
+ )
280
+ if generation_notes:
281
+ status += "\n" + "\n".join(generation_notes)
282
+ return _placeholder_summary(blocks), annotated if same_ast else source, status
283
+
284
+
285
+ def load_uploaded_python(file_obj: Any) -> tuple[str, str]:
286
+ if file_obj is None:
287
+ return "", "Choose a `.py` file to load it into the editor."
288
+
289
+ path = Path(file_obj if isinstance(file_obj, str) else file_obj.name)
290
+ try:
291
+ source = path.read_text(encoding="utf-8")
292
+ except UnicodeDecodeError:
293
+ try:
294
+ source = path.read_text(encoding="utf-8-sig")
295
+ except UnicodeDecodeError as exc:
296
+ return "", f"Could not read `{path.name}` as UTF-8 Python text: {exc}"
297
+ except OSError as exc:
298
+ return "", f"Could not read `{path.name}`: {exc}"
299
+
300
+ try:
301
+ tree = _parse_python(source)
302
+ except SyntaxError as exc:
303
+ return source, f"Loaded `{path.name}`, but parsing failed on line {exc.lineno}: {html.escape(exc.msg)}"
304
+
305
+ blocks = _collect_blocks(tree, source)
306
+ return source, f"Loaded `{path.name}`. Parsed {len(blocks)} class/function block(s)."
307
+
308
+
309
+ def build_app() -> gr.Blocks:
310
+ with gr.Blocks(
311
+ css=CSS,
312
+ title="between-the-lines",
313
+ theme=gr.themes.Base(
314
+ primary_hue="green",
315
+ neutral_hue="zinc",
316
+ radius_size="sm",
317
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"],
318
+ font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "Consolas", "monospace"],
319
+ ),
320
+ ) as demo:
321
+ with gr.Column(elem_id="btl-shell"):
322
+ gr.HTML(
323
+ """
324
+ <header id="btl-title">
325
+ <h1>between-the-lines</h1>
326
+ <p>Upload or paste a Python file, choose a comment model, then generate comments that are checked against the file's AST before they are shown.</p>
327
+ </header>
328
+ """
329
+ )
330
+
331
+ with gr.Row(equal_height=False, elem_id="btl-controls"):
332
+ upload_file = gr.File(
333
+ label="Upload Python File",
334
+ file_types=[".py"],
335
+ elem_classes=["btl-upload"],
336
+ scale=2,
337
+ )
338
+ model_choice = gr.Dropdown(
339
+ label="Comment Model",
340
+ choices=list(MODEL_LABELS),
341
+ value="Base Mellum2 (richer)",
342
+ interactive=True,
343
+ elem_id="model_choice",
344
+ scale=2,
345
+ )
346
+ run_button = gr.Button("Annotate", variant="primary", scale=1)
347
+ clear_button = gr.ClearButton(value="Clear", components=[], scale=1)
348
+
349
+ with gr.Row(equal_height=True):
350
+ with gr.Column(scale=1):
351
+ input_code = gr.Code(
352
+ label="Original Python",
353
+ language="python",
354
+ value="",
355
+ lines=24,
356
+ elem_id="input_code",
357
+ )
358
+ with gr.Column(scale=1):
359
+ output_code = gr.Code(
360
+ label="Annotated Python",
361
+ language="python",
362
+ lines=24,
363
+ elem_id="output_code",
364
+ )
365
+
366
+ with gr.Row(equal_height=True):
367
+ summary = gr.Textbox(label="File Summary", lines=3, elem_id="summary_box")
368
+ status = gr.Textbox(label="Validation", lines=3, elem_id="status_box")
369
+
370
+ clear_button.add([input_code, output_code, summary, status])
371
+
372
+ run_button.click(
373
+ annotate_code,
374
+ inputs=[input_code, model_choice],
375
+ outputs=[summary, output_code, status],
376
+ )
377
+ upload_file.upload(
378
+ load_uploaded_python,
379
+ inputs=upload_file,
380
+ outputs=[input_code, status],
381
+ )
382
+
383
+ return demo
384
+
385
+
386
+ demo = build_app()
387
+
388
+
389
+ if __name__ == "__main__":
390
+ demo.launch(
391
+ server_name="0.0.0.0",
392
+ server_port=7860,
393
+ inbrowser=False,
394
+ )
btl/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core helpers for between-the-lines."""
btl/model.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from functools import lru_cache
3
+ from typing import Literal
4
+
5
+ from .prompts import build_comment_messages
6
+
7
+
8
+ DEFAULT_MODEL_REPO = "JetBrains/Mellum2-12B-A2.5B-Instruct-GGUF-Q8_0"
9
+ DEFAULT_MODEL_FILE = "Mellum2-12B-A2.5B-Instruct-Q8_0.gguf"
10
+ DEFAULT_BASE_TRANSFORMERS_MODEL = "JetBrains/Mellum2-12B-A2.5B-Instruct"
11
+ DEFAULT_TUNED_ADAPTER_REPO = "coolbeanz79/between-the-lines-mellum2-lora"
12
+
13
+ ModelVariant = Literal["base", "tuned"]
14
+
15
+
16
+ class ModelUnavailableError(RuntimeError):
17
+ pass
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def _load_base_llm():
22
+ try:
23
+ from llama_cpp import Llama
24
+ except ImportError as exc:
25
+ raise ModelUnavailableError(
26
+ "llama-cpp-python is not installed. Install requirements before using model annotations."
27
+ ) from exc
28
+
29
+ repo_id = os.getenv("BTL_MODEL_REPO", DEFAULT_MODEL_REPO)
30
+ filename = os.getenv("BTL_MODEL_FILE", DEFAULT_MODEL_FILE)
31
+ n_ctx = int(os.getenv("BTL_MODEL_CTX", "4096"))
32
+ n_gpu_layers = int(os.getenv("BTL_MODEL_GPU_LAYERS", "-1"))
33
+
34
+ try:
35
+ return Llama.from_pretrained(
36
+ repo_id=repo_id,
37
+ filename=filename,
38
+ n_ctx=n_ctx,
39
+ n_gpu_layers=n_gpu_layers,
40
+ verbose=False,
41
+ )
42
+ except Exception as exc:
43
+ raise ModelUnavailableError(
44
+ f"Could not load `{repo_id}` / `{filename}` with llama-cpp-python: {exc}"
45
+ ) from exc
46
+
47
+
48
+ def _clean_comment(text: str) -> str:
49
+ line = text.strip().splitlines()[0].strip() if text.strip() else ""
50
+ line = line.strip("`").strip()
51
+
52
+ if line.startswith("Comment:"):
53
+ line = line.removeprefix("Comment:").strip()
54
+
55
+ if not line.startswith("#"):
56
+ line = "# " + line.lstrip("# ").strip()
57
+
58
+ if not line.startswith("# "):
59
+ line = "# " + line[1:].strip()
60
+
61
+ return line[:240].rstrip()
62
+
63
+
64
+ @lru_cache(maxsize=1)
65
+ def _load_tuned_model():
66
+ adapter_path_or_repo = (
67
+ os.getenv("BTL_TUNED_ADAPTER_PATH")
68
+ or os.getenv("BTL_TUNED_ADAPTER_REPO")
69
+ or DEFAULT_TUNED_ADAPTER_REPO
70
+ )
71
+ if not adapter_path_or_repo:
72
+ raise ModelUnavailableError(
73
+ "Tuned LoRA adapter is not configured. Set BTL_TUNED_ADAPTER_PATH or BTL_TUNED_ADAPTER_REPO."
74
+ )
75
+
76
+ try:
77
+ import torch
78
+ from peft import PeftModel
79
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
80
+ except ImportError as exc:
81
+ raise ModelUnavailableError(
82
+ "Tuned LoRA inference requires torch, transformers, peft, and bitsandbytes."
83
+ ) from exc
84
+
85
+ model_name = os.getenv("BTL_TUNED_BASE_MODEL", DEFAULT_BASE_TRANSFORMERS_MODEL)
86
+ try:
87
+ tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
88
+ if tokenizer.pad_token is None:
89
+ tokenizer.pad_token = tokenizer.eos_token
90
+
91
+ quantization_config = BitsAndBytesConfig(
92
+ load_in_4bit=True,
93
+ bnb_4bit_compute_dtype=torch.bfloat16,
94
+ bnb_4bit_quant_type="nf4",
95
+ bnb_4bit_use_double_quant=True,
96
+ )
97
+ base_model = AutoModelForCausalLM.from_pretrained(
98
+ model_name,
99
+ trust_remote_code=True,
100
+ device_map="auto",
101
+ quantization_config=quantization_config,
102
+ )
103
+ model = PeftModel.from_pretrained(base_model, adapter_path_or_repo)
104
+ model.eval()
105
+ return tokenizer, model
106
+ except Exception as exc:
107
+ raise ModelUnavailableError(f"Could not load tuned LoRA adapter `{adapter_path_or_repo}`: {exc}") from exc
108
+
109
+
110
+ def generate_comment(kind: str, name: str, source: str, variant: ModelVariant = "base") -> str:
111
+ if variant == "tuned":
112
+ return generate_comment_with_tuned_model(kind, name, source)
113
+ llm = load_llm()
114
+ return generate_comment_with_llm(llm, kind, name, source)
115
+
116
+
117
+ def load_llm():
118
+ return _load_base_llm()
119
+
120
+
121
+ def generate_comment_with_llm(llm, kind: str, name: str, source: str) -> str:
122
+ messages = build_comment_messages(kind, name, source)
123
+ response = llm.create_chat_completion(
124
+ messages=messages,
125
+ temperature=0.15,
126
+ top_p=0.9,
127
+ max_tokens=80,
128
+ stop=["\n\n", "```"],
129
+ )
130
+ text = response["choices"][0]["message"]["content"]
131
+ return _clean_comment(text)
132
+
133
+
134
+ def generate_comment_with_tuned_model(kind: str, name: str, source: str) -> str:
135
+ import torch
136
+
137
+ tokenizer, model = _load_tuned_model()
138
+ messages = build_comment_messages(kind, name, source)
139
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
140
+ encoded = tokenizer(
141
+ prompt,
142
+ return_tensors="pt",
143
+ truncation=True,
144
+ max_length=int(os.getenv("BTL_TUNED_MODEL_CTX", "4096")),
145
+ ).to(model.device)
146
+
147
+ with torch.inference_mode():
148
+ output_ids = model.generate(
149
+ **encoded,
150
+ do_sample=False,
151
+ max_new_tokens=80,
152
+ pad_token_id=tokenizer.pad_token_id,
153
+ eos_token_id=tokenizer.eos_token_id,
154
+ )[0]
155
+
156
+ generated_ids = output_ids[encoded["input_ids"].shape[-1] :]
157
+ text = tokenizer.decode(generated_ids, skip_special_tokens=True)
158
+ return _clean_comment(text)
btl/prompts.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ COMMENT_SYSTEM_PROMPT = """You explain Python code by writing safe, concise comments.
2
+
3
+ Rules:
4
+ - Output exactly one Python comment.
5
+ - The comment must start with "# ".
6
+ - Explain what the code block does.
7
+ - Do not mention behavior that is not present in the code.
8
+ - Do not suggest changes.
9
+ - Do not output Markdown.
10
+ - Do not output code other than the comment.
11
+ """
12
+
13
+
14
+ def build_comment_messages(kind: str, name: str, source: str) -> list[dict[str, str]]:
15
+ return [
16
+ {"role": "system", "content": COMMENT_SYSTEM_PROMPT},
17
+ {
18
+ "role": "user",
19
+ "content": (
20
+ f"Write one concise Python comment for this {kind} named `{name}`.\n\n"
21
+ "Python block:\n"
22
+ f"```python\n{source.strip()}\n```\n\n"
23
+ "Comment:"
24
+ ),
25
+ },
26
+ ]
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==5.50.0
2
+ huggingface_hub
3
+ llama-cpp-python
4
+ datasets==2.21.0
5
+ truststore
6
+ accelerate
7
+ bitsandbytes
8
+ peft
9
+ safetensors
10
+ torch
11
+ git+https://github.com/huggingface/transformers.git