Add daily morning briefing via ntfy push notifications
Sends a summary at 6:30 AM weekdays / 8:00 AM weekends (Central) with Cora reports needing action, outlines to approve, PRs to review, and errors — grouped by customer. Cora tasks cross-reference network share folders to show file pipeline status. No LLM API credits burned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>fix/customer-field-migration
parent
45ab4c1b33
commit
6b5d0ac71e
|
|
@ -10,6 +10,7 @@ import threading
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
|
|
||||||
|
|
@ -49,7 +50,10 @@ class Scheduler:
|
||||||
self._autocora_thread: threading.Thread | None = None
|
self._autocora_thread: threading.Thread | None = None
|
||||||
self._content_watch_thread: threading.Thread | None = None
|
self._content_watch_thread: threading.Thread | None = None
|
||||||
self._cora_distribute_thread: threading.Thread | None = None
|
self._cora_distribute_thread: threading.Thread | None = None
|
||||||
|
self._briefing_thread: threading.Thread | None = None
|
||||||
self._force_autocora = threading.Event()
|
self._force_autocora = threading.Event()
|
||||||
|
self._force_briefing = threading.Event()
|
||||||
|
self._last_briefing_date: str | None = None
|
||||||
self._clickup_client = None
|
self._clickup_client = None
|
||||||
self._field_filter_cache: dict | None = None
|
self._field_filter_cache: dict | None = None
|
||||||
self._loop_timestamps: dict[str, str | None] = {
|
self._loop_timestamps: dict[str, str | None] = {
|
||||||
|
|
@ -60,6 +64,7 @@ class Scheduler:
|
||||||
"autocora": None,
|
"autocora": None,
|
||||||
"content_watch": None,
|
"content_watch": None,
|
||||||
"cora_distribute": None,
|
"cora_distribute": None,
|
||||||
|
"briefing": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
|
@ -142,6 +147,16 @@ class Scheduler:
|
||||||
else:
|
else:
|
||||||
log.info("Cora distribution watcher disabled (no cora_human_inbox configured)")
|
log.info("Cora distribution watcher disabled (no cora_human_inbox configured)")
|
||||||
|
|
||||||
|
# Start morning briefing loop if ClickUp is configured
|
||||||
|
if self.config.clickup.enabled:
|
||||||
|
self._briefing_thread = threading.Thread(
|
||||||
|
target=self._briefing_loop, daemon=True, name="briefing"
|
||||||
|
)
|
||||||
|
self._briefing_thread.start()
|
||||||
|
log.info("Morning briefing loop started")
|
||||||
|
else:
|
||||||
|
log.info("Morning briefing disabled (ClickUp not configured)")
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Scheduler started (poll=%ds, heartbeat=%dm)",
|
"Scheduler started (poll=%ds, heartbeat=%dm)",
|
||||||
self.config.scheduler.poll_interval_seconds,
|
self.config.scheduler.poll_interval_seconds,
|
||||||
|
|
@ -184,6 +199,11 @@ class Scheduler:
|
||||||
"""Wake the AutoCora poll loop immediately."""
|
"""Wake the AutoCora poll loop immediately."""
|
||||||
self._force_autocora.set()
|
self._force_autocora.set()
|
||||||
|
|
||||||
|
def force_briefing(self):
|
||||||
|
"""Force the morning briefing to send now (ignores schedule/dedup)."""
|
||||||
|
self._last_briefing_date = None
|
||||||
|
self._force_briefing.set()
|
||||||
|
|
||||||
def get_loop_timestamps(self) -> dict[str, str | None]:
|
def get_loop_timestamps(self) -> dict[str, str | None]:
|
||||||
"""Return last_run timestamps for all loops (in-memory)."""
|
"""Return last_run timestamps for all loops (in-memory)."""
|
||||||
return dict(self._loop_timestamps)
|
return dict(self._loop_timestamps)
|
||||||
|
|
@ -1111,3 +1131,199 @@ class Scheduler:
|
||||||
f"Matched tasks: {', '.join(matched_names)}",
|
f"Matched tasks: {', '.join(matched_names)}",
|
||||||
category="autocora",
|
category="autocora",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Morning Briefing ──
|
||||||
|
|
||||||
|
_CENTRAL = ZoneInfo("America/Chicago")
|
||||||
|
|
||||||
|
def _briefing_loop(self):
|
||||||
|
"""Check every 60s if it's time to send the morning briefing."""
|
||||||
|
# Wait before first check
|
||||||
|
self._stop_event.wait(30)
|
||||||
|
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
try:
|
||||||
|
forced = self._force_briefing.is_set()
|
||||||
|
if forced:
|
||||||
|
self._force_briefing.clear()
|
||||||
|
|
||||||
|
now = datetime.now(self._CENTRAL)
|
||||||
|
today = now.strftime("%Y-%m-%d")
|
||||||
|
is_weekday = now.weekday() < 5 # Mon=0 .. Fri=4
|
||||||
|
|
||||||
|
target_hour = 6 if is_weekday else 8
|
||||||
|
target_minute = 30 if is_weekday else 0
|
||||||
|
|
||||||
|
at_briefing_time = (
|
||||||
|
now.hour == target_hour and now.minute == target_minute
|
||||||
|
)
|
||||||
|
already_sent = self._last_briefing_date == today
|
||||||
|
|
||||||
|
if forced or (at_briefing_time and not already_sent):
|
||||||
|
self._send_morning_briefing()
|
||||||
|
self._last_briefing_date = today
|
||||||
|
self._loop_timestamps["briefing"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Briefing loop error: %s", e)
|
||||||
|
|
||||||
|
self._interruptible_wait(60, self._force_briefing)
|
||||||
|
|
||||||
|
def _send_morning_briefing(self):
|
||||||
|
"""Build and send the morning briefing notification."""
|
||||||
|
client = self._get_clickup_client()
|
||||||
|
space_id = self.config.clickup.space_id
|
||||||
|
if not space_id:
|
||||||
|
log.warning("Briefing skipped — no space_id configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Query tasks in the 4 relevant statuses
|
||||||
|
briefing_statuses = [
|
||||||
|
"running cora",
|
||||||
|
"outline review",
|
||||||
|
self.config.clickup.pr_review_status, # "pr needs review"
|
||||||
|
self.config.clickup.error_status, # "error"
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks = client.get_tasks_from_overall_lists(
|
||||||
|
space_id, statuses=briefing_statuses
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Briefing: ClickUp query failed: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Bucket tasks by status
|
||||||
|
cora_tasks = []
|
||||||
|
outline_tasks = []
|
||||||
|
pr_tasks = []
|
||||||
|
error_tasks = []
|
||||||
|
|
||||||
|
for t in tasks:
|
||||||
|
if t.status == "running cora":
|
||||||
|
cora_tasks.append(t)
|
||||||
|
elif t.status == "outline review":
|
||||||
|
outline_tasks.append(t)
|
||||||
|
elif t.status == self.config.clickup.pr_review_status:
|
||||||
|
pr_tasks.append(t)
|
||||||
|
elif t.status == self.config.clickup.error_status:
|
||||||
|
error_tasks.append(t)
|
||||||
|
|
||||||
|
# If nothing needs attention, send a short all-clear
|
||||||
|
if not any([cora_tasks, outline_tasks, pr_tasks, error_tasks]):
|
||||||
|
self._notify("Morning briefing: All clear — nothing needs attention.", category="briefing")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = ["Morning Briefing", ""]
|
||||||
|
|
||||||
|
# Section 1: Cora reports
|
||||||
|
if cora_tasks:
|
||||||
|
cora_statuses = self._check_cora_file_status(cora_tasks)
|
||||||
|
lines.append(f"CORA REPORTS ({len(cora_tasks)})")
|
||||||
|
by_customer = self._group_by_customer(cora_tasks)
|
||||||
|
for customer, ctasks in by_customer.items():
|
||||||
|
lines.append(f" {customer}:")
|
||||||
|
for t in ctasks:
|
||||||
|
status_note = cora_statuses.get(t.id, "")
|
||||||
|
suffix = f" — {status_note}" if status_note else ""
|
||||||
|
lines.append(f" - {t.name}{suffix}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Section 2: Outlines to approve
|
||||||
|
if outline_tasks:
|
||||||
|
lines.append(f"OUTLINES TO APPROVE ({len(outline_tasks)})")
|
||||||
|
by_customer = self._group_by_customer(outline_tasks)
|
||||||
|
for customer, ctasks in by_customer.items():
|
||||||
|
lines.append(f" {customer}:")
|
||||||
|
for t in ctasks:
|
||||||
|
lines.append(f" - {t.name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Section 3: PRs to review
|
||||||
|
if pr_tasks:
|
||||||
|
lines.append(f"PRs TO REVIEW ({len(pr_tasks)})")
|
||||||
|
by_customer = self._group_by_customer(pr_tasks)
|
||||||
|
for customer, ctasks in by_customer.items():
|
||||||
|
lines.append(f" {customer}:")
|
||||||
|
for t in ctasks:
|
||||||
|
lines.append(f" - {t.name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Section 4: Errors
|
||||||
|
if error_tasks:
|
||||||
|
lines.append(f"ERRORS ({len(error_tasks)})")
|
||||||
|
by_customer = self._group_by_customer(error_tasks)
|
||||||
|
for customer, ctasks in by_customer.items():
|
||||||
|
lines.append(f" {customer}:")
|
||||||
|
for t in ctasks:
|
||||||
|
lines.append(f" - {t.name}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
message = "\n".join(lines).rstrip()
|
||||||
|
self._notify(message, category="briefing")
|
||||||
|
log.info("Morning briefing sent (%d cora, %d outlines, %d PRs, %d errors)",
|
||||||
|
len(cora_tasks), len(outline_tasks), len(pr_tasks), len(error_tasks))
|
||||||
|
|
||||||
|
def _group_by_customer(self, tasks) -> dict[str, list]:
|
||||||
|
"""Group tasks by their Customer custom field."""
|
||||||
|
groups: dict[str, list] = {}
|
||||||
|
for t in tasks:
|
||||||
|
customer = t.custom_fields.get("Customer", "") or "Unknown"
|
||||||
|
groups.setdefault(str(customer), []).append(t)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
def _check_cora_file_status(self, cora_tasks) -> dict[str, str]:
|
||||||
|
"""For each 'running cora' task, check where its xlsx sits on the network.
|
||||||
|
|
||||||
|
Returns a dict of task_id → human-readable status note.
|
||||||
|
"""
|
||||||
|
from .tools.linkbuilding import _fuzzy_keyword_match, _normalize_for_match
|
||||||
|
|
||||||
|
# Collect xlsx filenames from all relevant folders
|
||||||
|
folders = {
|
||||||
|
"cora_human": Path(self.config.autocora.cora_human_inbox),
|
||||||
|
"cora_human_processed": Path(self.config.autocora.cora_human_inbox) / "processed",
|
||||||
|
"lb_inbox": Path(self.config.link_building.watch_folder),
|
||||||
|
"lb_processed": Path(self.config.link_building.watch_folder) / "processed",
|
||||||
|
"content_inbox": Path(self.config.content.cora_inbox),
|
||||||
|
"content_processed": Path(self.config.content.cora_inbox) / "processed",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build a map: normalized_stem → set of folder keys
|
||||||
|
file_locations: dict[str, set[str]] = {}
|
||||||
|
for folder_key, folder_path in folders.items():
|
||||||
|
if not folder_path.exists():
|
||||||
|
continue
|
||||||
|
for xlsx in folder_path.glob("*.xlsx"):
|
||||||
|
if xlsx.name.startswith("~$"):
|
||||||
|
continue
|
||||||
|
stem = xlsx.stem.lower().replace("-", " ").replace("_", " ")
|
||||||
|
stem = re.sub(r"\s+", " ", stem).strip()
|
||||||
|
file_locations.setdefault(stem, set()).add(folder_key)
|
||||||
|
|
||||||
|
# Match each task's keyword against the file stems
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for task in cora_tasks:
|
||||||
|
keyword = task.custom_fields.get("Keyword", "") or task.name
|
||||||
|
keyword_norm = _normalize_for_match(str(keyword))
|
||||||
|
|
||||||
|
# Find which folders have a matching file
|
||||||
|
matched_folders: set[str] = set()
|
||||||
|
for stem, locs in file_locations.items():
|
||||||
|
if _fuzzy_keyword_match(keyword_norm, stem):
|
||||||
|
matched_folders.update(locs)
|
||||||
|
|
||||||
|
if not matched_folders:
|
||||||
|
result[task.id] = "needs cora.xlsx from worker machine"
|
||||||
|
elif matched_folders & {"lb_inbox", "content_inbox"}:
|
||||||
|
result[task.id] = "queued for processing"
|
||||||
|
elif matched_folders & {"lb_processed", "content_processed"}:
|
||||||
|
result[task.id] = "pipeline complete"
|
||||||
|
elif "cora_human" in matched_folders:
|
||||||
|
result[task.id] = "waiting for distribution"
|
||||||
|
elif "cora_human_processed" in matched_folders:
|
||||||
|
result[task.id] = "distributed, waiting for pipeline"
|
||||||
|
else:
|
||||||
|
result[task.id] = "needs cora.xlsx from worker machine"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,11 @@ ntfy:
|
||||||
include_patterns: ["failed", "FAILURE", "skipped", "no ClickUp match", "copy failed", "IMSURL is empty"]
|
include_patterns: ["failed", "FAILURE", "skipped", "no ClickUp match", "copy failed", "IMSURL is empty"]
|
||||||
priority: urgent
|
priority: urgent
|
||||||
tags: rotating_light
|
tags: rotating_light
|
||||||
|
- name: daily_briefing
|
||||||
|
topic_env_var: NTFY_TOPIC_DAILY_BRIEFING
|
||||||
|
categories: [briefing]
|
||||||
|
priority: high
|
||||||
|
tags: clipboard
|
||||||
|
|
||||||
# Multi-agent configuration
|
# Multi-agent configuration
|
||||||
# Each agent gets its own personality, tool whitelist, and memory scope.
|
# Each agent gets its own personality, tool whitelist, and memory scope.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue