New /prompts section extends the existing database (new Prompt model,
same SQLite file) and mirrors the Links experience: add a prompt, get
it auto-categorized and tagged by AI, filter by category, full-text
search, sort, edit, or have the AI re-categorize it. A header nav
switch ("Links" / "Prompts") toggles between the two collections; the
shared topbar/menu markup was factored into _topbar.html and
_topbar_actions.html so both pages (and settings) stay in sync.
Each prompt card has a dedicated copy-to-clipboard icon next to
edit/delete, so a stored prompt can be reused immediately. Copying
uses the raw markdown source (not the rendered HTML) so structure
survives when pasted into another AI tool. The copy handler tries the
async Clipboard API first and falls back to a hidden-textarea +
execCommand('copy') for plain-http/non-secure contexts, with clear
success/failure icon feedback either way.
Prompt content supports Markdown and is rendered server-side
(app/mdrender.py) for the card preview. Since this is user-supplied
HTML-adjacent content, rendering goes through two defenses: the raw
text is escaped (only '<' and '&', not '>', so blockquotes keep
working) before conversion so no raw tag can survive, and the
resulting HTML is passed through bleach with a tag/attribute/protocol
allowlist so Markdown-generated links can't carry a javascript: URL.
Verified against raw <script>, <img onerror>, and javascript: link
payloads. The edit form always shows the raw Markdown source, never
the rendered HTML.
Also bumps the default APP_VERSION (shown in the footer) from 1.0.0
to 2.0.0 to mark this feature addition.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
160 lines
5.1 KiB
Python
160 lines
5.1 KiB
Python
import json
|
|
|
|
from . import config
|
|
|
|
_client = None
|
|
|
|
|
|
def _client_or_none():
|
|
global _client
|
|
if not config.AI_ENABLED:
|
|
return None
|
|
if _client is None:
|
|
from openai import OpenAI
|
|
|
|
_client = OpenAI(api_key=config.OPENAI_API_KEY)
|
|
return _client
|
|
|
|
|
|
SYSTEM_PROMPT = (
|
|
"Du bist ein Assistent, der Web-Links organisiert. Analysiere den gegebenen "
|
|
"Inhalt und liefere strukturierte Metadaten als JSON. Verwende möglichst eine "
|
|
"der bereits existierenden Kategorien bzw. Hersteller/Quellen, wenn sie passt; "
|
|
"sonst erstelle eine kurze, allgemeingültige neue Bezeichnung. Antworte "
|
|
"ausschließlich auf Deutsch."
|
|
)
|
|
|
|
|
|
def categorize(
|
|
url: str,
|
|
title: str,
|
|
text: str,
|
|
existing_categories: list[str],
|
|
existing_manufacturers: list[str],
|
|
) -> dict:
|
|
"""Erzeuge Titel, Zusammenfassung, Kategorie, Hersteller und Tags."""
|
|
fallback = {
|
|
"title": title or url,
|
|
"summary": "",
|
|
"category": "Sonstiges",
|
|
"manufacturer": "",
|
|
"tags": [],
|
|
}
|
|
|
|
client = _client_or_none()
|
|
if client is None:
|
|
return fallback
|
|
|
|
user_prompt = f"""URL: {url}
|
|
Seitentitel: {title or "(unbekannt)"}
|
|
Bereits vorhandene Kategorien: {", ".join(existing_categories) or "(noch keine)"}
|
|
Bereits vorhandene Hersteller/Quellen: {", ".join(existing_manufacturers) or "(noch keine)"}
|
|
|
|
Seiteninhalt (gekürzt):
|
|
{text[:4000] or "(kein Inhalt abrufbar - nutze URL und Titel)"}
|
|
|
|
Gib ein JSON-Objekt mit genau diesen Feldern zurück:
|
|
- "title": kurzer, aussagekräftiger Titel
|
|
- "summary": 2-3 Sätze Zusammenfassung, worum es geht
|
|
- "category": eine passende Themen-Kategorie
|
|
- "manufacturer": Hersteller, Marke oder Quelle (leer lassen, wenn nicht erkennbar)
|
|
- "tags": Array aus 3-6 kurzen Schlagwörtern
|
|
"""
|
|
|
|
try:
|
|
resp = client.chat.completions.create(
|
|
model=config.OPENAI_MODEL,
|
|
messages=[
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
response_format={"type": "json_object"},
|
|
temperature=0.2,
|
|
)
|
|
data = json.loads(resp.choices[0].message.content)
|
|
except Exception:
|
|
return fallback
|
|
|
|
tags = data.get("tags") or []
|
|
if not isinstance(tags, list):
|
|
tags = []
|
|
|
|
return {
|
|
"title": (str(data.get("title") or title or url)).strip()[:300],
|
|
"summary": (str(data.get("summary") or "")).strip()[:1000],
|
|
"category": (str(data.get("category") or "Sonstiges")).strip()[:120]
|
|
or "Sonstiges",
|
|
"manufacturer": (str(data.get("manufacturer") or "")).strip()[:120],
|
|
"tags": [str(t).strip()[:40] for t in tags if str(t).strip()][:8],
|
|
}
|
|
|
|
|
|
PROMPT_SYSTEM_PROMPT = (
|
|
"Du bist ein Assistent, der eine Sammlung von KI-Prompts organisiert. "
|
|
"Analysiere den gegebenen Prompt-Text und liefere strukturierte Metadaten "
|
|
"als JSON. Verwende möglichst eine der bereits existierenden Kategorien, "
|
|
"wenn sie passt; sonst erstelle eine kurze, allgemeingültige neue "
|
|
"Bezeichnung (z.B. Schreiben, Code, Bild, Analyse, Sonstiges). Antworte "
|
|
"ausschließlich auf Deutsch."
|
|
)
|
|
|
|
|
|
def categorize_prompt(content: str, existing_categories: list[str]) -> dict:
|
|
"""Erzeuge Titel, Kategorie und Tags für einen gespeicherten Prompt."""
|
|
fallback_title = (content.strip().splitlines() or [""])[0].strip()[:80] or "Prompt"
|
|
fallback = {"title": fallback_title, "category": "Sonstiges", "tags": []}
|
|
|
|
client = _client_or_none()
|
|
if client is None:
|
|
return fallback
|
|
|
|
user_prompt = f"""Prompt-Text:
|
|
{content[:4000]}
|
|
|
|
Bereits vorhandene Kategorien: {", ".join(existing_categories) or "(noch keine)"}
|
|
|
|
Gib ein JSON-Objekt mit genau diesen Feldern zurück:
|
|
- "title": kurzer, aussagekräftiger Titel (max. 80 Zeichen)
|
|
- "category": eine passende Kategorie
|
|
- "tags": Array aus 3-6 kurzen Schlagwörtern
|
|
"""
|
|
|
|
try:
|
|
resp = client.chat.completions.create(
|
|
model=config.OPENAI_MODEL,
|
|
messages=[
|
|
{"role": "system", "content": PROMPT_SYSTEM_PROMPT},
|
|
{"role": "user", "content": user_prompt},
|
|
],
|
|
response_format={"type": "json_object"},
|
|
temperature=0.2,
|
|
)
|
|
data = json.loads(resp.choices[0].message.content)
|
|
except Exception:
|
|
return fallback
|
|
|
|
tags = data.get("tags") or []
|
|
if not isinstance(tags, list):
|
|
tags = []
|
|
|
|
return {
|
|
"title": (str(data.get("title") or fallback_title)).strip()[:120] or fallback_title,
|
|
"category": (str(data.get("category") or "Sonstiges")).strip()[:120]
|
|
or "Sonstiges",
|
|
"tags": [str(t).strip()[:40] for t in tags if str(t).strip()][:8],
|
|
}
|
|
|
|
|
|
def embed(text: str) -> list[float]:
|
|
"""Erzeuge einen Embedding-Vektor für die semantische Suche."""
|
|
client = _client_or_none()
|
|
if client is None or not text.strip():
|
|
return []
|
|
try:
|
|
resp = client.embeddings.create(
|
|
model=config.EMBEDDING_MODEL, input=text[:8000]
|
|
)
|
|
return list(resp.data[0].embedding)
|
|
except Exception:
|
|
return []
|