From 5e76de3d126df8eb96d6d7da77674be7f7782c74 Mon Sep 17 00:00:00 2001 From: kamlesh0928 Date: Thu, 8 Oct 2026 12:45:11 +0530 Subject: [PATCH 1/3] feat: add a cron job which will run the funtion un_pipeline() after every 1 day and create a post for LinkedIn --- app/agents/writer_agent.py | 5 ++-- app/discord/bot.py | 7 +++++ app/discovery/repository.py | 2 +- app/scheduler/jobs.py | 52 ++++++++++++++++++++++++++++++++++++ tests/scheduler/test_jobs.py | 32 ++++++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 app/scheduler/jobs.py create mode 100644 tests/scheduler/test_jobs.py diff --git a/app/agents/writer_agent.py b/app/agents/writer_agent.py index d4596a3..af55646 100644 --- a/app/agents/writer_agent.py +++ b/app/agents/writer_agent.py @@ -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.") diff --git a/app/discord/bot.py b/app/discord/bot.py index 94fda06..510a18b 100644 --- a/app/discord/bot.py +++ b/app/discord/bot.py @@ -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 ( diff --git a/app/discovery/repository.py b/app/discovery/repository.py index 50039a3..1a7176c 100644 --- a/app/discovery/repository.py +++ b/app/discovery/repository.py @@ -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 ( diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py new file mode 100644 index 0000000..4ad6cd9 --- /dev/null +++ b/app/scheduler/jobs.py @@ -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 + +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(), + 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.") diff --git a/tests/scheduler/test_jobs.py b/tests/scheduler/test_jobs.py new file mode 100644 index 0000000..c4964f2 --- /dev/null +++ b/tests/scheduler/test_jobs.py @@ -0,0 +1,32 @@ +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): + mocker.patch.object(jobs._scheduler, "running", False) + mock_add_job = mocker.patch.object(jobs._scheduler, "add_job") + mock_start = mocker.patch.object(jobs._scheduler, "start") + + jobs.start_scheduler() + + mock_add_job.assert_called_once() + mock_start.assert_called_once() + + +def test_start_scheduler_does_nothing_if_already_running(mocker): + mocker.patch.object(jobs._scheduler, "running", True) + mock_add_job = mocker.patch.object(jobs._scheduler, "add_job") + + jobs.start_scheduler() + + mock_add_job.assert_not_called() \ No newline at end of file From 4c8fe1644862228b52e8024937d6cea6d0790118 Mon Sep 17 00:00:00 2001 From: kamlesh0928 Date: Thu, 8 Oct 2026 12:59:30 +0530 Subject: [PATCH 2/3] fix: ruff issues --- app/scheduler/jobs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py index 4ad6cd9..c0eea47 100644 --- a/app/scheduler/jobs.py +++ b/app/scheduler/jobs.py @@ -6,7 +6,7 @@ trigger the exact same pipeline, just on a different schedule. """ -from datetime import datetime +from datetime import datetime, timezone from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -43,7 +43,7 @@ def start_scheduler() -> None: 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(), + next_run_time=datetime.now(timezone.utc), replace_existing=True, max_instances=1, # skip a run if the previous one is still in progress ) From ce5eb73c97723051eb77514f989e2af8c026907d Mon Sep 17 00:00:00 2001 From: kamlesh0928 Date: Thu, 8 Oct 2026 13:05:11 +0530 Subject: [PATCH 3/3] fix: test_jobs.py --- tests/scheduler/test_jobs.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/scheduler/test_jobs.py b/tests/scheduler/test_jobs.py index c4964f2..1ada4d1 100644 --- a/tests/scheduler/test_jobs.py +++ b/tests/scheduler/test_jobs.py @@ -13,20 +13,19 @@ async def test_scheduled_run_calls_run_pipeline(mocker): def test_start_scheduler_is_idempotent(mocker): - mocker.patch.object(jobs._scheduler, "running", False) - mock_add_job = mocker.patch.object(jobs._scheduler, "add_job") - mock_start = mocker.patch.object(jobs._scheduler, "start") + mock_scheduler = mocker.patch.object(jobs, "_scheduler") + mock_scheduler.running = False jobs.start_scheduler() - mock_add_job.assert_called_once() - mock_start.assert_called_once() + mock_scheduler.add_job.assert_called_once() + mock_scheduler.start.assert_called_once() def test_start_scheduler_does_nothing_if_already_running(mocker): - mocker.patch.object(jobs._scheduler, "running", True) - mock_add_job = mocker.patch.object(jobs._scheduler, "add_job") + mock_scheduler = mocker.patch.object(jobs, "_scheduler") + mock_scheduler.running = True jobs.start_scheduler() - mock_add_job.assert_not_called() \ No newline at end of file + mock_scheduler.add_job.assert_not_called() \ No newline at end of file