From 9bd31822d2c3f62ba08060aeca07378d5010772c Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 6 May 2026 20:11:11 +0200 Subject: [PATCH 1/4] First attempt to add the interface --- CLAUDE.md | 13 ++++ growth-agent/agent/models.py | 1 + growth-agent/agent/nodes/drafts.py | 58 +++++++++++++-- growth-agent/agent/nodes/publish.py | 1 + growth-agent/test/test_handler.py | 106 ++++++++++++++++++++++++++++ scw_js/growth_service.ts | 1 + website/pages/growth/+Page.tsx | 35 ++++++++- website/types/growth.ts | 1 + 8 files changed, 208 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d3b4116f..edcf348b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,19 @@ poetry run jupyter notebook poetry run python -m ipykernel install --user --name=merkle-tree-notebooks ``` +### growth-agent/ + +Python cron container (managed with `uv`). Runs daily on Scaleway: ingests Umami analytics, generates LLM social-media drafts, and publishes approved posts to Mastodon and Bluesky. Human-in-the-loop via the website's Growth UI (`/growth`). + +```bash +cd growth-agent/ +uv sync # install deps +uv run pytest test/ # run tests +uv run python run_local.py diagnose # inspect current S3 state +uv run python run_local.py publish # dry-run publish step +bash bin/deploy.sh # build + push + deploy (needs SCW creds) +``` + ## Architecture Patterns ### Smart Contracts (eth/) diff --git a/growth-agent/agent/models.py b/growth-agent/agent/models.py index 1187b43c..184bdb42 100644 --- a/growth-agent/agent/models.py +++ b/growth-agent/agent/models.py @@ -141,6 +141,7 @@ class Draft(BaseModel): review_outcome: str | None = None review_comment: str | None = None reviewed_at: datetime | None = None + published_at: datetime | None = None class ContentQueue(BaseModel): diff --git a/growth-agent/agent/nodes/drafts.py b/growth-agent/agent/nodes/drafts.py index 508a1e0b..bc29f980 100644 --- a/growth-agent/agent/nodes/drafts.py +++ b/growth-agent/agent/nodes/drafts.py @@ -5,6 +5,7 @@ import logging import os from datetime import datetime, timezone +from urllib.parse import urlparse, urlunparse from pydantic import BaseModel, Field @@ -63,8 +64,20 @@ def _system_prompt(strategy: Strategy) -> str: ) -def _mastodon_prompt(item, language: str, strategy: Strategy) -> str: +def _former_context_block(former_context: str) -> str: + """Wrap former-post context in a prompt section. Returns empty string when no context.""" + if not former_context: + return "" + return f""" +--- PREVIOUSLY PUBLISHED FOR THIS PAGE --- +{former_context} +Rules: do not reuse the same opening hook or the exact same claim framing from the entries above. +------------------------------------------""" + + +def _mastodon_prompt(item, language: str, strategy: Strategy, former_context: str = "") -> str: url = f"{item.page_url}?utm_source=mastodon&utm_campaign=growth-agent" + history_block = _former_context_block(former_context) if language == "de": return f"""Schreibe einen Mastodon-Post (max 500 Zeichen) über diesen Blog-Artikel: @@ -72,7 +85,7 @@ def _mastodon_prompt(item, language: str, strategy: Strategy) -> str: Titel: {item.page_title} Zusammenfassung: {item.page_description} Warum bewerben: {item.reason} - +{history_block} Anforderungen: - Hook im ersten Satz (Frage oder starke These) - Ein konkretes Insight aus dem Artikel erwähnen @@ -90,7 +103,7 @@ def _mastodon_prompt(item, language: str, strategy: Strategy) -> str: Title: {item.page_title} Article summary: {item.page_description} Why promote: {item.reason} - +{history_block} Context: {strategy.website_url} covers {pillars}. Target audience: {strategy.target_audience} @@ -105,8 +118,9 @@ def _mastodon_prompt(item, language: str, strategy: Strategy) -> str: Return ONLY the post text, nothing else.""" -def _bluesky_prompt(item, language: str, strategy: Strategy) -> str: +def _bluesky_prompt(item, language: str, strategy: Strategy, former_context: str = "") -> str: url = f"{item.page_url}?utm_source=bluesky&utm_campaign=growth-agent" + history_block = _former_context_block(former_context) if language == "de": return f"""Schreibe einen Bluesky-Post (max 300 Zeichen) über diesen Blog-Artikel: @@ -114,7 +128,7 @@ def _bluesky_prompt(item, language: str, strategy: Strategy) -> str: Titel: {item.page_title} Zusammenfassung: {item.page_description} Warum bewerben: {item.reason} - +{history_block} Anforderungen: - Knackiger Hook - Link einbinden @@ -129,7 +143,7 @@ def _bluesky_prompt(item, language: str, strategy: Strategy) -> str: Title: {item.page_title} Article summary: {item.page_description} Why promote: {item.reason} - +{history_block} Target audience: {strategy.target_audience} Requirements: @@ -194,6 +208,35 @@ def _refine_prompt( Return ONLY the improved post text, nothing else.""" +def _normalize_url(url: str) -> str: + """Strip query string and fragment so UTM-decorated URLs match their canonical form.""" + p = urlparse(url) + return urlunparse(p._replace(query="", fragment="")) + + +def _former_posts_context(queue: ContentQueue, page_url: str, channel: str, n: int = 3) -> str: + """Return a formatted block of the N most recent published posts for this page+channel. + + Returns empty string when no history exists. + """ + canonical = _normalize_url(page_url) + matches = [ + d + for d in queue.published + if d.channel == channel and _normalize_url(d.link or "") == canonical + ] + if not matches: + return "" + + matches.sort(key=lambda d: (d.published_at or d.created), reverse=True) + lines = [f"Former posts for this page on {channel} (newest first):"] + for i, d in enumerate(matches[:n], 1): + ts = (d.published_at or d.created).strftime("%Y-%m-%d") + preview = d.content[:140] + ("…" if len(d.content) > 140 else "") + lines.append(f"{i}. [{ts}] {preview}") + return "\n".join(lines) + + def _normalize_hashtags(hashtags: list[str]) -> list[str]: """Normalize hashtag list to '#tag' format and preserve order.""" normalized: list[str] = [] @@ -295,7 +338,8 @@ def create_drafts(storage, plan: ContentPlan) -> int: continue config = CHANNEL_CONFIG[channel] prompt_fn = {"mastodon": _mastodon_prompt, "bluesky": _bluesky_prompt}[channel] - prompt = prompt_fn(item, "en", strategy) + former_context = _former_posts_context(queue, item.page_url, channel) + prompt = prompt_fn(item, "en", strategy, former_context) max_tokens = config["max_tokens"] draft_hashtags: list[str] = [] diff --git a/growth-agent/agent/nodes/publish.py b/growth-agent/agent/nodes/publish.py index ce05e936..fe46d855 100644 --- a/growth-agent/agent/nodes/publish.py +++ b/growth-agent/agent/nodes/publish.py @@ -80,6 +80,7 @@ def publish_approved_drafts(storage) -> list[str]: continue draft.status = "published" + draft.published_at = datetime.now(timezone.utc) queue.published.append(draft) published_ids.append(draft.id) logger.info("Published draft %s to %s", draft.id, draft.channel) diff --git a/growth-agent/test/test_handler.py b/growth-agent/test/test_handler.py index 53521335..e8596e43 100644 --- a/growth-agent/test/test_handler.py +++ b/growth-agent/test/test_handler.py @@ -19,6 +19,8 @@ ) from agent.nodes.drafts import ( MastodonDraftOutput, + _former_posts_context, + _normalize_url, create_drafts, ) from agent.nodes.ingest import ingest_analytics @@ -961,3 +963,107 @@ def test_post_root_calls_handle(self, server): assert body["test"] is True mock_handle.assert_called_once() conn.close() + + +# --------------------------------------------------------------------------- +# Phase 2f — published_at, former-post context, backwards compatibility +# --------------------------------------------------------------------------- + + +@patch("agent.nodes.publish.MastodonClient") +def test_publish_sets_published_at(MockMasto, mock_storage): + """Successful publish populates published_at on the draft.""" + storage, store = mock_storage + + now = datetime.now(timezone.utc) + draft = Draft( + id="d1", + channel="mastodon", + language="en", + content="Hello world", + status="approved", + scheduled_at=now - timedelta(minutes=1), + ) + queue = ContentQueue(approved=[draft]) + storage.write("content_queue.json", queue) + + masto_inst = MockMasto.return_value + masto_inst.post.return_value = None + masto_inst.close.return_value = None + + with patch("agent.nodes.publish.publish_draft"): + publish_approved_drafts(storage) + + saved = ContentQueue.model_validate(store["content_queue.json"]) + assert len(saved.published) == 1 + assert saved.published[0].published_at is not None + + +def test_normalize_url_strips_query_and_fragment(): + """_normalize_url removes UTM params and fragments.""" + url = "https://fretchen.eu/blog/post?utm_source=mastodon&utm_campaign=growth-agent#section" + assert _normalize_url(url) == "https://fretchen.eu/blog/post" + + +def test_normalize_url_leaves_clean_url_unchanged(): + assert _normalize_url("https://fretchen.eu/blog/post") == "https://fretchen.eu/blog/post" + + +def test_former_posts_context_filters_by_channel(): + """_former_posts_context returns only entries matching the requested channel.""" + page = "https://fretchen.eu/blog/post" + now = datetime.now(timezone.utc) + + published = [ + Draft( + id="m1", + channel="mastodon", + language="en", + content="Mastodon post", + link=f"{page}?utm_source=mastodon&utm_campaign=growth-agent", + status="published", + published_at=now - timedelta(days=1), + ), + Draft( + id="b1", + channel="bluesky", + language="en", + content="Bluesky post", + link=f"{page}?utm_source=bluesky&utm_campaign=growth-agent", + status="published", + published_at=now - timedelta(days=2), + ), + ] + queue = ContentQueue(published=published) + + ctx = _former_posts_context(queue, page, "mastodon") + assert "Mastodon post" in ctx + assert "Bluesky post" not in ctx + + +def test_former_posts_context_empty_when_no_history(): + """_former_posts_context returns empty string when no history exists.""" + queue = ContentQueue() + assert _former_posts_context(queue, "https://fretchen.eu/blog/post", "mastodon") == "" + + +def test_old_queue_deserializes_without_published_at(): + """Queues persisted before Phase 2f (no published_at field) load without error.""" + raw = { + "drafts": [], + "approved": [], + "published": [ + { + "id": "old1", + "created": "2025-01-01T00:00:00", + "channel": "mastodon", + "language": "en", + "content": "old post", + "hashtags": [], + "status": "published", + } + ], + "rejected": [], + } + queue = ContentQueue.model_validate(raw) + assert queue.published[0].published_at is None diff --git a/scw_js/growth_service.ts b/scw_js/growth_service.ts index 9bd93eda..6456aeb7 100644 --- a/scw_js/growth_service.ts +++ b/scw_js/growth_service.ts @@ -26,6 +26,7 @@ export interface Draft { review_outcome?: string | null; review_comment?: string | null; reviewed_at?: string | null; + published_at?: string | null; } export interface ContentQueue { diff --git a/website/pages/growth/+Page.tsx b/website/pages/growth/+Page.tsx index 50c6e8c3..dbc8a32d 100644 --- a/website/pages/growth/+Page.tsx +++ b/website/pages/growth/+Page.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { useAccount, useConnect } from "wagmi"; import { css } from "../../styled-system/css"; import { useGrowthApi } from "../../hooks/useGrowthApi"; @@ -305,6 +305,7 @@ const reviewMeta = css({ function DraftCardView({ draft, showActions, + history, onApprove, onReject, onUpdate, @@ -312,6 +313,7 @@ function DraftCardView({ }: { draft: Draft; showActions: boolean; + history: Draft[]; onApprove: (id: string, scheduledAt?: string, reviewComment?: string) => Promise; onReject: (id: string, reviewComment?: string) => Promise; onUpdate: (id: string, body: Partial) => Promise; @@ -432,6 +434,23 @@ function DraftCardView({ {draft.source_blog_post || draft.link} )} + {history.length > 0 && ( +
+ + Previous posts for this page ({history.length}) + + {history.slice(0, 3).map((h) => ( +
+ + {new Date(h.published_at ?? h.created).toLocaleDateString()} · {h.channel} + +

+ {h.content.length > 140 ? h.content.slice(0, 140) + "…" : h.content} +

+
+ ))} +
+ )} {draft.review_outcome && (
Review: {draft.review_outcome} @@ -682,6 +701,19 @@ export default function Page() { ); } + const historyByPage = useMemo(() => { + const map: Record = {}; + for (const d of queue?.published ?? []) { + const key = d.source_blog_post ?? ""; + if (!key) continue; + (map[key] ??= []).push(d); + } + for (const arr of Object.values(map)) { + arr.sort((a, b) => (b.published_at ?? b.created).localeCompare(a.published_at ?? a.created)); + } + return map; + }, [queue?.published]); + const tabs: { key: Tab; label: string; count: number }[] = [ { key: "drafts", label: "Pending", count: queue?.drafts.length ?? 0 }, { key: "approved", label: "Approved", count: queue?.approved.length ?? 0 }, @@ -718,6 +750,7 @@ export default function Page() { key={draft.id} draft={draft} showActions={showActions} + history={historyByPage[draft.source_blog_post ?? ""] ?? []} onApprove={handleApprove} onReject={handleReject} onUpdate={handleUpdate} diff --git a/website/types/growth.ts b/website/types/growth.ts index 161db79b..8e404f9a 100644 --- a/website/types/growth.ts +++ b/website/types/growth.ts @@ -22,6 +22,7 @@ export interface Draft { review_outcome?: string | null; review_comment?: string | null; reviewed_at?: string | null; + published_at?: string | null; } export interface ContentQueue { From 70f8889b237a7595f46f1c9a1bab295508c486d2 Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 6 May 2026 20:15:48 +0200 Subject: [PATCH 2/4] Fix an issue --- website/package-lock.json | 18 ++++++++++-------- website/package.json | 1 - website/pages/growth/+Page.tsx | 26 +++++++++++++------------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/website/package-lock.json b/website/package-lock.json index c37a6d4d..2ffe1243 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -44,7 +44,6 @@ "@types/react-dom": "^19.2.2", "@vitest/coverage-v8": "^4.0.1", "@vitest/ui": "^4.0.1", - "baseline-browser-mapping": "^2.9.14", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", @@ -6698,13 +6697,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -6950,9 +6952,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001751", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", - "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "funding": [ { "type": "opencollective", diff --git a/website/package.json b/website/package.json index 4e29a405..6cfa455e 100644 --- a/website/package.json +++ b/website/package.json @@ -54,7 +54,6 @@ "@types/react-dom": "^19.2.2", "@vitest/coverage-v8": "^4.0.1", "@vitest/ui": "^4.0.1", - "baseline-browser-mapping": "^2.9.14", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", diff --git a/website/pages/growth/+Page.tsx b/website/pages/growth/+Page.tsx index dbc8a32d..265afb1b 100644 --- a/website/pages/growth/+Page.tsx +++ b/website/pages/growth/+Page.tsx @@ -589,6 +589,19 @@ export default function Page() { } }, [isOwner, loadData]); + const historyByPage = useMemo(() => { + const map: Record = {}; + for (const d of queue?.published ?? []) { + const key = d.source_blog_post ?? ""; + if (!key) continue; + (map[key] ??= []).push(d); + } + for (const arr of Object.values(map)) { + arr.sort((a, b) => (b.published_at ?? b.created).localeCompare(a.published_at ?? a.created)); + } + return map; + }, [queue?.published]); + const handleApprove = async (id: string, scheduledAt?: string, reviewComment?: string) => { setBusy(true); setError(null); @@ -701,19 +714,6 @@ export default function Page() { ); } - const historyByPage = useMemo(() => { - const map: Record = {}; - for (const d of queue?.published ?? []) { - const key = d.source_blog_post ?? ""; - if (!key) continue; - (map[key] ??= []).push(d); - } - for (const arr of Object.values(map)) { - arr.sort((a, b) => (b.published_at ?? b.created).localeCompare(a.published_at ?? a.created)); - } - return map; - }, [queue?.published]); - const tabs: { key: Tab; label: string; count: number }[] = [ { key: "drafts", label: "Pending", count: queue?.drafts.length ?? 0 }, { key: "approved", label: "Approved", count: queue?.approved.length ?? 0 }, From 31179f2d40d3662835cb280ec3f6520b08a7f0c2 Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 6 May 2026 20:21:28 +0200 Subject: [PATCH 3/4] Update +Page.tsx --- website/pages/growth/+Page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/website/pages/growth/+Page.tsx b/website/pages/growth/+Page.tsx index 265afb1b..8aaa3b79 100644 --- a/website/pages/growth/+Page.tsx +++ b/website/pages/growth/+Page.tsx @@ -436,9 +436,7 @@ function DraftCardView({ )} {history.length > 0 && (
- - Previous posts for this page ({history.length}) - + Previous posts for this page ({history.length}) {history.slice(0, 3).map((h) => (
From 771bd4a78227d6737023ca9a9be64ac266ff70e3 Mon Sep 17 00:00:00 2001 From: fretchen Date: Wed, 6 May 2026 20:22:09 +0200 Subject: [PATCH 4/4] Update drafts.py --- growth-agent/agent/nodes/drafts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/growth-agent/agent/nodes/drafts.py b/growth-agent/agent/nodes/drafts.py index bc29f980..53993fe8 100644 --- a/growth-agent/agent/nodes/drafts.py +++ b/growth-agent/agent/nodes/drafts.py @@ -228,7 +228,7 @@ def _former_posts_context(queue: ContentQueue, page_url: str, channel: str, n: i if not matches: return "" - matches.sort(key=lambda d: (d.published_at or d.created), reverse=True) + matches.sort(key=lambda d: d.published_at or d.created, reverse=True) lines = [f"Former posts for this page on {channel} (newest first):"] for i, d in enumerate(matches[:n], 1): ts = (d.published_at or d.created).strftime("%Y-%m-%d")