Files
linkvault/app/ai.py
Erik Thiele 47685f0d34 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>
2026-07-13 22:07:05 +02:00

104 lines
3.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],
}
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 []