Compare commits
3 commits
30b4d27c6a
...
e00207e986
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e00207e986 | ||
|
|
08618ee9a9 | ||
|
|
5d8b0ecc2d |
|
|
@ -14,6 +14,14 @@ 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
|
||||||
|
|
@ -27,6 +35,10 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional, Sequence
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
@ -115,3 +115,17 @@ 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 []
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
|
|
||||||
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):
|
||||||
|
|
@ -20,9 +19,9 @@ class TextImprovementError(OpenAIClientError):
|
||||||
|
|
||||||
|
|
||||||
class AltTextGenerator:
|
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:
|
if not api_key:
|
||||||
raise ValueError("OPENAI_API_KEY is required")
|
raise ValueError("OPENAI_API_KEY is required")
|
||||||
self.model = model
|
self.model = model
|
||||||
|
|
@ -38,10 +37,10 @@ class AltTextGenerator:
|
||||||
def generate_alt_text(
|
def generate_alt_text(
|
||||||
self,
|
self,
|
||||||
image_source: str,
|
image_source: str,
|
||||||
notes: Optional[str] = None,
|
notes: str | None = None,
|
||||||
captured_at: Optional[str] = None,
|
captured_at: str | None = None,
|
||||||
location: Optional[str] = None,
|
location: str | None = None,
|
||||||
coordinates: Optional[str] = None,
|
coordinates: str | None = 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")
|
||||||
|
|
@ -63,7 +62,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, Any]] = [
|
content: list[dict[str, typing.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}},
|
||||||
]
|
]
|
||||||
|
|
@ -102,20 +101,55 @@ class AltTextGenerator:
|
||||||
return content_text.strip()
|
return content_text.strip()
|
||||||
|
|
||||||
def improve_post_text(
|
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:
|
) -> 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 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.",
|
"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,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
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 (
|
||||||
|
|
@ -25,6 +27,17 @@ 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
|
||||||
|
|
@ -260,6 +273,7 @@ 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 = ""
|
||||||
|
|
@ -350,8 +364,43 @@ 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:
|
||||||
post_text = generator.improve_post_text(post_text, instructions)
|
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, 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)
|
||||||
|
|
|
||||||
|
|
@ -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 ChatGPT for phrasing help before posting.</p>
|
<p>Review alt text, edit your caption, and ask GPT for phrasing help before posting.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if not mastodon_ready %}
|
{% if not mastodon_ready %}
|
||||||
|
|
@ -24,9 +24,8 @@
|
||||||
{% 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 %}>
|
||||||
Reply to this post to continue the thread
|
Continue the thread
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
{% elif latest_status_error %}
|
{% elif latest_status_error %}
|
||||||
|
|
@ -64,12 +63,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post-instructions">
|
<div class="post-instructions">
|
||||||
<label for="post_instructions">Instructions for ChatGPT (optional)</label>
|
<label for="post_instructions">Instructions for GPT (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 ChatGPT</button>
|
<button type="submit" name="action" value="refine">Improve text with GPT</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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue