Compare commits

..

No commits in common. "e00207e986cef2e4b399395d3df3e2165d92468a" and "30b4d27c6a4ebc13090093206ee3378c563aeafc" have entirely different histories.

5 changed files with 19 additions and 127 deletions

View file

@ -14,14 +14,6 @@ from .mastodon import MastodonClient
from .openai_client import AltTextGenerator 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: def create_app() -> Flask:
"""Create and configure the Flask application.""" """Create and configure the Flask application."""
package_path = Path(__file__).resolve().parent package_path = Path(__file__).resolve().parent
@ -35,10 +27,6 @@ def create_app() -> Flask:
settings = load_settings() settings = load_settings()
app.config.update(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" secret_key = app.config.get("SECRET_KEY") or "dev-secret-key"
app.config["SECRET_KEY"] = secret_key app.config["SECRET_KEY"] = secret_key
app.config["UNIAUTH_URL"] = "https://edwardbetts.com/UniAuth" app.config["UNIAUTH_URL"] = "https://edwardbetts.com/UniAuth"

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, Optional, Sequence
import requests import requests
@ -115,17 +115,3 @@ class MastodonClient:
"url": status.get("url"), "url": status.get("url"),
} }
return None 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 []

View file

@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional
import requests import requests
import typing
class OpenAIClientError(RuntimeError): class OpenAIClientError(RuntimeError):
@ -19,9 +20,9 @@ class TextImprovementError(OpenAIClientError):
class AltTextGenerator: class AltTextGenerator:
"""Request alt text from a GPT compatible OpenAI endpoint.""" """Request alt text from a GPT-4o compatible OpenAI endpoint."""
def __init__(self, api_key: str, model: str = "gpt-4.1") -> None: def __init__(self, api_key: str, model: str = "gpt-4o-mini") -> None:
if not api_key: if not api_key:
raise ValueError("OPENAI_API_KEY is required") raise ValueError("OPENAI_API_KEY is required")
self.model = model self.model = model
@ -37,10 +38,10 @@ class AltTextGenerator:
def generate_alt_text( def generate_alt_text(
self, self,
image_source: str, image_source: str,
notes: str | None = None, notes: Optional[str] = None,
captured_at: str | None = None, captured_at: Optional[str] = None,
location: str | None = None, location: Optional[str] = None,
coordinates: str | None = None, coordinates: Optional[str] = None,
) -> str: ) -> str:
if not image_source: if not image_source:
raise AltTextGenerationError("Image URL required for alt text generation") raise AltTextGenerationError("Image URL required for alt text generation")
@ -62,7 +63,7 @@ class AltTextGenerator:
prompt_lines.append(f"Coordinates: {coordinates}") prompt_lines.append(f"Coordinates: {coordinates}")
text_prompt = "\n".join(prompt_lines) text_prompt = "\n".join(prompt_lines)
content: list[dict[str, typing.Any]] = [ content: List[Dict[str, Any]] = [
{"type": "text", "text": text_prompt}, {"type": "text", "text": text_prompt},
{"type": "image_url", "image_url": {"url": image_source}}, {"type": "image_url", "image_url": {"url": image_source}},
] ]
@ -101,55 +102,20 @@ class AltTextGenerator:
return content_text.strip() return content_text.strip()
def improve_post_text( def improve_post_text(
self, self, draft_text: str, instructions: Optional[str] = None
draft_text: str,
instructions: str | None = None,
hashtag_counts: str | None = None,
thread_context: list[str] | None = None,
) -> str: ) -> str:
if not draft_text or not draft_text.strip(): if not draft_text or not draft_text.strip():
raise TextImprovementError("Post text cannot be empty") raise TextImprovementError("Post text cannot be empty")
prompt_parts = [ prompt_parts = [
"You review Mastodon drafts and improve them. Use UK English spelling.", "You review Mastodon drafts and rewrite them in UK English.",
"Keep the tone warm, accessible, and descriptive without exaggeration.", "Keep the tone warm, accessible, and descriptive without exaggeration.",
"Ensure clarity, fix spelling or grammar, and keep content suitable for social media.", "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: if instructions:
prompt_parts.append(f"Additional instructions: {instructions.strip()}") 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.") 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 = { payload = {
"model": self.model, "model": self.model,

View file

@ -3,9 +3,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import re
from datetime import datetime from datetime import datetime
from html import unescape
import UniAuth.auth import UniAuth.auth
from flask import ( from flask import (
@ -27,17 +25,6 @@ from .openai_client import AltTextGenerationError, TextImprovementError
bp = Blueprint("main", __name__) 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: def _parse_timestamp(raw: str | None) -> datetime | None:
if not raw: if not raw:
return None return None
@ -273,7 +260,6 @@ def compose_draft():
immich_client = current_app.immich_client immich_client = current_app.immich_client
alt_cache = current_app.alt_text_cache alt_cache = current_app.alt_text_cache
generator = current_app.alt_text_generator generator = current_app.alt_text_generator
hashtag_counts = current_app.config.get("HASHTAG_COUNTS_TEXT")
error_message = None error_message = None
post_text = "" post_text = ""
@ -364,43 +350,8 @@ def compose_draft():
if request.method == "POST": if request.method == "POST":
action = request.form.get("action") action = request.form.get("action")
if action == "refine": 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: try:
ancestors = mastodon_client.get_status_ancestors(status_id) post_text = generator.improve_post_text(post_text, instructions)
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, hashtag_counts, thread_context
)
flash("Post refined with ChatGPT.") flash("Post refined with ChatGPT.")
except TextImprovementError as exc: except TextImprovementError as exc:
error_message = str(exc) error_message = str(exc)

View file

@ -3,7 +3,7 @@
{% block content %} {% block content %}
<section class="intro"> <section class="intro">
<h2>Draft Mastodon post</h2> <h2>Draft Mastodon post</h2>
<p>Review alt text, edit your caption, and ask GPT for phrasing help before posting.</p> <p>Review alt text, edit your caption, and ask ChatGPT for phrasing help before posting.</p>
</section> </section>
{% if not mastodon_ready %} {% if not mastodon_ready %}
@ -24,8 +24,9 @@
{% endif %} {% endif %}
<div class="status-content">{{ latest_status.content|safe }}</div> <div class="status-content">{{ latest_status.content|safe }}</div>
<label class="reply-checkbox"> <label class="reply-checkbox">
<input type="hidden" name="reply_to_latest" value="0">
<input type="checkbox" name="reply_to_latest" value="1" {% if reply_to_latest %}checked{% endif %} {% if not mastodon_ready %}disabled{% endif %}> <input type="checkbox" name="reply_to_latest" value="1" {% if reply_to_latest %}checked{% endif %} {% if not mastodon_ready %}disabled{% endif %}>
Continue the thread Reply to this post to continue the thread
</label> </label>
</section> </section>
{% elif latest_status_error %} {% elif latest_status_error %}
@ -63,12 +64,12 @@
</div> </div>
<div class="post-instructions"> <div class="post-instructions">
<label for="post_instructions">Instructions for GPT (optional)</label> <label for="post_instructions">Instructions for ChatGPT (optional)</label>
<textarea id="post_instructions" name="post_instructions" rows="3" placeholder="Mention tone, audience, hashtags, etc.">{{ instructions }}</textarea> <textarea id="post_instructions" name="post_instructions" rows="3" placeholder="Mention tone, audience, hashtags, etc.">{{ instructions }}</textarea>
</div> </div>
<div class="compose-actions"> <div class="compose-actions">
<button type="submit" name="action" value="refine">Improve text with GPT</button> <button type="submit" name="action" value="refine">Improve text with ChatGPT</button>
<button type="submit" name="action" value="post" {% if not mastodon_ready %}disabled{% endif %}>Post to Mastodon</button> <button type="submit" name="action" value="post" {% if not mastodon_ready %}disabled{% endif %}>Post to Mastodon</button>
</div> </div>
</form> </form>