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"]*>(.*?)", html, re.IGNORECASE | re.DOTALL) + if m: + title = re.sub(r"\s+", " ", m.group(1)).strip() + + return title.strip(), (text or "")[:6000] diff --git a/app/search.py b/app/search.py new file mode 100644 index 0000000..1c248fe --- /dev/null +++ b/app/search.py @@ -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 diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..b1c9d9c --- /dev/null +++ b/app/security.py @@ -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 diff --git a/app/templates/_link_card.html b/app/templates/_link_card.html new file mode 100644 index 0000000..3e1d860 --- /dev/null +++ b/app/templates/_link_card.html @@ -0,0 +1,42 @@ + diff --git a/app/templates/_links_list.html b/app/templates/_links_list.html new file mode 100644 index 0000000..bd1f5be --- /dev/null +++ b/app/templates/_links_list.html @@ -0,0 +1,9 @@ +{% if links %} + {% for link in links %} + {% include "_link_card.html" %} + {% endfor %} +{% else %} +
+ Noch keine Links gefunden. Füge oben deinen ersten Link hinzu. +
+{% endif %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..5bc2a4d --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}LinkVault{% endblock %} + + + + + {% block body %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..de4efc7 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} +{% block title %}Meine Links · LinkVault{% endblock %} +{% block body %} +
+
LinkVault
+ +
+ +
+ + +
+
+
+
+ + +
+
+ Der Link wird automatisch geladen, zusammengefasst und einsortiert. +
+
+
+ +
+
+ + + + {% if ai_enabled %} + + {% endif %} +
+
+ + +
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..526767f --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Anmelden · LinkVault{% endblock %} +{% block body %} +
+
+

LinkVault

+

Melde dich an, um deine Link-Sammlung zu verwalten.

+ {% if error %}
{{ error }}
{% endif %} +
+
+ + +
+
+ + +
+ +
+

+ Noch kein Konto? Jetzt registrieren +

+
+
+{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html new file mode 100644 index 0000000..f80b50e --- /dev/null +++ b/app/templates/register.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Registrieren · LinkVault{% endblock %} +{% block body %} +
+
+

Konto erstellen

+ {% if error %}
{{ error }}
{% endif %} +
+
+ + +
+
+ + +
+ +
+

+ Bereits ein Konto? Zur Anmeldung +

+
+
+{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..629c57e --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d29791d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..2455141 --- /dev/null +++ b/run.sh @@ -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