#!/usr/bin/env python3 # -*- coding: utf-8 -*- # ── LLM-based Triage ────────────────────────────────────────────────────────── # # A Python script to automate the triage of the backlog using a local LLM. # This script is intended to be a more robust and maintainable replacement for # the `deep_triage.sh` script. # # ───────────────────────────────────────────────────────────────────────────── import json import os import sys from pathlib import Path import ollama import httpx # Add src to PYTHONPATH sys.path.append(str(Path(__file__).parent.parent / "src")) from config import settings # ── Constants ──────────────────────────────────────────────────────────────── REPO_ROOT = Path(__file__).parent.parent QUEUE_PATH = REPO_ROOT / ".loop/queue.json" RETRO_PATH = REPO_ROOT / ".loop/retro/deep-triage.jsonl" SUMMARY_PATH = REPO_ROOT / ".loop/retro/summary.json" PROMPT_PATH = REPO_ROOT / "scripts/deep_triage_prompt.md" DEFAULT_MODEL = "qwen3:30b" class GiteaClient: """A client for the Gitea API.""" def __init__(self, url: str, token: str, repo: str): self.url = url self.token = token self.repo = repo self.headers = { "Authorization": f"token {token}", "Content-Type": "application/json", } def create_issue(self, title: str, body: str) -> None: """Creates a new issue.""" url = f"{self.url}/api/v1/repos/{self.repo}/issues" data = {"title": title, "body": body} with httpx.Client() as client: response = client.post(url, headers=self.headers, json=data) response.raise_for_status() def close_issue(self, issue_id: int) -> None: """Closes an issue.""" url = f"{self.url}/api/v1/repos/{self.repo}/issues/{issue_id}" data = {"state": "closed"} with httpx.Client() as client: response = client.patch(url, headers=self.headers, json=data) response.raise_for_status() def get_llm_client(): """Returns an Ollama client.""" return ollama.Client() def get_prompt(): """Returns the triage prompt.""" try: return PROMPT_PATH.read_text() except FileNotFoundError: print(f"Error: Prompt file not found at {PROMPT_PATH}") return "" def get_context(): """Returns the context for the triage prompt.""" queue_contents = "" if QUEUE_PATH.exists(): queue_contents = QUEUE_PATH.read_text() last_retro = "" if RETRO_PATH.exists(): with open(RETRO_PATH, "r") as f: lines = f.readlines() if lines: last_retro = lines[-1] summary = "" if SUMMARY_PATH.exists(): summary = SUMMARY_PATH.read_text() return f""" ═══════════════════════════════════════════════════════════════════════════════ CURRENT CONTEXT (auto-injected) ═══════════════════════════════════════════════════════════════════════════════ CURRENT QUEUE (.loop/queue.json): {queue_contents} CYCLE SUMMARY (.loop/retro/summary.json): {summary} LAST DEEP TRIAGE RETRO: {last_retro} Do your work now. """ def parse_llm_response(response: str) -> tuple[list, dict]: """Parses the LLM's response.""" try: data = json.loads(response) return data.get("queue", []), data.get("retro", {}) except json.JSONDecodeError: print("Error: Failed to parse LLM response as JSON.") return [], {} def write_queue(queue: list) -> None: """Writes the updated queue to disk.""" with open(QUEUE_PATH, "w") as f: json.dump(queue, f, indent=2) def write_retro(retro: dict) -> None: """Writes the retro entry to disk.""" with open(RETRO_PATH, "a") as f: json.dump(retro, f) f.write("\n") def run_triage(model: str = DEFAULT_MODEL): """Runs the triage process.""" client = get_llm_client() prompt = get_prompt() if not prompt: return context = get_context() full_prompt = f"{prompt}\n{context}" try: response = client.chat( model=model, messages=[ { "role": "user", "content": full_prompt, }, ], ) llm_output = response["message"]["content"] queue, retro = parse_llm_response(llm_output) if queue: write_queue(queue) if retro: write_retro(retro) gitea_client = GiteaClient( url=settings.gitea_url, token=settings.gitea_token, repo=settings.gitea_repo, ) for issue_id in retro.get("issues_closed", []): gitea_client.close_issue(issue_id) for issue in retro.get("issues_created", []): gitea_client.create_issue(issue["title"], issue["body"]) except ollama.ResponseError as e: print(f"Error: Ollama API request failed: {e}") except httpx.HTTPStatusError as e: print(f"Error: Gitea API request failed: {e}") if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Automated backlog triage using an LLM.") parser.add_argument( "--model", type=str, default=DEFAULT_MODEL, help=f"The Ollama model to use for triage (default: {DEFAULT_MODEL})", ) args = parser.parse_args() run_triage(model=args.model)