Files
hermes-agent/cron/scripts/classify_items.py
2026-09-02 19:32:42 -07:00

168 lines
6.3 KiB
Python

#!/usr/bin/env python3
"""Classify candidate items by urgency/importance and emit only the urgent ones.
The proactive-monitor pattern: a fetch step (watcher script, inbox dump, feed) produces a JSON list
of candidate items (stdin or --input-file); one call to the auxiliary ``monitor`` model scores the
whole batch and ONLY items at/above --threshold are printed. Empty stdout -> the cron job's
[SILENT]/empty-stdout path suppresses delivery, so quiet intervals never spam. A classifier
failure exits non-zero (never silently swallowed). Items are opaque objects; a
title/subject/summary/text field helps, and id/guid/message_id/url is echoed back for upstream dedup.
Usage: cat items.json | python classify_items.py --threshold 7 --criteria "Urgent if ..."
"""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any, Dict, List, Optional
_ID_KEYS = ("id", "guid", "message_id", "url", "link")
_VIEW_KEYS = ("title", "subject", "summary", "text", "body", "from", "sender", "url")
def _eprint(*args: Any) -> None:
print(*args, file=sys.stderr)
def _load_items(input_file: Optional[str]) -> List[Dict[str, Any]]:
if input_file:
with open(input_file, encoding="utf-8") as f:
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
return []
try:
data = json.loads(raw)
except json.JSONDecodeError as e:
_eprint(f"classify_items: input is not valid JSON: {e}")
sys.exit(2)
if isinstance(data, dict):
# Allow {"items": [...]} or a single object.
if isinstance(data.get("items"), list):
return data["items"]
return [data]
if isinstance(data, list):
return [x for x in data if isinstance(x, dict)]
_eprint("classify_items: expected a JSON list or {items: [...]}")
sys.exit(2)
def _item_id(item: Dict[str, Any], index: int) -> str:
return next((str(item[key]) for key in _ID_KEYS if item.get(key)), f"item-{index}")
def _build_prompt(items: List[Dict[str, Any]], criteria: str) -> str:
lines = [f"USER IMPORTANCE CRITERIA:\n{criteria}\n", "ITEMS:"]
for i, item in enumerate(items):
# Compact view of the salient fields; the whole object when none are present.
view = {k: item[k] for k in _VIEW_KEYS if k in item} or item
lines.append(f"[{i}] {json.dumps(view, ensure_ascii=False)[:1200]}")
lines.append("\nReturn the JSON array of scores now (one object per item, same order).")
return "\n".join(lines)
def _parse_scores(content: str, n_items: int) -> Dict[int, Dict[str, Any]]:
text = (content or "").strip()
# Tolerate accidental markdown fences.
if text.startswith("```"):
text = text.strip("`")
if "\n" in text:
text = text.split("\n", 1)[1]
try:
arr = json.loads(text)
except json.JSONDecodeError:
# Last-ditch: find the first [...] block.
start = text.find("[")
end = text.rfind("]")
if not (start >= 0 and end > start):
_eprint("classify_items: classifier returned no JSON array")
return {}
try:
arr = json.loads(text[start : end + 1])
except json.JSONDecodeError:
_eprint("classify_items: could not parse classifier output")
return {}
if not isinstance(arr, list):
return {}
return {
obj["index"]: obj
for obj in arr
if isinstance(obj, dict) and isinstance(obj.get("index"), int) and 0 <= obj["index"] < n_items
}
def _render_text(surfaced: list) -> str:
blocks = []
for i, item, s in surfaced:
title = item.get("title") or item.get("subject") or item.get("summary") or _item_id(item, i)
block = f"## [{s.get('score')}/10] {title}"
if url := item.get("url") or item.get("link") or "":
block += f"\n{url}"
if reason := s.get("reason", ""):
block += f"\n_{reason}_"
blocks.append(block)
return "\n\n".join(blocks)
def main() -> int:
parser = argparse.ArgumentParser(description="Classify items by urgency; emit only urgent ones.")
parser.add_argument("--criteria", required=True, help="Plain-language importance criteria.")
parser.add_argument("--threshold", type=int, default=7, help="Minimum score (0-10) to surface. Default 7.")
parser.add_argument("--input-file", default=None, help="Read items JSON from this file instead of stdin.")
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format for surfaced items.")
args = parser.parse_args()
items = _load_items(args.input_file)
if not items:
return 0 # nothing to classify -> silent (the common quiet-interval case)
# Import here so --help works without the package importable.
try:
from agent.auxiliary_client import call_llm
except Exception as e: # pragma: no cover - import guard
_eprint(f"classify_items: cannot import auxiliary client: {e}")
return 3
prompt = _build_prompt(items, args.criteria)
try:
resp = call_llm(
task="monitor", messages=[{"role": "user", "content": prompt}], max_tokens=1024,
temperature=0,
)
content = resp.choices[0].message.content
if not isinstance(content, str):
content = str(content) if content else ""
except Exception as e:
# A broken monitor must not quietly swallow important items: non-zero exit -> cron alerts.
_eprint(f"classify_items: classifier call failed: {e}")
return 4
scores = _parse_scores(content, len(items))
surfaced = []
for i, item in enumerate(items):
s = scores.get(i)
score = s.get("score") if isinstance(s, dict) else None
if isinstance(score, int) and score >= args.threshold:
surfaced.append((i, item, s))
if not surfaced:
return 0 # below threshold -> silent; empty stdout suppresses delivery
if args.format == "json":
out = [
{"id": _item_id(item, i), "score": s.get("score"), "reason": s.get("reason", ""), "item": item}
for (i, item, s) in surfaced
]
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print(_render_text(surfaced))
return 0
if __name__ == "__main__":
sys.exit(main())