Skip to content

Commit 4c9a3b8

Browse files
committed
Add roast/roaster.py
1 parent eeb04ae commit 4c9a3b8

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

roast/roaster.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""LLM roast generation."""
2+
3+
from __future__ import annotations
4+
5+
from collections import Counter
6+
from dataclasses import dataclass
7+
import json
8+
import os
9+
from typing import Any
10+
11+
from openai import OpenAI
12+
13+
from roast.analyzer import AnalysisReport, Issue
14+
from roast.scanner import FileResult
15+
16+
17+
@dataclass(slots=True)
18+
class RoastResult:
19+
headline: str
20+
roast_lines: list[str]
21+
verdict: str
22+
verdict_emoji: str
23+
24+
25+
def _verdict_from_score(score: int) -> tuple[str, str]:
26+
if score >= 75:
27+
return "SHIP IT", "🚀"
28+
if score >= 40:
29+
return "NEEDS WORK", "🔨"
30+
return "BURN IT DOWN", "🔥"
31+
32+
33+
def _severity_weight(issue: Issue) -> int:
34+
order = {"high": 0, "medium": 1, "low": 2}
35+
return order.get(issue.severity, 3)
36+
37+
38+
def _top_issues(report: AnalysisReport, limit: int = 10) -> list[Issue]:
39+
return sorted(report.issues, key=lambda issue: (_severity_weight(issue), issue.file, issue.line or 0))[:limit]
40+
41+
42+
def _worst_file(report: AnalysisReport) -> str | None:
43+
if not report.issues:
44+
return None
45+
counts = Counter(issue.file for issue in report.issues)
46+
return counts.most_common(1)[0][0]
47+
48+
49+
def _sample_from_file(files: list[FileResult], target_path: str | None, max_lines: int = 30) -> str:
50+
if not target_path:
51+
return "No issues found; no worst-file sample available."
52+
for file in files:
53+
if file.path == target_path:
54+
return "\n".join(file.content.splitlines()[:max_lines]) or "<empty file>"
55+
return "Unable to locate worst file contents."
56+
57+
58+
def _build_user_prompt(report: AnalysisReport, files: list[FileResult]) -> str:
59+
overall_score = report.scores.get("Overall", 0)
60+
top = _top_issues(report)
61+
issue_lines = [
62+
f"- {issue.file}:{issue.line or '-'} [{issue.severity}] {issue.description}"
63+
for issue in top
64+
]
65+
issues_block = "\n".join(issue_lines) if issue_lines else "- No issues found."
66+
worst = _worst_file(report) or "None"
67+
sample = _sample_from_file(files, _worst_file(report))
68+
return (
69+
"Here is a summary of a codebase scan:\n"
70+
f"- Total files: {report.total_files}, Total lines: {report.total_lines}\n"
71+
f"- Overall score: {overall_score}/100\n"
72+
f"- Top issues found:\n{issues_block}\n"
73+
f"- Worst file: {worst}\n"
74+
f"- Sample of actual code from worst file:\n{sample}\n\n"
75+
"Generate:\n"
76+
"1. A one-liner headline roast\n"
77+
"2. 5-8 specific roast bullets\n"
78+
"3. A verdict: SHIP IT (score >= 75), NEEDS WORK (40-74), BURN IT DOWN (<40)\n\n"
79+
"Respond strictly as JSON with keys: headline, roast_lines, verdict, verdict_emoji."
80+
)
81+
82+
83+
def _normalize_roast_payload(payload: dict[str, Any], overall_score: int) -> RoastResult:
84+
verdict, emoji = _verdict_from_score(overall_score)
85+
headline = str(payload.get("headline", "")).strip() or "Your code survived, your dignity did not."
86+
if len(headline.split()) > 15:
87+
headline = " ".join(headline.split()[:15])
88+
89+
lines_raw = payload.get("roast_lines", [])
90+
if not isinstance(lines_raw, list):
91+
lines_raw = []
92+
roast_lines = [str(line).strip() for line in lines_raw if str(line).strip()]
93+
roast_lines = roast_lines[:8]
94+
if len(roast_lines) < 5:
95+
roast_lines.extend(_fallback_roast_lines(overall_score, needed=5 - len(roast_lines)))
96+
97+
return RoastResult(
98+
headline=headline,
99+
roast_lines=roast_lines,
100+
verdict=verdict,
101+
verdict_emoji=emoji,
102+
)
103+
104+
105+
def _fallback_roast_lines(overall_score: int, needed: int = 6) -> list[str]:
106+
pool = [
107+
"I found TODOs with a stronger roadmap than your architecture.",
108+
"Your variable names feel like keyboard smash with commit access.",
109+
"This repo has copy-paste confidence and production consequences.",
110+
"Error handling here is mostly spiritual, not technical.",
111+
"Half the functions read like improv, none land the punchline.",
112+
"The linter looked away and pretended not to know you.",
113+
"Magic numbers are everywhere except where logic should be.",
114+
"This code compiles, but so do regrets.",
115+
]
116+
if overall_score >= 75:
117+
pool[0] = "The code is decent, but your TODOs still owe rent."
118+
elif overall_score < 40:
119+
pool[7] = "This codebase is one merge away from folklore."
120+
return pool[:needed]
121+
122+
123+
def _generate_fallback_roast(report: AnalysisReport) -> RoastResult:
124+
overall_score = report.scores.get("Overall", 0)
125+
verdict, emoji = _verdict_from_score(overall_score)
126+
if overall_score >= 75:
127+
headline = "This code mostly behaves, unlike your naming choices."
128+
elif overall_score >= 40:
129+
headline = "Refactor roulette: spin once, cry twice."
130+
else:
131+
headline = "Your repo needs therapy, not another feature branch."
132+
return RoastResult(
133+
headline=headline,
134+
roast_lines=_fallback_roast_lines(overall_score, needed=6),
135+
verdict=verdict,
136+
verdict_emoji=emoji,
137+
)
138+
139+
140+
def generate_roast(
141+
report: AnalysisReport,
142+
files: list[FileResult],
143+
model: str = "gpt-4o-mini",
144+
no_llm: bool = False,
145+
) -> RoastResult:
146+
"""Generate a roast using an LLM or deterministic fallback mode."""
147+
if no_llm:
148+
return _generate_fallback_roast(report)
149+
150+
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
151+
overall_score = report.scores.get("Overall", 0)
152+
response = client.chat.completions.create(
153+
model=model,
154+
temperature=0.9,
155+
response_format={"type": "json_object"},
156+
messages=[
157+
{
158+
"role": "system",
159+
"content": (
160+
"You are a senior developer who has seen too much bad code. "
161+
"You are brutally honest but funny, like a Gordon Ramsay for codebases. "
162+
"Be specific, reference actual file names and issues. Never be generic. "
163+
"Keep roast lines under 20 words each."
164+
),
165+
},
166+
{"role": "user", "content": _build_user_prompt(report, files)},
167+
],
168+
)
169+
content = response.choices[0].message.content or "{}"
170+
payload = json.loads(content)
171+
return _normalize_roast_payload(payload, overall_score)

0 commit comments

Comments
 (0)