diff --git a/station_announcer/__init__.py b/station_announcer/__init__.py
index 49f5339..f05ada8 100644
--- a/station_announcer/__init__.py
+++ b/station_announcer/__init__.py
@@ -14,6 +14,14 @@ from .mastodon import MastodonClient
from .openai_client import AltTextGenerator
+def _load_hashtag_counts(path: Path) -> str | None:
+ """Return newline-delimited hashtag counts formatted for prompts."""
+ try:
+ return path.read_text()
+ except FileNotFoundError:
+ return None
+
+
def create_app() -> Flask:
"""Create and configure the Flask application."""
package_path = Path(__file__).resolve().parent
@@ -27,6 +35,10 @@ def create_app() -> Flask:
settings = load_settings()
app.config.update(settings)
+ hashtag_counts = _load_hashtag_counts(project_root / "hash_tag_counts")
+ if hashtag_counts:
+ app.config["HASHTAG_COUNTS_TEXT"] = hashtag_counts
+
secret_key = app.config.get("SECRET_KEY") or "dev-secret-key"
app.config["SECRET_KEY"] = secret_key
app.config["UNIAUTH_URL"] = "https://edwardbetts.com/UniAuth"
diff --git a/station_announcer/mastodon.py b/station_announcer/mastodon.py
index 884b3c3..78e3410 100644
--- a/station_announcer/mastodon.py
+++ b/station_announcer/mastodon.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any, Dict, Optional, Sequence
+from typing import Any, Dict, List, Optional, Sequence
import requests
@@ -115,3 +115,17 @@ class MastodonClient:
"url": status.get("url"),
}
return None
+
+ def get_status(self, status_id: str) -> Dict[str, Any]:
+ if not status_id:
+ raise MastodonError("status_id is required")
+ return self._request("GET", f"/api/v1/statuses/{status_id}")
+
+ def get_status_ancestors(self, status_id: str) -> List[Dict[str, Any]]:
+ if not status_id:
+ raise MastodonError("status_id is required")
+ data = self._request("GET", f"/api/v1/statuses/{status_id}/context")
+ ancestors = data.get("ancestors")
+ if isinstance(ancestors, list):
+ return ancestors
+ return []
diff --git a/station_announcer/openai_client.py b/station_announcer/openai_client.py
index 1afae69..3e68a28 100644
--- a/station_announcer/openai_client.py
+++ b/station_announcer/openai_client.py
@@ -2,9 +2,8 @@
from __future__ import annotations
-from typing import Any, Dict, List, Optional
-
import requests
+import typing
class OpenAIClientError(RuntimeError):
@@ -20,9 +19,9 @@ class TextImprovementError(OpenAIClientError):
class AltTextGenerator:
- """Request alt text from a GPT-4o compatible OpenAI endpoint."""
+ """Request alt text from a GPT compatible OpenAI endpoint."""
- def __init__(self, api_key: str, model: str = "gpt-4o-mini") -> None:
+ def __init__(self, api_key: str, model: str = "gpt-4.1") -> None:
if not api_key:
raise ValueError("OPENAI_API_KEY is required")
self.model = model
@@ -38,10 +37,10 @@ class AltTextGenerator:
def generate_alt_text(
self,
image_source: str,
- notes: Optional[str] = None,
- captured_at: Optional[str] = None,
- location: Optional[str] = None,
- coordinates: Optional[str] = None,
+ notes: str | None = None,
+ captured_at: str | None = None,
+ location: str | None = None,
+ coordinates: str | None = None,
) -> str:
if not image_source:
raise AltTextGenerationError("Image URL required for alt text generation")
@@ -63,7 +62,7 @@ class AltTextGenerator:
prompt_lines.append(f"Coordinates: {coordinates}")
text_prompt = "\n".join(prompt_lines)
- content: List[Dict[str, Any]] = [
+ content: list[dict[str, typing.Any]] = [
{"type": "text", "text": text_prompt},
{"type": "image_url", "image_url": {"url": image_source}},
]
@@ -102,20 +101,55 @@ class AltTextGenerator:
return content_text.strip()
def improve_post_text(
- self, draft_text: str, instructions: Optional[str] = None
+ self,
+ draft_text: str,
+ instructions: str | None = None,
+ hashtag_counts: str | None = None,
+ thread_context: list[str] | None = None,
) -> str:
if not draft_text or not draft_text.strip():
raise TextImprovementError("Post text cannot be empty")
prompt_parts = [
- "You review Mastodon drafts and rewrite them in UK English.",
+ "You review Mastodon drafts and improve them. Use UK English spelling.",
"Keep the tone warm, accessible, and descriptive without exaggeration.",
"Ensure clarity, fix spelling or grammar, and keep content suitable for social media.",
+ "Don't add #TravelVibes if I'm at home in Bristol, okay to add if setting off on a trip.",
+ "Don't add #BetterByRail if not on a rail journey, equally don't remove it if present.",
+ "Always format hashtags in CamelCase (e.g. #BetterByRail).",
]
if instructions:
prompt_parts.append(f"Additional instructions: {instructions.strip()}")
+ if hashtag_counts:
+ prompt_parts.append(
+ "When you add hashtags, prefer the ones from my history below and "
+ "only invent new ones if absolutely necessary."
+ )
+ if thread_context:
+ prompt_parts.append(
+ "You are continuing a Mastodon thread. Use the previous posts for "
+ "context, avoid repeating them verbatim, and keep the narrative flowing."
+ )
prompt_parts.append("Return only the improved post text.")
- user_content = f"Draft post:\\n{draft_text.strip()}"
+
+ context_block = ""
+ if thread_context:
+ cleaned_context = [
+ entry.strip() for entry in thread_context if entry and entry.strip()
+ ]
+ if cleaned_context:
+ formatted_history = "\n".join(
+ f"{index + 1}. {value}"
+ for index, value in enumerate(cleaned_context)
+ )
+ context_block = f"Previous thread posts:\n{formatted_history}\n\n"
+
+ user_content = f"{context_block}Draft post:\n{draft_text.strip()}"
+ if hashtag_counts:
+ user_content = (
+ f"{user_content}\n\nHashtag history (tag with past uses):\n"
+ f"{hashtag_counts.strip()}"
+ )
payload = {
"model": self.model,
diff --git a/station_announcer/routes.py b/station_announcer/routes.py
index 0c92e73..948862d 100644
--- a/station_announcer/routes.py
+++ b/station_announcer/routes.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import base64
+import re
from datetime import datetime
+from html import unescape
import UniAuth.auth
from flask import (
@@ -25,6 +27,17 @@ from .openai_client import AltTextGenerationError, TextImprovementError
bp = Blueprint("main", __name__)
+_TAG_RE = re.compile(r"<[^>]+>")
+
+
+def _html_to_plain_text(raw: str | None) -> str:
+ if not raw:
+ return ""
+ text = _TAG_RE.sub(" ", raw)
+ text = unescape(text)
+ return " ".join(text.split()).strip()
+
+
def _parse_timestamp(raw: str | None) -> datetime | None:
if not raw:
return None
@@ -260,6 +273,7 @@ def compose_draft():
immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache
generator = current_app.alt_text_generator
+ hashtag_counts = current_app.config.get("HASHTAG_COUNTS_TEXT")
error_message = None
post_text = ""
@@ -350,8 +364,43 @@ def compose_draft():
if request.method == "POST":
action = request.form.get("action")
if action == "refine":
+ thread_context: list[str] | None = None
+ if (
+ reply_to_latest
+ and mastodon_client
+ and latest_status
+ and latest_status.get("id")
+ ):
+ status_id = str(latest_status["id"])
+ context_entries: list[str] = []
+ try:
+ ancestors = mastodon_client.get_status_ancestors(status_id)
+ except MastodonError as exc:
+ if not error_message:
+ error_message = str(exc)
+ ancestors = []
+ for item in ancestors:
+ ancestor_text = _html_to_plain_text(item.get("content"))
+ if ancestor_text:
+ context_entries.append(ancestor_text)
+ latest_content = latest_status.get("content")
+ if not latest_content:
+ try:
+ latest_payload = mastodon_client.get_status(status_id)
+ latest_content = latest_payload.get("content")
+ except MastodonError as exc:
+ if not error_message:
+ error_message = str(exc)
+ latest_content = None
+ latest_text = _html_to_plain_text(latest_content)
+ if latest_text:
+ context_entries.append(latest_text)
+ if context_entries:
+ thread_context = context_entries
try:
- post_text = generator.improve_post_text(post_text, instructions)
+ post_text = generator.improve_post_text(
+ post_text, instructions, hashtag_counts, thread_context
+ )
flash("Post refined with ChatGPT.")
except TextImprovementError as exc:
error_message = str(exc)
diff --git a/templates/compose_draft.html b/templates/compose_draft.html
index 8e742e9..0912ecd 100644
--- a/templates/compose_draft.html
+++ b/templates/compose_draft.html
@@ -3,7 +3,7 @@
{% block content %}
Review alt text, edit your caption, and ask ChatGPT for phrasing help before posting. Review alt text, edit your caption, and ask GPT for phrasing help before posting.Draft Mastodon post
-