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
1 change: 1 addition & 0 deletions .claude/scheduled_tasks.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sessionId":"75cf1462-99b4-4a81-b53f-b4e42ed0a32d","pid":71635,"acquiredAt":1777021697658}
31 changes: 31 additions & 0 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@
from starlette.requests import Request
from sqlmodel import Session, SQLModel, col, func, or_, select

import anthropic as anthropic_lib

from app.auth import create_access_token, require_admin, verify_admin_password
from app.db import get_session
from app.feed import months_to_collapse
from app.tag_suggester import suggest_tags
from app.images import (
ImageCommitError,
ImageGenerationError,
Expand Down Expand Up @@ -417,6 +420,34 @@ def clear_theme_image(
return theme


# ---------- tag suggestion endpoint ----------


class SuggestTagsResponse(SQLModel, table=False):
tags: List[str]


@router.post("/themes/{theme_id}/suggest-tags", response_model=SuggestTagsResponse)
def suggest_theme_tags(
theme_id: int,
session: Session = Depends(get_session),
_admin: None = Depends(require_admin),
):
theme = session.get(Theme, theme_id)
if not theme:
raise HTTPException(status_code=404, detail="Theme not found")

existing_tag_names = [t.name for t in session.exec(select(Tag)).all()]

try:
tags = suggest_tags(theme.body_md, existing_tag_names)
except anthropic_lib.APIError as e:
logger.warning("Tag suggestion failed for theme %d: %s", theme_id, e)
raise HTTPException(status_code=503, detail="Tag suggestion service unavailable")

return SuggestTagsResponse(tags=tags)


# ---------- breadcrumb endpoints ----------


Expand Down
56 changes: 56 additions & 0 deletions app/tag_suggester.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Claude-powered tag suggestions for themes."""

import json
import logging
import os
import re

import anthropic

logger = logging.getLogger(__name__)

_SYSTEM_PROMPT = """\
You suggest concise, reusable tags for blog themes.

Rules:
- Return 3–5 tags as a JSON object: {"tags": ["tag-one", "tag-two"]}
- Prefer existing tags when they fit; only invent new ones when needed
- Tags should be lowercase words or short phrases (e.g. "deep-work", "identity")
- Return ONLY the JSON object — no explanation, no markdown
"""


def suggest_tags(theme_body: str, existing_tag_names: list[str]) -> list[str]:
"""Call Claude Haiku to suggest tags for a theme, normalized to slug format."""
api_key = os.getenv("ANTHROPIC_API_KEY")
client = anthropic.Anthropic(api_key=api_key)

existing_context = (
f"\n\nExisting tags on this blog (prefer these when relevant): {', '.join(existing_tag_names)}"
if existing_tag_names
else ""
)

response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=150,
system=_SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": f"Theme content:\n\n{theme_body}{existing_context}",
}
],
)

raw_text = response.content[0].text
data = json.loads(raw_text)
raw_tags = data.get("tags", [])
return [_normalize(t) for t in raw_tags if t]


def _normalize(name: str) -> str:
v = re.sub(r"\s+", "-", name.strip().lower())
v = re.sub(r"[^a-z0-9\-]", "", v)
v = re.sub(r"-{2,}", "-", v)
return v.strip("-")
90 changes: 90 additions & 0 deletions tests/test_api_suggest_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Tests for POST /api/themes/{theme_id}/suggest-tags."""

import json
from unittest.mock import MagicMock, patch

import pytest

from app.models import Tag, Theme, Visibility


def _make_claude_response(tags: list[str]) -> MagicMock:
content_block = MagicMock()
content_block.text = json.dumps({"tags": tags})
msg = MagicMock()
msg.content = [content_block]
return msg


@pytest.fixture
def theme(session):
t = Theme(body_md="Reflections on solitude and the nature of attention", visibility=Visibility.draft)
session.add(t)
session.commit()
session.refresh(t)
return t


@pytest.fixture
def theme_with_tags(session, theme):
for name in ["mindfulness", "philosophy"]:
tag = Tag(name=name)
session.add(tag)
theme.tags.append(tag)
session.add(theme)
session.commit()
session.refresh(theme)
return theme


def test_suggest_tags_returns_normalized_tags(client, theme):
mock_response = _make_claude_response(["solitude", "attention", "reflection"])
with patch("app.tag_suggester.anthropic.Anthropic") as mock_cls:
mock_cls.return_value.messages.create.return_value = mock_response
response = client.post(f"/api/themes/{theme.id}/suggest-tags")

assert response.status_code == 200
data = response.json()
assert "tags" in data
assert data["tags"] == ["solitude", "attention", "reflection"]


def test_suggest_tags_passes_existing_tags_for_context(client, theme_with_tags):
mock_response = _make_claude_response(["mindfulness", "focus"])
with patch("app.tag_suggester.anthropic.Anthropic") as mock_cls:
mock_instance = mock_cls.return_value
mock_instance.messages.create.return_value = mock_response
client.post(f"/api/themes/{theme_with_tags.id}/suggest-tags")

call_kwargs = mock_instance.messages.create.call_args
prompt_text = call_kwargs.kwargs["messages"][0]["content"]
assert "mindfulness" in prompt_text
assert "philosophy" in prompt_text


def test_suggest_tags_normalizes_raw_output(client, theme):
mock_response = _make_claude_response(["Deep Work", "mind fullness", "self--care"])
with patch("app.tag_suggester.anthropic.Anthropic") as mock_cls:
mock_cls.return_value.messages.create.return_value = mock_response
response = client.post(f"/api/themes/{theme.id}/suggest-tags")

assert response.status_code == 200
assert response.json()["tags"] == ["deep-work", "mind-fullness", "self-care"]


def test_suggest_tags_returns_404_for_missing_theme(client):
with patch("app.tag_suggester.anthropic.Anthropic"):
response = client.post("/api/themes/999/suggest-tags")
assert response.status_code == 404


def test_suggest_tags_returns_503_on_api_error(client, theme):
import anthropic as anthropic_lib

with patch("app.tag_suggester.anthropic.Anthropic") as mock_cls:
mock_cls.return_value.messages.create.side_effect = anthropic_lib.APIError(
message="rate limited", request=MagicMock(), body=None
)
response = client.post(f"/api/themes/{theme.id}/suggest-tags")

assert response.status_code == 503
Loading