diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..b46d20c --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"75cf1462-99b4-4a81-b53f-b4e42ed0a32d","pid":71635,"acquiredAt":1777021697658} \ No newline at end of file diff --git a/app/api.py b/app/api.py index 6975a5b..501b908 100644 --- a/app/api.py +++ b/app/api.py @@ -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, @@ -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 ---------- diff --git a/app/tag_suggester.py b/app/tag_suggester.py new file mode 100644 index 0000000..009860f --- /dev/null +++ b/app/tag_suggester.py @@ -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("-") diff --git a/tests/test_api_suggest_tags.py b/tests/test_api_suggest_tags.py new file mode 100644 index 0000000..ecb93be --- /dev/null +++ b/tests/test_api_suggest_tags.py @@ -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