import ast import html from dataclasses import dataclass from pathlib import Path from typing import Any import gradio as gr from btl.model import ModelUnavailableError, generate_comment, generate_comment_with_llm, load_llm CSS = """ :root { --btl-bg: #f3f8f0; --btl-panel: #ffffff; --btl-ink: #111812; --btl-muted: #5b6a5f; --btl-line: #d8e4d6; --btl-green: #1f6f43; --btl-green-dark: #0d2818; --btl-green-soft: #e3f1df; --btl-code-bg: #07110b; } .gradio-container { background: radial-gradient(circle at 18% 0%, rgba(31, 111, 67, 0.16), transparent 28rem), linear-gradient(135deg, rgba(7, 17, 11, 0.08), transparent 24rem), linear-gradient(180deg, rgba(13, 40, 24, 0.08), transparent 260px), var(--btl-bg) !important; color: var(--btl-ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } #btl-shell { max-width: 1280px; margin: 0 auto; } #btl-title { padding: 22px 0 10px; } #btl-title h1 { color: var(--btl-green-dark); font-size: clamp(2rem, 5vw, 4.75rem); line-height: 0.96; letter-spacing: 0; margin: 0; } #btl-title p { color: var(--btl-muted); font-size: 1.02rem; max-width: 780px; margin: 14px 0 0; } .btl-upload .wrap { background: rgba(255, 255, 255, 0.76) !important; } #btl-controls { align-items: end; margin-bottom: 12px; } #btl-controls .form, #btl-controls .block { min-height: 72px !important; } #model_choice label, #input_code label, #output_code label, #summary_box label, #status_box label { color: var(--btl-green-dark) !important; font-weight: 700 !important; } .btl-card, .form, .block { border-color: var(--btl-line) !important; border-radius: 8px !important; box-shadow: 0 10px 35px rgba(7, 17, 11, 0.05) !important; } button.primary, .primary > button { background: var(--btl-green-dark) !important; border-color: var(--btl-green-dark) !important; color: #f8fff8 !important; border-radius: 8px !important; } button.secondary, .secondary > button { border-radius: 8px !important; } textarea, pre, code { font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace !important; } #summary_box textarea { background: #f7fbf4 !important; color: var(--btl-green-dark) !important; text-align: center !important; font-family: Inter, ui-sans-serif, system-ui, sans-serif !important; font-weight: 650 !important; line-height: 1.55 !important; } #status_box textarea { background: #123923 !important; color: #e7f8e8 !important; border-color: #123923 !important; font-family: Inter, ui-sans-serif, system-ui, sans-serif !important; line-height: 1.45 !important; } #input_code textarea, #output_code textarea { min-height: 520px !important; line-height: 1.45 !important; } #input_code .cm-editor, #output_code .cm-editor { min-height: 520px !important; } #input_code .cm-scroller, #output_code .cm-scroller { min-height: 520px !important; } #output_code textarea { background: var(--btl-code-bg) !important; color: #dbf8df !important; } #output_code .cm-editor { background: var(--btl-code-bg) !important; } #output_code .cm-content, #output_code .cm-gutters { background: var(--btl-code-bg) !important; color: #dbf8df !important; } """ @dataclass(frozen=True) class BlockInfo: kind: str name: str lineno: int source: str def _parse_python(source: str) -> ast.Module: return ast.parse(source) def _strip_docstrings(node: ast.AST) -> ast.AST: copied = ast.fix_missing_locations(ast.parse(ast.unparse(node))) for child in ast.walk(copied): if isinstance(child, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): if ( child.body and isinstance(child.body[0], ast.Expr) and isinstance(child.body[0].value, ast.Constant) and isinstance(child.body[0].value.value, str) ): child.body = child.body[1:] return copied def _semantic_ast_dump(source: str) -> str: tree = _parse_python(source) stripped = _strip_docstrings(tree) return ast.dump(stripped, include_attributes=False) def _collect_blocks(tree: ast.Module, source: str) -> list[BlockInfo]: blocks: list[BlockInfo] = [] for node in ast.walk(tree): if isinstance(node, ast.ClassDef): block_source = ast.get_source_segment(source, node) or ast.unparse(node) blocks.append(BlockInfo("class", node.name, node.lineno, block_source)) elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): kind = "async function" if isinstance(node, ast.AsyncFunctionDef) else "function" block_source = ast.get_source_segment(source, node) or ast.unparse(node) blocks.append(BlockInfo(kind, node.name, node.lineno, block_source)) return sorted(blocks, key=lambda item: item.lineno) def _placeholder_summary(blocks: list[BlockInfo]) -> str: if not blocks: return "This Python file has no classes or functions to annotate yet." names = ", ".join(f"{block.kind} `{block.name}`" for block in blocks[:8]) overflow = "" if len(blocks) <= 8 else f", plus {len(blocks) - 8} more" return f"This file defines {names}{overflow}. Model-generated summaries will replace this deterministic placeholder." def _insert_comments(source: str, comments: dict[int, str]) -> str: lines = source.splitlines() inserts: dict[int, list[str]] = {} for lineno, comment_text in comments.items(): indent = len(lines[lineno - 1]) - len(lines[lineno - 1].lstrip()) inserts.setdefault(lineno - 1, []).append(" " * indent + comment_text) annotated: list[str] = [] for index, line in enumerate(lines): annotated.extend(inserts.get(index, [])) annotated.append(line) return "\n".join(annotated) + ("\n" if source.endswith("\n") else "") MODEL_LABELS = { "Base Mellum2 (richer)": "base", "Fine-tuned LoRA (concise)": "tuned", } def _generate_block_comments(blocks: list[BlockInfo], model_label: str) -> tuple[dict[int, str], list[str]]: comments: dict[int, str] = {} notes: list[str] = [] model_variant = MODEL_LABELS.get(model_label, "base") llm = load_llm() if model_variant == "base" else None for block in blocks: try: if model_variant == "base": comments[block.lineno] = generate_comment_with_llm(llm, block.kind, block.name, block.source) else: comments[block.lineno] = generate_comment(block.kind, block.name, block.source, variant="tuned") except Exception as exc: comments[block.lineno] = f"# TODO(model): Could not explain {block.kind} `{block.name}`." notes.append(f"{block.name}: model generation failed ({type(exc).__name__}).") return comments, notes def annotate_code(source: str, model_label: str) -> tuple[str, str, str]: source = source.strip("\ufeff") if not source.strip(): return "", "", "Paste a Python file to annotate." try: original_tree = _parse_python(source) except SyntaxError as exc: return "", "", f"Syntax error on line {exc.lineno}: {html.escape(exc.msg)}" blocks = _collect_blocks(original_tree, source) if not blocks: return _placeholder_summary(blocks), source, "Parsed successfully. No classes or functions to annotate." try: comments, generation_notes = _generate_block_comments(blocks, model_label) except ModelUnavailableError as exc: return _placeholder_summary(blocks), source, f"Model unavailable: {html.escape(str(exc))}" annotated = _insert_comments(source, comments) try: same_ast = _semantic_ast_dump(source) == _semantic_ast_dump(annotated) except SyntaxError as exc: return "", annotated, f"Generated annotation failed to parse on line {exc.lineno}: {html.escape(exc.msg)}" status = ( f"Validated: parsed {len(blocks)} block(s), inserted {model_label} comments, semantic AST unchanged." if same_ast else "Rejected: annotation changed the semantic AST." ) if generation_notes: status += "\n" + "\n".join(generation_notes) return _placeholder_summary(blocks), annotated if same_ast else source, status def load_uploaded_python(file_obj: Any) -> tuple[str, str]: if file_obj is None: return "", "Choose a `.py` file to load it into the editor." path = Path(file_obj if isinstance(file_obj, str) else file_obj.name) try: source = path.read_text(encoding="utf-8") except UnicodeDecodeError: try: source = path.read_text(encoding="utf-8-sig") except UnicodeDecodeError as exc: return "", f"Could not read `{path.name}` as UTF-8 Python text: {exc}" except OSError as exc: return "", f"Could not read `{path.name}`: {exc}" try: tree = _parse_python(source) except SyntaxError as exc: return source, f"Loaded `{path.name}`, but parsing failed on line {exc.lineno}: {html.escape(exc.msg)}" blocks = _collect_blocks(tree, source) return source, f"Loaded `{path.name}`. Parsed {len(blocks)} class/function block(s)." def build_app() -> gr.Blocks: with gr.Blocks( css=CSS, title="between-the-lines", theme=gr.themes.Base( primary_hue="green", neutral_hue="zinc", radius_size="sm", font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"], font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "Consolas", "monospace"], ), ) as demo: with gr.Column(elem_id="btl-shell"): gr.HTML( """

between-the-lines

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.

""" ) with gr.Row(equal_height=False, elem_id="btl-controls"): upload_file = gr.File( label="Upload Python File", file_types=[".py"], elem_classes=["btl-upload"], scale=2, ) model_choice = gr.Dropdown( label="Comment Model", choices=list(MODEL_LABELS), value="Base Mellum2 (richer)", interactive=True, elem_id="model_choice", scale=2, ) run_button = gr.Button("Annotate", variant="primary", scale=1) clear_button = gr.ClearButton(value="Clear", components=[], scale=1) with gr.Row(equal_height=True): with gr.Column(scale=1): input_code = gr.Code( label="Original Python", language="python", value="", lines=24, elem_id="input_code", ) with gr.Column(scale=1): output_code = gr.Code( label="Annotated Python", language="python", lines=24, elem_id="output_code", ) with gr.Row(equal_height=True): summary = gr.Textbox(label="File Summary", lines=3, elem_id="summary_box") status = gr.Textbox(label="Validation", lines=3, elem_id="status_box") clear_button.add([input_code, output_code, summary, status]) run_button.click( annotate_code, inputs=[input_code, model_choice], outputs=[summary, output_code, status], ) upload_file.upload( load_uploaded_python, inputs=upload_file, outputs=[input_code, status], ) return demo demo = build_app() if __name__ == "__main__": demo.launch( server_name="0.0.0.0", server_port=7860, inbrowser=False, )