Polish UI and generate file summaries

#7
Files changed (4) hide show
  1. app.py +78 -9
  2. btl/annotate.py +21 -3
  3. btl/model.py +57 -7
  4. btl/prompts.py +27 -0
app.py CHANGED
@@ -48,7 +48,8 @@ CSS = """
48
  }
49
 
50
  #btl-title {
51
- padding: 22px 0 10px;
 
52
  }
53
 
54
  #btl-title h1 {
@@ -62,24 +63,74 @@ CSS = """
62
  #btl-title p {
63
  color: var(--btl-muted);
64
  font-size: 1.02rem;
 
 
 
 
 
 
65
  max-width: 780px;
66
- margin: 14px 0 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  .btl-upload .wrap {
70
  background: rgba(255, 255, 255, 0.76) !important;
71
  }
72
 
 
 
 
 
 
 
73
  #btl-controls {
74
  align-items: end;
75
- margin-bottom: 12px;
76
  }
77
 
78
- #btl-controls .form,
79
- #btl-controls .block {
 
 
 
 
80
  min-height: 72px !important;
81
  }
82
 
 
 
 
 
 
 
 
 
 
83
  #model_choice label,
84
  #input_code label,
85
  #output_code label,
@@ -135,18 +186,23 @@ code {
135
 
136
  #input_code textarea,
137
  #output_code textarea {
138
- min-height: 520px !important;
139
  line-height: 1.45 !important;
 
140
  }
141
 
142
  #input_code .cm-editor,
143
  #output_code .cm-editor {
144
- min-height: 520px !important;
 
 
145
  }
146
 
147
  #input_code .cm-scroller,
148
  #output_code .cm-scroller {
149
- min-height: 520px !important;
 
 
150
  }
151
 
152
  #output_code textarea {
@@ -163,6 +219,13 @@ code {
163
  background: var(--btl-code-bg) !important;
164
  color: #dbf8df !important;
165
  }
 
 
 
 
 
 
 
166
  """
167
 
168
 
@@ -222,8 +285,12 @@ def build_app() -> gr.Blocks:
222
  """
223
  <header id="btl-title">
224
  <h1>between-the-lines</h1>
225
- <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>
226
  </header>
 
 
 
 
227
  """
228
  )
229
 
@@ -242,6 +309,8 @@ def build_app() -> gr.Blocks:
242
  elem_id="model_choice",
243
  scale=2,
244
  )
 
 
245
  run_button = gr.Button("Annotate", variant="primary", scale=1)
246
  clear_button = gr.ClearButton(value="Clear", components=[], scale=1)
247
 
 
48
  }
49
 
50
  #btl-title {
51
+ padding: 22px 0 12px;
52
+ text-align: center;
53
  }
54
 
55
  #btl-title h1 {
 
63
  #btl-title p {
64
  color: var(--btl-muted);
65
  font-size: 1.02rem;
66
+ line-height: 1.6;
67
+ max-width: 850px;
68
+ margin: 14px auto 0;
69
+ }
70
+
71
+ #btl-cli {
72
  max-width: 780px;
73
+ margin: 0 auto 22px;
74
+ }
75
+
76
+ #btl-cli .btl-cli-label {
77
+ color: var(--btl-muted);
78
+ font-size: 0.82rem;
79
+ font-weight: 750;
80
+ letter-spacing: 0;
81
+ margin: 0 0 7px;
82
+ text-transform: uppercase;
83
+ }
84
+
85
+ #btl-cli pre {
86
+ background: #f1f3f5;
87
+ border: 1px solid #d5d9dd;
88
+ border-radius: 8px;
89
+ color: #18201b;
90
+ margin: 0;
91
+ overflow-x: auto;
92
+ padding: 14px 16px;
93
+ text-align: left;
94
+ }
95
+
96
+ #btl-cli code {
97
+ color: #18201b;
98
+ font-size: 0.94rem;
99
+ white-space: pre;
100
  }
101
 
102
  .btl-upload .wrap {
103
  background: rgba(255, 255, 255, 0.76) !important;
104
  }
105
 
106
+ #btl-controls,
107
+ #btl-actions {
108
+ justify-content: center;
109
+ margin-bottom: 12px;
110
+ }
111
+
112
  #btl-controls {
113
  align-items: end;
 
114
  }
115
 
116
+ #btl-actions {
117
+ align-items: center;
118
+ }
119
+
120
+ #btl-controls > .form,
121
+ #btl-controls > .block {
122
  min-height: 72px !important;
123
  }
124
 
125
+ #btl-controls > *,
126
+ #btl-actions > * {
127
+ flex: 0 1 360px !important;
128
+ }
129
+
130
+ #btl-actions > * {
131
+ max-width: 190px;
132
+ }
133
+
134
  #model_choice label,
135
  #input_code label,
136
  #output_code label,
 
186
 
187
  #input_code textarea,
188
  #output_code textarea {
189
+ height: min(56vh, 620px) !important;
190
  line-height: 1.45 !important;
191
+ overflow: auto !important;
192
  }
193
 
194
  #input_code .cm-editor,
195
  #output_code .cm-editor {
196
+ height: min(56vh, 620px) !important;
197
+ max-height: 620px !important;
198
+ border-top: 0 !important;
199
  }
200
 
201
  #input_code .cm-scroller,
202
  #output_code .cm-scroller {
203
+ height: min(56vh, 620px) !important;
204
+ max-height: 620px !important;
205
+ overflow: auto !important;
206
  }
207
 
208
  #output_code textarea {
 
219
  background: var(--btl-code-bg) !important;
220
  color: #dbf8df !important;
221
  }
222
+
223
+ #input_code .label-wrap,
224
+ #output_code .label-wrap,
225
+ #input_code .wrap,
226
+ #output_code .wrap {
227
+ border-top-color: transparent !important;
228
+ }
229
  """
230
 
231
 
 
285
  """
286
  <header id="btl-title">
287
  <h1>between-the-lines</h1>
288
+ <p>Annotate Python files from the web or terminal with semantically meaningful comments. The model comments only on AST-backed code blocks, and the application deterministically verifies that the executable AST is unchanged after annotation.</p>
289
  </header>
290
+ <section id="btl-cli" aria-label="Terminal command">
291
+ <p class="btl-cli-label">Run from your terminal</p>
292
+ <pre><code>npx between-the-lines-cli path/to/file.py --model base --summary</code></pre>
293
+ </section>
294
  """
295
  )
296
 
 
309
  elem_id="model_choice",
310
  scale=2,
311
  )
312
+
313
+ with gr.Row(equal_height=False, elem_id="btl-actions"):
314
  run_button = gr.Button("Annotate", variant="primary", scale=1)
315
  clear_button = gr.ClearButton(value="Clear", components=[], scale=1)
316
 
btl/annotate.py CHANGED
@@ -2,7 +2,7 @@ import ast
2
  from dataclasses import dataclass
3
  from typing import Literal
4
 
5
- from .model import ModelUnavailableError, generate_comment
6
 
7
 
8
  ModelChoice = Literal["base", "tuned"]
@@ -71,6 +71,20 @@ def summarize_blocks(blocks: list[BlockInfo]) -> str:
71
  return f"This file defines {names}{overflow}."
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def insert_comments(source: str, comments: dict[int, str]) -> str:
75
  lines = source.splitlines()
76
  inserts: dict[int, list[str]] = {}
@@ -111,11 +125,15 @@ def annotate_python(source: str, model_choice: ModelChoice = "base") -> Annotati
111
  return AnnotationResult("", "", f"Syntax error on line {exc.lineno}: {exc.msg}", False, 0)
112
 
113
  blocks = collect_blocks(original_tree, source)
114
- summary = summarize_blocks(blocks)
115
  if not blocks:
116
- return AnnotationResult(summary, source, "Parsed successfully. No classes or functions to annotate.", True, 0)
 
 
 
117
 
118
  comments, generation_notes = generate_block_comments(blocks, model_choice)
 
119
  annotated = insert_comments(source, comments)
120
 
121
  try:
 
2
  from dataclasses import dataclass
3
  from typing import Literal
4
 
5
+ from .model import ModelUnavailableError, generate_comment, generate_file_summary
6
 
7
 
8
  ModelChoice = Literal["base", "tuned"]
 
71
  return f"This file defines {names}{overflow}."
72
 
73
 
74
+ def generate_summary(source: str, blocks: list[BlockInfo], model_choice: ModelChoice = "base") -> tuple[str, list[str]]:
75
+ notes: list[str] = []
76
+ try:
77
+ summary = generate_file_summary(source, variant=model_choice)
78
+ if summary:
79
+ return summary, notes
80
+ notes.append("file summary: model returned an empty summary.")
81
+ except ModelUnavailableError:
82
+ raise
83
+ except Exception as exc:
84
+ notes.append(f"file summary: model generation failed ({type(exc).__name__}).")
85
+ return summarize_blocks(blocks), notes
86
+
87
+
88
  def insert_comments(source: str, comments: dict[int, str]) -> str:
89
  lines = source.splitlines()
90
  inserts: dict[int, list[str]] = {}
 
125
  return AnnotationResult("", "", f"Syntax error on line {exc.lineno}: {exc.msg}", False, 0)
126
 
127
  blocks = collect_blocks(original_tree, source)
128
+ summary, summary_notes = generate_summary(source, blocks, model_choice)
129
  if not blocks:
130
+ status = "Parsed successfully. No classes or functions to annotate."
131
+ if summary_notes:
132
+ status += "\n" + "\n".join(summary_notes)
133
+ return AnnotationResult(summary, source, status, True, 0, tuple(summary_notes))
134
 
135
  comments, generation_notes = generate_block_comments(blocks, model_choice)
136
+ generation_notes = summary_notes + generation_notes
137
  annotated = insert_comments(source, comments)
138
 
139
  try:
btl/model.py CHANGED
@@ -2,7 +2,7 @@ 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_BASE_TRANSFORMERS_MODEL = "JetBrains/Mellum2-12B-A2.5B-Instruct"
@@ -66,6 +66,16 @@ def _clean_comment(text: str) -> str:
66
  return line[:240].rstrip()
67
 
68
 
 
 
 
 
 
 
 
 
 
 
69
  @lru_cache(maxsize=1)
70
  def _load_tuned_model():
71
  adapter_path_or_repo = (
@@ -100,10 +110,39 @@ def generate_comment(kind: str, name: str, source: str, variant: ModelVariant =
100
  return generate_comment_with_base_model(kind, name, source)
101
 
102
 
103
- def _generate_with_transformers(tokenizer, model, kind: str, name: str, source: str, max_length_env: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  import torch
105
 
106
- messages = build_comment_messages(kind, name, source)
107
  prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
108
  encoded = tokenizer(
109
  prompt,
@@ -120,21 +159,32 @@ def _generate_with_transformers(tokenizer, model, kind: str, name: str, source:
120
  output_ids = model.generate(
121
  **encoded,
122
  do_sample=False,
123
- max_new_tokens=80,
124
  pad_token_id=tokenizer.pad_token_id,
125
  eos_token_id=tokenizer.eos_token_id,
126
  )[0]
127
 
128
  generated_ids = output_ids[encoded["input_ids"].shape[-1] :]
129
  text = tokenizer.decode(generated_ids, skip_special_tokens=True)
130
- return _clean_comment(text)
 
 
 
 
 
 
 
 
 
 
 
131
 
132
 
133
  def generate_comment_with_base_model(kind: str, name: str, source: str) -> str:
134
  tokenizer, model = _load_base_model()
135
- return _generate_with_transformers(tokenizer, model, kind, name, source, "BTL_MODEL_CTX")
136
 
137
 
138
  def generate_comment_with_tuned_model(kind: str, name: str, source: str) -> str:
139
  tokenizer, model = _load_tuned_model()
140
- return _generate_with_transformers(tokenizer, model, kind, name, source, "BTL_TUNED_MODEL_CTX")
 
2
  from functools import lru_cache
3
  from typing import Literal
4
 
5
+ from .prompts import build_comment_messages, build_summary_messages
6
 
7
 
8
  DEFAULT_BASE_TRANSFORMERS_MODEL = "JetBrains/Mellum2-12B-A2.5B-Instruct"
 
66
  return line[:240].rstrip()
67
 
68
 
69
+ def _clean_summary(text: str) -> str:
70
+ cleaned = " ".join(line.strip().strip("`") for line in text.strip().splitlines() if line.strip())
71
+ if cleaned.startswith("Summary:"):
72
+ cleaned = cleaned.removeprefix("Summary:").strip()
73
+ cleaned = cleaned.strip('"').strip("'").strip()
74
+ if cleaned.startswith("#"):
75
+ cleaned = cleaned.lstrip("# ").strip()
76
+ return cleaned[:500].rstrip()
77
+
78
+
79
  @lru_cache(maxsize=1)
80
  def _load_tuned_model():
81
  adapter_path_or_repo = (
 
110
  return generate_comment_with_base_model(kind, name, source)
111
 
112
 
113
+ def generate_file_summary(source: str, variant: ModelVariant = "base") -> str:
114
+ if variant == "tuned":
115
+ tokenizer, model = _load_tuned_model()
116
+ return _generate_text_with_transformers(
117
+ tokenizer,
118
+ model,
119
+ build_summary_messages(source),
120
+ "BTL_TUNED_MODEL_CTX",
121
+ max_new_tokens=120,
122
+ cleaner=_clean_summary,
123
+ )
124
+
125
+ tokenizer, model = _load_base_model()
126
+ return _generate_text_with_transformers(
127
+ tokenizer,
128
+ model,
129
+ build_summary_messages(source),
130
+ "BTL_MODEL_CTX",
131
+ max_new_tokens=120,
132
+ cleaner=_clean_summary,
133
+ )
134
+
135
+
136
+ def _generate_text_with_transformers(
137
+ tokenizer,
138
+ model,
139
+ messages: list[dict[str, str]],
140
+ max_length_env: str,
141
+ max_new_tokens: int,
142
+ cleaner,
143
+ ) -> str:
144
  import torch
145
 
 
146
  prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
147
  encoded = tokenizer(
148
  prompt,
 
159
  output_ids = model.generate(
160
  **encoded,
161
  do_sample=False,
162
+ max_new_tokens=max_new_tokens,
163
  pad_token_id=tokenizer.pad_token_id,
164
  eos_token_id=tokenizer.eos_token_id,
165
  )[0]
166
 
167
  generated_ids = output_ids[encoded["input_ids"].shape[-1] :]
168
  text = tokenizer.decode(generated_ids, skip_special_tokens=True)
169
+ return cleaner(text)
170
+
171
+
172
+ def _generate_comment_with_transformers(tokenizer, model, kind: str, name: str, source: str, max_length_env: str) -> str:
173
+ return _generate_text_with_transformers(
174
+ tokenizer,
175
+ model,
176
+ build_comment_messages(kind, name, source),
177
+ max_length_env,
178
+ max_new_tokens=80,
179
+ cleaner=_clean_comment,
180
+ )
181
 
182
 
183
  def generate_comment_with_base_model(kind: str, name: str, source: str) -> str:
184
  tokenizer, model = _load_base_model()
185
+ return _generate_comment_with_transformers(tokenizer, model, kind, name, source, "BTL_MODEL_CTX")
186
 
187
 
188
  def generate_comment_with_tuned_model(kind: str, name: str, source: str) -> str:
189
  tokenizer, model = _load_tuned_model()
190
+ return _generate_comment_with_transformers(tokenizer, model, kind, name, source, "BTL_TUNED_MODEL_CTX")
btl/prompts.py CHANGED
@@ -11,6 +11,18 @@ Rules:
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},
@@ -24,3 +36,18 @@ def build_comment_messages(kind: str, name: str, source: str) -> list[dict[str,
24
  ),
25
  },
26
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  """
12
 
13
 
14
+ SUMMARY_SYSTEM_PROMPT = """You summarize Python files for developers.
15
+
16
+ Rules:
17
+ - Output one or two concise sentences.
18
+ - Explain the file's overall purpose and main responsibilities.
19
+ - Do not output Markdown.
20
+ - Do not wrap the answer in quotes.
21
+ - Do not suggest changes.
22
+ - Do not mention ASTs, prompts, or the annotation tool.
23
+ """
24
+
25
+
26
  def build_comment_messages(kind: str, name: str, source: str) -> list[dict[str, str]]:
27
  return [
28
  {"role": "system", "content": COMMENT_SYSTEM_PROMPT},
 
36
  ),
37
  },
38
  ]
39
+
40
+
41
+ def build_summary_messages(source: str) -> list[dict[str, str]]:
42
+ return [
43
+ {"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
44
+ {
45
+ "role": "user",
46
+ "content": (
47
+ "Summarize this Python file for a developer who is about to read it.\n\n"
48
+ "Python file:\n"
49
+ f"```python\n{source.strip()}\n```\n\n"
50
+ "Summary:"
51
+ ),
52
+ },
53
+ ]