For this walkthrough, imagine a small team maintaining Luma, a field-service app. Their tracker holds 412 open issues. Support keeps filing duplicates. The engineering lead spends every Monday re-reading old threads. The two junior developers have no clear order for what to fix next. The backlog is not short on data. It is short on a pipeline.
The team does not need another priority matrix. They need a repeatable way to shrink the backlog before the weekly planning call. The workflow in this article uses two passes. The first pass asks a free model to read each bug report and return a severity, type, and one-sentence summary. The second pass applies rules built from the team's own bug history. A static dashboard then puts the ranked list in front of the lead without a backend.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The team used MonkeyCode's free model access and free server option as the deployment target for this experiment. The scripts below are reference implementations. They assume an HTTP endpoint that accepts a prompt and returns JSON. Replace the placeholder endpoint and credentials from the provider console before running anything.
Start with a small export
Start with an export from the issue tracker. A small JSON file is enough for this workflow. Each record contains an id, title, body, report count, component, and age in days. Keep the export small at first. A hundred issues is enough to learn the thresholds.
# sample_triage.py
sample = [
{'id': 'LUMA-221', 'title': 'App crashes when photo upload fails on Android 11', 'body': 'User taps retry three times. The app exits. Logs show a null bitmap.', 'reports': 6, 'component': 'mobile', 'age_days': 4},
{'id': 'LUMA-198', 'title': 'Billing page shows stale total after discount', 'body': 'The total does not update until refresh.', 'reports': 2, 'component': 'billing', 'age_days': 12},
{'id': 'LUMA-150', 'title': 'Typo in email template', 'body': 'The word receipt is misspelled.', 'reports': 1, 'component': 'messaging', 'age_days': 40},
]
First pass: ask the model to summarize
The first pass is deliberately narrow. The model receives title and body. It returns severity, type, and a one-sentence summary. The prompt names the severity scale so the output does not drift.
# triage.py
import json
import os
import time
import requests
MODEL_URL = os.getenv('MONKEYCODE_MODEL_URL')
API_KEY = os.getenv('MONKEYCODE_API_KEY')
PROMPT = '''
Classify this bug report.
Return only a JSON object like this:
{
'severity': 1,
'type': 'crash',
'summary': 'short sentence'
}
Severity 1 is critical, 2 is major, 3 is minor, 4 is cosmetic.
Bug report:
'''
def call_model(issue):
if not MODEL_URL:
return {'severity': 3, 'type': 'unconfigured', 'summary': 'no model endpoint set'}
text = issue['title'] + chr(10) + issue.get('body', '')
headers = {'Authorization': 'Bearer ' + (API_KEY or '')}
response = requests.post(
MODEL_URL,
headers=headers,
json={'prompt': PROMPT + text, 'max_tokens': 80},
timeout=30,
)
response.raise_for_status()
data = response.json()
return data.get('output', {'severity': 3, 'type': 'unknown', 'summary': 'unparsed model output'})
RULES = [
(lambda issue: 'crash' in issue['title'].lower(), 4),
(lambda issue: 'data loss' in issue['title'].lower(), 4),
(lambda issue: issue.get('reports', 0) >= 5, 2),
(lambda issue: issue.get('age_days', 0) > 30, 1),
(lambda issue: issue.get('component') == 'billing', 2),
]
def rule_score(issue):
return sum(weight for rule, weight in RULES if rule(issue))
def combined_score(issue):
severity = issue.get('model', {}).get('severity', 3)
model_points = max(0, 5 - int(severity)) * 3
freshness_points = max(0, 60 - issue.get('age_days', 0)) / 10.0
return model_points + rule_score(issue) + freshness_points
def main():
path = os.getenv('ISSUES_PATH', 'issues.json')
with open(path, encoding='utf-8') as f:
issues = json.load(f)
for issue in issues:
issue['model'] = call_model(issue)
issue['rule_score'] = rule_score(issue)
issue['final_score'] = combined_score(issue)
time.sleep(0.2)
issues.sort(key=lambda x: x['final_score'], reverse=True)
with open('results.json', 'w', encoding='utf-8') as f:
json.dump(issues, f, indent=2)
if __name__ == '__main__':
main()
Second pass: score from history
The rule pass adds signal the model cannot see. Crash words, data loss words, report count, age, and component all get small weights. The weights come from the team's own bug history. A team that regularly loses revenue on billing issues can raise the billing weight. A team with a noisy tracker can keep it low.
The combine step treats the model as one signal, not the whole decision. A severity 1 report receives twelve model points. A crash title receives four rule points. A fresh report earns a fractional freshness bonus. The formula is deliberately simple so it can be explained during the planning call.
Dashboard: static and read-only
The ranking becomes useful only when someone can see it. A static dashboard keeps the infrastructure small. The script renders the top twenty-five issues into a single HTML file. No backend, no database, no login.
# build_dashboard.py
import html
import json
import os
rows = json.load(open('results.json', encoding='utf-8'))
rows.sort(key=lambda x: x['final_score'], reverse=True)
out = []
out.append('<html><head><title>Bug triage</title></head><body>')
out.append('<table border=1>')
out.append('<tr><th>Score</th><th>Issue</th><th>Type</th><th>Summary</th></tr>')
for issue in rows[:25]:
line = ['<tr><td>']
line.append(format(issue['final_score'], '.1f'))
line.append('</td><td>')
line.append(html.escape(issue['title']))
line.append('</td><td>')
line.append(html.escape(issue.get('model', {}).get('type', '')))
line.append('</td><td>')
line.append(html.escape(issue.get('model', {}).get('summary', '')))
line.append('</td></tr>')
out.append(''.join(line))
out.append('</table></body></html>')
os.makedirs('out', exist_ok=True)
open('out/index.html', 'w', encoding='utf-8').write(chr(10).join(out))
Run it once
Run the pipeline in a local virtual environment. The environment variables keep the endpoint and key out of the script.
python3 -m venv .venv
source .venv/bin/activate
pip install requests pytest
export MONKEYCODE_MODEL_URL='https://replace-me.example/v1/chat'
export MONKEYCODE_API_KEY='replace-me'
export ISSUES_PATH='issues.json'
python triage.py
python build_dashboard.py
python -m pytest test_triage.py
Upload the out/ directory to a static host. If the team uses MonkeyCode's free server option, the dashboard can sit behind a read-only route. Keep API keys out of the directory. The rendered page contains only the twenty-five highest scored issues.
Test the deterministic half
The model call may change between runs. The rule pass should not. That makes the rules the right place for a small test.
# test_triage.py
from triage import rule_score, combined_score
def test_crash_rule_scores_high():
issue = {'title': 'Crash in cart', 'reports': 6, 'age_days': 1, 'component': 'mobile'}
assert rule_score(issue) >= 6
def test_missing_reports_does_not_break():
issue = {'title': 'Typo on login page', 'age_days': 2}
assert rule_score(issue) == 0
def test_severity_one_gets_more_model_points():
issue = {'title': 'Data loss after sync', 'reports': 1, 'age_days': 1, 'component': 'sync', 'model': {'severity': 1, 'type': 'data-loss', 'summary': 'sync loses record'}}
assert combined_score(issue) > 5
Limits of this approach
The free model output is not deterministic. The same report can receive a different severity on two runs. Treat the model pass as a suggestion, not a label. Quotas may cap batch size, so the script sleeps between calls and the first run may take minutes. Rule weights are only as good as the team's labels. Stale labels make the second pass stale too. Do not send credentials, stack traces with secrets, or customer data to a third-party model without review. Free server options usually have storage and uptime limits, so keep the dashboard disposable.
Who should skip this
Teams with strict data residency rules should skip the model pass. Very large backlogs, above a few thousand issues per run, will strain most free quotas. Teams that want a one-click auto-close tool should look elsewhere. This pipeline shrinks the review step. It does not replace the human decision.
The pipeline earns its keep because it makes the review step smaller. The model pass reads the words. The rule pass reads the history. The lead still makes the call. Adapt the prompt to your tracker's severity definitions and run the rule tests first. That keeps the free model on a short leash.
Top comments (0)