feat(dupe-guard): add max_posts per-sub cap to prevent one-shot intro campaigns from re-posting
Adds max_posts INTEGER to campaign_subs (NULL = unlimited/evergreen). Adds successful_post_count() query counting lifetime success records. poster.py checks max_posts before the 7-day rolling dupe guard. Root cause: campaign 2 fired 8 days after the last post (just outside the 7-day window), allowing a duplicate r/opensource pitch. Fix: set max_posts=1 on intro campaigns so the lifetime cap fires regardless of window.
This commit is contained in:
parent
01e5990f58
commit
a2620570fa
3 changed files with 21 additions and 0 deletions
2
app/db/migrations/018_campaign_subs_max_posts.sql
Normal file
2
app/db/migrations/018_campaign_subs_max_posts.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- Add per-sub post cap; NULL = unlimited (evergreen). Set max_posts=1 for one-shot intro campaigns.
|
||||||
|
ALTER TABLE campaign_subs ADD COLUMN max_posts INTEGER;
|
||||||
|
|
@ -319,6 +319,14 @@ class Store:
|
||||||
)
|
)
|
||||||
return row is not None
|
return row is not None
|
||||||
|
|
||||||
|
def successful_post_count(self, campaign_id: int, target: str) -> int:
|
||||||
|
"""Return lifetime count of successful posts for this campaign+target pair."""
|
||||||
|
row = self._fetchone(
|
||||||
|
"SELECT COUNT(*) AS n FROM posts WHERE campaign_id = ? AND target = ? AND status = 'success'",
|
||||||
|
(campaign_id, target),
|
||||||
|
)
|
||||||
|
return row["n"] if row else 0
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Sub rules
|
# Sub rules
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,17 @@ def _run_post(db_path: str, campaign_id: int, target: str,
|
||||||
)
|
)
|
||||||
return {"skipped": True, "reason": f"occurrence {occurrence_str} not today"}
|
return {"skipped": True, "reason": f"occurrence {occurrence_str} not today"}
|
||||||
|
|
||||||
|
# Per-sub post cap (max_posts=None means unlimited)
|
||||||
|
max_posts = sub_row.get("max_posts")
|
||||||
|
if max_posts is not None:
|
||||||
|
count = store.successful_post_count(campaign_id, target)
|
||||||
|
if count >= max_posts:
|
||||||
|
logger.info(
|
||||||
|
"Skipping %s / %s — reached max_posts=%d (posted %d time(s))",
|
||||||
|
campaign_id, target, max_posts, count,
|
||||||
|
)
|
||||||
|
return {"skipped": True, "reason": f"max_posts={max_posts} reached for {target!r}"}
|
||||||
|
|
||||||
# Dupe guard (opt-out allowed per strategy)
|
# Dupe guard (opt-out allowed per strategy)
|
||||||
if strategy.supports_dupe_guard() and store.already_posted_this_week(campaign_id, target):
|
if strategy.supports_dupe_guard() and store.already_posted_this_week(campaign_id, target):
|
||||||
return {"skipped": True, "reason": f"already posted to {target!r} this week"}
|
return {"skipped": True, "reason": f"already posted to {target!r} this week"}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue