168 lines
6.3 KiB
Python
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())
|