diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..412a966 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.venv/ +__pycache__/ +*.pyc +*.db +.env +.git/ +.gitignore +.DS_Store +README.md +run.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a89aed --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Kopiere diese Datei nach ".env" und trage deine Werte ein. + +# Geheimer Schlüssel für Session-Cookies (beliebiger zufälliger String). +# Erzeugen z.B. mit: python3 -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=change-me-please-set-a-random-secret + +# OpenAI API-Key (https://platform.openai.com/api-keys) +OPENAI_API_KEY= + +# Modelle (Standardwerte sind günstig & gut) +OPENAI_MODEL=gpt-4o-mini +EMBEDDING_MODEL=text-embedding-3-small + +# Datenbank (Standard: lokale SQLite-Datei) +DATABASE_URL=sqlite:///./linkvault.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57e4172 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# --- Umgebung & Secrets --- +.env +.env.* +!.env.example + +# --- Python --- +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ +*.egg + +# Virtuelle Umgebungen +.venv/ +venv/ +env/ +ENV/ + +# --- Datenbank / Daten --- +*.db +*.sqlite +*.sqlite3 +/data/ + +# --- Test & Coverage --- +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# --- Logs --- +*.log + +# --- Docker --- +# (Images/Volumes liegen nicht im Repo; nur lokale Override-Dateien ignorieren) +docker-compose.override.yml + +# --- IDE / Editor --- +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# --- Betriebssystem --- +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7796aaa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.13-slim + +# Keine .pyc-Dateien, ungepufferte Logs +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# Abhängigkeiten zuerst (bessere Layer-Caches) +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Anwendungscode +COPY app ./app + +# SQLite-Datenbank liegt im persistenten Volume /data +ENV DATABASE_URL=sqlite:////data/linkvault.db +RUN mkdir -p /data + +# Nicht als root laufen +RUN useradd --create-home --uid 1000 appuser \ + && chown -R appuser:appuser /app /data +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/login').status==200 else 1)" + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ai.py b/app/ai.py new file mode 100644 index 0000000..5a6efad --- /dev/null +++ b/app/ai.py @@ -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 [] diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..e99a003 --- /dev/null +++ b/app/config.py @@ -0,0 +1,15 @@ +import os + +from dotenv import load_dotenv + +load_dotenv() + +SECRET_KEY = os.getenv("SECRET_KEY", "dev-insecure-secret-change-me") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "").strip() +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") +EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./linkvault.db") + +# KI-Funktionen (Kategorisierung, Zusammenfassung, semantische Suche) +# sind nur aktiv, wenn ein API-Key hinterlegt ist. +AI_ENABLED = bool(OPENAI_API_KEY) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..c0ed620 --- /dev/null +++ b/app/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from . import config + +connect_args = ( + {"check_same_thread": False} + if config.DATABASE_URL.startswith("sqlite") + else {} +) + +engine = create_engine(config.DATABASE_URL, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4c5051f --- /dev/null +++ b/app/main.py @@ -0,0 +1,283 @@ +import json +from pathlib import Path + +from fastapi import Depends, FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates +from sqlalchemy import func, select +from sqlalchemy.orm import Session +from starlette.middleware.sessions import SessionMiddleware + +from . import ai, config, scraper, search +from .database import Base, engine, get_db +from .models import Link, User +from .security import hash_password, verify_password + +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="LinkVault") +app.add_middleware(SessionMiddleware, secret_key=config.SECRET_KEY, max_age=60 * 60 * 24 * 14) + +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) + + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- +def current_user(request: Request, db: Session) -> User | None: + user_id = request.session.get("user_id") + if not user_id: + return None + return db.get(User, user_id) + + +def normalize_url(url: str) -> str: + url = url.strip() + if url and not url.startswith(("http://", "https://")): + url = "https://" + url + return url + + +def facets(db: Session, user: User) -> dict: + cat_rows = db.execute( + select(Link.category, func.count()) + .where(Link.user_id == user.id, Link.category != "") + .group_by(Link.category) + .order_by(func.count().desc()) + ).all() + man_rows = db.execute( + select(Link.manufacturer, func.count()) + .where(Link.user_id == user.id, Link.manufacturer != "") + .group_by(Link.manufacturer) + .order_by(func.count().desc()) + ).all() + return { + "categories": [(name, count) for name, count in cat_rows], + "manufacturers": [(name, count) for name, count in man_rows], + } + + +def query_links( + db: Session, + user: User, + q: str = "", + category: str = "", + manufacturer: str = "", + semantic: bool = False, +) -> list[Link]: + stmt = select(Link).where(Link.user_id == user.id) + if category: + stmt = stmt.where(Link.category == category) + if manufacturer: + stmt = stmt.where(Link.manufacturer == manufacturer) + links = list(db.scalars(stmt.order_by(Link.created_at.desc())).all()) + + q = (q or "").strip() + if not q: + return links + + if semantic and config.AI_ENABLED: + query_vec = ai.embed(q) + ranked = search.cosine_rank(query_vec, links) + result = [link for link, score in ranked if score > 0.20][:50] + if result: + return result + # Fallback auf Textsuche, falls keine Embeddings vorhanden sind. + + ql = q.lower() + return [ + link + for link in links + if ql + in " ".join( + [link.title, link.summary, link.tags, link.category, link.manufacturer] + ).lower() + ] + + +# --------------------------------------------------------------------------- +# Authentifizierung +# --------------------------------------------------------------------------- +@app.get("/login", response_class=HTMLResponse) +def login_form(request: Request): + return templates.TemplateResponse("login.html", {"request": request, "error": None}) + + +@app.post("/login", response_class=HTMLResponse) +def login( + request: Request, + email: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db), +): + user = db.scalar(select(User).where(User.email == email.strip().lower())) + if not user or not verify_password(password, user.password_hash): + return templates.TemplateResponse( + "login.html", + {"request": request, "error": "E-Mail oder Passwort ist falsch."}, + status_code=400, + ) + request.session["user_id"] = user.id + return RedirectResponse("/", status_code=303) + + +@app.get("/register", response_class=HTMLResponse) +def register_form(request: Request): + return templates.TemplateResponse( + "register.html", {"request": request, "error": None} + ) + + +@app.post("/register", response_class=HTMLResponse) +def register( + request: Request, + email: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db), +): + email = email.strip().lower() + if not email or len(password) < 6: + return templates.TemplateResponse( + "register.html", + { + "request": request, + "error": "Bitte gültige E-Mail und Passwort (min. 6 Zeichen) angeben.", + }, + status_code=400, + ) + if db.scalar(select(User).where(User.email == email)): + return templates.TemplateResponse( + "register.html", + {"request": request, "error": "Diese E-Mail ist bereits registriert."}, + status_code=400, + ) + user = User(email=email, password_hash=hash_password(password)) + db.add(user) + db.commit() + request.session["user_id"] = user.id + return RedirectResponse("/", status_code=303) + + +@app.post("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/login", status_code=303) + + +# --------------------------------------------------------------------------- +# Dashboard & Links +# --------------------------------------------------------------------------- +@app.get("/", response_class=HTMLResponse) +def index( + request: Request, + q: str = "", + category: str = "", + manufacturer: str = "", + semantic: str = "", + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return RedirectResponse("/login", status_code=303) + + is_semantic = semantic in ("1", "on", "true") + links = query_links(db, user, q, category, manufacturer, is_semantic) + return templates.TemplateResponse( + "index.html", + { + "request": request, + "user": user, + "links": links, + "facets": facets(db, user), + "q": q, + "active_category": category, + "active_manufacturer": manufacturer, + "semantic": is_semantic, + "ai_enabled": config.AI_ENABLED, + }, + ) + + +@app.get("/search", response_class=HTMLResponse) +def search_links( + request: Request, + q: str = "", + category: str = "", + manufacturer: str = "", + semantic: str = "", + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + + is_semantic = semantic in ("1", "on", "true") + links = query_links(db, user, q, category, manufacturer, is_semantic) + return templates.TemplateResponse( + "_links_list.html", {"request": request, "links": links} + ) + + +@app.post("/links", response_class=HTMLResponse) +def add_link( + request: Request, + url: str = Form(...), + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + + url = normalize_url(url) + if not url: + return Response(status_code=400) + + title, text = scraper.fetch(url) + existing = facets(db, user) + meta = ai.categorize( + url, + title, + text, + [name for name, _ in existing["categories"]], + [name for name, _ in existing["manufacturers"]], + ) + + embed_source = " ".join( + [meta["title"], meta["summary"], " ".join(meta["tags"]), text[:1000]] + ) + embedding = ai.embed(embed_source) + + link = Link( + user_id=user.id, + url=url, + title=meta["title"], + summary=meta["summary"], + category=meta["category"], + manufacturer=meta["manufacturer"], + tags=json.dumps(meta["tags"], ensure_ascii=False), + embedding=json.dumps(embedding) if embedding else "", + status="ok" if text else "no_content", + ) + db.add(link) + db.commit() + db.refresh(link) + + return templates.TemplateResponse( + "_link_card.html", {"request": request, "link": link} + ) + + +@app.delete("/links/{link_id}", response_class=HTMLResponse) +def delete_link( + link_id: int, + request: Request, + db: Session = Depends(get_db), +): + user = current_user(request, db) + if not user: + return Response(status_code=401, headers={"HX-Redirect": "/login"}) + link = db.get(Link, link_id) + if link and link.user_id == user.id: + db.delete(link) + db.commit() + return Response(status_code=200) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..c3d6d53 --- /dev/null +++ b/app/models.py @@ -0,0 +1,49 @@ +import json +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .database import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + password_hash: Mapped[str] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) + + links: Mapped[list["Link"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + +class Link(Base): + __tablename__ = "links" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + url: Mapped[str] = mapped_column(Text) + title: Mapped[str] = mapped_column(Text, default="") + summary: Mapped[str] = mapped_column(Text, default="") + category: Mapped[str] = mapped_column(String(120), default="", index=True) + manufacturer: Mapped[str] = mapped_column(String(120), default="", index=True) + tags: Mapped[str] = mapped_column(Text, default="[]") # JSON-Liste + embedding: Mapped[str] = mapped_column(Text, default="") # JSON-Liste (Vektor) + status: Mapped[str] = mapped_column(String(20), default="ok") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) + + user: Mapped["User"] = relationship(back_populates="links") + + @property + def tag_list(self) -> list[str]: + try: + return json.loads(self.tags) + except (ValueError, TypeError): + return [] diff --git a/app/scraper.py b/app/scraper.py new file mode 100644 index 0000000..827bcbc --- /dev/null +++ b/app/scraper.py @@ -0,0 +1,59 @@ +import re + +import httpx +import trafilatura + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (compatible; LinkVault/1.0; +https://example.local)" + ) +} + + +def _download(url: str) -> str: + try: + with httpx.Client( + follow_redirects=True, timeout=15.0, headers=HEADERS + ) as client: + resp = client.get(url) + resp.raise_for_status() + ctype = resp.headers.get("content-type", "") + if "html" not in ctype and "xml" not in ctype and ctype: + return "" + return resp.text + except Exception: + return "" + + +def fetch(url: str) -> tuple[str, str]: + """Lade die Seite und liefere (Titel, Textinhalt). Wirft nie eine Exception.""" + html = _download(url) + if not html: + return "", "" + + title, text = "", "" + try: + data = trafilatura.bare_extraction( + html, + include_comments=False, + include_tables=False, + with_metadata=True, + ) + if isinstance(data, dict): + title = data.get("title") or "" + text = data.get("text") or "" + except Exception: + pass + + if not text: + try: + text = trafilatura.extract(html) or "" + except Exception: + text = "" + + if not title: + m = re.search(r"
{{ link.summary }}
+ {% elif link.status == 'no_content' %} ++ Inhalt konnte nicht abgerufen werden – Kategorisierung anhand der URL. +
+ {% endif %} + +Melde dich an, um deine Link-Sammlung zu verwalten.
+ {% if error %}+ Noch kein Konto? Jetzt registrieren +
+