Initial commit: LinkVault – KI-gestützte Link-Sammlung
FastAPI + HTMX Web-App zum Speichern von Links mit automatischer KI-Kategorisierung, Zusammenfassung und semantischer Suche (OpenAI). Mehrbenutzer mit Login, SQLite-Persistenz, Docker/Docker-Compose-Setup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
103
app/ai.py
Normal file
103
app/ai.py
Normal file
@@ -0,0 +1,103 @@
|
||||
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],
|
||||
}
|
||||
|
||||
|
||||
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 []
|
||||
Reference in New Issue
Block a user