Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/agents/writer_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ def _draft_post(topic: Topic) -> list[DraftPost]:

async def generate_post(topic: Topic) -> ApprovedPost | None:
"""
Draft a post for `topic`, send it to Discord, and keep regenerating on
"Re-try" until the reviewer Accepts or Rejects. Returns True if accepted.
Draft a LinkedIn post for `topic`, send it to Discord, and keep
regenerating on "Re-try" until the reviewer Accepts or Rejects.
Returns the approved content, or None if rejected.
"""
if topic.title is None:
raise ValueError("Topic title cannot be None.")
Expand Down
7 changes: 7 additions & 0 deletions app/discord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,19 @@ async def send_for_approval(embed: discord.Embed, view: discord.ui.View) -> disc

@bot.event
async def on_ready() -> None:
# Deferred import - avoids a circular import, since scheduler/jobs.py
# pulls in orchestrator.py, which pulls in writer_agent.py, which
# imports send_for_approval from this very module.
from app.scheduler.jobs import start_scheduler

guild = discord.Object(id=settings.DISCORD_SERVER_ID)
bot.tree.copy_global_to(guild=guild)
synced = await bot.tree.sync(guild=guild)
logger.info("Synced %d slash command(s) to guild %s", len(synced), settings.DISCORD_SERVER_ID)
logger.info("Discord bot logged in as %s", bot.user)

start_scheduler()


def run_discord_bot() -> None:
from app.discord import (
Expand Down
2 changes: 1 addition & 1 deletion app/discovery/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

def get_pending_topics() -> list[Topic]:
"""
Get unused ides from a previous run. Return the best ranked first.
Unused ideas from a previous run, best-ranked first.
"""
with SessionLocal() as session:
return (
Expand Down
52 changes: 52 additions & 0 deletions app/scheduler/jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Cron-style scheduling for the content pipeline.

Runs run_pipeline() automatically every 1 days, independent of the
on-demand /generate Discord command (app/discord/commands.py) - both
trigger the exact same pipeline, just on a different schedule.
"""

from datetime import datetime, timezone

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger

from app.agents.orchestrator import run_pipeline
from app.utils.logger import setup_logger

logger = setup_logger("scheduler")

_scheduler = AsyncIOScheduler()


async def _scheduled_run() -> None:
logger.info("Scheduled run starting.")
await run_pipeline()


def start_scheduler() -> None:
"""
Start the every-1-days posting job, tied to the bot's own event loop.

Call this from on_ready() (after the bot is connected) - AsyncIOScheduler
needs a running event loop to start against. Safe to call more than once
(Discord can fire on_ready again after a reconnect); does nothing if the
scheduler is already running, so it won't register duplicate jobs.
"""

if _scheduler.running:
return

_scheduler.add_job(
_scheduled_run,
trigger=IntervalTrigger(days=1),
id="post-every-1-day",
# Fires once immediately on startup, then every 1 days after that.
# Remove `next_run_time` if you'd rather wait a full 1 days before the first post.
next_run_time=datetime.now(timezone.utc),
replace_existing=True,
max_instances=1, # skip a run if the previous one is still in progress
)

_scheduler.start()
logger.info("Scheduler started - a new post will be generated every 1 days.")
31 changes: 31 additions & 0 deletions tests/scheduler/test_jobs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pytest

from app.scheduler import jobs


@pytest.mark.asyncio
async def test_scheduled_run_calls_run_pipeline(mocker):
mock_run_pipeline = mocker.patch("app.scheduler.jobs.run_pipeline", new=mocker.AsyncMock())

await jobs._scheduled_run()

mock_run_pipeline.assert_awaited_once()


def test_start_scheduler_is_idempotent(mocker):
mock_scheduler = mocker.patch.object(jobs, "_scheduler")
mock_scheduler.running = False

jobs.start_scheduler()

mock_scheduler.add_job.assert_called_once()
mock_scheduler.start.assert_called_once()


def test_start_scheduler_does_nothing_if_already_running(mocker):
mock_scheduler = mocker.patch.object(jobs, "_scheduler")
mock_scheduler.running = True

jobs.start_scheduler()

mock_scheduler.add_job.assert_not_called()
Loading