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:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
.env
|
||||
.git/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
README.md
|
||||
run.sh
|
||||
15
.env.example
Normal file
15
.env.example
Normal file
@@ -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
|
||||
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -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
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -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"]
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
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 []
|
||||
15
app/config.py
Normal file
15
app/config.py
Normal file
@@ -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)
|
||||
25
app/database.py
Normal file
25
app/database.py
Normal file
@@ -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()
|
||||
283
app/main.py
Normal file
283
app/main.py
Normal file
@@ -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)
|
||||
49
app/models.py
Normal file
49
app/models.py
Normal file
@@ -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 []
|
||||
59
app/scraper.py
Normal file
59
app/scraper.py
Normal file
@@ -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"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
|
||||
if m:
|
||||
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
|
||||
return title.strip(), (text or "")[:6000]
|
||||
36
app/search.py
Normal file
36
app/search.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def cosine_rank(query_vec: list[float], links: list) -> list[tuple]:
|
||||
"""Sortiere Links nach Kosinus-Ähnlichkeit zum Query-Vektor.
|
||||
|
||||
Liefert eine Liste aus (Link, Score), absteigend sortiert.
|
||||
Links ohne Embedding werden ignoriert.
|
||||
"""
|
||||
if not query_vec:
|
||||
return []
|
||||
|
||||
q = np.asarray(query_vec, dtype=float)
|
||||
q_norm = np.linalg.norm(q)
|
||||
if q_norm == 0:
|
||||
return []
|
||||
|
||||
ranked: list[tuple] = []
|
||||
for link in links:
|
||||
if not link.embedding:
|
||||
continue
|
||||
try:
|
||||
v = np.asarray(json.loads(link.embedding), dtype=float)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if v.shape != q.shape:
|
||||
continue
|
||||
denom = q_norm * np.linalg.norm(v)
|
||||
if denom == 0:
|
||||
continue
|
||||
ranked.append((link, float(np.dot(q, v) / denom)))
|
||||
|
||||
ranked.sort(key=lambda item: item[1], reverse=True)
|
||||
return ranked
|
||||
15
app/security.py
Normal file
15
app/security.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
# bcrypt akzeptiert max. 72 Bytes.
|
||||
pw = password.encode("utf-8")[:72]
|
||||
return bcrypt.hashpw(pw, bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
pw = password.encode("utf-8")[:72]
|
||||
return bcrypt.checkpw(pw, password_hash.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
42
app/templates/_link_card.html
Normal file
42
app/templates/_link_card.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<div class="panel link-card" id="link-{{ link.id }}">
|
||||
<div style="display:flex; justify-content:space-between; gap:12px;">
|
||||
<div style="min-width:0;">
|
||||
<a href="{{ link.url }}" target="_blank" rel="noopener"
|
||||
style="font-weight:600; font-size:1.05rem;">{{ link.title or link.url }}</a>
|
||||
<div class="muted" style="font-size:.8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||
{{ link.url }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="danger"
|
||||
hx-delete="/links/{{ link.id }}"
|
||||
hx-target="#link-{{ link.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Diesen Link wirklich löschen?">✕</button>
|
||||
</div>
|
||||
|
||||
{% if link.summary %}
|
||||
<p style="margin:10px 0 8px;">{{ link.summary }}</p>
|
||||
{% elif link.status == 'no_content' %}
|
||||
<p class="muted" style="margin:10px 0 8px; font-style:italic;">
|
||||
Inhalt konnte nicht abgerufen werden – Kategorisierung anhand der URL.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:6px; align-items:center; font-size:.8rem;">
|
||||
{% if link.category %}
|
||||
<span style="background:var(--accent); color:white; padding:2px 9px; border-radius:99px;">
|
||||
{{ link.category }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if link.manufacturer %}
|
||||
<span style="background:#0e7490; color:#cffafe; padding:2px 9px; border-radius:99px;">
|
||||
🏢 {{ link.manufacturer }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% for tag in link.tag_list %}
|
||||
<span style="background:var(--panel2); color:var(--muted); padding:2px 9px; border-radius:99px;">
|
||||
#{{ tag }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
9
app/templates/_links_list.html
Normal file
9
app/templates/_links_list.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% if links %}
|
||||
{% for link in links %}
|
||||
{% include "_link_card.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div id="empty-state" class="panel" style="text-align:center; color:var(--muted); padding:40px;">
|
||||
Noch keine Links gefunden. Füge oben deinen ersten Link hinzu.
|
||||
</div>
|
||||
{% endif %}
|
||||
67
app/templates/base.html
Normal file
67
app/templates/base.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}LinkVault{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a; --panel: #1e293b; --panel2: #273449;
|
||||
--text: #e2e8f0; --muted: #94a3b8; --accent: #6366f1;
|
||||
--accent-hover: #818cf8; --border: #334155; --danger: #ef4444;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: var(--bg); color: var(--text); line-height: 1.5;
|
||||
}
|
||||
a { color: var(--accent-hover); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 24px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
header.topbar .brand { font-weight: 700; font-size: 1.2rem; }
|
||||
header.topbar .brand span { color: var(--accent-hover); }
|
||||
.user-info { color: var(--muted); font-size: .9rem; display: flex; gap: 12px; align-items: center; }
|
||||
button, input[type=submit] {
|
||||
background: var(--accent); color: white; border: none; border-radius: 8px;
|
||||
padding: 9px 16px; font-size: .95rem; cursor: pointer; font-weight: 500;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button.ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); }
|
||||
button.ghost:hover { background: var(--panel2); color: var(--text); }
|
||||
button.danger { background: transparent; color: var(--muted); padding: 4px 8px; font-size: .8rem; }
|
||||
button.danger:hover { color: var(--danger); background: transparent; }
|
||||
input[type=text], input[type=email], input[type=password], input[type=url] {
|
||||
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; padding: 10px 12px; font-size: .95rem; width: 100%;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--accent); }
|
||||
.layout { display: flex; gap: 24px; max-width: 1200px; margin: 24px auto; padding: 0 24px; }
|
||||
.sidebar { width: 240px; flex-shrink: 0; }
|
||||
.main { flex: 1; min-width: 0; }
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
|
||||
.facet-group h3 { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin: 16px 0 8px; }
|
||||
.facet { display: flex; justify-content: space-between; padding: 5px 8px; border-radius: 6px; font-size: .9rem; }
|
||||
.facet:hover { background: var(--panel2); text-decoration: none; }
|
||||
.facet.active { background: var(--accent); color: white; }
|
||||
.facet .count { color: var(--muted); font-size: .8rem; }
|
||||
.facet.active .count { color: #e0e7ff; }
|
||||
.auth-wrap { max-width: 380px; margin: 8vh auto; }
|
||||
.auth-wrap .panel { padding: 28px; }
|
||||
.auth-wrap h1 { margin-top: 0; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: .85rem; color: var(--muted); margin-bottom: 5px; }
|
||||
.error { background: #7f1d1d; color: #fecaca; padding: 10px 12px; border-radius: 8px; margin-bottom: 14px; font-size: .9rem; }
|
||||
.muted { color: var(--muted); }
|
||||
.htmx-indicator { opacity: 0; transition: opacity .2s; }
|
||||
.htmx-request .htmx-indicator { opacity: 1; }
|
||||
.htmx-request.htmx-indicator { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
94
app/templates/index.html
Normal file
94
app/templates/index.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meine Links · LinkVault{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div class="brand">Link<span>Vault</span></div>
|
||||
<div class="user-info">
|
||||
{% if not ai_enabled %}
|
||||
<span title="Kein OPENAI_API_KEY gesetzt – Auto-Kategorisierung deaktiviert">
|
||||
⚠️ KI aus
|
||||
</span>
|
||||
{% endif %}
|
||||
<span>{{ user.email }}</span>
|
||||
<form method="post" action="/logout" style="margin:0;">
|
||||
<button class="ghost" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="panel">
|
||||
<a href="/" class="facet {% if not active_category and not active_manufacturer %}active{% endif %}">
|
||||
<span>📚 Alle Links</span>
|
||||
</a>
|
||||
|
||||
<div class="facet-group">
|
||||
<h3>Kategorien</h3>
|
||||
{% for name, count in facets.categories %}
|
||||
<a href="/?category={{ name | urlencode }}"
|
||||
class="facet {% if active_category == name %}active{% endif %}">
|
||||
<span>{{ name }}</span><span class="count">{{ count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="facet-group">
|
||||
<h3>Hersteller / Quellen</h3>
|
||||
{% for name, count in facets.manufacturers %}
|
||||
<a href="/?manufacturer={{ name | urlencode }}"
|
||||
class="facet {% if active_manufacturer == name %}active{% endif %}">
|
||||
<span>{{ name }}</span><span class="count">{{ count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="muted" style="font-size:.85rem; padding:4px 8px;">noch keine</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="panel">
|
||||
<form hx-post="/links" hx-target="#links" hx-swap="afterbegin"
|
||||
hx-disabled-elt="button"
|
||||
hx-on::after-request="if(event.detail.successful){this.reset(); var e=document.getElementById('empty-state'); if(e) e.remove();}">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<input type="url" name="url" placeholder="Link einfügen, z.B. https://hersteller.de/produkt" required>
|
||||
<button type="submit" style="white-space:nowrap;">
|
||||
<span class="htmx-indicator">⏳</span> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div class="muted" style="font-size:.8rem; margin-top:6px;">
|
||||
Der Link wird automatisch geladen, zusammengefasst und einsortiert.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<form id="searchform" onsubmit="return false;" style="margin:0;">
|
||||
<input type="hidden" name="category" value="{{ active_category }}">
|
||||
<input type="hidden" name="manufacturer" value="{{ active_manufacturer }}">
|
||||
<input type="text" name="q" value="{{ q }}"
|
||||
placeholder="🔍 Suche in Titel, Zusammenfassung, Tags..."
|
||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||
hx-trigger="keyup changed delay:400ms, search"
|
||||
hx-include="#searchform">
|
||||
{% if ai_enabled %}
|
||||
<label class="muted" style="display:inline-flex; align-items:center; gap:6px; font-size:.85rem; margin-top:10px;">
|
||||
<input type="checkbox" name="semantic" value="1" {% if semantic %}checked{% endif %}
|
||||
hx-get="/search" hx-target="#links" hx-swap="innerHTML"
|
||||
hx-trigger="change" hx-include="#searchform" style="width:auto;">
|
||||
KI-Suche (findet auch sinnverwandte Treffer)
|
||||
</label>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="links">
|
||||
{% include "_links_list.html" %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
25
app/templates/login.html
Normal file
25
app/templates/login.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Anmelden · LinkVault{% endblock %}
|
||||
{% block body %}
|
||||
<div class="auth-wrap">
|
||||
<div class="panel">
|
||||
<h1>Link<span style="color:var(--accent-hover)">Vault</span></h1>
|
||||
<p class="muted">Melde dich an, um deine Link-Sammlung zu verwalten.</p>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Passwort</label>
|
||||
<input type="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" style="width:100%">Anmelden</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:16px">
|
||||
Noch kein Konto? <a href="/register">Jetzt registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
app/templates/register.html
Normal file
24
app/templates/register.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Registrieren · LinkVault{% endblock %}
|
||||
{% block body %}
|
||||
<div class="auth-wrap">
|
||||
<div class="panel">
|
||||
<h1>Konto erstellen</h1>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="/register">
|
||||
<div class="field">
|
||||
<label>E-Mail</label>
|
||||
<input type="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Passwort (min. 6 Zeichen)</label>
|
||||
<input type="password" name="password" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" style="width:100%">Registrieren</button>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:16px">
|
||||
Bereits ein Konto? <a href="/login">Zur Anmeldung</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
docker-compose.yml
Normal file
47
docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
image: linkvault
|
||||
container_name: linkvault
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Erzwingt die SQLite-Datei im persistenten Volume (überschreibt .env).
|
||||
DATABASE_URL: sqlite:////data/linkvault.db
|
||||
volumes:
|
||||
- linkvault-data:/data
|
||||
|
||||
volumes:
|
||||
linkvault-data:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Optional: PostgreSQL statt SQLite verwenden.
|
||||
# 1. Den "db"-Service unten einkommentieren.
|
||||
# 2. Bei "web" die environment-Zeile DATABASE_URL ersetzen durch:
|
||||
# DATABASE_URL: postgresql+psycopg://linkvault:linkvault@db:5432/linkvault
|
||||
# 3. "psycopg[binary]" zu requirements.txt hinzufügen und Image neu bauen.
|
||||
# 4. depends_on ergänzen:
|
||||
# depends_on:
|
||||
# db:
|
||||
# condition: service_healthy
|
||||
# ---------------------------------------------------------------------------
|
||||
# db:
|
||||
# image: postgres:16
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# POSTGRES_USER: linkvault
|
||||
# POSTGRES_PASSWORD: linkvault
|
||||
# POSTGRES_DB: linkvault
|
||||
# volumes:
|
||||
# - linkvault-pg:/var/lib/postgresql/data
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "pg_isready -U linkvault"]
|
||||
# interval: 10s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
#
|
||||
# (unter "volumes:" ergänzen)
|
||||
# linkvault-pg:
|
||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi>=0.115,<0.116
|
||||
uvicorn[standard]>=0.32,<0.33
|
||||
sqlalchemy>=2.0,<2.1
|
||||
jinja2>=3.1,<4
|
||||
python-multipart>=0.0.12
|
||||
itsdangerous>=2.2,<3
|
||||
bcrypt>=4.2,<5
|
||||
httpx>=0.27,<0.28
|
||||
trafilatura>=1.12,<2
|
||||
openai>=1.54,<2
|
||||
python-dotenv>=1.0,<2
|
||||
numpy>=2.0
|
||||
22
run.sh
Executable file
22
run.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ ! -d .venv ]; then
|
||||
echo "==> Erstelle virtuelle Umgebung..."
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
echo "==> Installiere Abhängigkeiten..."
|
||||
pip install -q --upgrade pip
|
||||
pip install -q -r requirements.txt
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "==> Keine .env gefunden – kopiere .env.example nach .env"
|
||||
cp .env.example .env
|
||||
echo " Bitte trage in .env deinen OPENAI_API_KEY ein."
|
||||
fi
|
||||
|
||||
echo "==> Starte LinkVault auf http://127.0.0.1:8000"
|
||||
exec uvicorn app.main:app --reload --port 8000
|
||||
Reference in New Issue
Block a user