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
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
1 change: 1 addition & 0 deletions growth-agent/agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
58 changes: 51 additions & 7 deletions growth-agent/agent/nodes/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os
from datetime import datetime, timezone
from urllib.parse import urlparse, urlunparse

from pydantic import BaseModel, Field

Expand Down Expand Up @@ -63,16 +64,28 @@ 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:

URL: {url}
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
Expand All @@ -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}

Expand All @@ -105,16 +118,17 @@ 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:

URL: {url}
Titel: {item.page_title}
Zusammenfassung: {item.page_description}
Warum bewerben: {item.reason}

{history_block}
Anforderungen:
- Knackiger Hook
- Link einbinden
Expand All @@ -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:
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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] = []

Expand Down
1 change: 1 addition & 0 deletions growth-agent/agent/nodes/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
106 changes: 106 additions & 0 deletions growth-agent/test/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
)
from agent.nodes.drafts import (
MastodonDraftOutput,
_former_posts_context,
_normalize_url,
create_drafts,
)
from agent.nodes.ingest import ingest_analytics
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions scw_js/growth_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 10 additions & 8 deletions website/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading