coolbeanz79's picture
Upload between-the-lines app
f581815 verified
Raw
History Blame
12.3 kB
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(
"""
<header id="btl-title">
<h1>between-the-lines</h1>
<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>
</header>
"""
)
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,
)