feat: Add AI-driven auto-update & restructure into package
The monolithic check_updates.py has been split into the update_monitor/ package with separate modules for fetching, extraction, comparison, classification, and notification. This makes the codebase easier to navigate and test. The flat services.yaml is replaced by per-service directories under services/<name>/config.yaml, co-locating config with update scripts and AI review examples. When --auto-update is enabled, services with an auto-update.sh script get their release notes classified by a local Ollama model (using few-shot examples from review-examples/) to decide whether the update can be applied automatically or needs manual admin intervention. Results are reported in a single consolidated Matrix message. Immich is the first service with auto-update configured, using 7 real release note examples to train the classifier.
This commit is contained in:
parent
f52ff222f0
commit
f55f3d8e91
36 changed files with 2612 additions and 821 deletions
108
update_monitor/classifier.py
Normal file
108
update_monitor/classifier.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def load_update_examples(examples_dir: Path) -> List[Dict[str, str]]:
|
||||
examples = []
|
||||
for path in sorted(examples_dir.glob("*.txt")):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "---" not in text:
|
||||
continue
|
||||
header, _, body = text.partition("---\n")
|
||||
action = reason = None
|
||||
for line in header.strip().splitlines():
|
||||
if line.startswith("action:"):
|
||||
action = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("reason:"):
|
||||
reason = line.split(":", 1)[1].strip()
|
||||
if action not in ("auto", "manual"):
|
||||
continue
|
||||
examples.append({"action": action, "reason": reason or "", "notes": body.strip()})
|
||||
return examples
|
||||
|
||||
|
||||
class Classifier:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "",
|
||||
model: str = "",
|
||||
) -> None:
|
||||
self.base_url = base_url or os.getenv("OLLAMA_BASE_URL", "http://192.168.1.20:11434")
|
||||
self.model = model or os.getenv("OLLAMA_MODEL", "gemma4:26b-64k")
|
||||
|
||||
def classify(
|
||||
self,
|
||||
service_dir: Path,
|
||||
release_notes: Optional[str],
|
||||
current: str,
|
||||
latest: str,
|
||||
timeout: float,
|
||||
) -> Dict[str, str]:
|
||||
if not release_notes:
|
||||
return {"action": "manual", "reason": "No release notes available; defaulting to manual review."}
|
||||
|
||||
examples_dir = service_dir / "review-examples"
|
||||
examples = load_update_examples(examples_dir) if examples_dir.is_dir() else []
|
||||
if not examples:
|
||||
return {"action": "manual", "reason": "No review examples configured; defaulting to manual review."}
|
||||
|
||||
messages = self._build_messages(service_dir.name, examples, release_notes)
|
||||
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0},
|
||||
}).encode("utf-8")
|
||||
req = Request(
|
||||
f"{self.base_url}/v1/chat/completions",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read())
|
||||
text = data["choices"][0]["message"]["content"].strip()
|
||||
start, end = text.find("{"), text.rfind("}") + 1
|
||||
if start == -1 or end == 0:
|
||||
raise ValueError(f"No JSON in response: {text[:200]}")
|
||||
result = json.loads(text[start:end])
|
||||
action = result.get("action", "")
|
||||
if action not in ("auto", "manual"):
|
||||
raise ValueError(f"Unexpected action value: {action!r}")
|
||||
return {"action": action, "reason": result.get("reason", "")}
|
||||
except Exception as exc:
|
||||
return {"action": "manual", "reason": f"Classification error: {exc}"}
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
service_name: str,
|
||||
examples: List[Dict[str, str]],
|
||||
release_notes: str,
|
||||
) -> List[Dict[str, str]]:
|
||||
system_prompt = (
|
||||
f"You are an update safety classifier for self-hosted {service_name} instances.\n"
|
||||
"Given release notes for a software update, decide whether the update can be applied\n"
|
||||
"automatically (action: auto) or requires the admin to intervene manually before or\n"
|
||||
"during the update (action: manual).\n\n"
|
||||
"Rules:\n"
|
||||
"- Classify only THIS release, not future or past releases mentioned in the notes.\n"
|
||||
"- Warnings or announcements about breaking changes in upcoming versions do NOT\n"
|
||||
" make the current release manual.\n"
|
||||
'- Respond ONLY with a JSON object: {"action": "auto", "reason": "..."}\n'
|
||||
' or {"action": "manual", "reason": "..."}\n'
|
||||
"- Keep reason under 60 words."
|
||||
)
|
||||
messages: List[Dict[str, str]] = [{"role": "system", "content": system_prompt}]
|
||||
for ex in examples:
|
||||
messages.append({"role": "user", "content": f"Release notes:\n{ex['notes']}"})
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": json.dumps({"action": ex["action"], "reason": ex["reason"]}),
|
||||
})
|
||||
messages.append({"role": "user", "content": f"Release notes:\n{release_notes}"})
|
||||
return messages
|
||||
Loading…
Add table
Add a link
Reference in a new issue