Wire scheduler to API/UI and improve loop control and PR headlines
- Pass scheduler instance to API router and UI for loop timestamps and force-run endpoints - Add interruptible waits and force_heartbeat/force_poll methods - Record last_run timestamps for all scheduler loops in KV store - Update press release headline examples with real client headlines Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>cora-start
parent
ffa8ad49e5
commit
0e3e3bc945
|
|
@ -122,6 +122,7 @@ def main():
|
||||||
log.warning("Notification bus not available: %s", e)
|
log.warning("Notification bus not available: %s", e)
|
||||||
|
|
||||||
# Scheduler (uses default agent)
|
# Scheduler (uses default agent)
|
||||||
|
scheduler = None
|
||||||
try:
|
try:
|
||||||
from .scheduler import Scheduler
|
from .scheduler import Scheduler
|
||||||
|
|
||||||
|
|
@ -132,7 +133,9 @@ def main():
|
||||||
log.warning("Scheduler not available: %s", e)
|
log.warning("Scheduler not available: %s", e)
|
||||||
|
|
||||||
log.info("Launching Gradio UI on %s:%s...", config.host, config.port)
|
log.info("Launching Gradio UI on %s:%s...", config.host, config.port)
|
||||||
blocks = create_ui(registry, config, default_llm, notification_bus=notification_bus)
|
blocks = create_ui(
|
||||||
|
registry, config, default_llm, notification_bus=notification_bus, scheduler=scheduler
|
||||||
|
)
|
||||||
|
|
||||||
# Build a parent FastAPI app so we can mount the dashboard alongside Gradio.
|
# Build a parent FastAPI app so we can mount the dashboard alongside Gradio.
|
||||||
# Inserting routes into blocks.app before launch() doesn't work because
|
# Inserting routes into blocks.app before launch() doesn't work because
|
||||||
|
|
@ -148,7 +151,7 @@ def main():
|
||||||
# Mount API endpoints
|
# Mount API endpoints
|
||||||
from .api import create_api_router
|
from .api import create_api_router
|
||||||
|
|
||||||
api_router = create_api_router(config, db, registry)
|
api_router = create_api_router(config, db, registry, scheduler=scheduler)
|
||||||
fastapi_app.include_router(api_router)
|
fastapi_app.include_router(api_router)
|
||||||
log.info("API router mounted at /api/")
|
log.info("API router mounted at /api/")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ class Scheduler:
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
self.notification_bus = notification_bus
|
self.notification_bus = notification_bus
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
|
self._force_heartbeat = threading.Event()
|
||||||
|
self._force_poll = threading.Event()
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
self._heartbeat_thread: threading.Thread | None = None
|
self._heartbeat_thread: threading.Thread | None = None
|
||||||
self._clickup_thread: threading.Thread | None = None
|
self._clickup_thread: threading.Thread | None = None
|
||||||
|
|
@ -111,15 +113,49 @@ class Scheduler:
|
||||||
else:
|
else:
|
||||||
log.info("Notification [%s]: %s", category, message)
|
log.info("Notification [%s]: %s", category, message)
|
||||||
|
|
||||||
|
# ── Loop control ──
|
||||||
|
|
||||||
|
def _interruptible_wait(self, seconds: float, force_event: threading.Event | None = None):
|
||||||
|
"""Wait for *seconds*, returning early if stop or force event fires."""
|
||||||
|
remaining = seconds
|
||||||
|
while remaining > 0 and not self._stop_event.is_set():
|
||||||
|
if force_event and force_event.is_set():
|
||||||
|
force_event.clear()
|
||||||
|
return
|
||||||
|
self._stop_event.wait(min(5, remaining))
|
||||||
|
remaining -= 5
|
||||||
|
|
||||||
|
def force_heartbeat(self):
|
||||||
|
"""Wake the heartbeat loop immediately."""
|
||||||
|
self._force_heartbeat.set()
|
||||||
|
|
||||||
|
def force_poll(self):
|
||||||
|
"""Wake the scheduler poll loop immediately."""
|
||||||
|
self._force_poll.set()
|
||||||
|
|
||||||
|
def get_loop_timestamps(self) -> dict[str, str | None]:
|
||||||
|
"""Return last_run timestamps for all loops."""
|
||||||
|
return {
|
||||||
|
"heartbeat": self.db.kv_get("system:loop:heartbeat:last_run"),
|
||||||
|
"poll": self.db.kv_get("system:loop:poll:last_run"),
|
||||||
|
"clickup": self.db.kv_get("system:loop:clickup:last_run"),
|
||||||
|
"folder_watch": self.db.kv_get("system:loop:folder_watch:last_run"),
|
||||||
|
}
|
||||||
|
|
||||||
# ── Scheduled Tasks ──
|
# ── Scheduled Tasks ──
|
||||||
|
|
||||||
def _poll_loop(self):
|
def _poll_loop(self):
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self._run_due_tasks()
|
self._run_due_tasks()
|
||||||
|
self.db.kv_set(
|
||||||
|
"system:loop:poll:last_run", datetime.now(UTC).isoformat()
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Scheduler poll error: %s", e)
|
log.error("Scheduler poll error: %s", e)
|
||||||
self._stop_event.wait(self.config.scheduler.poll_interval_seconds)
|
self._interruptible_wait(
|
||||||
|
self.config.scheduler.poll_interval_seconds, self._force_poll
|
||||||
|
)
|
||||||
|
|
||||||
def _run_due_tasks(self):
|
def _run_due_tasks(self):
|
||||||
tasks = self.db.get_due_tasks()
|
tasks = self.db.get_due_tasks()
|
||||||
|
|
@ -154,9 +190,12 @@ class Scheduler:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self._run_heartbeat()
|
self._run_heartbeat()
|
||||||
|
self.db.kv_set(
|
||||||
|
"system:loop:heartbeat:last_run", datetime.now(UTC).isoformat()
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Heartbeat error: %s", e)
|
log.error("Heartbeat error: %s", e)
|
||||||
self._stop_event.wait(interval)
|
self._interruptible_wait(interval, self._force_heartbeat)
|
||||||
|
|
||||||
def _run_heartbeat(self):
|
def _run_heartbeat(self):
|
||||||
heartbeat_path = self.config.identity_dir / "HEARTBEAT.md"
|
heartbeat_path = self.config.identity_dir / "HEARTBEAT.md"
|
||||||
|
|
@ -201,9 +240,12 @@ class Scheduler:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self._poll_clickup()
|
self._poll_clickup()
|
||||||
|
self.db.kv_set(
|
||||||
|
"system:loop:clickup:last_run", datetime.now(UTC).isoformat()
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("ClickUp poll error: %s", e)
|
log.error("ClickUp poll error: %s", e)
|
||||||
self._stop_event.wait(interval)
|
self._interruptible_wait(interval)
|
||||||
|
|
||||||
def _discover_field_filter(self, client):
|
def _discover_field_filter(self, client):
|
||||||
"""Discover and cache the Work Category field UUID + option map."""
|
"""Discover and cache the Work Category field UUID + option map."""
|
||||||
|
|
@ -468,9 +510,12 @@ class Scheduler:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
self._scan_watch_folder()
|
self._scan_watch_folder()
|
||||||
|
self.db.kv_set(
|
||||||
|
"system:loop:folder_watch:last_run", datetime.now(UTC).isoformat()
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Folder watcher error: %s", e)
|
log.error("Folder watcher error: %s", e)
|
||||||
self._stop_event.wait(interval)
|
self._interruptible_wait(interval)
|
||||||
|
|
||||||
def _scan_watch_folder(self):
|
def _scan_watch_folder(self):
|
||||||
"""Scan the watch folder for new .xlsx files and match to ClickUp tasks."""
|
"""Scan the watch folder for new .xlsx files and match to ClickUp tasks."""
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,20 @@ When the user provides a press release topic, follow this workflow:
|
||||||
- Title case
|
- Title case
|
||||||
- News-focused (not promotional)
|
- News-focused (not promotional)
|
||||||
- Free of location keywords, superlatives (best/top/leading/#1), and questions
|
- Free of location keywords, superlatives (best/top/leading/#1), and questions
|
||||||
- Contains actual news announcement
|
- Not make up information that isn't true.
|
||||||
- Present all 7 titles to an AI agent to judge which is best. This can be decided by looking at titles on Press Advantage for other businesses, and seeing how closely the headline follows the instructions.
|
- Present all 7 titles to an AI agent to judge which is best. This can be decided by looking at titles on Press Advantage for other businesses, and seeing how closely the headline follows the instructions.
|
||||||
|
|
||||||
|
** EXAMPLE GREAT HEADLINES: **
|
||||||
|
- Dietz Electric Highlights Flameproof Motor Safety Options
|
||||||
|
- MOD-TRONIC Reaffirms Position as Largest MINCO Stocking Distributor
|
||||||
|
- Hogge Precision Parts Delivers Precision Machining for the Medical Industry
|
||||||
|
- Lubrication Engineers Drives Awareness of Fuel Treatment Benefits for Year-Round Fleet Efficiency
|
||||||
|
- Renown Electric Champions Proactive Downtime Protection With Contingency Planning Insights
|
||||||
|
- MCM Composites Releases Enhanced Thermoset Comparison Resource
|
||||||
|
- AGI Fabricators Publishes New Resource on Custom Process Hopper Fabrication
|
||||||
|
- Paragon Steel Strengthens Support For Central Los Angeles Commercial Projects
|
||||||
|
- McCormick Industries Reinforces Quality Standards With ISO 9001:2015-Certified Medical Machining
|
||||||
|
|
||||||
|
|
||||||
2. **Gather Any Additional Required Information**:
|
2. **Gather Any Additional Required Information**:
|
||||||
- If the user provides LSI terms explicitly, use them
|
- If the user provides LSI terms explicitly, use them
|
||||||
|
|
@ -58,19 +70,6 @@ When generating the 7 headline options:
|
||||||
- No promotional language (e.g., "Revolutionary," "Game-Changing")
|
- No promotional language (e.g., "Revolutionary," "Game-Changing")
|
||||||
- Focus on the news, not the hype
|
- Focus on the news, not the hype
|
||||||
|
|
||||||
**Examples of Good Headlines**:
|
|
||||||
- "TechCorp Launches AI-Powered Customer Service Platform" (56 chars)
|
|
||||||
- "Green Solutions Secures $50M Series B Funding Round" (52 chars)
|
|
||||||
- "Acme Industries Expands Operations to European Markets" (55 chars)
|
|
||||||
- "DataFlow Announces Strategic Partnership with IBM" (50 chars)
|
|
||||||
- "HealthTech Achieves ISO 27001 Certification" (44 chars)
|
|
||||||
- Also check the headlines.md file (if it exists) for other examples of good headlines.
|
|
||||||
|
|
||||||
**Examples of Bad Headlines** (DO NOT USE):
|
|
||||||
- ❌ "Is Your Business Ready for AI Customer Service?" (question)
|
|
||||||
- ❌ "Chicago's Leading TechCorp Launches New Platform" (location + superlative)
|
|
||||||
- ❌ "Best-in-Class AI Solution Revolutionizes Support" (superlative + hype)
|
|
||||||
- ❌ "TechCorp: The #1 Choice for Customer Service AI" (superlative + promotional)
|
|
||||||
|
|
||||||
## Critical Press Advantage Requirements
|
## Critical Press Advantage Requirements
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue